OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / scripts / patch-autobase-warmup.cjs

106 lines · javascript ref:
// Durable patch (runs as `postinstall`): guard autobase's _drain warmup
// pre-download against a null user-view core. A view may not be materialized
// yet during warmup, so `this.views[k].core` can be null; the warmup download
// is a PERF optimization, not correctness, so a null must be SKIPPED, never
// fatal. Without this, pgv3 SIGABRT-crash-loops on boot when an org/tracker
// autobase drains a not-yet-materialized view (pear-git #3, ashatron 2026-07-18).
//
// Idempotent: a no-op if already patched or the anchor is gone (autobase bump).
// Vendored here rather than patch-package to avoid a network/registry dependency.
const fs = require('fs')
const path = require('path')

const file = path.join(__dirname, '..', 'node_modules', 'autobase', 'lib', 'apply-state.js')

// NOTE: patch 1 must NOT process.exit() when it is already applied — patch 2
// below still has to run. (It did exit, which is why the appendBatch bound
// silently never landed on an already-patched tree.)
let patch1Done = false

try {
  let s = fs.readFileSync(file, 'utf8')
  if (s.includes('GUARD (pgv3 patch)')) {
    patch1Done = true // already patched — fall through to patch 2, do NOT exit
  } else {
    const before = '      const view = this.views[k]\n      view.core.download({ blocks })'
    const after = '      const view = this.views[k]\n' +
      '      // GUARD (pgv3 patch): a user view may not be materialized yet during\n' +
      '      // _drain warmup — its core can be null. This warmup pre-download is a\n' +
      '      // perf optimization, NOT correctness, so a null view.core must be SKIPPED,\n' +
      '      // never fatal. Before this guard the whole daemon SIGABRT-crash-looped on\n' +
      '      // boot (pear-git #3). Log the autobase key so the offending base is named.\n' +
      '      if (!view || !view.core) {\n' +
      '        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' +
      '        continue\n' +
      '      }\n' +
      '      view.core.download({ blocks })'
    if (!s.includes(before)) {
      console.error('[patch-autobase-warmup] anchor not found (autobase version changed?) — SKIPPING; re-verify the warmup guard manually')
    } else {
      s = s.replace(before, after)
      fs.writeFileSync(file, s)
      patch1Done = true
      console.error('[patch-autobase-warmup] applied null-view.core warmup guard to autobase')
    }
  }
} catch (err) {
  console.error('[patch-autobase-warmup] failed:', err && err.message)
  // Never fail the install over the patch — the guard is defensive.
}

// ── Patch 2: bound the _appendBatch flush loop ──────────────────────────────
//
// autobase/index.js _appendBatch spins until the appended nodes flush:
//
//   while (this._appended < target && !this._interrupting) {
//     await this._bump()
//     if (runs++ >= 16 && this.localWriter && this.localWriter.idle()) break
//   }
//
// The escape hatch requires BOTH runs >= 16 AND localWriter.idle(). A writer
// that can never flush never goes idle, so the break never fires and this spins
// forever — `await this._bump()` is a MICROtask chain into _advance, so the
// event loop is never handed back: no accept(), no timers, no SIGCHLD.
//
// Reached on a MIRROR whose writer key the origin has not enrolled yet. The
// 'Not writable' guard above only fires when localWriter is null or removed;
// stortron's writer EXISTS but cannot linearize, so it passes that check and
// then hangs here.
//
// Measured on stortron (mirror, 145 repos), caller stack captured from inside
// _advance at call #200,000 AND #800,000, identical both times:
//   _doAnnounce -> registerMirrorWriters -> _appendBatch -> _bump -> _advance
// 800,000 _advance calls == 800,000 iterations of this one while loop. The
// daemon never printed its banner and never served a request.
//
// The fix makes the bound unconditional: 16 bumps, then stop. A short/failed
// append is recoverable; an unkillable daemon is not.
const idx = path.join(__dirname, '..', 'node_modules', 'autobase', 'index.js')

try {
  let s2 = fs.readFileSync(idx, 'utf8')
  if (s2.includes('BOUND (pgv3 patch)')) {
    console.error('[patch-autobase-warmup] _appendBatch flush bound already present')
    return
  }
  const b2 = '      // safety\n' +
    '      if (runs++ >= 16 && this.localWriter && this.localWriter.idle()) break'
  const a2 = '      // BOUND (pgv3 patch): the original also required\n' +
    '      // `this.localWriter.idle()`, so a writer that can never flush (a mirror\n' +
    '      // whose key the origin has not enrolled) never satisfied the break and\n' +
    '      // this loop spun forever. `await this._bump()` is a microtask chain into\n' +
    '      // _advance, so the event loop was never handed back — no accept(), no\n' +
    '      // timers, no SIGCHLD reaping. Bound it unconditionally: a short append is\n' +
    '      // recoverable, a wedged daemon is not.\n' +
    '      if (runs++ >= 16) break'
  if (!s2.includes(b2)) {
    console.error('[patch-autobase-warmup] appendBatch anchor not found (autobase version changed?) — SKIPPING; re-verify the flush bound manually')
    process.exit(0)
  }
  s2 = s2.replace(b2, a2)
  fs.writeFileSync(idx, s2)
  console.error('[patch-autobase-warmup] applied _appendBatch flush bound to autobase')
} catch (err) {
  console.error('[patch-autobase-warmup] appendBatch patch failed:', err && err.message)
}