OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-overlay commits / ae6d20f

0.3.14: never serve a path-keyed chunk map that does not cover the file
tabertron 2026-10-11, serving hyper-tps texture packs: the stored map for e.g. `+0med_but1_normal.png` was one 191-byte chunk — the git POINTER the path held when first served — while the file is the real 6196-byte PNG with the same mtime. prepareFile trusted it on mtime alone, _onChunkNeed served offsets from it, and the receiver rejected every chunk on hash: ~2.6k receives on stortron stayed open for days (one partial fd each). - prepareFile reuses a cached map only if the stored size AND the map's chunk lengths both equal the file's current size; otherwise it re-chunks. - On re-chunk a path-keyed map that exists for a small (<1 MB) file is replaced too — _onFileRequest only writes one when none exists, and _onChunkNeed serves by it, so it could never heal before. test/transfer-stale-chunk-map.test.js (red: served 195 of 6196 bytes). Not in this change: receiver-side cleanup — a legacy receive whose chunks fail the hash, or whose peer leaves, still holds its partial fd. Pre-existing, untouched: test/overlay-v2-corestore-mux + overlay-v2-keyed-fetch import lib/overlay-v2.js, removed in 0.3.0, and abort `npm test` at import. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ae6d20f297596be7e4d5747aa2f8c38b64c10f82
by plankatron <fleeky@gmail.com> · 2026-10-11T23:41:29.000Z

Changed files (3) +68 -2

lib/transfer.js +12 -1
……@@ -156,7 +156,14 @@ export class TransferManager {
156156 const existing = await this._fileIndex.getChunkMap(overlayPath)
157157 const fileMeta = await this._fileIndex.getFile(overlayPath)
158158
159 if (existing && fileMeta && fileMeta.mtime === mtimeBefore) {
159 // A matching mtime is not enough: the map must also COVER this file. A
160 // path-keyed map written while the path held a 191-byte git pointer survived
161 // the real 6196-byte file landing with the same mtime (tabertron, 2026-10-11):
162 // it was served for days, the receiver's hash check rejected every chunk.
163 const covers = existing && fileMeta && fileMeta.size === stat.size &&
164 existing.reduce((n, c) => n + c.length, 0) === stat.size
165
166 if (covers && fileMeta.mtime === mtimeBefore) {
160167 // Chunk map still valid — file hasn't changed.
161168 // Backfill the content-addressed alias so the serve path (a
162169 // different overlay key) reuses this map instead of re-reading the file —
……@@ -219,6 +226,10 @@ export class TransferManager {
219226 // byHashOnly (the publish prepareForServe pass) keeps ONLY the
220227 // content-addressed map — the serve path resolves by hash, so the path-keyed map
221228 // + putFile under a throwaway overlay key would be dead, never-read state.
229 // A path-keyed map that exists for a small file (protocol _onFileRequest
230 // writes one on first serve) is REPLACED too: _onChunkNeed serves offsets
231 // from it, so leaving a stale one would keep serving the old bytes' layout.
232 if (!opts.byHashOnly && stat.size < 1048576 && existing) await this._fileIndex.putChunkMap(overlayPath, chunks)
222233 if (stat.size >= 1048576) {
223234 if (!opts.byHashOnly) await this._fileIndex.putChunkMap(overlayPath, chunks)
224235 if (!await this._fileIndex.hasChunkMapByHash(contentHash)) await this._fileIndex.putChunkMapByHash(contentHash, chunks)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "hyper-overlay",
3 "version": "0.3.13",
3 "version": "0.3.14",
44 "description": "Content-addressed P2P file distribution over Protomux — FastCDC chunking, multi-source fetch, resumable transfers, zero blob duplication. Files live on disk; the overlay is a thin index and transfer protocol.",
55 "type": "module",
66 "main": "index.js",
test/transfer-stale-chunk-map.test.js +55 -0
……@@ -0,0 +1,55 @@
1// A path-keyed chunk map that no longer describes the file must not be served.
2//
3// 2026-10-11, tabertron serving hyper-tps texture packs: the stored map for
4// `+0med_but1_normal.png` was ONE 191-byte chunk — the git POINTER the path held
5// when it was first served — while the file is the real 6196-byte PNG with the
6// same mtime. prepareFile trusted it (mtime matched), _onChunkNeed served by it,
7// and stortron asked for chunk 0 forever: the bytes never matched the hash, and
8// ~2.6k receives stayed open. Maps < 1 MB were never rewritten.
9
10import test from 'brittle'
11import { tmpStore, tmpDir, fs, path } from './helpers.js'
12import { FileIndex } from '../lib/file-index.js'
13import { TransferManager } from '../lib/transfer.js'
14import { hashChunk } from '../lib/chunker.js'
15
16test('prepareFile — a cached map whose chunks do not cover the file is rebuilt and replaced', async (t) => {
17 const index = new FileIndex(tmpStore('stale-map'))
18 await index.ready()
19 const transfer = new TransferManager(index)
20
21 const pointer = Buffer.from('hyper-overlay v1\noid blake2b:' + 'a'.repeat(64) + '\nsize 6196\n' + 'x'.repeat(91))
22 const real = Buffer.alloc(6196, 7)
23 const dir = tmpDir('stale-map')
24 const filePath = path.join(dir, 'tex.png')
25 fs.writeFileSync(filePath, real)
26 const st = fs.statSync(filePath)
27
28 // The state tabertron was in: stale pointer-sized map, metadata of the real file.
29 await index.putChunkMap('/tex.png', [{ hash: hashChunk(pointer), offset: 0, length: pointer.length }])
30 await index.putFile('/tex.png', { contentHash: 'b'.repeat(64), size: real.length, mtime: st.mtimeMs })
31
32 const res = await transfer.prepareFile(filePath, '/tex.png')
33 const total = res.chunks.reduce((n, c) => n + c.length, 0)
34 t.is(total, real.length, 'served map covers the whole file')
35 t.is(res.chunks[0].hash, hashChunk(real.subarray(0, res.chunks[0].length)), 'chunk hash is of the real bytes')
36
37 const stored = await index.getChunkMap('/tex.png')
38 t.alike(stored, res.chunks, 'the path-keyed map (what _onChunkNeed serves by) is replaced')
39})
40
41test('prepareFile — a cached map that does cover the file is still reused', async (t) => {
42 const index = new FileIndex(tmpStore('good-map'))
43 await index.ready()
44 const transfer = new TransferManager(index)
45 const dir = tmpDir('good-map')
46 const filePath = path.join(dir, 'f.bin')
47 fs.writeFileSync(filePath, Buffer.alloc(3000, 1))
48 const first = await transfer.prepareFile(filePath, '/f.bin')
49 await index.putChunkMap('/f.bin', first.chunks)
50 const marker = first.chunks.map(c => ({ ...c }))
51 marker[0].hash = 'c'.repeat(64) // same lengths, different hash: proves the cache path was taken
52 await index.putChunkMap('/f.bin', marker)
53 const again = await transfer.prepareFile(filePath, '/f.bin')
54 t.is(again.chunks[0].hash, 'c'.repeat(64), 'cache hit when the map covers the file size')
55})