// 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()