OverviewFilesCommitsIssuesDocsReleasesPeers

hyper-vdrive commits / 77d13cf

Crash during append: no tree node is ever left without its pointer
Eight SIGKILL trials across four kill points, stock and virtual, on a 367MB artifact. Seven died mid-publish; every store reopened, and the state that would prove the tagged-record scheme broken — the tree claiming a block whose bytes cannot be produced — occurred zero times. Because the pointer IS the block value, a crash can only lose whole blocks. Hyperdrive writes the bee entry after the blob stream closes, so no half-written file is ever exposed either. Two observations. Virtual publishes get ~3x further per unit time (816 blocks vs 272 at the same kill point), matching the faster wall-clock. And a crashed publish leaks orphaned blocks: compacted, a crashed stock publish left 182.5MB with no entry pointing at it, against 0.28MB for the virtual one that had written MORE blocks. With clear() not reclaiming promptly, aborted publishes on a stock drive accumulate garbage that is awkward to remove. Not a power-loss test — SIGKILL leaves the page cache to flush. Adds test/crash-append.mjs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
77d13cfb4a677029be57ca44305b82bb73dd538f
by plankatron <fleeky@gmail.com> · 2026-08-24T14:26:10.000Z

Changed files (3) +182 -2

README.md +4 -2
……@@ -94,7 +94,8 @@ node example/seed.mjs ./work/app # serve it on the DHT
9494## Status — spike
9595
9696Proven: identical drive key/blobs key/root hash, reopen from disk, replication to a
97stock peer, and a real `pear install` from a drive holding zero blobs.
97stock peer, a real `pear install` from a drive holding zero blobs, parity through
98clear/truncate, and crash-during-append leaving no tree node without its pointer.
9899
99100Known gaps, in the order they'd bite:
100101
……@@ -112,4 +113,5 @@ Known gaps, in the order they'd bite:
112113 so a later `truncate()` fails with `Expected tree node N from storage, got (nil)`.
113114 Same on a stock drive. And `clear()` reclaims no disk promptly: on a virtual drive
114115 that does not matter, because the bytes are the overlay's to free.
1156. Untested: crash during append.
1166. Untested: power loss (SIGKILL is covered and passes; fsync durability is not),
117 and encryption.
RESULTS.md +32 -0
……@@ -166,6 +166,38 @@ upstream and both reproduced on stock:
166166 truncate then needs one of them. Same-session clear-then-truncate happens to work
167167 (the node is still cached); across sessions it fails. Pick one operation, not both.
168168
169## Crash during append
170
171SIGKILL a publish in flight, reopen, and look for the one state that would prove the
172tagged-record scheme broken: the tree claims a block whose bytes cannot be produced.
173Eight trials (four kill points × stock and virtual), one 367 MB artifact.
174
175```
176trials 8
177killed mid-publish 7
178all stores reopened true
179tree claims a block it cannot produce 0
180verdict PASS
181```
182
183Every killed publish left `entry: none` — hyperdrive writes the bee entry only after
184the blob stream closes, so a crash can never expose a half-written file. The one trial
185that completed read back byte-identical. **Parity with stock throughout.**
186
187Two things worth noting:
188
189- **Virtual publishes get further per unit time.** At the 950 ms kill point the virtual
190 drive had committed 816 blocks to stock's 272 — the same ~3× that shows up as faster
191 publish wall-clock, because the RocksDB block writes are gone.
192- **A crashed publish leaks orphaned blocks**, and that is where the two diverge
193 sharply. Compacted, a crashed stock publish left **182.5 MB** of blocks with no entry
194 pointing at them; the virtual equivalent — further along, 816 blocks vs 560 — left
195 **0.28 MB**. Combined with `clear()` not reclaiming promptly, aborted publishes on a
196 stock drive accumulate garbage that is awkward to get rid of.
197
198Caveat: SIGKILL ends the process but the OS still flushes its page cache. This is not a
199power-loss test, and nothing here should be read as an fsync-durability claim.
200
169201## GC / bitfield
170202
171203```
test/crash-append.mjs +146 -0
……@@ -0,0 +1,146 @@
1// Crash during append — SIGKILL a publish in flight, reopen, and check the tree
2// and the pointer records still agree. The tagged-record scheme exists so the
3// pointer commits in the same CoreTX as the tree node; this is what tests it.
4//
5// node test/crash-append.mjs <workDir> <bigFile> [trials]
6// node test/crash-append.mjs --child <virtual|stock> <dir> <file> (internal)
7import { createRequire } from 'module'
8const require = createRequire(import.meta.url)
9
10const fs = require('fs')
11const { spawn } = require('child_process')
12const Corestore = require('corestore')
13const Hyperdrive = require('hyperdrive')
14const { ContentStore, build } = require('../lib/storage.cjs')
15const { chunk } = await import('../../hyper-overlay/lib/chunker.js')
16
17const NAME = '/app.bin'
18const PRIMARY = Buffer.alloc(32, 7)
19
20function indexContent (file) {
21 const buf = fs.readFileSync(file)
22 const content = new ContentStore()
23 const chunks = chunk(buf)
24 for (const c of chunks) content.add(c.hash, file, c.offset, c.length)
25 return { buf, chunks, content }
26}
27
28function openStore (dir, mode, content) {
29 if (mode === 'stock') return new Corestore(dir, { primaryKey: PRIMARY, unsafe: true })
30 const Storage = build(content)
31 return new Corestore(new Storage(dir), { primaryKey: PRIMARY, unsafe: true })
32}
33
34function write (ws, buf) {
35 return new Promise((resolve) => {
36 if (ws.write(buf)) return resolve()
37 ws.once('drain', resolve)
38 })
39}
40
41// ── child: publish, and get killed partway through ──────────────────────────
42if (process.argv[2] === '--child') {
43 const [, , , mode, dir, file] = process.argv
44 const { chunks, content } = indexContent(file)
45 const store = openStore(dir, mode, content)
46 const drive = new Hyperdrive(store)
47 await drive.ready()
48 const ws = drive.createWriteStream(NAME, { dedup: true })
49 process.stdout.write('READY\n')
50 for (const c of chunks) await write(ws, c.data)
51 await new Promise((resolve, reject) => {
52 ws.once('error', reject)
53 ws.once('close', resolve)
54 ws.end()
55 })
56 await drive.close()
57 await store.close()
58 process.stdout.write('DONE\n')
59 process.exit(0)
60}
61
62// ── parent ──────────────────────────────────────────────────────────────────
63const ROOT = process.argv[2]
64const FILE = process.argv[3]
65const TRIALS = Number(process.argv[4] || 4)
66const { buf, content } = indexContent(FILE)
67
68function crashRun (dir, mode, killAfterMs) {
69 return new Promise((resolve) => {
70 const child = spawn(process.execPath, [process.argv[1], '--child', mode, dir, FILE], {
71 stdio: ['ignore', 'pipe', 'ignore']
72 })
73 let killed = false
74 let finished = false
75 child.stdout.on('data', (d) => {
76 if (d.toString().includes('READY')) {
77 setTimeout(() => {
78 if (!finished) {
79 child.kill('SIGKILL')
80 killed = true
81 }
82 }, killAfterMs)
83 }
84 if (d.toString().includes('DONE')) finished = true
85 })
86 child.on('exit', (code, signal) => resolve({ killed, finished, code, signal }))
87 })
88}
89
90// Reopen and look for the one state that would prove the scheme broken:
91// the tree claims a block, but its bytes cannot be produced.
92async function audit (dir, mode) {
93 const store = openStore(dir, mode, content)
94 const drive = new Hyperdrive(store)
95 const out = { opened: false, blobsLength: 0, claimedButMissing: 0, entry: null, bytesOk: null }
96 try {
97 await drive.ready()
98 await drive.getBlobs()
99 out.opened = true
100 const core = drive.blobs.core
101 out.blobsLength = core.length
102
103 for (let i = 0; i < core.length; i++) {
104 if (!(await core.has(i))) continue
105 const block = await core.get(i, { wait: false })
106 if (block === null) out.claimedButMissing++
107 }
108
109 const entry = await drive.entry(NAME)
110 out.entry = entry === null ? 'none' : 'present'
111 if (entry !== null) {
112 const got = await drive.get(NAME, { timeout: 5000 }).catch(() => null)
113 out.bytesOk = got !== null && got.equals(buf)
114 }
115 } catch (err) {
116 out.error = err.code || err.message
117 }
118 await drive.close().catch(() => {})
119 await store.close().catch(() => {})
120 return out
121}
122
123const results = []
124for (let t = 0; t < TRIALS; t++) {
125 for (const mode of ['virtual', 'stock']) {
126 const dir = `${ROOT}/${mode}-${t}`
127 const killAfterMs = 150 + t * 400
128 const run = await crashRun(dir, mode, killAfterMs)
129 const state = await audit(dir, mode)
130 results.push({ trial: t, mode, killAfterMs, run, state })
131 console.log(JSON.stringify({ trial: t, mode, killAfterMs, ...run, ...state }))
132 }
133}
134
135content.close()
136
137const broken = results.filter((r) => r.state.claimedButMissing > 0 || r.state.bytesOk === false)
138console.log(
139 JSON.stringify({
140 trials: results.length,
141 killedMidPublish: results.filter((r) => r.run.killed).length,
142 allReopened: results.every((r) => r.state.opened),
143 inconsistent: broken.length,
144 verdict: broken.length === 0 ? 'PASS' : 'FAIL'
145 })
146)