OverviewFilesCommitsIssuesDocsReleasesPeers

pgh-sidecar commits / b2cc1bb

0.2.69: a failed autoupdate retries by itself (pear-git #47)
The backoff only gated the NEXT registry 'update' event, so after a failure nothing retried until some unrelated release was published. Observed for 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)" seconds after the publish, both fetchable a minute later, neither ever retried. On failure, arm one retry per app at the backoff (+50 ms so the backoff gate admits it). It re-runs the whole release check, so dedupe, staged, backoff and a newer release all still apply. Replaced by a newer failure, cleared on success and on stop, unref'd. The log says "retrying in Ns" because now it does. Tests first: 3 new in autoupdate-backoff (fail once, no further event, the install lands; repeated failures follow the schedule with one timer; stop clears timers), red without the timer. Suite (Bare) 56/56. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
b2cc1bb69dc8d2de30184e6597b60bc11147314e
by plankatron <fleeky@gmail.com> · 2026-10-13T23:16:50.000Z

Changed files (3) +105 -2

lib/index.js +45 -1
……@@ -211,6 +211,12 @@ export class Sidecar {
211211 // (2026-07-13). We back off exponentially per (name, token); a new token
212212 // (version/tree bump) resets the counter for a fresh attempt.
213213 this._installFailures = new Map()
214 // pear-git #47: one armed retry per app whose last autoupdate failed. The backoff above only gated
215 // the NEXT registry 'update' event, so after a failure nothing retried until some unrelated release
216 // was published — observed for pgh-sidecar 0.2.68 and favela-gateway 0.2.0, both failing on a tree
217 // the publish had created seconds earlier and fetchable a minute later. The retry re-runs the whole
218 // release check, so every gate (dedupe, staged, backoff, a newer release) still applies.
219 this._installRetryTimers = new Map()
214220 // Overlay subsystem — per-repo file metadata index + cross-peer file
215221 // serving. Initialised lazily during start() because it needs the
216222 // identity (for the swarm topic). Stays null when enableSeed === false
……@@ -578,6 +584,7 @@ export class Sidecar {
578584
579585 async stop () {
580586 if (!this._started) return
587 this._clearInstallRetries()
581588 if (this._compactTimer) {
582589 clearInterval(this._compactTimer)
583590 this._compactTimer = null
……@@ -729,6 +736,8 @@ export class Sidecar {
729736 console.error('[pgh-sidecar] release-notify failed:', err.message)
730737 })
731738 }
739 // The same check an armed install retry runs (pear-git #47).
740 this._fireReleaseCheck = fire
732741 try { this._registry._base.on('update', fire) } catch {}
733742 for (const r of this._releaseReplicas) {
734743 try { r.registry._base.on('update', fire) } catch {}
……@@ -822,6 +831,7 @@ export class Sidecar {
822831 }
823832 this._lastNotifiedVersion.set(name, token)
824833 this._installFailures.delete(name)
834 this._cancelInstallRetry(name)
825835 } catch (err) {
826836 // Record the failure and back off. Don't poison _lastNotifiedVersion so
827837 // a later fire (once the backoff elapses, or a new version appears) gets
……@@ -832,13 +842,47 @@ export class Sidecar {
832842 const backoffMs = this._installBackoffMs(count)
833843 this._installFailures.set(name, { token, count, nextRetryAt: Date.now() + backoffMs })
834844 console.error('[pgh-sidecar] autoupdate failed for ' + name + ': ' + err.message +
835 ' (attempt ' + count + '; backing off ' + Math.round(backoffMs / 1000) + 's)')
845 ' (attempt ' + count + '; retrying in ' + Math.round(backoffMs / 1000) + 's)')
846 // ⛔ And actually retry: the backoff alone only gates the next event (pear-git #47).
847 this._armInstallRetry(name, backoffMs)
836848 } finally {
837849 this._installInFlight.delete(name)
838850 }
839851 }
840852 }
841853
854 // pear-git #47: after a failed autoupdate, re-run the release check once the backoff has elapsed. One
855 // timer per app (a newer failure replaces it); cleared on success and on stop; unref'd so it never holds
856 // the process open. The small margin lands the check just past nextRetryAt, so the backoff gate admits it.
857 // Lazily created, so an instance built without the constructor (the stub tests) still works.
858 _retryTimers () {
859 if (!this._installRetryTimers) this._installRetryTimers = new Map()
860 return this._installRetryTimers
861 }
862
863 _armInstallRetry (name, backoffMs) {
864 this._cancelInstallRetry(name)
865 const timer = setTimeout(() => {
866 this._retryTimers().delete(name)
867 const fire = this._fireReleaseCheck
868 if (fire) fire()
869 }, backoffMs + 50)
870 if (timer.unref) timer.unref()
871 this._retryTimers().set(name, timer)
872 }
873
874 _cancelInstallRetry (name) {
875 const timers = this._retryTimers()
876 const t = timers.get(name)
877 if (t) { clearTimeout(t); timers.delete(name) }
878 }
879
880 _clearInstallRetries () {
881 const timers = this._retryTimers()
882 for (const t of timers.values()) clearTimeout(t)
883 timers.clear()
884 }
885
842886 // Exponential backoff for a persistently-failing autoupdate. Attempt 1 waits
843887 // 30s, then doubles (1m, 2m, 4m, …) capped at 1h. Bounds the retry rate of an
844888 // un-installable release so a stuck app can't drive an unbounded fd/socket
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh-sidecar",
3 "version": "0.2.68",
3 "version": "0.2.69",
44 "description": "Small optional user-level daemon for pgh \u2014 shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host",
55 "type": "module",
66 "main": "lib/index.js",
test/autoupdate-backoff.test.js +59 -0
……@@ -55,3 +55,62 @@ test('_isInInstallBackoff: only the failing (name, token) inside its window is s
5555 // A different app is unaffected.
5656 t.absent(s._isInInstallBackoff('other-app', token), 'unrelated app ⇒ attempt allowed')
5757})
58
59// ── pear-git #47: a failed autoupdate must RETRY by itself, not wait for an unrelated publish ───────
60//
61// Observed twice: pgh-sidecar 0.2.68 (2026-10-12) and favela-gateway 0.2.0 (2026-10-13), both
62// "tree … not found on any of 1 peer(s) (attempt 1; backing off 30s)" right after the publish — and then
63// nothing, ever: the backoff only gated the NEXT registry 'update' event, and none came. The release was
64// fetchable a minute later. A retry has to be scheduled, not hoped for.
65
66function releaseStub ({ failures = 1, backoffMs = 40 } = {}) {
67 const s = Object.create(Sidecar.prototype)
68 s._installFailures = new Map()
69 s._installInFlight = new Set()
70 s._lastNotifiedVersion = new Map()
71 s._releaseReplicas = []
72 s._installRetryTimers = new Map()
73 s._registry = { latest: async () => ({ version: '0.2.0', treeHash: 'ab35f67f0b03aa', publishedAt: 1 }) }
74 s._listUpdateCandidates = async () => [{ name: 'favela-gateway', currentVersion: '0.1.0', currentTreeHash: 'old', stagedVersion: null, registered: false, udsPath: null }]
75 s._isAutoupdateEnabled = () => true
76 s._reconcileBootstrap = async () => {}
77 s._installBackoffMs = () => backoffMs
78 s.installs = []
79 let left = failures
80 s._installRelease = async (name, best) => {
81 s.installs.push(best.version)
82 if (left-- > 0) throw new Error('NetworkFetcher: tree 62d8ae7eac6d… not found on any of 1 peer(s)')
83 }
84 // What _watchForReleases wires; the retry timer calls the same check.
85 s._fireReleaseCheck = () => s._notifyAppsOfNewReleases()
86 return s
87}
88
89const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms))
90
91test('⛔ #47: a failed install is retried after its backoff with NO further registry event', async (t) => {
92 const s = releaseStub({ failures: 1, backoffMs: 40 })
93 await s._notifyAppsOfNewReleases() // the one event: fails
94 t.is(s.installs.length, 1, 'first attempt failed')
95 await wait(250) // no event of any kind
96 t.is(s.installs.length, 2, 'the retry ran on its own')
97 t.is(s._lastNotifiedVersion.get('favela-gateway'), '0.2.0@ab35f67f0b03aa', 'and the release landed')
98 t.is(s._installFailures.size, 0, 'the failure record is cleared')
99 t.is(s._installRetryTimers.size, 0, 'and no timer is left armed')
100})
101
102test('a release that keeps failing is retried on the backoff schedule, one timer at a time', async (t) => {
103 const s = releaseStub({ failures: 3, backoffMs: 30 })
104 await s._notifyAppsOfNewReleases()
105 await wait(400)
106 t.is(s.installs.length, 4, '3 failures then success, each retry scheduled by the previous failure')
107 t.is(s._installRetryTimers.size, 0)
108})
109
110test('stopping the retries clears every armed timer', async (t) => {
111 const s = releaseStub({ failures: 5, backoffMs: 60000 })
112 await s._notifyAppsOfNewReleases()
113 t.is(s._installRetryTimers.size, 1, 'one retry armed')
114 s._clearInstallRetries()
115 t.is(s._installRetryTimers.size, 0, 'cleared — nothing fires after the sidecar stops')
116})