| … | … | @@ -739,7 +739,7 @@ export class Sidecar { |
| 739 | 739 | if (candidates.length === 0) return |
| 740 | 740 | const registries = [this._registry, ...this._releaseReplicas.map(r => r.registry)] |
| 741 | 741 | for (const cand of candidates) { |
| 742 | const { name, currentVersion, currentTreeHash, registered, udsPath } = cand | |
| 742 | const { name, currentVersion, currentTreeHash, stagedVersion, registered, udsPath } = cand | |
| 743 | 743 | let best = null |
| 744 | 744 | for (const reg of registries) { |
| 745 | 745 | try { |
| … | … | @@ -768,6 +768,13 @@ export class Sidecar { |
| 768 | 768 | this._lastNotifiedVersion.set(name, token) |
| 769 | 769 | continue |
| 770 | 770 | } |
| 771 | // Already STAGED: the app was running when this version was installed, so | |
| 772 | // pgh left it as <name>.next to apply at the app's next launch (pgh | |
| 773 | // materializers/atomic.js). Re-installing would only rebuild the same tree. | |
| 774 | if (stagedVersion && best.version && stagedVersion === best.version) { | |
| 775 | this._lastNotifiedVersion.set(name, token) | |
| 776 | continue | |
| 777 | } | |
| 771 | 778 | |
| 772 | 779 | // Auto-install new release (skip if user explicitly disabled). |
| 773 | 780 | const autoupdate = this._isAutoupdateEnabled(name) |
| … | … | @@ -869,6 +876,7 @@ export class Sidecar { |
| 869 | 876 | name, |
| 870 | 877 | currentVersion: version, |
| 871 | 878 | currentTreeHash: treeHash, |
| 879 | stagedVersion: this._stagedVersion(name, readInstallManifest), | |
| 872 | 880 | registered: true, |
| 873 | 881 | udsPath: app.udsPath |
| 874 | 882 | }) |
| … | … | @@ -882,6 +890,7 @@ export class Sidecar { |
| 882 | 890 | name: n, |
| 883 | 891 | currentVersion: m.version || null, |
| 884 | 892 | currentTreeHash: m.treeHash || null, |
| 893 | stagedVersion: this._stagedVersion(n, readInstallManifest), | |
| 885 | 894 | registered: false, |
| 886 | 895 | udsPath: null |
| 887 | 896 | }) |
| … | … | @@ -894,6 +903,13 @@ export class Sidecar { |
| 894 | 903 | return [...seen.values()] |
| 895 | 904 | } |
| 896 | 905 | |
| 906 | // Version of an update pgh STAGED because the app was running (<name>.next, | |
| 907 | // which carries its own install manifest), or null. | |
| 908 | _stagedVersion (name, readInstallManifest) { | |
| 909 | if (!readInstallManifest) return null | |
| 910 | try { return (readInstallManifest(name + '.next') || {}).version || null } catch { return null } | |
| 911 | } | |
| 912 | ||
| 897 | 913 | // Admin setting, apps.json `bootstrapServeAllArches`. Unlike autoupdate this |
| 898 | 914 | // defaults to FALSE: holding another arch's ~150MB tarball only pays off on a |
| 899 | 915 | // machine that actually answers cold-start HTTPS /bootstrap fetches, i.e. a |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh-sidecar", |
| 3 | "version": "0.2.62", | |
| 3 | "version": "0.2.63", | |
| 4 | 4 | "description": "Small optional user-level daemon for pgh \u2014 shared identity vault, OTA polling, device linking, bootstrap seed, NameRegistry host", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "lib/index.js", |
| … | … | @@ -0,0 +1,53 @@ |
| 1 | /** | |
| 2 | * Autoupdate does not re-install a version pgh already STAGED. | |
| 3 | * | |
| 4 | * pgh never swaps a running app's tree: an install that finds the app running | |
| 5 | * leaves the new tree as <name>.next, applied at the app's next launch (pgh | |
| 6 | * lib/materializers/atomic.js). The live manifest still says the old version, | |
| 7 | * so after a sidecar restart (empty _lastNotifiedVersion) every update tick | |
| 8 | * would rebuild and re-stage the same tree. The staged manifest's version is | |
| 9 | * the tie-breaker. | |
| 10 | */ | |
| 11 | import test from 'brittle' | |
| 12 | import { Sidecar } from '../lib/index.js' | |
| 13 | ||
| 14 | function stub (cand) { | |
| 15 | const s = Object.create(Sidecar.prototype) | |
| 16 | s._installFailures = new Map() | |
| 17 | s._installInFlight = new Set() | |
| 18 | s._lastNotifiedVersion = new Map() | |
| 19 | s._releaseReplicas = [] | |
| 20 | s._registry = { latest: async () => ({ version: '2', treeHash: 't2', publishedAt: 1 }) } | |
| 21 | s._listUpdateCandidates = async () => [cand] | |
| 22 | s._isAutoupdateEnabled = () => true | |
| 23 | s._reconcileBootstrap = async () => {} | |
| 24 | s.installs = [] | |
| 25 | s._installRelease = async (name, best) => { s.installs.push(name + '@' + best.version) } | |
| 26 | return s | |
| 27 | } | |
| 28 | ||
| 29 | test('a release already staged for the running app is not installed again', async (t) => { | |
| 30 | const s = stub({ name: 'app', currentVersion: '1', currentTreeHash: 't1', stagedVersion: '2', registered: false }) | |
| 31 | await s._notifyAppsOfNewReleases() | |
| 32 | t.alike(s.installs, [], 'no install: version 2 is already staged') | |
| 33 | }) | |
| 34 | ||
| 35 | test('a newer release than the staged one is installed', async (t) => { | |
| 36 | const s = stub({ name: 'app', currentVersion: '1', currentTreeHash: 't1', stagedVersion: '1.5', registered: false }) | |
| 37 | await s._notifyAppsOfNewReleases() | |
| 38 | t.alike(s.installs, ['app@2']) | |
| 39 | }) | |
| 40 | ||
| 41 | test('nothing staged: installed as before', async (t) => { | |
| 42 | const s = stub({ name: 'app', currentVersion: '1', currentTreeHash: 't1', stagedVersion: null, registered: false }) | |
| 43 | await s._notifyAppsOfNewReleases() | |
| 44 | t.alike(s.installs, ['app@2']) | |
| 45 | }) | |
| 46 | ||
| 47 | test('_stagedVersion reads <name>.next\'s manifest', (t) => { | |
| 48 | const s = Object.create(Sidecar.prototype) | |
| 49 | const read = (n) => (n === 'app.next' ? { version: '2' } : null) | |
| 50 | t.is(s._stagedVersion('app', read), '2') | |
| 51 | t.is(s._stagedVersion('other', read), null) | |
| 52 | t.is(s._stagedVersion('app', null), null) | |
| 53 | }) |