| … | … | @@ -16,6 +16,7 @@ import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../store.js' |
| 16 | 16 | import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js' |
| 17 | 17 | import { installComplete, resolveBundledRuntime } from '../runtime-factory.js' |
| 18 | 18 | import { ReleaseRegistry } from '../release-registry.js' |
| 19 | import { resolveRelease as sharedResolveRelease } from '../resolve-release.js' | |
| 19 | 20 | import { NetworkFetcher } from '../network-fetcher.js' |
| 20 | 21 | import { |
| 21 | 22 | appDir, pidFile, logFile, installManifestFile, |
| … | … | @@ -459,50 +460,18 @@ export async function run (client, args) { |
| 459 | 460 | // installViaRegistry. Caller owns closing `registry`/`swarm`. This is the |
| 460 | 461 | // "registry/identity" half that stays in pgh (the engine half is the runtime). |
| 461 | 462 | async 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 } | |
| 506 | 475 | } |
| 507 | 476 | |
| 508 | 477 | // Collapsed install path (Phase 2): resolve the release (registry/identity, |
| … | … | @@ -516,12 +485,7 @@ async function installViaRuntime (pgh, args) { |
| 516 | 485 | const installName = (renameIdx !== -1 && args[renameIdx + 1]) || pgh.name |
| 517 | 486 | console.log('Installing ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' via runtime engine (registry ' + pgh.registryKeyHex.slice(0, 12) + ')...') |
| 518 | 487 | |
| 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) | |
| 525 | 489 | if (!entry) { await cleanup(); throw new Error('no release found for ' + pgh.name + (pgh.version ? '@' + pgh.version : '') + ' in this registry') } |
| 526 | 490 | console.log(' found ' + entry.name + '@' + entry.version + ' tree ' + entry.treeHash.slice(0, 12) + '...') |
| 527 | 491 |
| … | … | @@ -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 | ||
| 36 | import process from '#node:process' // NOT a global under bare — process.env below needs it | |
| 37 | import fs from '#node:fs' | |
| 38 | import path from '#node:path' | |
| 39 | import { homedir } from '#node:os' | |
| 40 | import b4a from 'b4a' | |
| 41 | import { Client } from 'hyper-svc' | |
| 42 | ||
| 43 | import { ReleaseRegistry } from './release-registry.js' | |
| 44 | ||
| 45 | const SIDECAR_SOCK = process.env.PGH_SIDECAR_SOCK || | |
| 46 | path.join(homedir(), '.pgh', 'svc.sock') | |
| 47 | ||
| 48 | /** Where a directly-replicated registry's corestore lives. */ | |
| 49 | export 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 | */ | |
| 62 | export 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 | */ | |
| 137 | export 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 | } |
| … | … | @@ -33,6 +33,7 @@ import { materialize as nodeMaterialize } from './materializers/node.js' |
| 33 | 33 | import { materialize as godotMaterialize } from './materializers/godot.js' |
| 34 | 34 | import { atomicMaterializer } from './materializers/atomic.js' |
| 35 | 35 | import { PghRuntime, installRelease } from 'pgh-runtime' |
| 36 | import { makeGetLatestRelease } from './resolve-release.js' | |
| 36 | 37 | |
| 37 | 38 | function defaultBinDir () { |
| 38 | 39 | 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 |
| 233 | 234 | * await rt.start() |
| 234 | 235 | * |
| 235 | 236 | * @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. | |
| 237 | 242 | * @param {string} [opts.sidecarSock] pgh-sidecar UDS (default ~/.pgh/svc.sock) |
| 238 | 243 | * @param {TreeStore} [opts.store] default: device store (~/.local/share/store) |
| 239 | 244 | * @param {string} [opts.host] default: detected (e.g. linux-x64) |
| … | … | @@ -252,10 +257,26 @@ export async function createRuntime (opts = {}) { |
| 252 | 257 | const binDir = opts.binDir || defaultBinDir() |
| 253 | 258 | const runtimePath = opts.runtimePath || defaultRuntimePath(store, opts.name) |
| 254 | 259 | |
| 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 | } | |
| 259 | 280 | |
| 260 | 281 | const latest = await resolveLatest(opts.name) |
| 261 | 282 | if (!latest || !latest.serverKey) { |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh", |
| 3 | "version": "0.1.148", | |
| 3 | "version": "0.1.149", | |
| 4 | 4 | "description": "pgh — P2P content-addressed package manager and app launcher", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |