| … | … | @@ -78,6 +78,52 @@ const MAX_RETRY_MS = 5 * 60 * 1000 |
| 78 | 78 | * @param {object} remoteIndex the publisher's parsed _index.json |
| 79 | 79 | * @param {(relPath: string) => ({size:number, hash:string}|null)} localStat |
| 80 | 80 | */ |
| 81 | /** | |
| 82 | * Turn a failed fetchFile() into a message that says what actually happened. | |
| 83 | * | |
| 84 | * fetchFile returns null for three unrelated reasons and the caller used to | |
| 85 | * report all of them as "no holder", which asserted a cause nobody measured. | |
| 86 | * The scheduler already computes the distinction and hands it to onEnd; this | |
| 87 | * just refuses to throw it away. | |
| 88 | * | |
| 89 | * end === null -> no scheduler ever ran. fetchFile gave up during its 3s | |
| 90 | * peer wait, so the holder question was never asked. | |
| 91 | * receivedBytes 0 -> peers were connected and none served a chunk. This is | |
| 92 | * the only case the words "no holder" describe. | |
| 93 | * receivedBytes>0 -> a holder existed and sent bytes, then the transfer | |
| 94 | * stalled. A partial, not an absent holder. | |
| 95 | * | |
| 96 | * @param {object} f the index entry being fetched ({ path, hash }) | |
| 97 | * @param {object|null} end the scheduler's terminal diagnostic, if one ran | |
| 98 | */ | |
| 99 | export function describeFetchFailure (f, end) { | |
| 100 | const where = (f && f.path) || '(unknown path)' | |
| 101 | const hash = f && f.hash ? ' (' + String(f.hash).slice(0, 12) + ')' : '' | |
| 102 | ||
| 103 | if (!end || typeof end !== 'object') { | |
| 104 | return 'no peer connected for ' + where + hash + | |
| 105 | ' - gave up during the peer wait; the holder lookup never ran' | |
| 106 | } | |
| 107 | ||
| 108 | const peers = Number.isFinite(end.peers) ? end.peers : 0 | |
| 109 | const got = Number.isFinite(end.receivedBytes) ? end.receivedBytes : 0 | |
| 110 | const total = Number.isFinite(end.totalBytes) ? end.totalBytes : 0 | |
| 111 | const totalChunks = Number.isFinite(end.totalChunks) ? end.totalChunks : 0 | |
| 112 | const remaining = Number.isFinite(end.chunksRemaining) ? end.chunksRemaining : totalChunks | |
| 113 | const done = Math.max(0, totalChunks - remaining) | |
| 114 | const ms = Number.isFinite(end.elapsedMs) ? end.elapsedMs : 0 | |
| 115 | const why = end.reason ? ' - ' + end.reason : '' | |
| 116 | ||
| 117 | if (got > 0) { | |
| 118 | return 'stalled fetching ' + where + hash + ' - received ' + got + ' of ' + total + | |
| 119 | ' bytes, ' + done + ' of ' + totalChunks + ' chunks, from ' + peers + | |
| 120 | ' peer(s) in ' + ms + 'ms' + why | |
| 121 | } | |
| 122 | return 'no holder for ' + where + hash + ' - ' + peers + | |
| 123 | ' peer(s) connected, none served any chunk (' + done + ' of ' + totalChunks + | |
| 124 | ' chunks) after ' + ms + 'ms' + why | |
| 125 | } | |
| 126 | ||
| 81 | 127 | export function planPull (remoteIndex, localStat, isDeleted) { |
| 82 | 128 | const plan = { fetch: [], skip: [], replace: [], suspect: [], tombstoned: [] } |
| 83 | 129 | for (const f of (remoteIndex && remoteIndex.files) || []) { |
| … | … | @@ -279,9 +325,15 @@ export class ReleasePuller { |
| 279 | 325 | // Land via a temp path then rename: a crash mid-transfer must not leave |
| 280 | 326 | // a truncated artifact that the next diff would read as complete. |
| 281 | 327 | const tmp = abs + '.pull-tmp' |
| 282 | const got = await this._overlay.fetchFile(f.hash, { destPath: tmp }) | |
| 328 | // Capture the scheduler's terminal diagnostic so a failure can say which | |
| 329 | // of the three fetchFile null-paths it actually was. | |
| 330 | let endInfo = null | |
| 331 | const got = await this._overlay.fetchFile(f.hash, { | |
| 332 | destPath: tmp, | |
| 333 | onEnd: (info) => { endInfo = info } | |
| 334 | }) | |
| 283 | 335 | if (!got) { |
| 284 | this.stats.errors.push('no holder for ' + f.path + ' (' + String(f.hash).slice(0, 12) + ')') | |
| 336 | this.stats.errors.push(describeFetchFailure(f, endInfo)) | |
| 285 | 337 | try { fs.unlinkSync(tmp) } catch {} |
| 286 | 338 | try { fs.unlinkSync(tmp + '.overlay-partial') } catch {} |
| 287 | 339 | continue |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pear-git-v3", |
| 3 | "version": "0.1.243", | |
| 3 | "version": "0.1.244", | |
| 4 | 4 | "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,99 @@ |
| 1 | // "no holder" is three different failures wearing one label. | |
| 2 | // | |
| 3 | // ReleasePuller reports every falsy fetchFile() as: | |
| 4 | // | |
| 5 | // no holder for <path> (<hash12>) | |
| 6 | // | |
| 7 | // but hyper-overlay's fetchFile returns null for at least three unrelated | |
| 8 | // reasons, and throws away a diagnostic it has already computed: | |
| 9 | // | |
| 10 | // 1. NO PEERS CONNECTED. fetchFile waits opts.peerWaitMs (3s) for a peer and | |
| 11 | // returns null before ever creating a scheduler. Nothing about holders was | |
| 12 | // established -- the question was never asked. | |
| 13 | // 2. PEERS CONNECTED, NONE SERVED. A scheduler ran, no chunk ever arrived. | |
| 14 | // This is the only case the words "no holder" actually describe. | |
| 15 | // 3. TRANSFER STARTED THEN DIED. Bytes arrived, then the idle timer fired | |
| 16 | // (DEFAULT_IDLE_TIMEOUT 30s of no progress). A partial transfer, not an | |
| 17 | // absent holder. | |
| 18 | // | |
| 19 | // The scheduler already reports all of this through onEnd: | |
| 20 | // | |
| 21 | // { ok, reason, receivedBytes, totalBytes, chunksRemaining, totalChunks, | |
| 22 | // peers, elapsedMs } | |
| 23 | // | |
| 24 | // and the puller passes no onEnd, so it is discarded. That is why 2026-08-08, | |
| 25 | // 2026-08-25 and 2026-08-29 all read identically in the log while being, for | |
| 26 | // all anyone could tell, different bugs -- and why two of my own diagnoses | |
| 27 | // (null contentHash, then size-dependence) were wrong: the message asserted a | |
| 28 | // cause nobody had measured. | |
| 29 | // | |
| 30 | // This does not fix the fetch. It makes the log state what actually happened. | |
| 31 | ||
| 32 | import test from 'brittle' | |
| 33 | ||
| 34 | import { describeFetchFailure } from '../lib/services/release-puller.js' | |
| 35 | ||
| 36 | const f = { path: 'app/versions/v1/big.apk', hash: 'ab'.repeat(32) } | |
| 37 | ||
| 38 | test('no scheduler ever ran: says the peer wait expired, NOT that a holder is missing', (t) => { | |
| 39 | const msg = describeFetchFailure(f, null) | |
| 40 | t.ok(msg.includes('app/versions/v1/big.apk'), 'names the artifact') | |
| 41 | t.ok(msg.includes('abababab'), 'and its hash') | |
| 42 | t.ok(/no peer/i.test(msg), 'attributes it to peers, not holders') | |
| 43 | t.absent(/no holder/i.test(msg), 'must not claim a holder question that was never asked') | |
| 44 | }) | |
| 45 | ||
| 46 | test('peers connected but nothing served: this IS a genuine no-holder', (t) => { | |
| 47 | const msg = describeFetchFailure(f, { | |
| 48 | ok: false, | |
| 49 | reason: 'multi-source fetch stalled (no progress for 30000ms)', | |
| 50 | receivedBytes: 0, | |
| 51 | totalBytes: 168326045, | |
| 52 | chunksRemaining: 4100, | |
| 53 | totalChunks: 4100, | |
| 54 | peers: 2, | |
| 55 | elapsedMs: 30001 | |
| 56 | }) | |
| 57 | t.ok(/no holder/i.test(msg), 'the one case the phrase fits') | |
| 58 | t.ok(msg.includes('2 peer'), 'reports how many peers were connected') | |
| 59 | t.ok(msg.includes('0 of 4100'), 'and that no chunk arrived') | |
| 60 | }) | |
| 61 | ||
| 62 | test('partial transfer that stalled: reported as a stall with progress, not no-holder', (t) => { | |
| 63 | const msg = describeFetchFailure(f, { | |
| 64 | ok: false, | |
| 65 | reason: 'multi-source fetch stalled (no progress for 30000ms)', | |
| 66 | receivedBytes: 50000000, | |
| 67 | totalBytes: 168326045, | |
| 68 | chunksRemaining: 2900, | |
| 69 | totalChunks: 4100, | |
| 70 | peers: 1, | |
| 71 | elapsedMs: 92000 | |
| 72 | }) | |
| 73 | t.absent(/no holder/i.test(msg), 'a holder plainly existed -- it sent 50 MB') | |
| 74 | t.ok(/stall/i.test(msg), 'named as a stall') | |
| 75 | t.ok(msg.includes('50000000') || msg.includes('50.0 MB') || msg.includes('47.7 MiB'), | |
| 76 | 'reports bytes received so the partial is visible') | |
| 77 | t.ok(msg.includes('1200 of 4100') || msg.includes('2900'), 'and chunk progress') | |
| 78 | }) | |
| 79 | ||
| 80 | test('the underlying reason string is carried through verbatim', (t) => { | |
| 81 | const msg = describeFetchFailure(f, { | |
| 82 | ok: false, | |
| 83 | reason: 'EHOSTUNREACH something specific', | |
| 84 | receivedBytes: 0, | |
| 85 | totalBytes: 10, | |
| 86 | chunksRemaining: 1, | |
| 87 | totalChunks: 1, | |
| 88 | peers: 3, | |
| 89 | elapsedMs: 12 | |
| 90 | }) | |
| 91 | t.ok(msg.includes('EHOSTUNREACH something specific'), | |
| 92 | 'never swallow the overlay reason -- it is the only first-hand account') | |
| 93 | }) | |
| 94 | ||
| 95 | test('a malformed diagnostic degrades instead of throwing', (t) => { | |
| 96 | t.ok(typeof describeFetchFailure(f, {}) === 'string', 'empty object') | |
| 97 | t.ok(typeof describeFetchFailure(f, { peers: 'x' }) === 'string', 'nonsense field') | |
| 98 | t.ok(describeFetchFailure({}, null).length > 0, 'missing artifact fields') | |
| 99 | }) |
| … | … | @@ -265,7 +265,10 @@ test('a new error is logged with its text, not just counted', async (t) => { |
| 265 | 265 | |
| 266 | 266 | const puller = new ReleasePuller({ |
| 267 | 267 | releaseDir: dir, |
| 268 | overlay: { fetchFile: async () => null }, // null = "no holder" | |
| 268 | // null with no onEnd call = fetchFile gave up during its peer wait, before | |
| 269 | // any scheduler ran. That is NOT a no-holder: nothing about holders was | |
| 270 | // established. See describeFetchFailure in services/release-puller.js. | |
| 271 | overlay: { fetchFile: async () => null }, | |
| 269 | 272 | log: (l) => lines.push(l) |
| 270 | 273 | }) |
| 271 | 274 | |
| … | … | @@ -276,7 +279,14 @@ test('a new error is logged with its text, not just counted', async (t) => { |
| 276 | 279 | }) |
| 277 | 280 | |
| 278 | 281 | t.is(puller.stats.errors.length, 1, 'the failure was recorded') |
| 279 | const logged = lines.filter((l) => l.includes('no holder')) | |
| 282 | // Assert the INTENT -- the error text reaches the log naming the artifact -- | |
| 283 | // rather than a literal phrase. This previously grepped for "no holder", | |
| 284 | // which pinned an imprecise message as the contract: the puller called every | |
| 285 | // falsy fetchFile a missing holder, including this case where it never got | |
| 286 | // far enough to ask. | |
| 287 | const logged = lines.filter((l) => l.includes('app.bin')) | |
| 280 | 288 | t.is(logged.length, 1, 'and its TEXT reached the log, not only the count') |
| 289 | t.absent(/no holder/i.test(logged[0]), | |
| 290 | 'and does not claim a holder was missing when the peer wait expired first') | |
| 281 | 291 | t.ok(logged[0].includes('app.bin'), 'naming the artifact that failed') |
| 282 | 292 | }) |