OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-proto-peer / scripts / hypercore-frozen-repro.mjs

117 lines · javascript ref:
// hypercore: a conflicted core's `frozen` flag is never persisted.
//
// checkConflict (lib/core.js:663-681) takes the state mutex specifically to persist the freeze:
//
//     this.header.frozen = true
//     tx.setAuth({ key, discoveryKey, manifest, keyPair, frozen: true })
//     await this.state.flush()
//
// but the @core/auth hyperschema encoding
// (hypercore-storage/encoding/spec/hyperschema/index.js:440-475) encodes exactly five fields —
// key, discoveryKey, manifest, keyPair, encryptionKey. There is no `frozen`. It is dropped on
// encode, and decode never returns it, so getCoreInfo() spreads an authInfo that has no such key
// and header.frozen comes back undefined on the next open.
//
// Consequence: `frozen` gates Peer.isActive() (lib/replicator.js:1901). While the process lives,
// a conflicted core stops replicating. After a restart the flag is gone, the core replicates
// again, meets the same divergent peer, and the conflict is re-detected — forever. Downstream this
// reads as "conflicts fire at every re-join", which is what sent us looking for a re-join trigger
// that does not exist.
//
// VERIFIED ON LATEST, not just what we vendor. Reproduced identically on hypercore 11.33.5 /
// hypercore-storage 3.1.2 (vendored, under bare) AND on hypercore 11.35.1 / hypercore-storage 3.2.0
// (current npm latest, under node). `frozen` appears nowhere in 3.2.0's auth encoding — the only hits
// are view.js `_frozen()`, an unrelated snapshot concept — and 11.35.1 core.js:669/676 still performs
// the write. So this is neither vendor-pinned, runtime-specific, nor already fixed upstream.
//
// Run: bare scripts/hypercore-frozen-repro.mjs   (or `node` after swapping bare-fs for node:fs)
import Hypercore from 'hypercore'
import crypto from 'hypercore-crypto'
import b4a from 'b4a'
import fs from 'bare-fs'

const root = '/tmp/frozen-repro-' + Math.floor(Math.random() * 1e9)
const A = root + '/a'
const B = root + '/b'
fs.mkdirSync(A, { recursive: true })
fs.mkdirSync(B, { recursive: true })

// One keypair, two divergent histories — the only way to make two valid signatures exist for one
// length. B is longer so A has a reason to request an upgrade and thus discover the divergence.
const keyPair = crypto.keyPair()

const a = new Hypercore(A, { keyPair })
const b = new Hypercore(B, { keyPair })
await a.ready()
await b.ready()

for (const blk of ['shared', 'MINE']) await a.append(b4a.from(blk))
for (const blk of ['shared', 'THEIRS', 'extra']) await b.append(b4a.from(blk))

const conflicted = new Promise((resolve) => a.once('conflict', (length) => resolve(length)))

const s1 = a.replicate(true)
const s2 = b.replicate(false)
s1.on('error', () => {})
s2.on('error', () => {})
s1.pipe(s2).pipe(s1)

const deadline = setTimeout(() => {}, 20000)
a.update().catch(() => {})
a.get(2, { timeout: 5000 }).catch(() => {})

const at = await Promise.race([
  conflicted,
  new Promise((resolve) => setTimeout(() => resolve(null), 20000))
])
clearTimeout(deadline)

console.log('conflict emitted at length:', at)
console.log('BEFORE close  header.frozen =', a.core.header.frozen)

try { s1.destroy() } catch {}
try { s2.destroy() } catch {}
try { await a.close() } catch {}
try { await b.close() } catch {}

// Reopen the SAME storage. The freeze should survive — a core known to have two signed histories
// must not silently resume replicating just because the process restarted.
const a2 = new Hypercore(A, { keyPair })
await a2.ready()
console.log('AFTER reopen  header.frozen =', a2.core.header.frozen)

const persisted = a2.core.header.frozen === true
console.log('')
console.log(persisted ? 'PASS — freeze persisted' : 'BUG  — freeze LOST across reopen')

// Phase 3: the LOOP. `frozen` gates Peer.isActive() (replicator.js:1901), so with the flag gone the
// reopened core is willing to replicate again. Reconnect it to the same divergent peer and see whether
// the identical conflict is re-detected. If it is, this is not a cosmetic persistence gap: every
// restart re-arms the same detection, forever, on a fork that was already known and already handled.
const b2 = new Hypercore(B, { keyPair })
await b2.ready()

const reconflicted = new Promise((resolve) => a2.once('conflict', (length) => resolve(length)))
const t1 = a2.replicate(true)
const t2 = b2.replicate(false)
t1.on('error', () => {})
t2.on('error', () => {})
t1.pipe(t2).pipe(t1)
a2.update().catch(() => {})
a2.get(2, { timeout: 5000 }).catch(() => {})

const again = await Promise.race([
  reconflicted,
  new Promise((resolve) => setTimeout(() => resolve(null), 20000))
])
console.log('')
console.log('after reopen, re-detected conflict at length:', again)
console.log(again !== null
  ? 'LOOP CONFIRMED — same fork, same conflict, re-detected after restart'
  : 'no re-detection observed within 20s')

try { t1.destroy() } catch {}
try { t2.destroy() } catch {}
await a2.close()
await b2.close()