OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 37a6155

0.1.138: a corrupt tree-meta must not break `store show` / `store pin`
getMeta guarded a MISSING meta file but not an EMPTY one, and JSON.parse('') throws "Unexpected end of JSON input". So ONE zero-byte file broke `pgh store show` and `pgh store pin` for EVERY hash on that host — the reader throws before it can even report which file is at fault, which is why this presented as "pin is broken" rather than "one entry is corrupt". Measured on stortron 2026-08-05: exactly one bad file out of the whole meta dir (208b258c…json, 0 bytes). Consequence recorded in hyper-social #24 as an open item — "stortron has no pinned hpp rollback tree" — so rollback pins could not be taken on that host at all. Hit it again today taking a pin before the ap-bridge deploy. Meta is a CACHE (pulledAt/source/pinned/schema), never the tree itself, so degrading to "no meta" is always safe. `_readRefs` in this same class already read-and-tolerated exactly this way; getMeta was the outlier. ★ It SELF-HEALS. pin() already handled a null meta (`|| { pulledAt: Date.now() }`), so returning null lets the next pin rewrite a corrupt entry instead of needing a hand-edit on the box. No file is deleted to fix this. 7 tests / 10 asserts, covering empty, truncated, whitespace-only, non-object JSON, missing, a valid round-trip (the guard must not swallow good data), and the heal. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
37a6155949b0ece4fe357871b15457e1da2a7f5d
by plankatron <fleeky@gmail.com> · 2026-08-05T22:18:17.000Z

Changed files (3) +88 -2

lib/store.js +21 -1
……@@ -113,10 +113,30 @@ export class TreeStore {
113113 return true
114114 }
115115
116 // Meta is a CACHE — pulledAt, source, pinned, schema. It is derived bookkeeping, never the tree
117 // itself, so an unreadable one must degrade to "no meta" rather than take the caller down.
118 //
119 // THE BUG THIS FIXES. `existsSync` guarded a MISSING file but not an EMPTY or truncated one, and
120 // JSON.parse('') throws "Unexpected end of JSON input". A single zero-byte meta file on stortron
121 // therefore broke `pgh store show` and `pgh store pin` for EVERY hash — the reader throws before it
122 // can tell you which file is at fault. That is why hyper-social #24 recorded "stortron has no
123 // pinned hpp rollback tree" as an open item: rollback pins could not be taken on that host at all,
124 // which is the worst possible moment to lose a safety net.
125 //
126 // `_readRefs` in this same class already reads-and-tolerates exactly this way; getMeta was the
127 // outlier. And `pin()` below already handles a null meta (`|| { pulledAt: Date.now() }`), so
128 // returning null here lets a corrupt entry HEAL on the next pin rather than needing a manual fix.
116129 getMeta (hash) {
117130 const p = this.metaPath(hash)
118131 if (!fs.existsSync(p)) return null
119 return JSON.parse(fs.readFileSync(p, 'utf8'))
132 try {
133 const raw = fs.readFileSync(p, 'utf8')
134 if (!raw.trim()) return null
135 const parsed = JSON.parse(raw)
136 return (parsed && typeof parsed === 'object') ? parsed : null
137 } catch {
138 return null
139 }
120140 }
121141
122142 // ── Materialization ─────────────────────────────────────────
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.137",
3 "version": "0.1.138",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/store-meta-corrupt.test.js +66 -0
……@@ -0,0 +1,66 @@
1// A corrupt tree-meta file must not take the store reader down.
2//
3// Regression: getMeta guarded a MISSING file but not an EMPTY one, and JSON.parse('') throws
4// "Unexpected end of JSON input". ONE zero-byte meta file on stortron therefore broke
5// `pgh store show` and `pgh store pin` for EVERY hash — the reader throws before it can even say
6// which file is at fault. hyper-social #24 recorded the consequence: rollback pins could not be
7// taken on that host, which is exactly the safety net you want before a risky deploy.
8//
9// Meta is a CACHE (pulledAt/source/pinned/schema), never the tree, so degrading to "no meta" is
10// always safe. _readRefs in the same class already tolerated this; getMeta was the outlier.
11import test from 'brittle'
12import fs from 'fs'
13import os from 'os'
14import path from 'path'
15import { TreeStore } from '../lib/store.js'
16
17const tmp = () => fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-meta-'))
18const HASH = 'a'.repeat(64)
19
20function storeWith (metaContent) {
21 const root = tmp()
22 const s = new TreeStore(root)
23 s.init()
24 if (metaContent !== null) fs.writeFileSync(s.metaPath(HASH), metaContent)
25 return s
26}
27
28test('an EMPTY meta file reads as no-meta rather than throwing', (t) => {
29 const s = storeWith('')
30 t.is(s.getMeta(HASH), null, 'the exact stortron case: zero bytes')
31})
32
33test('a TRUNCATED meta file reads as no-meta', (t) => {
34 const s = storeWith('{"pulledAt": 17')
35 t.is(s.getMeta(HASH), null, 'a half-written file (crash mid-write) does not throw')
36})
37
38test('whitespace-only reads as no-meta', (t) => {
39 t.is(storeWith(' \n ').getMeta(HASH), null)
40})
41
42test('non-object JSON reads as no-meta', (t) => {
43 t.is(storeWith('42').getMeta(HASH), null, 'valid JSON that is not a meta object is still no-meta')
44 t.is(storeWith('null').getMeta(HASH), null)
45})
46
47test('a MISSING meta file still reads as no-meta (unchanged)', (t) => {
48 t.is(storeWith(null).getMeta(HASH), null)
49})
50
51test('a VALID meta file still round-trips — the guard must not swallow good data', (t) => {
52 const s = storeWith(JSON.stringify({ pulledAt: 123, source: 'x', pinned: true }))
53 const m = s.getMeta(HASH)
54 t.is(m.pulledAt, 123)
55 t.is(m.pinned, true, 'pinned survives')
56})
57
58// The payoff: pin() already tolerates a null meta, so a corrupt entry HEALS on the next pin
59// instead of needing a hand-edit on the box.
60test('pin() repairs a corrupt meta rather than failing', (t) => {
61 const s = storeWith('')
62 s.pin(HASH)
63 const m = s.getMeta(HASH)
64 t.ok(m, 'meta is readable again')
65 t.is(m.pinned, true, 'and the pin actually took — this is what #24 could not do')
66})