OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 62da124

registry: make release resolution reachable without a sidecar
NetworkFetcher's header called this a follow-on: getLatestRelease currently reads from the LOCAL ReleaseRegistry. Replication of the publisher's remote registry is a follow-on. It was not. The capability already existed as resolveRelease() — private to commands/app.js, reachable only by running `pgh app install`. So an embedded updater could fetch trees and content over Hyperswarm but had no way to learn WHICH tree to fetch, which is the whole of why standalone OTA was blocked. Extracted to lib/resolve-release.js, behaviour unchanged, with both hazards its original comments document preserved: ORDER. Sidecar first, direct replication second — not a preference. Both open the same ~/.config/pgh/registries/<key16> Corestore and it takes an EXCLUSIVE fd-lock, so on a machine whose sidecar is also re-opening it a direct open loses the race with "File descriptor could not be locked". That is why createRuntime routed getLatestRelease through the sidecar. Path 2 exists for machines with no sidecar at all — every standalone install, every Windows one. CLEANUP. ReleaseRegistry.close() closes only the autobase. Dropping the handle without closing the store orphans its fd-lock for the life of the process and every later resolve in it fails. cleanup() closes registry, swarm AND store. createRuntime now takes opts.registryKeyHex (the hex already in pgh.upgrade) and uses the shared resolver when given it, falling back to the sidecar-only path otherwise — so existing callers are unaffected and a standalone one works. commands/app.js keeps its resolveRelease name as a thin wrapper, so there is one implementation rather than two that drift. Proven against the live registry, sidecar explicitly bypassed (preferSidecar:false): resolved hyper-tps@0.916.0, tree bcc7017bff8d98c4, serverKey d92e2f4e4caef42c, 463 deps, via=replica, 3.8s. One bug of my own, caught by the suite rather than by review: resolve-release.js read process.env without importing process. Fine under node, fatal under bare where process is not a global — runtime-factory's first import line says exactly that. Tests run under bare, which is how it surfaced. runtime-closure-install, launcher-runtime-path, materializer-resolve and pgh-tree-install all pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
62da124728a9bc15d29b64d99c7fd308da8c8e2b
by plankatron <fleeky@gmail.com> · 2026-08-25T21:58:14.000Z

Changed files (4) +185 -56

lib/commands/app.js +14 -50
……@@ -16,6 +16,7 @@ import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../store.js'
1616import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js'
1717import { installComplete, resolveBundledRuntime } from '../runtime-factory.js'
1818import { ReleaseRegistry } from '../release-registry.js'
19import { resolveRelease as sharedResolveRelease } from '../resolve-release.js'
1920import { NetworkFetcher } from '../network-fetcher.js'
2021import {
2122 appDir, pidFile, logFile, installManifestFile,
……@@ -459,50 +460,18 @@ export async function run (client, args) {
459460// installViaRegistry. Caller owns closing `registry`/`swarm`. This is the
460461// "registry/identity" half that stays in pgh (the engine half is the runtime).
461462async function resolveRelease (pgh) {
462 const Corestore = (await import('corestore')).default
463 const Hyperswarm = (await import('hyperswarm')).default
464 let store = null; let registry = null; let swarm = null; let entry = null
465
466 if (fs.existsSync(SIDECAR_SOCK)) {
467 let c = null
468 try {
469 c = await Client.connectUds(SIDECAR_SOCK)
470 const res = await c.call('resolve-release', { registryKey: pgh.registryKeyHex, name: pgh.name, version: pgh.version || undefined })
471 if (res && res.found && res.entry) { entry = res.entry; console.log(' resolved via pgh-sidecar (live replica)') }
472 } catch {} finally { try { if (c) await c.close() } catch {} }
473 }
474
475 if (!entry) {
476 const registriesDir = path.join(homedir(), '.config', 'pgh', 'registries')
477 fs.mkdirSync(registriesDir, { recursive: true })
478 store = new Corestore(path.join(registriesDir, pgh.registryKeyHex.slice(0, 16)))
479 try {
480 registry = new ReleaseRegistry(store, { bootstrap: b4a.from(pgh.registryKeyHex, 'hex') })
481 await registry.ready()
482 swarm = new Hyperswarm()
483 swarm.on('connection', (conn) => store.replicate(conn))
484 swarm.join(registry._base.discoveryKey, { server: false, client: true })
485 await swarm.flush()
486 await new Promise(r => setTimeout(r, 500))
487 await registry.update()
488 entry = pgh.version ? await registry.get(pgh.name, pgh.version) : await registry.latest(pgh.name)
489 } catch (err) {
490 // Ready/replicate failed — e.g. the registries/<key16> Corestore's
491 // exclusive fd-lock is already held (the sidecar's in-process autoupdate
492 // re-opens this same dir). ReleaseRegistry.close() only closes the
493 // autobase, NOT the store, so if we throw here without closing `store`
494 // ourselves we orphan its fd-lock in this long-lived process forever —
495 // every later attempt then dies with "File descriptor could not be
496 // locked". Release everything before rethrowing.
497 try { if (registry) await registry.close() } catch {}
498 try { if (swarm) await swarm.destroy() } catch {}
499 try { if (store) await store.close() } catch {}
500 throw err
501 }
502 }
503 // NOTE: `store` is a caller-owned Corestore holding an exclusive fd-lock. The
504 // caller MUST close it (not just registry/swarm) — see cleanup() below.
505 return { entry, registry, swarm, store }
463 // Thin wrapper over the shared resolver (lib/resolve-release.js). The body
464 // used to live here, which is why embedded OTA could not resolve a release:
465 // the only way to learn "what is the latest tree" was to run the CLI's
466 // install path. Same behaviour, same sidecar-first ordering, same fd-lock
467 // discipline — now reachable by pgh-runtime too.
468 const { entry, via, cleanup } = await sharedResolveRelease({
469 registryKeyHex: pgh.registryKeyHex,
470 name: pgh.name,
471 version: pgh.version || undefined
472 })
473 if (via === 'sidecar') console.log(' resolved via pgh-sidecar (live replica)')
474 return { entry, cleanup }
506475}
507476
508477// Collapsed install path (Phase 2): resolve the release (registry/identity,
……@@ -516,12 +485,7 @@ async function installViaRuntime (pgh, args) {
516485 const installName = (renameIdx !== -1 && args[renameIdx + 1]) || pgh.name
517486 console.log('Installing ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' via runtime engine (registry ' + pgh.registryKeyHex.slice(0, 12) + ')...')
518487
519 const { entry, registry, swarm, store } = await resolveRelease(pgh)
520 // Close the caller-owned registry Corestore too. ReleaseRegistry.close() only
521 // closes the autobase — leaking `store` orphans its exclusive fd-lock on
522 // registries/<key16> in this (potentially long-lived, in-process autoupdate)
523 // process, which is exactly the "File descriptor could not be locked" leak.
524 const cleanup = async () => { try { if (registry) await registry.close() } catch {}; try { if (swarm) await swarm.destroy() } catch {}; try { if (store) await store.close() } catch {} }
488 const { entry, cleanup } = await resolveRelease(pgh)
525489 if (!entry) { await cleanup(); throw new Error('no release found for ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' in this registry') }
526490 console.log(' found ' + entry.name + '@' + entry.version + ' tree ' + entry.treeHash.slice(0, 12) + '...')
527491
lib/resolve-release.js +144 -0
……@@ -0,0 +1,144 @@
1/**
2 * resolveRelease — "what is the latest release of <name>?", with or without a
3 * pgh-sidecar on this machine.
4 *
5 * Extracted verbatim in behaviour from commands/app.js, where it was the install
6 * path's private helper. It is here because it is the missing half of embedded
7 * OTA: NetworkFetcher's own header says
8 *
9 * getLatestRelease currently reads from the LOCAL ReleaseRegistry.
10 * Replication of the publisher's remote registry is a follow-on.
11 *
12 * and that follow-on turned out to already exist — as this function, reachable
13 * only from `pgh app install`. A standalone app (no sidecar, no CLI) could fetch
14 * trees and content over Hyperswarm but had no way to learn WHICH tree to fetch.
15 *
16 * TWO ORDERED PATHS, and the order is not a preference:
17 *
18 * 1. the sidecar's live replica, when ~/.pgh/svc.sock exists;
19 * 2. otherwise replicate the publisher's registry directly by its bootstrap
20 * key, over Hyperswarm.
21 *
22 * The sidecar goes first because BOTH paths open the same
23 * ~/.config/pgh/registries/<key16> Corestore, and that store takes an EXCLUSIVE
24 * FD-LOCK. On a machine where the sidecar is also re-opening it (its in-process
25 * autoupdate does), a direct open loses the race and dies with "File descriptor
26 * could not be locked". runtime-factory's createRuntime routes getLatestRelease
27 * through the sidecar for exactly this reason. Path 2 is for machines that have
28 * no sidecar at all — which is every standalone install, and every Windows one.
29 *
30 * THE CALLER OWNS THE CLEANUP. `cleanup()` closes registry, swarm AND store, in
31 * that order. ReleaseRegistry.close() closes only the autobase, so dropping the
32 * handle without calling this orphans the store's fd-lock for the life of the
33 * process and every later resolve in it fails.
34 */
35
36import process from '#node:process' // NOT a global under bare — process.env below needs it
37import fs from '#node:fs'
38import path from '#node:path'
39import { homedir } from '#node:os'
40import b4a from 'b4a'
41import { Client } from 'hyper-svc'
42
43import { ReleaseRegistry } from './release-registry.js'
44
45const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK ||
46 path.join(homedir(), '.pgh', 'svc.sock')
47
48/** Where a directly-replicated registry's corestore lives. */
49export function registryStoreDir (registryKeyHex) {
50 return path.join(homedir(), '.config', 'pgh', 'registries', registryKeyHex.slice(0, 16))
51}
52
53/**
54 * @param {object} opts
55 * @param {string} opts.registryKeyHex 64-char registry (autobase bootstrap) key
56 * @param {string} opts.name app name
57 * @param {string} [opts.version] exact version; omitted = latest
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
60 * @returns {Promise<{ entry: object|null, via: string, cleanup: function }>}
61 */
62export async function resolveRelease (opts = {}) {
63 const { registryKeyHex, name, version } = opts
64 if (!registryKeyHex || registryKeyHex.length !== 64) {
65 throw new Error('resolveRelease requires a 64-char registryKeyHex')
66 }
67 if (!name) throw new Error('resolveRelease requires a name')
68
69 const preferSidecar = opts.preferSidecar !== false
70 const settleMs = opts.settleMs != null ? opts.settleMs : 500
71
72 const noop = async () => {}
73 let entry = null
74
75 if (preferSidecar && fs.existsSync(SIDECAR_SOCK)) {
76 let c = null
77 try {
78 c = await Client.connectUds(SIDECAR_SOCK)
79 const res = await c.call('resolve-release', {
80 registryKey: registryKeyHex, name, version: version || undefined
81 })
82 if (res && res.found && res.entry) entry = res.entry
83 } catch {
84 // Sidecar present but unhelpful (down mid-restart, older build without the
85 // RPC). Fall through and replicate directly — worst case that fails on the
86 // fd-lock, which is a clearer error than a silent miss.
87 } finally {
88 try { if (c) await c.close() } catch {}
89 }
90 if (entry) return { entry, via: 'sidecar', cleanup: noop }
91 }
92
93 const Corestore = (await import('corestore')).default
94 const Hyperswarm = (await import('hyperswarm')).default
95
96 let store = null
97 let registry = null
98 let swarm = null
99 const cleanup = async () => {
100 try { if (registry) await registry.close() } catch {}
101 try { if (swarm) await swarm.destroy() } catch {}
102 try { if (store) await store.close() } catch {}
103 }
104
105 const dir = registryStoreDir(registryKeyHex)
106 fs.mkdirSync(dir, { recursive: true })
107 store = new Corestore(dir)
108 try {
109 registry = new ReleaseRegistry(store, { bootstrap: b4a.from(registryKeyHex, 'hex') })
110 await registry.ready()
111 swarm = new Hyperswarm()
112 swarm.on('connection', (conn) => store.replicate(conn))
113 // client-only: we are reading someone else's registry, not serving ours.
114 swarm.join(registry._base.discoveryKey, { server: false, client: true })
115 await swarm.flush()
116 // The view is built by autobase's apply as blocks arrive; flush returns when
117 // peers are connected, not when the log has caught up.
118 await new Promise(r => setTimeout(r, settleMs))
119 await registry.update()
120 entry = version ? await registry.get(name, version) : await registry.latest(name)
121 } catch (err) {
122 await cleanup()
123 throw err
124 }
125
126 return { entry, via: 'replica', cleanup }
127}
128
129/**
130 * A `getLatestRelease(name)` for pgh-runtime's fetcher shape, backed by the above.
131 *
132 * pgh-runtime polls, so this opens and CLOSES per call rather than holding the
133 * store's fd-lock for the life of the app: an app that kept it would block the
134 * sidecar, `pgh app install`, and its own next resolve. Polling is hourly by
135 * default, so the reconnect cost is irrelevant beside that.
136 */
137export function makeGetLatestRelease ({ registryKeyHex, preferSidecar, settleMs } = {}) {
138 return async function getLatestRelease (name) {
139 const { entry, cleanup } = await resolveRelease({
140 registryKeyHex, name, preferSidecar, settleMs
141 })
142 try { return entry } finally { await cleanup() }
143 }
144}
lib/runtime-factory.js +26 -5
……@@ -33,6 +33,7 @@ import { materialize as nodeMaterialize } from './materializers/node.js'
3333import { materialize as godotMaterialize } from './materializers/godot.js'
3434import { atomicMaterializer } from './materializers/atomic.js'
3535import { PghRuntime, installRelease } from 'pgh-runtime'
36import { makeGetLatestRelease } from './resolve-release.js'
3637
3738function defaultBinDir () {
3839 return process.env.PGH_BIN_DIR || path.join(homedir(), '.local', 'bin')
……@@ -233,7 +234,11 @@ export async function installComplete ({ store, release, fetcher, name, host, bi
233234 * await rt.start()
234235 *
235236 * @param {object} opts
236 * @param {string} opts.name app name (resolved via sidecar)
237 * @param {string} opts.name app name
238 * @param {string} [opts.registryKeyHex] publisher registry key (the hex in
239 * pgh.upgrade). WITH it, resolution works
240 * without a sidecar — see resolve-release.js.
241 * WITHOUT it, a local sidecar is required.
237242 * @param {string} [opts.sidecarSock] pgh-sidecar UDS (default ~/.pgh/svc.sock)
238243 * @param {TreeStore} [opts.store] default: device store (~/.local/share/store)
239244 * @param {string} [opts.host] default: detected (e.g. linux-x64)
……@@ -252,10 +257,26 @@ export async function createRuntime (opts = {}) {
252257 const binDir = opts.binDir || defaultBinDir()
253258 const runtimePath = opts.runtimePath || defaultRuntimePath(store, opts.name)
254259
255 const resolveLatest = async (name) => {
256 const c = await Client.connectUds(sidecarSock)
257 try { return await c.call('releases-latest', { name }) } finally { try { await c.close() } catch {} }
258 }
260 // TWO RESOLVERS, and which one you get depends on whether you told us the
261 // registry key.
262 //
263 // Without it we can only ask a local pgh-sidecar "what is the latest <name>",
264 // which is fine on a pgh-managed machine and impossible anywhere else — no
265 // sidecar, no answer, and embedded OTA stops at the first step even though the
266 // transfer half works peer-to-peer. That was the gap: NetworkFetcher's
267 // getLatestRelease reads the LOCAL registry, and a standalone install has no
268 // local registry to read.
269 //
270 // WITH a registry key (it is the hex in `pgh.upgrade`,
271 // pgh://<registryKeyHex>/<name>) we use the shared resolver, which tries the
272 // sidecar first and otherwise replicates the publisher's registry directly over
273 // Hyperswarm. Same ordering, same fd-lock discipline — see resolve-release.js.
274 const resolveLatest = opts.registryKeyHex
275 ? makeGetLatestRelease({ registryKeyHex: opts.registryKeyHex })
276 : async (name) => {
277 const c = await Client.connectUds(sidecarSock)
278 try { return await c.call('releases-latest', { name }) } finally { try { await c.close() } catch {} }
279 }
259280
260281 const latest = await resolveLatest(opts.name)
261282 if (!latest || !latest.serverKey) {
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.148",
3 "version": "0.1.149",
44 "description": "pgh — P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",