// The index and the overlay can disagree, and nothing used to reconcile them.
//
// There are THREE ways an artifact ends up advertised-but-unfetchable, and they
// are distinct. The first two already have fixes:
//
// 1. ORDER — _index.json written before registration, so a mirror woken by
// the release-event asks before the bytes are registered.
// Fixed by registering first. See release-index-serve-order.
// 2. RESTART — the overlay's hash→path maps are in-memory, so a reboot forgets
// how to serve files it still holds. Fixed by seedOverlayServeMap.
// 3. FAILURE — registerFile THREW, and the call sat in a bare `catch {}` while
// _index.json was written unconditionally afterwards. This one.
//
// (3) is permanent in a way the others are not. _writeIndex only registers
// NEW-or-CHANGED entries, and a published artifact's hash never changes, so the
// failed registration is never retried. seedOverlayServeMap cannot help either:
// it seeds from the OVERLAY's index, which is precisely where the entry is
// missing. The mirror asks forever and is told "no holder"; the origin logs
// nothing, because the failure was swallowed.
//
// Measured 2026-08-25, hyper-tps v0.919.0: tabertron held a 471 MB
// hypertps-win64.zip on disk and advertised its exact hash in _index.json, while
// stortron retried
// no holder for hyper-tps/versions/v0.919.0/dist/hypertps-win64.zip (07af6b4a31a0)
// indefinitely — and build.log and manifest.json from the SAME publish
// replicated fine, same peer, same pass. Per-content, not per-peer.
//
// Run: bare test/release-overlay-repair.test.js
import test from 'brittle'
import { mkdtemp, mkdir, writeFile } from '#node:fs/promises'
import { tmpdir } from '#node:os'
import { join } from '#node:path'
import { ReleaseManager, RELEASE_OVERLAY_PREFIX as PREFIX } from '../lib/release-manager.js'
// An overlay that knows about SOME paths and records what it is asked to add.
function stubOverlay (knownPaths = []) {
const known = new Set(knownPaths)
const registered = []
return {
known,
registered,
async seedFilePathsFromIndex (resolve) {
let n = 0
for (const p of known) { if (resolve(p)) n++ }
return n
},
async registerFile (overlayPath, diskPath, meta = {}) {
registered.push({ overlayPath, diskPath, prepare: meta.prepare })
known.add(overlayPath)
return { contentHash: meta.contentHash, size: meta.size }
}
}
}
async function fixture (files) {
const dir = await mkdtemp(join(tmpdir(), 'rel-repair-'))
for (const f of files) {
await mkdir(join(dir, f.path, '..'), { recursive: true }).catch(() => {})
await writeFile(join(dir, f.path), f.body || 'x')
}
await writeFile(join(dir, '_index.json'),
JSON.stringify({ schema: 1, files: files.map(f => ({ path: f.path, hash: f.hash, size: 1 })), channels: {} }))
return dir
}
test('an artifact the overlay never registered is repaired at seed time', async (t) => {
const files = [
{ path: 'app/versions/v1/manifest.json', hash: 'aaa1' },
{ path: 'app/versions/v1/build.log', hash: 'bbb2' },
{ path: 'app/versions/v1/dist/big.zip', hash: 'ccc3' } // the one that "failed"
]
const dir = await fixture(files)
// The overlay knows the two small files and NOT the zip — exactly the live
// shape: same publish, same pass, one hash missing.
const overlay = stubOverlay([PREFIX + files[0].path, PREFIX + files[1].path])
const rm = new ReleaseManager({ releaseDir: dir, overlay })
await rm.seedOverlayServeMap()
const repaired = overlay.registered.map(r => r.overlayPath)
t.is(repaired.length, 1, 'only the missing one is registered')
t.is(repaired[0], PREFIX + 'app/versions/v1/dist/big.zip')
t.is(overlay.registered[0].prepare, false,
'repaired WITHOUT an eager chunk-map build — no file read, no re-hash')
})
test('a fully-registered index repairs nothing', async (t) => {
const files = [
{ path: 'app/versions/v1/manifest.json', hash: 'aaa1' },
{ path: 'app/versions/v1/build.log', hash: 'bbb2' }
]
const dir = await fixture(files)
const overlay = stubOverlay(files.map(f => PREFIX + f.path))
const rm = new ReleaseManager({ releaseDir: dir, overlay })
await rm.seedOverlayServeMap()
t.is(overlay.registered.length, 0, 'the common case costs nothing')
})
test('an index entry whose file is gone is skipped, not an error', async (t) => {
// gc collects a version; its files vanish while _index.json still names them
// until the next write. registerFile returns null rather than throwing.
const dir = await fixture([{ path: 'app/versions/v1/build.log', hash: 'bbb2' }])
await writeFile(join(dir, '_index.json'), JSON.stringify({
schema: 1,
files: [
{ path: 'app/versions/v1/build.log', hash: 'bbb2', size: 1 },
{ path: 'app/versions/collected/build.log', hash: 'ddd4', size: 1 }
],
channels: {}
}))
const overlay = {
...stubOverlay([]),
async registerFile (overlayPath, diskPath, meta) {
// Mirror the real behaviour: a missing source is null, not a throw.
const { existsSync } = await import('#node:fs')
if (!existsSync(diskPath)) return null
return { contentHash: meta.contentHash }
}
}
overlay.seedFilePathsFromIndex = async () => 0
const rm = new ReleaseManager({ releaseDir: dir, overlay })
await t.execution(rm.seedOverlayServeMap(), 'a collected version does not break the repair')
})
test('no overlay at all is a no-op', async (t) => {
const dir = await fixture([{ path: 'app/versions/v1/build.log', hash: 'bbb2' }])
const rm = new ReleaseManager({ releaseDir: dir })
t.is(await rm.seedOverlayServeMap(), 0)
})