/**
* 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')
})