OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / 10b31fd

0.1.217: bound autobase's _appendBatch flush loop (postinstall patch)
autobase/index.js _appendBatch spins until its appended nodes flush: while (this._appended < target && !this._interrupting) { await this._bump() if (runs++ >= 16 && this.localWriter && this.localWriter.idle()) break } The author clearly intended `runs++ >= 16` as a hard safety valve. ANDing it with `localWriter.idle()` disables the valve in exactly the state it exists to escape: a writer that can never flush never goes idle. `await this._bump()` is a MICROtask chain into _advance, so the loop never hands the event loop back — no accept(), no timers, no SIGCHLD reaping. This patch makes the bound unconditional. A short or failed append is recoverable; a daemon that cannot be reached is not. Evidence: caller stack captured from inside _advance on stortron at call #200,000 AND #800,000, byte-identical both times: _doAnnounce -> registerMirrorWriters -> _appendBatch -> _bump -> _advance 800,000 _advance calls == 800,000 iterations of this one while loop. registerMirrorWriters is a single `await this._base.append(...)` — no loop of its own — so the spin is inside that one append. WHY ONLY STORTRON: the loop is only reachable via _doAnnounce, wired under `if (originCfg)`, i.e. only on a bundle-booted mirror. tabertron is the origin (`[release-pull] no origin configured`); ashatron has no origin-bundle.json. stortron is the only node in the fleet that calls registerMirrorWriters, which is why an identical build and repo count sat at ~15% CPU on tabertron while stortron pinned a core and never printed its banner. ⚠ THIS IS A SAFETY VALVE, NOT A ROOT-CAUSE FIX. Why the append never flushes is NOT established. The likely reason is that the local writer exists but has not been admitted to the system writer set — the guard at :1027 only rejects localWriter === null or isRemoved, so a mirror pending enrolment passes it and then blocks forever. The durable fix is to not append when the writer cannot flush (widen that check upstream, or gate _doAnnounce on real admission rather than base.writable). Also worth upstreaming to holepunch. Also fixes a latent bug in this script: patch 1 called process.exit(0) when already applied, which would have prevented patch 2 from ever running on an already-patched tree — i.e. the new bound would have silently never landed. Verified: node --check passes; script run applies cleanly and is idempotent; grep -c "GUARD (pgv3 patch)" = 1 and "BOUND (pgv3 patch)" = 1. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
10b31fd7cc432f717020109d2d25a3ebfc05d43e
by plankatron <fleeky@gmail.com> · 2026-08-09T04:01:58.000Z

Changed files (2) +85 -20

package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.216",
3 "version": "0.1.217",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
scripts/patch-autobase-warmup.cjs +84 -19
……@@ -12,29 +12,94 @@ const path = require('path')
1212
1313const file = path.join(__dirname, '..', 'node_modules', 'autobase', 'lib', 'apply-state.js')
1414
15// NOTE: patch 1 must NOT process.exit() when it is already applied — patch 2
16// below still has to run. (It did exit, which is why the appendBatch bound
17// silently never landed on an already-patched tree.)
18let patch1Done = false
19
1520try {
1621 let s = fs.readFileSync(file, 'utf8')
17 if (s.includes('GUARD (pgv3 patch)')) { process.exit(0) } // already patched
18 const before = ' const view = this.views[k]\n view.core.download({ blocks })'
19 const after = ' const view = this.views[k]\n' +
20 ' // GUARD (pgv3 patch): a user view may not be materialized yet during\n' +
21 ' // _drain warmup — its core can be null. This warmup pre-download is a\n' +
22 ' // perf optimization, NOT correctness, so a null view.core must be SKIPPED,\n' +
23 ' // never fatal. Before this guard the whole daemon SIGABRT-crash-looped on\n' +
24 ' // boot (pear-git #3). Log the autobase key so the offending base is named.\n' +
25 ' if (!view || !view.core) {\n' +
26 ' try { console.error("[autobase][warmup] null view.core at view index " + k + " on base " + (this.base && this.base.key ? this.base.key.toString("hex").slice(0,16) : "?") + " — skipping (was fatal)") } catch {}\n' +
27 ' continue\n' +
28 ' }\n' +
29 ' view.core.download({ blocks })'
30 if (!s.includes(before)) {
31 console.error('[patch-autobase-warmup] anchor not found (autobase version changed?) — SKIPPING; re-verify the warmup guard manually')
32 process.exit(0)
22 if (s.includes('GUARD (pgv3 patch)')) {
23 patch1Done = true // already patched — fall through to patch 2, do NOT exit
24 } else {
25 const before = ' const view = this.views[k]\n view.core.download({ blocks })'
26 const after = ' const view = this.views[k]\n' +
27 ' // GUARD (pgv3 patch): a user view may not be materialized yet during\n' +
28 ' // _drain warmup — its core can be null. This warmup pre-download is a\n' +
29 ' // perf optimization, NOT correctness, so a null view.core must be SKIPPED,\n' +
30 ' // never fatal. Before this guard the whole daemon SIGABRT-crash-looped on\n' +
31 ' // boot (pear-git #3). Log the autobase key so the offending base is named.\n' +
32 ' if (!view || !view.core) {\n' +
33 ' try { console.error("[autobase][warmup] null view.core at view index " + k + " on base " + (this.base && this.base.key ? this.base.key.toString("hex").slice(0,16) : "?") + " — skipping (was fatal)") } catch {}\n' +
34 ' continue\n' +
35 ' }\n' +
36 ' view.core.download({ blocks })'
37 if (!s.includes(before)) {
38 console.error('[patch-autobase-warmup] anchor not found (autobase version changed?) — SKIPPING; re-verify the warmup guard manually')
39 } else {
40 s = s.replace(before, after)
41 fs.writeFileSync(file, s)
42 patch1Done = true
43 console.error('[patch-autobase-warmup] applied null-view.core warmup guard to autobase')
44 }
3345 }
34 s = s.replace(before, after)
35 fs.writeFileSync(file, s)
36 console.error('[patch-autobase-warmup] applied null-view.core warmup guard to autobase')
3746} catch (err) {
3847 console.error('[patch-autobase-warmup] failed:', err && err.message)
3948 // Never fail the install over the patch — the guard is defensive.
4049}
50
51// ── Patch 2: bound the _appendBatch flush loop ──────────────────────────────
52//
53// autobase/index.js _appendBatch spins until the appended nodes flush:
54//
55// while (this._appended < target && !this._interrupting) {
56// await this._bump()
57// if (runs++ >= 16 && this.localWriter && this.localWriter.idle()) break
58// }
59//
60// The escape hatch requires BOTH runs >= 16 AND localWriter.idle(). A writer
61// that can never flush never goes idle, so the break never fires and this spins
62// forever — `await this._bump()` is a MICROtask chain into _advance, so the
63// event loop is never handed back: no accept(), no timers, no SIGCHLD.
64//
65// Reached on a MIRROR whose writer key the origin has not enrolled yet. The
66// 'Not writable' guard above only fires when localWriter is null or removed;
67// stortron's writer EXISTS but cannot linearize, so it passes that check and
68// then hangs here.
69//
70// Measured on stortron (mirror, 145 repos), caller stack captured from inside
71// _advance at call #200,000 AND #800,000, identical both times:
72// _doAnnounce -> registerMirrorWriters -> _appendBatch -> _bump -> _advance
73// 800,000 _advance calls == 800,000 iterations of this one while loop. The
74// daemon never printed its banner and never served a request.
75//
76// The fix makes the bound unconditional: 16 bumps, then stop. A short/failed
77// append is recoverable; an unkillable daemon is not.
78const idx = path.join(__dirname, '..', 'node_modules', 'autobase', 'index.js')
79
80try {
81 let s2 = fs.readFileSync(idx, 'utf8')
82 if (s2.includes('BOUND (pgv3 patch)')) {
83 console.error('[patch-autobase-warmup] _appendBatch flush bound already present')
84 return
85 }
86 const b2 = ' // safety\n' +
87 ' if (runs++ >= 16 && this.localWriter && this.localWriter.idle()) break'
88 const a2 = ' // BOUND (pgv3 patch): the original also required\n' +
89 ' // `this.localWriter.idle()`, so a writer that can never flush (a mirror\n' +
90 ' // whose key the origin has not enrolled) never satisfied the break and\n' +
91 ' // this loop spun forever. `await this._bump()` is a microtask chain into\n' +
92 ' // _advance, so the event loop was never handed back — no accept(), no\n' +
93 ' // timers, no SIGCHLD reaping. Bound it unconditionally: a short append is\n' +
94 ' // recoverable, a wedged daemon is not.\n' +
95 ' if (runs++ >= 16) break'
96 if (!s2.includes(b2)) {
97 console.error('[patch-autobase-warmup] appendBatch anchor not found (autobase version changed?) — SKIPPING; re-verify the flush bound manually')
98 process.exit(0)
99 }
100 s2 = s2.replace(b2, a2)
101 fs.writeFileSync(idx, s2)
102 console.error('[patch-autobase-warmup] applied _appendBatch flush bound to autobase')
103} catch (err) {
104 console.error('[patch-autobase-warmup] appendBatch patch failed:', err && err.message)
105}