| … | … | @@ -17,7 +17,7 @@ import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js' |
| 17 | 17 | import { installComplete, resolveBundledRuntime, launcherScript, livePath } from '../runtime-factory.js' |
| 18 | 18 | import { materializeAtomic, applyStaged } from '../materializers/atomic.js' |
| 19 | 19 | import { ReleaseRegistry } from '../release-registry.js' |
| 20 | import { resolveRelease as sharedResolveRelease } from '../resolve-release.js' | |
| 20 | import { resolveRelease as sharedResolveRelease, isOlderVersion } from '../resolve-release.js' | |
| 21 | 21 | import { NetworkFetcher } from '../network-fetcher.js' |
| 22 | 22 | import { |
| 23 | 23 | appDir, pidFile, logFile, installManifestFile, |
| … | … | @@ -222,7 +222,7 @@ export async function run (client, args) { |
| 222 | 222 | if (!pgh) throw new Error(name + ': pgh.upgrade is not a pgh:// URL: ' + upgradeUrl) |
| 223 | 223 | |
| 224 | 224 | console.log('Updating ' + name + ' from ' + upgradeUrl + '...') |
| 225 | return await installViaRegistry(pgh, ['install', upgradeUrl, '--name', name]) | |
| 225 | return await installViaRegistry(pgh, ['install', upgradeUrl, '--name', name, '--no-downgrade']) | |
| 226 | 226 | } |
| 227 | 227 | |
| 228 | 228 | // Swap in an update that was STAGED because the app was running (see |
| … | … | @@ -423,6 +423,19 @@ export async function run (client, args) { |
| 423 | 423 | |
| 424 | 424 | // ── Install via pgh:// registry resolution ─────────────────── |
| 425 | 425 | |
| 426 | // `pgh app update` must never move an app BACKWARDS. 2026-10-11 on stortron a | |
| 427 | // half-synced registry view resolved pear-git-v3 "latest" = 0.1.46 (April) while | |
| 428 | // 0.1.254 was current; only a missing tree stopped the install. An explicit | |
| 429 | // `@version` install is not an update and is not guarded. | |
| 430 | function refuseDowngrade (args, installName, entry) { | |
| 431 | if (!args.includes('--no-downgrade')) return | |
| 432 | const cur = readInstallManifest(installName) | |
| 433 | if (cur && isOlderVersion(entry.version, cur.version) === true) { | |
| 434 | throw new Error('refusing to downgrade ' + installName + ' from ' + cur.version + ' to ' + entry.version + | |
| 435 | ': the registry view looks stale. Check the sidecar (`pgh releases latest ' + entry.name + '`) and retry.') | |
| 436 | } | |
| 437 | } | |
| 438 | ||
| 426 | 439 | // Resolve a pgh:// reference to a release entry — sidecar-first (live replica), |
| 427 | 440 | // else swarm-replicate the publisher's ReleaseRegistry. Mirrors the HEAD of |
| 428 | 441 | // installViaRegistry. Caller owns closing `registry`/`swarm`. This is the |
| … | … | @@ -456,6 +469,7 @@ async function installViaRuntime (pgh, args) { |
| 456 | 469 | const { entry, cleanup } = await resolveRelease(pgh) |
| 457 | 470 | if (!entry) { await cleanup(); throw new Error('no release found for ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' in this registry') } |
| 458 | 471 | console.log(' found ' + entry.name + '@' + entry.version + ' tree ' + entry.treeHash.slice(0, 12) + '...') |
| 472 | try { refuseDowngrade(args, installName, entry) } catch (err) { await cleanup(); throw err } | |
| 459 | 473 | |
| 460 | 474 | const cryptoMod = (await import('hypercore-crypto')).default |
| 461 | 475 | const registryTopic = cryptoMod.discoveryKey(b4a.from(pgh.registryKeyHex, 'hex')) |
| … | … | @@ -624,6 +638,7 @@ async function installViaRegistry (pgh, args) { |
| 624 | 638 | } |
| 625 | 639 | |
| 626 | 640 | console.log(' found ' + entry.name + '@' + entry.version + ' tree ' + entry.treeHash.slice(0, 12) + '...') |
| 641 | try { refuseDowngrade(args, installName, entry) } catch (err) { await closeResolution(); throw err } | |
| 627 | 642 | |
| 628 | 643 | // Multi-source content distribution. Two candidate deploy topics, tried |
| 629 | 644 | // in order: |
| … | … | @@ -56,7 +56,8 @@ export function registryStoreDir (registryKeyHex) { |
| 56 | 56 | * @param {string} opts.name app name |
| 57 | 57 | * @param {string} [opts.version] exact version; omitted = latest |
| 58 | 58 | * @param {boolean} [opts.preferSidecar=true] try the sidecar's replica first |
| 59 | * @param {number} [opts.settleMs=500] post-flush settle before reading the view | |
| 59 | * @param {number} [opts.settleMs] IGNORED (was a post-flush sleep; see catchUp) | |
| 60 | * @param {Array} [opts.bootstrap] DHT bootstrap nodes (tests: a local testnet) | |
| 60 | 61 | * @param {string} [opts.storeDir] where to keep the registry replica. Defaults to a path under |
| 61 | 62 | * the home directory, which does not exist on Android — an embedder must pass its own. |
| 62 | 63 | * @returns {Promise<{ entry: object|null, via: string, cleanup: function }>} |
| … | … | @@ -69,7 +70,6 @@ export async function resolveRelease (opts = {}) { |
| 69 | 70 | if (!name) throw new Error('resolveRelease requires a name') |
| 70 | 71 | |
| 71 | 72 | const preferSidecar = opts.preferSidecar !== false |
| 72 | const settleMs = opts.settleMs != null ? opts.settleMs : 500 | |
| 73 | 73 | |
| 74 | 74 | const noop = async () => {} |
| 75 | 75 | let entry = null |
| … | … | @@ -119,15 +119,16 @@ export async function resolveRelease (opts = {}) { |
| 119 | 119 | try { |
| 120 | 120 | registry = new ReleaseRegistry(store, { bootstrap: b4a.from(registryKeyHex, 'hex') }) |
| 121 | 121 | await registry.ready() |
| 122 | swarm = new Hyperswarm() | |
| 122 | swarm = new Hyperswarm(opts.bootstrap ? { bootstrap: opts.bootstrap } : {}) | |
| 123 | 123 | swarm.on('connection', (conn) => store.replicate(conn)) |
| 124 | 124 | // client-only: we are reading someone else's registry, not serving ours. |
| 125 | 125 | swarm.join(registry._base.discoveryKey, { server: false, client: true }) |
| 126 | 126 | await swarm.flush() |
| 127 | // The view is built by autobase's apply as blocks arrive; flush returns when | |
| 128 | // peers are connected, not when the log has caught up. | |
| 129 | await new Promise(r => setTimeout(r, settleMs)) | |
| 130 | await registry.update() | |
| 127 | // flush returns when peers are CONNECTED, not when the log has caught up — | |
| 128 | // so catch up by the protocol, never by a timer. A fixed 500 ms settle read a | |
| 129 | // half-synced view on stortron 2026-10-11 and resolved pear-git-v3 0.1.46 | |
| 130 | // (April) instead of 0.1.254. `settleMs` is accepted and ignored. | |
| 131 | await catchUp(registry._base, registry._store) | |
| 131 | 132 | entry = version ? await registry.get(name, version) : await registry.latest(name) |
| 132 | 133 | } catch (err) { |
| 133 | 134 | await cleanup() |
| … | … | @@ -137,6 +138,58 @@ export async function resolveRelease (opts = {}) { |
| 137 | 138 | return { entry, via: 'replica', cleanup } |
| 138 | 139 | } |
| 139 | 140 | |
| 141 | /** | |
| 142 | * Is `candidate` an OLDER version than `installed`? Dotted numeric compare | |
| 143 | * (missing segment = 0). null when either is not purely numeric — no opinion, | |
| 144 | * the caller must not guess. | |
| 145 | */ | |
| 146 | export function isOlderVersion (candidate, installed) { | |
| 147 | const parse = (v) => (typeof v === 'string' && /^\d+(\.\d+)*$/.test(v)) ? v.split('.').map(Number) : null | |
| 148 | const a = parse(candidate) | |
| 149 | const b = parse(installed) | |
| 150 | if (!a || !b) return null | |
| 151 | for (let i = 0; i < Math.max(a.length, b.length); i++) { | |
| 152 | const x = a[i] || 0 | |
| 153 | const y = b[i] || 0 | |
| 154 | if (x !== y) return x < y | |
| 155 | } | |
| 156 | return false | |
| 157 | } | |
| 158 | ||
| 159 | /** | |
| 160 | * Bring a freshly replicated registry view up to what the connected peers hold. | |
| 161 | * | |
| 162 | * With peers connected, `core.update({ wait: true })` resolves once every peer | |
| 163 | * has reported its length (replicator: `peer.remoteSynced`) — true if that | |
| 164 | * upgraded us. Then download up to that length, and `base.update()` applies it. Applying can reveal | |
| 165 | * more writers (add-writer ops), so repeat until a pass over the bootstrap core | |
| 166 | * plus every active writer grows nothing and reveals no new writer. With no | |
| 167 | * peers, update() returns false at once: we read what we have. | |
| 168 | */ | |
| 169 | async function catchUp (base, store) { | |
| 170 | const boot = store.get({ key: base.key }) | |
| 171 | try { | |
| 172 | await boot.ready() | |
| 173 | let seen = 0 | |
| 174 | for (;;) { | |
| 175 | const cores = [boot, ...[...base.activeWriters].map(w => w.core)] | |
| 176 | const grew = await Promise.all(cores.map(c => c.update({ wait: true }).catch(() => false))) | |
| 177 | // update() learns the LENGTH; autobase applies only blocks already on | |
| 178 | // disk and does not fetch them (measured: base.update() right after a | |
| 179 | // successful update() still saw nothing). Download up to that length. | |
| 180 | await Promise.all(cores.map(c => c.contiguousLength < c.length | |
| 181 | ? c.download({ start: c.contiguousLength, end: c.length }).done().catch(() => {}) | |
| 182 | : null)) | |
| 183 | await base.update() | |
| 184 | const writers = base.activeWriters.size | |
| 185 | if (!grew.some(Boolean) && writers === seen) return | |
| 186 | seen = writers | |
| 187 | } | |
| 188 | } finally { | |
| 189 | await boot.close() | |
| 190 | } | |
| 191 | } | |
| 192 | ||
| 140 | 193 | /** |
| 141 | 194 | * A `getLatestRelease(name)` for pgh-runtime's fetcher shape, backed by the above. |
| 142 | 195 | * |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh", |
| 3 | "version": "0.1.158", | |
| 3 | "version": "0.1.159", | |
| 4 | 4 | "description": "pgh \u2014 P2P content-addressed package manager and app launcher", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -0,0 +1,89 @@ |
| 1 | /** | |
| 2 | * resolveRelease's direct-replica path must not depend on a settle TIMER. | |
| 3 | * | |
| 4 | * 2026-10-11, stortron: the sidecar was wedged (UDS backlog full, EAGAIN), so | |
| 5 | * `pgh app update pear-git-v3` fell back to replicating the registry itself, | |
| 6 | * slept a fixed 500 ms, and read `latest` from a half-synced view: 0.1.46 (April) | |
| 7 | * instead of 0.1.254. Correctness has to come from the replication protocol — | |
| 8 | * findingPeers + update({ wait: true }) — not from how long we waited. | |
| 9 | * settleMs: 0 makes the old timer-dependence deterministic. | |
| 10 | */ | |
| 11 | ||
| 12 | import test from 'brittle' | |
| 13 | import fs from '#node:fs' | |
| 14 | import path from '#node:path' | |
| 15 | import os from '#node:os' | |
| 16 | import Corestore from 'corestore' | |
| 17 | import Hyperswarm from 'hyperswarm' | |
| 18 | import createTestnet from 'hyperdht/testnet' | |
| 19 | ||
| 20 | import { ReleaseRegistry } from '../lib/release-registry.js' | |
| 21 | import { resolveRelease } from '../lib/resolve-release.js' | |
| 22 | ||
| 23 | const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p + '-')) | |
| 24 | ||
| 25 | async function publisher (t, bootstrap, versions) { | |
| 26 | const store = new Corestore(tmp('pub-store')) | |
| 27 | const registry = new ReleaseRegistry(store) | |
| 28 | await registry.ready() | |
| 29 | for (const [version, publishedAt] of versions) { | |
| 30 | await registry.add({ name: 'app', version, publishedAt, treeHash: 'f'.repeat(64) }) | |
| 31 | } | |
| 32 | const swarm = new Hyperswarm({ bootstrap }) | |
| 33 | swarm.on('connection', (c) => store.replicate(c)) | |
| 34 | swarm.join(registry._base.discoveryKey, { server: true, client: false }) | |
| 35 | await swarm.flush() | |
| 36 | t.teardown(async () => { await swarm.destroy(); await registry.close(); await store.close() }) | |
| 37 | return registry | |
| 38 | } | |
| 39 | ||
| 40 | test('cold direct replica resolves the NEWEST release with no settle time', async (t) => { | |
| 41 | const testnet = await createTestnet(3) | |
| 42 | t.teardown(() => testnet.destroy()) | |
| 43 | const versions = [] | |
| 44 | for (let i = 1; i <= 40; i++) versions.push(['0.1.' + i, new Date(Date.UTC(2026, 0, i)).toISOString()]) | |
| 45 | const reg = await publisher(t, testnet.bootstrap, versions) | |
| 46 | ||
| 47 | const { entry, via, cleanup } = await resolveRelease({ | |
| 48 | registryKeyHex: reg.keyHex, | |
| 49 | name: 'app', | |
| 50 | preferSidecar: false, | |
| 51 | settleMs: 0, | |
| 52 | storeDir: tmp('cold-replica'), | |
| 53 | bootstrap: testnet.bootstrap | |
| 54 | }) | |
| 55 | await cleanup() | |
| 56 | t.is(via, 'replica') | |
| 57 | t.is(entry && entry.version, '0.1.40', 'latest is the newest published, not whatever had arrived') | |
| 58 | }) | |
| 59 | ||
| 60 | test('no peer at all: resolves null once discovery ends (no hang, no timer)', async (t) => { | |
| 61 | const testnet = await createTestnet(3) | |
| 62 | t.teardown(() => testnet.destroy()) | |
| 63 | const lonely = new Corestore(tmp('lonely')) | |
| 64 | const reg = new ReleaseRegistry(lonely) | |
| 65 | await reg.ready() | |
| 66 | const key = reg.keyHex | |
| 67 | await reg.close(); await lonely.close() | |
| 68 | ||
| 69 | const { entry, cleanup } = await resolveRelease({ | |
| 70 | registryKeyHex: key, | |
| 71 | name: 'app', | |
| 72 | preferSidecar: false, | |
| 73 | storeDir: tmp('cold-replica-2'), | |
| 74 | bootstrap: testnet.bootstrap | |
| 75 | }) | |
| 76 | await cleanup() | |
| 77 | t.is(entry, null) | |
| 78 | }) | |
| 79 | ||
| 80 | test('isOlderVersion — numeric dotted compare; null when not comparable', async (t) => { | |
| 81 | const { isOlderVersion } = await import('../lib/resolve-release.js') | |
| 82 | t.is(isOlderVersion('0.1.46', '0.1.254'), true, 'the stortron case') | |
| 83 | t.is(isOlderVersion('0.1.254', '0.1.251'), false) | |
| 84 | t.is(isOlderVersion('0.1.254', '0.1.254'), false, 'same is not older') | |
| 85 | t.is(isOlderVersion('0.1431.0', '0.1429.0'), false) | |
| 86 | t.is(isOlderVersion('1.0', '0.9.9'), false, 'missing segment = 0') | |
| 87 | t.is(isOlderVersion('master-ed31de1', '0.1.1'), null, 'non-numeric: no opinion') | |
| 88 | t.is(isOlderVersion('0.1.5', undefined), null) | |
| 89 | }) |