OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 0da10fe

0.1.159: resolve a release by the protocol, not a 500 ms timer; update never downgrades
2026-10-11 stortron: the sidecar was wedged (UDS backlog 511/511, EAGAIN), so `pgh app update pear-git-v3` fell back to replicating the registry directly, slept a fixed 500 ms, and read `latest` from a half-synced view: 0.1.46 (April) instead of 0.1.254. Only a missing tree stopped it installing. - resolveRelease's direct-replica path now catches up event-driven: after swarm.flush(), core.update({ wait: true }) on the bootstrap core + every active writer (resolves once connected peers report their lengths), then download to that length (measured: autobase applies only local blocks, and base.update() alone still saw nothing), then base.update(); repeat until nothing grows and no new writer appears. settleMs is accepted and ignored. - `pgh app update` passes --no-downgrade: a resolved version older than the installed one is refused with a pointer at the stale view (isOlderVersion: dotted numeric, null = no opinion). Explicit @version installs unguarded. test/resolve-release-cold-replica.test.js: cold replica over a local testnet with settleMs 0 resolved null before (red), 0.1.40 of 40 after; no-peer case returns null without hanging; isOlderVersion cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
0da10feef825c3ff1451572026370d4642f5e967
by plankatron <fleeky@gmail.com> · 2026-10-11T22:40:57.000Z

Changed files (4) +167 -10

lib/commands/app.js +17 -2
……@@ -17,7 +17,7 @@ import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js'
1717import { installComplete, resolveBundledRuntime, launcherScript, livePath } from '../runtime-factory.js'
1818import { materializeAtomic, applyStaged } from '../materializers/atomic.js'
1919import { ReleaseRegistry } from '../release-registry.js'
20import { resolveRelease as sharedResolveRelease } from '../resolve-release.js'
20import { resolveRelease as sharedResolveRelease, isOlderVersion } from '../resolve-release.js'
2121import { NetworkFetcher } from '../network-fetcher.js'
2222import {
2323 appDir, pidFile, logFile, installManifestFile,
……@@ -222,7 +222,7 @@ export async function run (client, args) {
222222 if (!pgh) throw new Error(name + ': pgh.upgrade is not a pgh:// URL: ' + upgradeUrl)
223223
224224 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'])
226226 }
227227
228228 // Swap in an update that was STAGED because the app was running (see
……@@ -423,6 +423,19 @@ export async function run (client, args) {
423423
424424// ── Install via pgh:// registry resolution ───────────────────
425425
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.
430function 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
426439// Resolve a pgh:// reference to a release entry — sidecar-first (live replica),
427440// else swarm-replicate the publisher's ReleaseRegistry. Mirrors the HEAD of
428441// installViaRegistry. Caller owns closing `registry`/`swarm`. This is the
……@@ -456,6 +469,7 @@ async function installViaRuntime (pgh, args) {
456469 const { entry, cleanup } = await resolveRelease(pgh)
457470 if (!entry) { await cleanup(); throw new Error('no release found for ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' in this registry') }
458471 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 }
459473
460474 const cryptoMod = (await import('hypercore-crypto')).default
461475 const registryTopic = cryptoMod.discoveryKey(b4a.from(pgh.registryKeyHex, 'hex'))
……@@ -624,6 +638,7 @@ async function installViaRegistry (pgh, args) {
624638 }
625639
626640 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 }
627642
628643 // Multi-source content distribution. Two candidate deploy topics, tried
629644 // in order:
lib/resolve-release.js +60 -7
……@@ -56,7 +56,8 @@ export function registryStoreDir (registryKeyHex) {
5656 * @param {string} opts.name app name
5757 * @param {string} [opts.version] exact version; omitted = latest
5858 * @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)
6061 * @param {string} [opts.storeDir] where to keep the registry replica. Defaults to a path under
6162 * the home directory, which does not exist on Android — an embedder must pass its own.
6263 * @returns {Promise<{ entry: object|null, via: string, cleanup: function }>}
……@@ -69,7 +70,6 @@ export async function resolveRelease (opts = {}) {
6970 if (!name) throw new Error('resolveRelease requires a name')
7071
7172 const preferSidecar = opts.preferSidecar !== false
72 const settleMs = opts.settleMs != null ? opts.settleMs : 500
7373
7474 const noop = async () => {}
7575 let entry = null
……@@ -119,15 +119,16 @@ export async function resolveRelease (opts = {}) {
119119 try {
120120 registry = new ReleaseRegistry(store, { bootstrap: b4a.from(registryKeyHex, 'hex') })
121121 await registry.ready()
122 swarm = new Hyperswarm()
122 swarm = new Hyperswarm(opts.bootstrap ? { bootstrap: opts.bootstrap } : {})
123123 swarm.on('connection', (conn) => store.replicate(conn))
124124 // client-only: we are reading someone else's registry, not serving ours.
125125 swarm.join(registry._base.discoveryKey, { server: false, client: true })
126126 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)
131132 entry = version ? await registry.get(name, version) : await registry.latest(name)
132133 } catch (err) {
133134 await cleanup()
……@@ -137,6 +138,58 @@ export async function resolveRelease (opts = {}) {
137138 return { entry, via: 'replica', cleanup }
138139}
139140
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 */
146export 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 */
169async 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
140193/**
141194 * A `getLatestRelease(name)` for pgh-runtime's fetcher shape, backed by the above.
142195 *
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.158",
3 "version": "0.1.159",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/resolve-release-cold-replica.test.js +89 -0
……@@ -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
12import test from 'brittle'
13import fs from '#node:fs'
14import path from '#node:path'
15import os from '#node:os'
16import Corestore from 'corestore'
17import Hyperswarm from 'hyperswarm'
18import createTestnet from 'hyperdht/testnet'
19
20import { ReleaseRegistry } from '../lib/release-registry.js'
21import { resolveRelease } from '../lib/resolve-release.js'
22
23const tmp = (p) => fs.mkdtempSync(path.join(os.tmpdir(), p + '-'))
24
25async 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
40test('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
60test('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
80test('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})