OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar / test / autoupdate-backoff.test.js

117 lines · javascript ref:
/**
 * Regression: autoupdate persistent-failure backoff (2026-07-13).
 *
 * Before this fix, _notifyAppsOfNewReleases retried a failing install on EVERY
 * autobase 'update' event with zero backoff. One un-installable app (pgh-hello,
 * whose install hit a leaked registry fd-lock) turned that into an infinite
 * failing-install loop that leaked ~90 fds/min → 232k fds / 15.8 GB on stortron.
 *
 * The backoff bounds the retry rate of any persistently-failing release so a
 * single stuck app can never drive an unbounded fd/socket leak again. These
 * tests pin the backoff schedule and the (name, token) gate.
 */

import test from 'brittle'
import { Sidecar } from '../lib/index.js'

// Build a stub with just the state the two pure methods touch — the real
// constructor stands up stores/swarms we don't need here.
function stub () {
  const s = Object.create(Sidecar.prototype)
  s._installFailures = new Map()
  return s
}

test('_installBackoffMs: 30s → doubling → capped at 1h', (t) => {
  const s = stub()
  t.is(s._installBackoffMs(1), 30000, 'attempt 1 = 30s')
  t.is(s._installBackoffMs(2), 60000, 'attempt 2 = 1m')
  t.is(s._installBackoffMs(3), 120000, 'attempt 3 = 2m')
  t.is(s._installBackoffMs(4), 240000, 'attempt 4 = 4m')
  t.is(s._installBackoffMs(8), 3600000, 'attempt 8 = 1h (cap)')
  t.is(s._installBackoffMs(50), 3600000, 'far past cap stays 1h (no overflow)')
  t.is(s._installBackoffMs(0), 30000, 'defensive: count<1 clamps to attempt 1')
})

test('_isInInstallBackoff: only the failing (name, token) inside its window is suppressed', (t) => {
  const s = stub()
  const token = '0.1.3@f33dcafe'

  // No record yet → not in backoff (fresh app gets an immediate attempt).
  t.absent(s._isInInstallBackoff('pgh-hello', token), 'no record ⇒ attempt allowed')

  // Record a failure with a future retry time → suppressed.
  s._installFailures.set('pgh-hello', { token, count: 1, nextRetryAt: Date.now() + 60000 })
  t.ok(s._isInInstallBackoff('pgh-hello', token), 'inside window ⇒ suppressed')

  // A NEW token (version/tree bump) is never suppressed — a fresh release always
  // gets an immediate attempt even while the old one is backing off.
  t.absent(s._isInInstallBackoff('pgh-hello', '0.1.4@beefbeef'), 'new token ⇒ attempt allowed')

  // Once the window elapses, retry is allowed again.
  s._installFailures.set('pgh-hello', { token, count: 1, nextRetryAt: Date.now() - 1 })
  t.absent(s._isInInstallBackoff('pgh-hello', token), 'elapsed window ⇒ attempt allowed')

  // A different app is unaffected.
  t.absent(s._isInInstallBackoff('other-app', token), 'unrelated app ⇒ attempt allowed')
})

// ── pear-git #47: a failed autoupdate must RETRY by itself, not wait for an unrelated publish ───────
//
// Observed twice: pgh-sidecar 0.2.68 (2026-10-12) and favela-gateway 0.2.0 (2026-10-13), both
// "tree … not found on any of 1 peer(s) (attempt 1; backing off 30s)" right after the publish — and then
// nothing, ever: the backoff only gated the NEXT registry 'update' event, and none came. The release was
// fetchable a minute later. A retry has to be scheduled, not hoped for.

function releaseStub ({ failures = 1, backoffMs = 40 } = {}) {
  const s = Object.create(Sidecar.prototype)
  s._installFailures = new Map()
  s._installInFlight = new Set()
  s._lastNotifiedVersion = new Map()
  s._releaseReplicas = []
  s._installRetryTimers = new Map()
  s._registry = { latest: async () => ({ version: '0.2.0', treeHash: 'ab35f67f0b03aa', publishedAt: 1 }) }
  s._listUpdateCandidates = async () => [{ name: 'favela-gateway', currentVersion: '0.1.0', currentTreeHash: 'old', stagedVersion: null, registered: false, udsPath: null }]
  s._isAutoupdateEnabled = () => true
  s._reconcileBootstrap = async () => {}
  s._installBackoffMs = () => backoffMs
  s.installs = []
  let left = failures
  s._installRelease = async (name, best) => {
    s.installs.push(best.version)
    if (left-- > 0) throw new Error('NetworkFetcher: tree 62d8ae7eac6d… not found on any of 1 peer(s)')
  }
  // What _watchForReleases wires; the retry timer calls the same check.
  s._fireReleaseCheck = () => s._notifyAppsOfNewReleases()
  return s
}

const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))

test('⛔ #47: a failed install is retried after its backoff with NO further registry event', async (t) => {
  const s = releaseStub({ failures: 1, backoffMs: 40 })
  await s._notifyAppsOfNewReleases() // the one event: fails
  t.is(s.installs.length, 1, 'first attempt failed')
  await wait(250) // no event of any kind
  t.is(s.installs.length, 2, 'the retry ran on its own')
  t.is(s._lastNotifiedVersion.get('favela-gateway'), '0.2.0@ab35f67f0b03aa', 'and the release landed')
  t.is(s._installFailures.size, 0, 'the failure record is cleared')
  t.is(s._installRetryTimers.size, 0, 'and no timer is left armed')
})

test('a release that keeps failing is retried on the backoff schedule, one timer at a time', async (t) => {
  const s = releaseStub({ failures: 3, backoffMs: 30 })
  await s._notifyAppsOfNewReleases()
  await wait(400)
  t.is(s.installs.length, 4, '3 failures then success, each retry scheduled by the previous failure')
  t.is(s._installRetryTimers.size, 0)
})

test('stopping the retries clears every armed timer', async (t) => {
  const s = releaseStub({ failures: 5, backoffMs: 60000 })
  await s._notifyAppsOfNewReleases()
  t.is(s._installRetryTimers.size, 1, 'one retry armed')
  s._clearInstallRetries()
  t.is(s._installRetryTimers.size, 0, 'cleared — nothing fires after the sidecar stops')
})