OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / bf2b17e

defaultRuntimePath must DELEGATE — a third resolver was shadowing the fixed one
#17 fixed a drift between two copies of resolveBundledRuntime (commands/app.js fell back to process.execPath, runtime-factory fell back to the bare NAME). A THIRD copy survived one level down, and it is the one the OTA install/update path actually uses: function defaultRuntimePath (store) { const bundled = path.join(store.appsDir, 'pgh', 'runtime', 'bare') try { fs.accessSync(bundled); return bundled } catch { return 'bare' } } One candidate, then the bare name. Worse than merely weaker: it SHADOWS the good resolver, because finalizeInstall does `runtimePath || resolveBundledRuntime(...)` and this value is always truthy — so on the path it feeds, the five-candidate resolver was never consulted at all. The launcher this produces is exactly the PATH-dependent `exec bare` that test/launcher-runtime-path.test.js exists to prevent, reachable by a different route. Now delegates, and takes the app name so the per-install and sibling lookups key off the real installPath. That also gains the candidate that matters most here: process.execPath. An OTA update is applied BY a process already running under bare, so even when every bundled lookup misses, the runtime executing us is absolute and by definition able to run the stack — the fallback that would have kept ashatron working. HONESTY: this is a real defect fixed on its own merits, but I have NOT proven it is what rewrote ashatron's launcher to `exec bare` at 23:57 UTC. hpp does not appear to wire pgh-runtime at all, so the OTA theory is unproven; the file's mtime shows only that something rewrote it well after `pgh app install` had written an absolute path. Whatever did, this closes one route to it. Tested: 4 new assertions — the app's OWN runtime is now found (the old single-candidate version could not see it), the sibling still resolves when the CLI runs under node, execPath is used before giving up, and the bare name remains the last resort. Note the sibling case needs execPath forced to node: under bare, candidate 3 legitimately wins first, which is the pre-existing contract and pinning it proves the delegation honours the real order instead of inventing another one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bf2b17e3ca64dc4bd5ded4f0e5edfb292f4eed2e
by plankatron <fleeky@gmail.com> · 2026-07-30T00:39:41.000Z

Changed files (3) +61 -6

lib/runtime-factory.js +18 -4
……@@ -46,9 +46,23 @@ function defaultSidecarSock () {
4646}
4747
4848// Prefer the pgh-bundled bare runtime in the same store; fall back to PATH bare.
49function defaultRuntimePath (store) {
50 const bundled = path.join(store.appsDir, 'pgh', 'runtime', 'bare')
51 try { fs.accessSync(bundled); return bundled } catch { return 'bare' }
49// The runtime an OTA install/update should exec. DELEGATES to resolveBundledRuntime — it must not
50// reimplement the resolution.
51//
52// It used to check a SINGLE candidate (apps/pgh/runtime/bare) and otherwise return the bare NAME. That
53// made it a THIRD copy of the resolution logic, and worse, one that SHADOWED the fixed one: finalizeInstall
54// does `runtimePath || resolveBundledRuntime(...)`, and this value is always truthy, so the five-candidate
55// resolver was never consulted on the path this feeds. The exact drift #17 fixed between commands/app.js
56// and this file, surviving one level down.
57//
58// Delegating also gains the candidate that matters most here: process.execPath. An OTA update is applied
59// BY a process already running under bare, so even if every bundled-runtime lookup misses, the runtime
60// executing us is absolute and by definition able to run the stack.
61export function defaultRuntimePath (store, name, { execPath } = {}) {
62 // resolveBundledRuntime keys its per-install and sibling lookups off installPath, so give it the real
63 // one when we know the app; otherwise a path under appsDir so the sibling scan still resolves.
64 const installPath = name ? store.appPath(name) : path.join(store.appsDir, '_')
65 return resolveBundledRuntime(installPath, 'bare', detectHost(), execPath ? { execPath } : {})
5266}
5367
5468// Select the materializer for a runtime — node/bare share the npm layout;
……@@ -236,7 +250,7 @@ export async function createRuntime (opts = {}) {
236250 store.init()
237251 const host = opts.host || detectHost()
238252 const binDir = opts.binDir || defaultBinDir()
239 const runtimePath = opts.runtimePath || defaultRuntimePath(store)
253 const runtimePath = opts.runtimePath || defaultRuntimePath(store, opts.name)
240254
241255 const resolveLatest = async (name) => {
242256 const c = await Client.connectUds(sidecarSock)
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.127",
3 "version": "0.1.128",
44 "description": "pgh — P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/launcher-runtime-path.test.js +42 -1
……@@ -17,7 +17,7 @@ import test from 'brittle'
1717import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from '#node:fs'
1818import { join } from '#node:path'
1919import { tmpdir } from '#node:os'
20import { resolveBundledRuntime } from '../lib/runtime-factory.js'
20import { resolveBundledRuntime, defaultRuntimePath } from '../lib/runtime-factory.js'
2121
2222const HOST = 'linux-x64'
2323function store (t) {
……@@ -88,3 +88,44 @@ test('a node app is unaffected', (t) => {
8888 t.is(resolveBundledRuntime(appDir, 'node', HOST, { execPath: '/usr/bin/node' }), '/usr/bin/node',
8989 'node resolves to the running node; the bare-only sibling search never applies')
9090})
91
92// defaultRuntimePath was a THIRD copy of this logic, and one that SHADOWED the fixed resolver:
93// finalizeInstall does `runtimePath || resolveBundledRuntime(...)`, and defaultRuntimePath's return is
94// always truthy, so on the OTA install/update path the five-candidate resolver was never consulted. It
95// checked ONE candidate (apps/pgh/runtime/bare) and otherwise returned the bare name — the same
96// PATH-dependent launcher this file exists to prevent, reachable by a different route.
97function fakeStore (h) {
98 return { appsDir: h.apps, appPath: (n) => join(h.apps, n) }
99}
100
101test('defaultRuntimePath delegates — it finds the app\'s OWN bundled runtime', (t) => {
102 const h = store(t)
103 // The old single-candidate version could not see this: it only ever looked at apps/pgh.
104 const own = h.bare('myapp')
105 t.is(defaultRuntimePath(fakeStore(h), 'myapp'), own, 'the install\'s own runtime wins')
106})
107
108// The sibling is candidate 4, so it is only REACHED when the CLI is not itself running under bare —
109// under bare, process.execPath (candidate 3) legitimately wins first. That ordering is the pre-existing
110// contract (see 'falls back to a SIBLING app\'s runtime when the CLI runs under node' above); pinning it
111// here proves the delegation honours the real order rather than reimplementing a different one.
112test('defaultRuntimePath still finds a sibling pgh runtime (no regression)', (t) => {
113 const h = store(t)
114 const sib = h.bare('pgh')
115 h.app('myapp')
116 t.is(defaultRuntimePath(fakeStore(h), 'myapp', { execPath: '/usr/bin/node' }), sib, 'the sibling is used when the app ships none and the CLI runs under node')
117})
118
119// The case that would have saved ashatron: an OTA update is applied BY a process already running under
120// bare, so execPath is absolute and runnable even when every bundled lookup misses.
121test('defaultRuntimePath falls back to the runtime executing us, not the bare NAME', (t) => {
122 const h = store(t)
123 h.app('myapp')
124 t.is(defaultRuntimePath(fakeStore(h), 'myapp', { execPath: '/opt/bare/bin/bare' }), '/opt/bare/bin/bare', 'execPath is used before giving up')
125})
126
127test('defaultRuntimePath returns the bare name only when genuinely unresolvable', (t) => {
128 const h = store(t)
129 h.app('myapp')
130 t.is(defaultRuntimePath(fakeStore(h), 'myapp', { execPath: '/usr/bin/node' }), 'bare', 'last resort is unchanged')
131})