OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 6e927e2

0.1.155: single-flight the release resolver — the handset's fd-lock error
⛔ MEASURED ON A HANDSET 2026-09-15, minutes after an OTA staged a release: ota: File descriptor could not be locked @ FDLock._resume (…/node_modules/pgh/node_modules/fd-lock/index.js:62) < FDLock._open < open (ready-resource) < FDLock.ready `makeGetLatestRelease` ran a FULL resolveRelease per call — open a Corestore on `storeDir`, read, cleanup. RocksDB's lock is EXCLUSIVE, so two OVERLAPPING calls mean the second loses and throws, and the caller sees an OTA error rather than the double-open it is. On the phone the two callers are the release watch firing and the settings page asking for update status; they overlap by construction, which is why this appeared the moment a release existed to react to. ★ THE FIX ALREADY EXISTED ONE LAYER DOWN, and NetworkFetcher.ready() says why: "Memoize the in-flight promise. Without this, concurrent callers … each run new Corestore(this._storeDir) on the same path. The device-file FDLock admits only one; the loser throws 'File descriptor could not be locked'." The resolver above it never got the same treatment. ⛔ THE IN-FLIGHT PROMISE, NEVER THE RESULT. A cached entry would go stale and a poller would stop seeing new releases — the opposite failure and a worse one, because it is silent. The entry is dropped as soon as the call settles, including when it throws: memoising a failure would poison every later call from one transient swarm miss. Keyed by name, so two apps do not block each other. ⚠ THE TEST REPRODUCED IT UNMOCKED ON ITS FIRST RUN. The injection point did not exist yet, so it ran the real resolver, opened a real Corestore twice concurrently and threw the handset's exact stack. It now drives an injected resolver that models exclusivity deterministically, and asserts the three things that could go wrong in the fix itself: one holder at a time, a later call still resolving afresh, and a failure not being memoised. 339/339, 958/958 under Bare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6e927e2aa2c06357f1101fb3afddbd4392cd978e
by plankatron <fleeky@gmail.com> · 2026-09-15T18:27:55.000Z

Changed files (3) +136 -6

lib/resolve-release.js +39 -5
……@@ -228,11 +228,45 @@ export async function watchLatestRelease (opts = {}) {
228228 }
229229}
230230
231export function makeGetLatestRelease ({ registryKeyHex, preferSidecar, settleMs, storeDir } = {}) {
231export function makeGetLatestRelease ({ registryKeyHex, preferSidecar, settleMs, storeDir, _resolve = resolveRelease } = {}) {
232 // ⛔ SINGLE-FLIGHT, BECAUSE THE STORE'S LOCK IS EXCLUSIVE. Each call opens a Corestore on the same
233 // `storeDir`, and RocksDB admits one holder — so two OVERLAPPING calls mean the second throws
234 // "File descriptor could not be locked" and the caller sees an OTA error rather than the
235 // double-open it is.
236 //
237 // Measured on a handset 2026-09-15, after an OTA staged a release: the release watch fired and the
238 // settings page asked for update status, and those overlap by construction.
239 //
240 // ★ The fix already existed one layer down and said why — NetworkFetcher.ready(): "Memoize the
241 // in-flight promise. Without this, concurrent callers … each run new Corestore(this._storeDir) on
242 // the same path. The device-file FDLock admits only one; the loser throws …". The resolver above it
243 // never got the same treatment.
244 //
245 // ⛔ THE IN-FLIGHT PROMISE, NEVER THE RESULT. A cached entry would go stale and a poller would stop
246 // seeing new releases — the opposite failure, and a worse one because it is silent. The map entry is
247 // deleted as soon as the call settles, including when it throws: memoising a failure would poison
248 // every later call from one transient swarm miss.
249 //
250 // ⚠ Keyed by NAME. Two different apps resolve different entries and must not block each other, even
251 // though they share a store — the lock is held only for the length of one resolve either way.
252 const inFlight = new Map()
253
232254 return async function getLatestRelease (name) {
233 const { entry, cleanup } = await resolveRelease({
234 registryKeyHex, name, preferSidecar, settleMs, storeDir
235 })
236 try { return entry } finally { await cleanup() }
255 const pending = inFlight.get(name)
256 if (pending) return pending
257
258 const p = (async () => {
259 const { entry, cleanup } = await _resolve({
260 registryKeyHex, name, preferSidecar, settleMs, storeDir
261 })
262 try { return entry } finally { await cleanup() }
263 })()
264
265 inFlight.set(name, p)
266 try {
267 return await p
268 } finally {
269 inFlight.delete(name)
270 }
237271 }
238272}
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.154",
3 "version": "0.1.155",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/resolve-single-flight.test.js +96 -0
……@@ -0,0 +1,96 @@
1// ★ Two overlapping resolves must not fight over the registry store's lock.
2//
3// ⛔ MEASURED ON A HANDSET, 2026-09-15. After an OTA staged a release, the next check threw:
4//
5// ota: File descriptor could not be locked @ FDLock._resume
6// (…/node_modules/pgh/node_modules/fd-lock/index.js:62) < FDLock._open
7// < open (ready-resource) < FDLock.ready
8//
9// `makeGetLatestRelease` returns a function that runs a FULL resolveRelease per call — open a
10// Corestore on `storeDir`, read, cleanup. RocksDB's lock is EXCLUSIVE, so two overlapping calls on
11// one storeDir means the second loses and throws. On the phone the two callers are the release watch
12// firing and the settings page asking for update status, and they overlap by construction.
13//
14// ★ THE FIX ALREADY EXISTS ONE LAYER DOWN and says why, in NetworkFetcher.ready():
15//
16// "Memoize the in-flight promise. Without this, concurrent callers … each run
17// new Corestore(this._storeDir) on the same path. The device-file FDLock admits only one; the
18// loser throws 'File descriptor could not be locked' …"
19//
20// ⚠ THE IN-FLIGHT PROMISE, NEVER THE RESULT. A cached entry would go stale and a poller would stop
21// seeing new releases — which is the opposite failure and a worse one, because it is silent.
22
23import test from 'brittle'
24import { makeGetLatestRelease } from '../lib/resolve-release.js'
25
26// A resolver whose "store" admits exactly one holder at a time, which is what RocksDB does.
27function exclusiveStore () {
28 const state = { held: false, opens: 0, peak: 0, live: 0 }
29 return {
30 state,
31 async open () {
32 state.opens++
33 if (state.held) throw new Error('File descriptor could not be locked')
34 state.held = true
35 state.live++
36 state.peak = Math.max(state.peak, state.live)
37 await new Promise((r) => setTimeout(r, 30))
38 return { entry: { name: 'app', version: '1.0.0' }, cleanup: async () => { state.held = false; state.live-- } }
39 }
40 }
41}
42
43test('★★★ concurrent calls do not open the store twice', async (t) => {
44 const store = exclusiveStore()
45 const get = makeGetLatestRelease({ registryKeyHex: 'ab'.repeat(32), _resolve: store.open })
46
47 const [a, b, c] = await Promise.all([get('app'), get('app'), get('app')])
48
49 t.is(store.state.peak, 1, 'never more than one holder — the lock is exclusive and this respects it')
50 t.alike(a, b, 'every caller gets the same answer')
51 t.alike(b, c)
52})
53
54test('⛔ a LATER call still resolves afresh — the promise is memoised, not the result', async (t) => {
55 const store = exclusiveStore()
56 const get = makeGetLatestRelease({ registryKeyHex: 'ab'.repeat(32), _resolve: store.open })
57
58 await get('app')
59 await get('app')
60
61 // ⚠ Caching the ENTRY would make a poller blind to new releases — silent, and worse than the error
62 // this fixes.
63 t.is(store.state.opens, 2, 'two sequential calls are two resolves')
64})
65
66test('⛔ a failure is not memoised — the next call retries', async (t) => {
67 let n = 0
68 const get = makeGetLatestRelease({
69 registryKeyHex: 'ab'.repeat(32),
70 _resolve: async () => {
71 n++
72 if (n === 1) throw new Error('swarm down')
73 return { entry: { name: 'app', version: '2.0.0' }, cleanup: async () => {} }
74 }
75 })
76
77 await t.exception(() => get('app'), /swarm down/)
78 const second = await get('app')
79 t.is(second.version, '2.0.0', 'a transient failure does not poison every later call')
80})
81
82test('different names do not block each other', async (t) => {
83 const seen = []
84 const get = makeGetLatestRelease({
85 registryKeyHex: 'ab'.repeat(32),
86 _resolve: async ({ name }) => {
87 seen.push(name)
88 await new Promise((r) => setTimeout(r, 10))
89 return { entry: { name }, cleanup: async () => {} }
90 }
91 })
92 const [x, y] = await Promise.all([get('one'), get('two')])
93 t.is(x.name, 'one')
94 t.is(y.name, 'two')
95 t.is(seen.length, 2)
96})