OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 commits / ad27456

0.1.244: say WHICH failure it was instead of always "no holder"
fetchFile() returns null for three unrelated reasons; the puller called all of them "no holder", asserting a cause nobody had measured: 1. NO PEERS CONNECTED -- fetchFile waits 3s for a peer and returns before any scheduler runs. The holder question was never asked. 2. PEERS CONNECTED, NONE SERVED -- a scheduler ran and no chunk arrived. The only case the phrase actually describes. 3. TRANSFER STARTED THEN STALLED -- bytes arrived, then the 30s IDLE timer fired. A partial, not an absent holder. The scheduler already computes the distinction and hands it to onEnd ({ ok, reason, receivedBytes, totalBytes, chunksRemaining, totalChunks, peers, elapsedMs }). The puller passed no onEnd, so it was discarded. That is why the 2026-08-08, 08-25 and 08-29 sightings read identically in the log while possibly being different bugs -- and why two diagnoses of the current one (null contentHash, then size-dependence) were wrong. The message named a cause that had never been established, and everyone reasoned from it, including me. Note the existing release-index-serve-order.test.js header already records that in the 08-17 sighting size was irrelevant: a 508-byte manifest failed while a 49 MB tarball succeeded. This fixes no fetch. It makes the log report what the code already knows, so the next investigation starts from evidence instead of a label. release-pull-retry.test.js grepped for the literal "no holder", which pinned the imprecise message as the contract -- its mock returns null without onEnd, i.e. case 1. Updated to assert the intent (the error text reaches the log naming the artifact) plus the new guarantee that it does NOT claim a missing holder when the peer wait expired first. Pre-existing and unrelated: test/release-overlay-repair.test.js fails 3/6, confirmed earlier by reverting lib/overlay.js to pre-0.3.13. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ad274566498392fb32543bb81cc7911ec4e316ef
by plankatron <fleeky@gmail.com> · 2026-08-29T20:23:15.000Z

Changed files (4) +166 -5

lib/services/release-puller.js +54 -2
……@@ -78,6 +78,52 @@ const MAX_RETRY_MS = 5 * 60 * 1000
7878 * @param {object} remoteIndex the publisher's parsed _index.json
7979 * @param {(relPath: string) => ({size:number, hash:string}|null)} localStat
8080 */
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 */
99export 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
81127export function planPull (remoteIndex, localStat, isDeleted) {
82128 const plan = { fetch: [], skip: [], replace: [], suspect: [], tombstoned: [] }
83129 for (const f of (remoteIndex && remoteIndex.files) || []) {
……@@ -279,9 +325,15 @@ export class ReleasePuller {
279325 // Land via a temp path then rename: a crash mid-transfer must not leave
280326 // a truncated artifact that the next diff would read as complete.
281327 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 })
283335 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))
285337 try { fs.unlinkSync(tmp) } catch {}
286338 try { fs.unlinkSync(tmp + '.overlay-partial') } catch {}
287339 continue
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pear-git-v3",
3 "version": "0.1.243",
3 "version": "0.1.244",
44 "description": "P2P git forge — multi-writer issue/org/release/peer-mirror daemon. Built bottom-up after the failed v2.",
55 "type": "module",
66 "main": "index.js",
test/release-pull-failure-reason.test.js +99 -0
……@@ -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
32import test from 'brittle'
33
34import { describeFetchFailure } from '../lib/services/release-puller.js'
35
36const f = { path: 'app/versions/v1/big.apk', hash: 'ab'.repeat(32) }
37
38test('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
46test('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
62test('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
80test('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
95test('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})
test/release-pull-retry.test.js +12 -2
……@@ -265,7 +265,10 @@ test('a new error is logged with its text, not just counted', async (t) => {
265265
266266 const puller = new ReleasePuller({
267267 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 },
269272 log: (l) => lines.push(l)
270273 })
271274
……@@ -276,7 +279,14 @@ test('a new error is logged with its text, not just counted', async (t) => {
276279 })
277280
278281 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'))
280288 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')
281291 t.ok(logged[0].includes('app.bin'), 'naming the artifact that failed')
282292})