// The index must not advertise bytes this daemon cannot yet serve.
//
// `_writeIndex` wrote `_index.json` FIRST and registered the artifacts with the
// overlay afterwards, sequentially and awaited. `_rescan` then appends the
// release-event that wakes every mirror. So the publish order was:
//
// write _index.json → register file 1 → register file 2 → … → event
// ^ ^
// the mirror is told about all of these ------------- here
//
// but a mirror reacts within milliseconds, and registering a large artifact
// means chunking and hashing it. Any file not yet registered when the mirror
// asks is answered with "no holder" — the index names bytes nobody can fetch.
//
// Measured on stortron 2026-08-17 for ifsrenderer-linux linux-port-14855c7,
// published 21:53:32, pulled 21:54:
//
// fetched dist/ifsrenderer-linux-x86_64.tar.gz (49170279 B) <- first in the list
// error: no holder for dist/IFSRenderer-x86_64.AppImage <- 49 MB, still chunking
// error: no holder for build.log <- 8.5 KB
// error: no holder for manifest.json <- 508 B
// channel ifsrenderer-linux/experimental NOT pinned to linux-port-14855c7
//
// The file ORDER is the tell: the artifact registered first arrived, everything
// after it did not. Size was irrelevant — a 508-byte manifest failed while a
// 49 MB tarball succeeded. Verified afterwards with hyper-overlay's own
// hashChunk that all four hashes on the origin matched the index exactly, so the
// bytes and the advertised hashes were correct all along; only the timing was
// wrong.
//
// This is a DIFFERENT window from the one `seedOverlayServeMap` addresses. That
// one is boot-time: the overlay's hash→path maps are in-memory, so a restart
// loses them. This one happens at publish time on a daemon that never restarted.
//
// The fix is an ordering constraint, so that is what is asserted: every artifact
// is registered with the overlay BEFORE the index that names it is published.
// The existing "the event is appended AFTER the index is written" test in
// release-event-log.test.js pins the other half, giving the full order:
//
// register overlay → write _index.json → append event
import test from 'brittle'
import { mkdtempSync, readFileSync, existsSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import b4a from 'b4a'
import { ReleaseManager, RELEASE_OVERLAY_PREFIX } from '../lib/release-manager.js'
const INDEX_FILE = '_index.json'
function tmpStorage (p = 'pgv3-rel-serve-order-') {
return mkdtempSync(join(tmpdir(), p))
}
/**
* An overlay that records, for each registerFile call, whether the on-disk
* index ALREADY advertises the path being registered.
*
* Reading the index from disk at that instant is what makes this a real
* ordering assertion rather than a restatement of the implementation: if the
* index already names the file, a mirror woken now would ask for bytes this
* overlay has not been told about yet.
*/
function recordingOverlay (dir) {
const calls = []
return {
calls,
async registerFile (overlayPath, absPath, opts) {
let advertisedAlready = false
try {
const idx = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'))
const rel = overlayPath.slice(RELEASE_OVERLAY_PREFIX.length)
advertisedAlready = (idx.files || []).some((f) => f.path === rel)
} catch {
advertisedAlready = false // no index on disk yet — nothing is advertised
}
calls.push({ overlayPath, advertisedAlready, contentHash: opts && opts.contentHash })
}
}
}
test('artifacts are registered with the overlay BEFORE the index names them', async (t) => {
const dir = tmpStorage()
const overlay = recordingOverlay(dir)
const rm = new ReleaseManager({ releaseDir: dir, overlay })
await rm.publish({
repo: 'demo',
version: 'v1',
artifacts: [
{ path: 'dist/big.bin', data: b4a.alloc(4096, 1) },
{ path: 'dist/small.bin', data: b4a.from('small') },
{ path: 'build.log', data: b4a.from('log output') }
]
})
t.ok(overlay.calls.length >= 4,
'every artifact plus manifest.json was registered (' + overlay.calls.length + ')')
const premature = overlay.calls.filter((c) => c.advertisedAlready)
t.is(premature.length, 0,
'no file was still being registered while the index already advertised it' +
(premature.length ? ' — ' + premature.map((c) => c.overlayPath).join(', ') : ''))
})
test('the index is complete once written, not filled in behind the mirror', async (t) => {
const dir = tmpStorage()
const registered = new Set()
const overlay = {
async registerFile (overlayPath) {
registered.add(overlayPath.slice(RELEASE_OVERLAY_PREFIX.length))
}
}
const rm = new ReleaseManager({ releaseDir: dir, overlay })
await rm.publish({
repo: 'demo',
version: 'v1',
artifacts: [{ path: 'dist/a.bin', data: b4a.from('A') }, { path: 'dist/b.bin', data: b4a.from('B') }]
})
t.ok(existsSync(join(dir, INDEX_FILE)), 'an index was written')
const idx = JSON.parse(readFileSync(join(dir, INDEX_FILE), 'utf8'))
const missing = (idx.files || []).filter((f) => !registered.has(f.path)).map((f) => f.path)
t.alike(missing, [],
'every path the index advertises is one the overlay was already told to serve')
})
test('a second publish still registers the NEW version before advertising it', async (t) => {
// The first publish is the easy case: there is no index on disk at all, so any
// order passes a naive check. The regression this guards is the second one,
// where a stale index exists and the new entries are appended to it.
const dir = tmpStorage()
const overlay = recordingOverlay(dir)
const rm = new ReleaseManager({ releaseDir: dir, overlay })
await rm.publish({ repo: 'demo', version: 'v1', artifacts: [{ path: 'a.bin', data: b4a.from('A') }] })
const afterFirst = overlay.calls.length
await rm.publish({ repo: 'demo', version: 'v2', artifacts: [{ path: 'a.bin', data: b4a.from('B') }] })
const second = overlay.calls.slice(afterFirst)
t.ok(second.length >= 2, 'the second publish registered its own files (' + second.length + ')')
t.is(second.filter((c) => c.advertisedAlready).length, 0,
'and none of them were advertised before being registered')
})