| … | … | @@ -211,6 +211,12 @@ export class Sidecar { |
| 211 | 211 | // (2026-07-13). We back off exponentially per (name, token); a new token |
| 212 | 212 | // (version/tree bump) resets the counter for a fresh attempt. |
| 213 | 213 | 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() |
| 214 | 220 | // Overlay subsystem — per-repo file metadata index + cross-peer file |
| 215 | 221 | // serving. Initialised lazily during start() because it needs the |
| 216 | 222 | // identity (for the swarm topic). Stays null when enableSeed === false |
| … | … | @@ -578,6 +584,7 @@ export class Sidecar { |
| 578 | 584 | |
| 579 | 585 | async stop () { |
| 580 | 586 | if (!this._started) return |
| 587 | this._clearInstallRetries() |
| 581 | 588 | if (this._compactTimer) { |
| 582 | 589 | clearInterval(this._compactTimer) |
| 583 | 590 | this._compactTimer = null |
| … | … | @@ -729,6 +736,8 @@ export class Sidecar { |
| 729 | 736 | console.error('[pgh-sidecar] release-notify failed:', err.message) |
| 730 | 737 | }) |
| 731 | 738 | } |
| 739 | // The same check an armed install retry runs (pear-git #47). |
| 740 | this._fireReleaseCheck = fire |
| 732 | 741 | try { this._registry._base.on('update', fire) } catch {} |
| 733 | 742 | for (const r of this._releaseReplicas) { |
| 734 | 743 | try { r.registry._base.on('update', fire) } catch {} |
| … | … | @@ -822,6 +831,7 @@ export class Sidecar { |
| 822 | 831 | } |
| 823 | 832 | this._lastNotifiedVersion.set(name, token) |
| 824 | 833 | this._installFailures.delete(name) |
| 834 | this._cancelInstallRetry(name) |
| 825 | 835 | } catch (err) { |
| 826 | 836 | // Record the failure and back off. Don't poison _lastNotifiedVersion so |
| 827 | 837 | // a later fire (once the backoff elapses, or a new version appears) gets |
| … | … | @@ -832,13 +842,47 @@ export class Sidecar { |
| 832 | 842 | const backoffMs = this._installBackoffMs(count) |
| 833 | 843 | this._installFailures.set(name, { token, count, nextRetryAt: Date.now() + backoffMs }) |
| 834 | 844 | 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) |
| 836 | 848 | } finally { |
| 837 | 849 | this._installInFlight.delete(name) |
| 838 | 850 | } |
| 839 | 851 | } |
| 840 | 852 | } |
| 841 | 853 | |
| 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 | |
| 842 | 886 | // Exponential backoff for a persistently-failing autoupdate. Attempt 1 waits |
| 843 | 887 | // 30s, then doubles (1m, 2m, 4m, …) capped at 1h. Bounds the retry rate of an |
| 844 | 888 | // un-installable release so a stuck app can't drive an unbounded fd/socket |