// ★ Two overlapping resolves must not fight over the registry store's lock.
//
// ⛔ MEASURED ON A HANDSET, 2026-09-15. After an OTA staged a release, the next check threw:
//
// 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` returns a function that runs a FULL resolveRelease per call — open a
// Corestore on `storeDir`, read, cleanup. RocksDB's lock is EXCLUSIVE, so two overlapping calls on
// one storeDir means the second loses and throws. On the phone the two callers are the release watch
// firing and the settings page asking for update status, and they overlap by construction.
//
// ★ THE FIX ALREADY EXISTS ONE LAYER DOWN and says why, in NetworkFetcher.ready():
//
// "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 IN-FLIGHT PROMISE, NEVER THE RESULT. A cached entry would go stale and a poller would stop
// seeing new releases — which is the opposite failure and a worse one, because it is silent.
import test from 'brittle'
import { makeGetLatestRelease } from '../lib/resolve-release.js'
// A resolver whose "store" admits exactly one holder at a time, which is what RocksDB does.
function exclusiveStore () {
const state = { held: false, opens: 0, peak: 0, live: 0 }
return {
state,
async open () {
state.opens++
if (state.held) throw new Error('File descriptor could not be locked')
state.held = true
state.live++
state.peak = Math.max(state.peak, state.live)
await new Promise((r) => setTimeout(r, 30))
return { entry: { name: 'app', version: '1.0.0' }, cleanup: async () => { state.held = false; state.live-- } }
}
}
}
test('★★★ concurrent calls do not open the store twice', async (t) => {
const store = exclusiveStore()
const get = makeGetLatestRelease({ registryKeyHex: 'ab'.repeat(32), _resolve: store.open })
const [a, b, c] = await Promise.all([get('app'), get('app'), get('app')])
t.is(store.state.peak, 1, 'never more than one holder — the lock is exclusive and this respects it')
t.alike(a, b, 'every caller gets the same answer')
t.alike(b, c)
})
test('⛔ a LATER call still resolves afresh — the promise is memoised, not the result', async (t) => {
const store = exclusiveStore()
const get = makeGetLatestRelease({ registryKeyHex: 'ab'.repeat(32), _resolve: store.open })
await get('app')
await get('app')
// ⚠ Caching the ENTRY would make a poller blind to new releases — silent, and worse than the error
// this fixes.
t.is(store.state.opens, 2, 'two sequential calls are two resolves')
})
test('⛔ a failure is not memoised — the next call retries', async (t) => {
let n = 0
const get = makeGetLatestRelease({
registryKeyHex: 'ab'.repeat(32),
_resolve: async () => {
n++
if (n === 1) throw new Error('swarm down')
return { entry: { name: 'app', version: '2.0.0' }, cleanup: async () => {} }
}
})
await t.exception(() => get('app'), /swarm down/)
const second = await get('app')
t.is(second.version, '2.0.0', 'a transient failure does not poison every later call')
})
test('different names do not block each other', async (t) => {
const seen = []
const get = makeGetLatestRelease({
registryKeyHex: 'ab'.repeat(32),
_resolve: async ({ name }) => {
seen.push(name)
await new Promise((r) => setTimeout(r, 10))
return { entry: { name }, cleanup: async () => {} }
}
})
const [x, y] = await Promise.all([get('one'), get('two')])
t.is(x.name, 'one')
t.is(y.name, 'two')
t.is(seen.length, 2)
})