OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / bc68f03

runtime-factory: pick the materializer from the RELEASE, not from a constant
createRuntime hardcoded `atomicMaterializer({ materialize: nodeMaterialize })` while loadMaterializer sat six lines up in the same file, already knowing that node/bare share the npm layout and godot has its own. The live install path (commands/app.js) has always selected on `entry.runtime || 'node'`; the embedded path did not. Correct for every node/bare app — which is nearly all of them, and is why nothing caught it. Wrong for a godot one, and wrong in the worst way: the npm layout would be written for an app needing the godot layout, giving an install that exists, reports success and does not run. Now `opts.runtime || latest.runtime`, resolved once from the release the runtime was created against — an app that changes runtime kind mid-life is a re-install, not an update. Verified the field is really there rather than assumed: hyper-tps resolves off the live registry with runtime:"bare", though release-registry.js's schema comment predates the field and lists neither it nor publisherChain. selectMaterializer is exported and tested because it is the entire decision and the only part of createRuntime provable without a network. It THROWS on an unknown kind rather than defaulting: a release naming a runtime this pgh lacks means the pgh is too old for the release, and guessing writes a wrong layout and calls it success. materializer-selection 4/4. runtime-closure-install, launcher-runtime-path, materializer-resolve, pgh-tree-install all still pass under bare. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bc68f038a293701d552445b0ad8d852ca3136dcb
by plankatron <fleeky@gmail.com> · 2026-08-25T22:01:11.000Z

Changed files (3) +73 -2

lib/runtime-factory.js +29 -1
……@@ -66,6 +66,20 @@ export function defaultRuntimePath (store, name, { execPath } = {}) {
6666 return resolveBundledRuntime(installPath, 'bare', detectHost(), execPath ? { execPath } : {})
6767}
6868
69/**
70 * The materializer for a runtime kind, as the RELEASE declares it.
71 *
72 * Exported because it is the whole of the selection logic and the only part of
73 * createRuntime worth testing without a network: given a kind, which layout.
74 *
75 * Throwing on an unknown kind is deliberate. The alternative — defaulting to the
76 * npm layout — would materialize a godot app as if it were a node one and produce
77 * an install that exists, passes every "did it install" check, and does not run.
78 */
79export function selectMaterializer (runtimeKind) {
80 return loadMaterializer(runtimeKind || 'node').materialize
81}
82
6983// Select the materializer for a runtime — node/bare share the npm layout;
7084// godot uses its own. Parity with app.js loadMaterializer (the live path).
7185function loadMaterializer (runtime) {
……@@ -235,6 +249,8 @@ export async function installComplete ({ store, release, fetcher, name, host, bi
235249 *
236250 * @param {object} opts
237251 * @param {string} opts.name app name
252 * @param {string} [opts.runtime] override the materializer kind; default
253 * is the RELEASE's own `runtime` field
238254 * @param {string} [opts.registryKeyHex] publisher registry key (the hex in
239255 * pgh.upgrade). WITH it, resolution works
240256 * without a sidecar — see resolve-release.js.
……@@ -296,7 +312,19 @@ export async function createRuntime (opts = {}) {
296312 host,
297313 interval: opts.interval,
298314 currentTreeHash: opts.currentTreeHash,
299 materialize: atomicMaterializer({ materialize: nodeMaterialize })
315 // FOLLOWS THE RELEASE, not the caller and not a constant. This was
316 // hardcoded to nodeMaterialize, which is correct for every node/bare app —
317 // hyper-tps declares runtime 'bare' — and silently wrong for a godot one,
318 // where it would produce an install that looks complete and does not run.
319 // The live entries carry `runtime` (verified against the registry: hyper-tps
320 // resolves with runtime:"bare"), even though release-registry.js's schema
321 // comment predates the field and does not list it.
322 //
323 // Resolved ONCE, from the release we started against. An app that changes
324 // runtime kind mid-life is a re-install, not an update.
325 materialize: atomicMaterializer({
326 materialize: selectMaterializer(opts.runtime || latest.runtime)
327 })
300328 })
301329
302330 // Finalize (launcher + manifest) after every install/update, then hand off
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.149",
3 "version": "0.1.150",
44 "description": "pgh — P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/materializer-selection.test.js +43 -0
……@@ -0,0 +1,43 @@
1/**
2 * createRuntime picks its materializer from the RELEASE's runtime kind.
3 *
4 * It used to hardcode nodeMaterialize. That is correct for every node/bare app —
5 * which is most of them, and is why nothing caught it — and silently wrong for a
6 * godot one: the npm layout would be written for an app that needs the godot
7 * layout, producing an install that exists, reports success, and does not run.
8 *
9 * The live registry entries carry the field (hyper-tps resolves with
10 * runtime:"bare"), even though release-registry.js's schema comment predates it.
11 *
12 * Run: bare test/materializer-selection.test.js
13 */
14
15import test from 'brittle'
16
17import { selectMaterializer } from '../lib/runtime-factory.js'
18import { materialize as nodeMaterialize } from '../lib/materializers/node.js'
19import { materialize as godotMaterialize } from '../lib/materializers/godot.js'
20
21test('node and bare share the npm layout', (t) => {
22 t.is(selectMaterializer('node'), nodeMaterialize)
23 t.is(selectMaterializer('bare'), nodeMaterialize, 'bare apps are npm-shaped too')
24})
25
26test('godot gets the godot layout', (t) => {
27 t.is(selectMaterializer('godot'), godotMaterialize)
28 t.not(selectMaterializer('godot'), nodeMaterialize, 'the bug this test exists for')
29})
30
31test('an absent runtime defaults to node', (t) => {
32 // Entries published before the field existed have no runtime; app.js's live
33 // path defaults them the same way (`entry.runtime || 'node'`).
34 t.is(selectMaterializer(undefined), nodeMaterialize)
35 t.is(selectMaterializer(null), nodeMaterialize)
36 t.is(selectMaterializer(''), nodeMaterialize)
37})
38
39test('an unknown runtime THROWS rather than guessing', (t) => {
40 // Defaulting would write the wrong layout and call it a success. A release
41 // naming a runtime this pgh does not have is a pgh too old for that release.
42 t.exception(() => selectMaterializer('unity'), /no materializer for runtime: unity/)
43})