OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / resolve-single-flight.test.js

97 lines · javascript ref:
// ★ 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)
})