OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar commits / f63bedd

autoupdate: exponential backoff on persistent install failure
_notifyAppsOfNewReleases retried a failing install on EVERY autobase 'update' event with zero backoff. One un-installable app (pgh-hello, whose install hit the leaked registry fd-lock) became an infinite failing-install loop that leaked ~90 fds/min → 232k fds / 15.8 GB on stortron. Add a per-app failure tracker keyed by (name, token): on failure, record an exponential backoff (30s → doubling → capped at 1h); skip the app while it's inside its backoff window; clear on success. A new token (version/tree bump) resets the counter for an immediate fresh attempt. This bounds the retry rate of ANY persistently-failing release so a single stuck app can never drive an unbounded fd/socket leak again — defense in depth alongside the pgh-side fd-lock fix (pgh 0.1.113). Regression test pins the backoff schedule and the (name, token) gate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
f63bedd4b06d6b0099452ed4cee92acf66db8e69
by plankatron <fleeky@gmail.com> · 2026-07-13T18:45:53.000Z

Changed files (3) +101 -4

lib/index.js +43 -3
……@@ -152,6 +152,14 @@ export class Sidecar {
152152 // ENOTEMPTYs mid-rm and leaves an empty (or partially-populated)
153153 // install dir behind.
154154 this._installInFlight = new Set()
155 // Per-app persistent-failure backoff. Keyed by app name →
156 // { token, count, nextRetryAt }. An autoupdate that fails the SAME way every
157 // tick (un-fetchable tree, a registry Corestore whose fd-lock can't be
158 // acquired, etc.) must NOT be retried on every autobase 'update' event — that
159 // zero-backoff tight loop bled the sidecar to 232k fds / 15.8 GB on stortron
160 // (2026-07-13). We back off exponentially per (name, token); a new token
161 // (version/tree bump) resets the counter for a fresh attempt.
162 this._installFailures = new Map()
155163 // Overlay subsystem — per-repo file metadata index + cross-peer file
156164 // serving. Initialised lazily during start() because it needs the
157165 // identity (for the swarm topic). Stays null when enableSeed === false
……@@ -698,6 +706,12 @@ export class Sidecar {
698706 continue
699707 }
700708
709 // Persistent-failure backoff. If this exact release (name, token) has been
710 // failing, don't hammer it on every autobase 'update' tick — wait out the
711 // exponential backoff. A new token (version/tree bump) falls through with a
712 // fresh counter. See _installFailures in the constructor.
713 if (this._isInInstallBackoff(name, token)) continue
714
701715 // Per-app install mutex — see _installInFlight comment in
702716 // constructor. Skip if a previous tick is still installing.
703717 if (this._installInFlight.has(name)) {
……@@ -717,16 +731,42 @@ export class Sidecar {
717731 this._pushReleaseReady({ name, udsPath }, best).catch(() => {})
718732 }
719733 this._lastNotifiedVersion.set(name, token)
734 this._installFailures.delete(name)
720735 } catch (err) {
721 console.error('[pgh-sidecar] autoupdate failed for ' + name + ': ' + err.message)
722 // Retry on the next tick; don't poison _lastNotifiedVersion so
723 // a later fire gets another shot.
736 // Record the failure and back off. Don't poison _lastNotifiedVersion so
737 // a later fire (once the backoff elapses, or a new version appears) gets
738 // another shot — but no longer on EVERY tick (the leak that melted the
739 // sidecar). Backoff resets when the release token changes.
740 const prev = this._installFailures.get(name)
741 const count = (prev && prev.token === token ? prev.count : 0) + 1
742 const backoffMs = this._installBackoffMs(count)
743 this._installFailures.set(name, { token, count, nextRetryAt: Date.now() + backoffMs })
744 console.error('[pgh-sidecar] autoupdate failed for ' + name + ': ' + err.message +
745 ' (attempt ' + count + '; backing off ' + Math.round(backoffMs / 1000) + 's)')
724746 } finally {
725747 this._installInFlight.delete(name)
726748 }
727749 }
728750 }
729751
752 // Exponential backoff for a persistently-failing autoupdate. Attempt 1 waits
753 // 30s, then doubles (1m, 2m, 4m, …) capped at 1h. Bounds the retry rate of an
754 // un-installable release so a stuck app can't drive an unbounded fd/socket
755 // leak (see _installFailures in the constructor).
756 _installBackoffMs (count) {
757 const base = 30000
758 const cappedExp = Math.min(Math.max(count, 1) - 1, 7) // cap so no overflow
759 return Math.min(base * Math.pow(2, cappedExp), 3600000)
760 }
761
762 // True while (name, token) is inside its post-failure backoff window. A token
763 // change (new version/tree) is NOT in backoff — a fresh release always gets an
764 // immediate attempt. Extracted for testability.
765 _isInInstallBackoff (name, token) {
766 const fail = this._installFailures.get(name)
767 return !!(fail && fail.token === token && Date.now() < fail.nextRetryAt)
768 }
769
730770 /**
731771 * Union of running-registered apps (sidecar's in-memory map) plus
732772 * installed-on-disk apps (have a .pgh-install.json). CLIs like pgh
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh-sidecar",
3 "version": "0.2.45",
3 "version": "0.2.46",
44 "description": "Small optional user-level daemon for pgh — shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host",
55 "type": "module",
66 "main": "lib/index.js",
test/autoupdate-backoff.test.js +57 -0
……@@ -0,0 +1,57 @@
1/**
2 * Regression: autoupdate persistent-failure backoff (2026-07-13).
3 *
4 * Before this fix, _notifyAppsOfNewReleases retried a failing install on EVERY
5 * autobase 'update' event with zero backoff. One un-installable app (pgh-hello,
6 * whose install hit a leaked registry fd-lock) turned that into an infinite
7 * failing-install loop that leaked ~90 fds/min → 232k fds / 15.8 GB on stortron.
8 *
9 * The backoff bounds the retry rate of any persistently-failing release so a
10 * single stuck app can never drive an unbounded fd/socket leak again. These
11 * tests pin the backoff schedule and the (name, token) gate.
12 */
13
14import test from 'brittle'
15import { Sidecar } from '../lib/index.js'
16
17// Build a stub with just the state the two pure methods touch — the real
18// constructor stands up stores/swarms we don't need here.
19function stub () {
20 const s = Object.create(Sidecar.prototype)
21 s._installFailures = new Map()
22 return s
23}
24
25test('_installBackoffMs: 30s → doubling → capped at 1h', (t) => {
26 const s = stub()
27 t.is(s._installBackoffMs(1), 30000, 'attempt 1 = 30s')
28 t.is(s._installBackoffMs(2), 60000, 'attempt 2 = 1m')
29 t.is(s._installBackoffMs(3), 120000, 'attempt 3 = 2m')
30 t.is(s._installBackoffMs(4), 240000, 'attempt 4 = 4m')
31 t.is(s._installBackoffMs(8), 3600000, 'attempt 8 = 1h (cap)')
32 t.is(s._installBackoffMs(50), 3600000, 'far past cap stays 1h (no overflow)')
33 t.is(s._installBackoffMs(0), 30000, 'defensive: count<1 clamps to attempt 1')
34})
35
36test('_isInInstallBackoff: only the failing (name, token) inside its window is suppressed', (t) => {
37 const s = stub()
38 const token = '0.1.3@f33dcafe'
39
40 // No record yet → not in backoff (fresh app gets an immediate attempt).
41 t.absent(s._isInInstallBackoff('pgh-hello', token), 'no record ⇒ attempt allowed')
42
43 // Record a failure with a future retry time → suppressed.
44 s._installFailures.set('pgh-hello', { token, count: 1, nextRetryAt: Date.now() + 60000 })
45 t.ok(s._isInInstallBackoff('pgh-hello', token), 'inside window ⇒ suppressed')
46
47 // A NEW token (version/tree bump) is never suppressed — a fresh release always
48 // gets an immediate attempt even while the old one is backing off.
49 t.absent(s._isInInstallBackoff('pgh-hello', '0.1.4@beefbeef'), 'new token ⇒ attempt allowed')
50
51 // Once the window elapses, retry is allowed again.
52 s._installFailures.set('pgh-hello', { token, count: 1, nextRetryAt: Date.now() - 1 })
53 t.absent(s._isInInstallBackoff('pgh-hello', token), 'elapsed window ⇒ attempt allowed')
54
55 // A different app is unaffected.
56 t.absent(s._isInInstallBackoff('other-app', token), 'unrelated app ⇒ attempt allowed')
57})