// "no holder" is three different failures wearing one label.
//
// ReleasePuller reports every falsy fetchFile() as:
//
// no holder for <path> (<hash12>)
//
// but hyper-overlay's fetchFile returns null for at least three unrelated
// reasons, and throws away a diagnostic it has already computed:
//
// 1. NO PEERS CONNECTED. fetchFile waits opts.peerWaitMs (3s) for a peer and
// returns null before ever creating a scheduler. Nothing about holders was
// established -- the question was never asked.
// 2. PEERS CONNECTED, NONE SERVED. A scheduler ran, no chunk ever arrived.
// This is the only case the words "no holder" actually describe.
// 3. TRANSFER STARTED THEN DIED. Bytes arrived, then the idle timer fired
// (DEFAULT_IDLE_TIMEOUT 30s of no progress). A partial transfer, not an
// absent holder.
//
// The scheduler already reports all of this through onEnd:
//
// { ok, reason, receivedBytes, totalBytes, chunksRemaining, totalChunks,
// peers, elapsedMs }
//
// and the puller passes no onEnd, so it is discarded. That is why 2026-08-08,
// 2026-08-25 and 2026-08-29 all read identically in the log while being, for
// all anyone could tell, different bugs -- and why two of my own diagnoses
// (null contentHash, then size-dependence) were wrong: the message asserted a
// cause nobody had measured.
//
// This does not fix the fetch. It makes the log state what actually happened.
import test from 'brittle'
import { describeFetchFailure } from '../lib/services/release-puller.js'
const f = { path: 'app/versions/v1/big.apk', hash: 'ab'.repeat(32) }
test('no scheduler ever ran: says the peer wait expired, NOT that a holder is missing', (t) => {
const msg = describeFetchFailure(f, null)
t.ok(msg.includes('app/versions/v1/big.apk'), 'names the artifact')
t.ok(msg.includes('abababab'), 'and its hash')
t.ok(/no peer/i.test(msg), 'attributes it to peers, not holders')
t.absent(/no holder/i.test(msg), 'must not claim a holder question that was never asked')
})
test('peers connected but nothing served: this IS a genuine no-holder', (t) => {
const msg = describeFetchFailure(f, {
ok: false,
reason: 'multi-source fetch stalled (no progress for 30000ms)',
receivedBytes: 0,
totalBytes: 168326045,
chunksRemaining: 4100,
totalChunks: 4100,
peers: 2,
elapsedMs: 30001
})
t.ok(/no holder/i.test(msg), 'the one case the phrase fits')
t.ok(msg.includes('2 peer'), 'reports how many peers were connected')
t.ok(msg.includes('0 of 4100'), 'and that no chunk arrived')
})
test('partial transfer that stalled: reported as a stall with progress, not no-holder', (t) => {
const msg = describeFetchFailure(f, {
ok: false,
reason: 'multi-source fetch stalled (no progress for 30000ms)',
receivedBytes: 50000000,
totalBytes: 168326045,
chunksRemaining: 2900,
totalChunks: 4100,
peers: 1,
elapsedMs: 92000
})
t.absent(/no holder/i.test(msg), 'a holder plainly existed -- it sent 50 MB')
t.ok(/stall/i.test(msg), 'named as a stall')
t.ok(msg.includes('50000000') || msg.includes('50.0 MB') || msg.includes('47.7 MiB'),
'reports bytes received so the partial is visible')
t.ok(msg.includes('1200 of 4100') || msg.includes('2900'), 'and chunk progress')
})
test('the underlying reason string is carried through verbatim', (t) => {
const msg = describeFetchFailure(f, {
ok: false,
reason: 'EHOSTUNREACH something specific',
receivedBytes: 0,
totalBytes: 10,
chunksRemaining: 1,
totalChunks: 1,
peers: 3,
elapsedMs: 12
})
t.ok(msg.includes('EHOSTUNREACH something specific'),
'never swallow the overlay reason -- it is the only first-hand account')
})
test('a malformed diagnostic degrades instead of throwing', (t) => {
t.ok(typeof describeFetchFailure(f, {}) === 'string', 'empty object')
t.ok(typeof describeFetchFailure(f, { peers: 'x' }) === 'string', 'nonsense field')
t.ok(describeFetchFailure({}, null).length > 0, 'missing artifact fields')
})