OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / d69ebb3

0.1.162: a staged update runs with its own manifest env
The supervisor re-resolves the child env every spawn, but did so BEFORE applyStaged() swapped the staged release in. The install manifest lives inside the app dir, so it read the previous release's defaults.env: every staged OTA (the normal path while an app runs) started the new code with the old env, and a release that added or changed an env default took effect one update later. Found on stortron 2026-10-14: favela-gateway 0.3.0 added the lens to GATEWAY_ROLES and came up with 0.2.1's roles (and the FAVELA_WEB_UPSTREAM 0.2.x had dropped). The supervisor now resolves the env after the apply. Test first: test/supervisor-staged-env.test.js runs the real supervisor; a staged v2 whose manifest says WHICH=env-2 ran as "2:env-1"; now "2:env-2". Suite 358/358. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d69ebb3fe249aaef6a1e326578e14696b7648586
by plankatron <fleeky@gmail.com> · 2026-10-14T00:42:35.000Z

Changed files (3) +128 -4

lib/supervisor.js +7 -3
……@@ -119,9 +119,6 @@ async function loop () {
119119 while (!shuttingDown) {
120120 const startedAtMs = Date.now()
121121 log('spawning ' + RUNTIME_PATH + ' ' + ENTRYPOINT + (ARGS.length ? ' ' + ARGS.join(' ') : ''))
122 // Re-resolved every spawn, so an OTA respawn picks up the new tree's manifest env.
123 const childEnv = childEnvFor()
124 logEnvDelta(childEnv)
125122 // IN-USE CONTRACT (materializers/atomic.js): no child is running here, so an
126123 // update that was STAGED while it ran goes live now — this is what makes an
127124 // exit-75 OTA respawn, or any restart, pick it up. Then the child runs
……@@ -144,6 +141,13 @@ async function loop () {
144141 } catch (err) {
145142 log('staged-update check failed (spawning as before): ' + (err && err.message))
146143 }
144 // Re-resolved every spawn, so an OTA respawn picks up the new tree's manifest env — and ⛔ AFTER
145 // applyStaged(): the install manifest lives INSIDE the app dir, so before the swap it is still the
146 // previous release's. Resolved first, a staged update (the normal OTA path while an app runs) started
147 // the new code with the old defaults.env; favela-gateway 0.3.0 came up without its new role
148 // (stortron, 2026-10-14). test/supervisor-staged-env.test.js.
149 const childEnv = childEnvFor()
150 logEnvDelta(childEnv)
147151 child = spawn(cmd, argv, {
148152 stdio: ['ignore', 'inherit', 'inherit'],
149153 env: childEnv,
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.161",
3 "version": "0.1.162",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/supervisor-staged-env.test.js +120 -0
……@@ -0,0 +1,120 @@
1// Regression: a staged update must run with the env ITS OWN manifest declares.
2//
3// The supervisor re-resolves the child env every spawn (child-env.js) — but it did so BEFORE applying a
4// staged update, so it read the manifest of the release being replaced. Every staged OTA (the normal
5// path while an app runs) therefore started the new code with the previous release's defaults.env, and a
6// release that added or changed an env default took effect only one update later.
7
8import test from 'brittle'
9import process from '#node:process'
10import fs from '#node:fs'
11import { join } from '#node:path'
12import { tmpdir } from '#node:os'
13import { spawn, spawnSync } from '#node:child_process'
14import { fileURLToPath } from '#node:url'
15
16import { TreeStore } from '../lib/store.js'
17import { materializeAtomic, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js'
18
19const SUPERVISOR = fileURLToPath(new URL('../lib/supervisor.js', import.meta.url))
20const NODE_MODULES = fileURLToPath(new URL('../node_modules', import.meta.url))
21const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0
22
23// An app tree for `version` whose INSTALL MANIFEST declares `defaults.env.WHICH = 'env-' + version`, as a
24// real release's manifest declares its env. The entry records "<code version>:<env it was given>".
25function appMaterializer (version, marker) {
26 return {
27 materialize ({ store, appName }) {
28 const dir = store.appPath(appName)
29 fs.mkdirSync(dir, { recursive: true })
30 fs.writeFileSync(join(dir, 'package.json'), JSON.stringify({
31 type: 'module',
32 imports: {
33 '#node:fs': { bare: 'bare-fs', default: 'fs' },
34 '#node:process': { bare: 'bare-process', default: 'process' }
35 }
36 }))
37 fs.writeFileSync(join(dir, '.pgh-install.json'), JSON.stringify({ name: appName, version, defaults: { env: { WHICH: 'env-' + version } } }))
38 fs.symlinkSync(NODE_MODULES, join(dir, 'node_modules'))
39 fs.writeFileSync(join(dir, 'entry.js'),
40 'import fs from "#node:fs"\n' +
41 'import process from "#node:process"\n' +
42 'fs.appendFileSync(' + JSON.stringify(marker) + ', ' + JSON.stringify(version + ':') + ' + process.env.WHICH + "\\n")\n' +
43 'setTimeout(() => process.exit(75), 300)\n')
44 return { referenced: [], rootTarget: dir, depCount: 0 }
45 }
46 }
47}
48
49// Hold the app's shared lock the way a running app does, so an install STAGES.
50function holdShared (lockPath) {
51 return new Promise((resolve, reject) => {
52 fs.writeFileSync(lockPath, '', { flag: 'a' })
53 const p = spawn('sh', ['-c', 'exec 9>>"$0"; flock -s 9; echo held; exec sleep 60', lockPath])
54 p.stdout.on('data', (d) => { if (String(d).includes('held')) resolve(p) })
55 p.on('error', reject)
56 })
57}
58
59async function stage (store, version, marker) {
60 const holder = await holdShared(lockPathFor(store, 'app'))
61 try {
62 const r = materializeAtomic(store, appMaterializer(version, marker), { installName: 'app' })
63 if (!r.staged) throw new Error('setup: version ' + version + ' was not staged')
64 } finally {
65 holder.kill('SIGKILL')
66 }
67}
68
69const runs = (marker) => (fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').split('\n').filter(Boolean) : [])
70async function until (fn, ms) {
71 const end = Date.now() + ms
72 while (Date.now() < end) { if (fn()) return true; await new Promise((r) => setTimeout(r, 100)) }
73 return false
74}
75
76test('⛔ a STAGED update runs with ITS OWN manifest env, not the previous release\'s', { skip: !HAS_FLOCK }, async (t) => {
77 const root = fs.mkdtempSync(join(tmpdir(), 'sup-staged-env-'))
78 const storeRoot = join(root, 'store')
79 const store = new TreeStore({ root: storeRoot })
80 store.init()
81 const marker = join(root, 'runs.txt')
82
83 materializeAtomic(store, appMaterializer('1', marker), { installName: 'app' })
84 const live = store.appPath('app')
85
86 const sup = spawn(process.execPath, [SUPERVISOR], {
87 cwd: live,
88 stdio: ['ignore', 'ignore', 'pipe'],
89 env: {
90 ...process.env,
91 XDG_RUNTIME_DIR: join(root, 'run'),
92 PGH_STORE_ROOT: storeRoot,
93 PGH_SUP_NAME: 'app',
94 PGH_SUP_ENTRYPOINT: join(live, 'entry.js'),
95 PGH_SUP_RUNTIME: 'node',
96 PGH_SUP_RUNTIME_PATH: process.execPath,
97 PGH_SUP_ARGS_JSON: '[]',
98 PGH_SUP_CHILD_ENV: JSON.stringify({ ...process.env, WHICH: 'env-1' })
99 }
100 })
101 let supLog = ''
102 sup.stderr.on('data', (d) => { supLog += d })
103 t.teardown(async () => {
104 try { sup.kill('SIGTERM') } catch {}
105 await new Promise((r) => setTimeout(r, 500))
106 try { sup.kill('SIGKILL') } catch {}
107 fs.rmSync(root, { recursive: true, force: true })
108 })
109
110 t.ok(await until(() => runs(marker).includes('1:env-1'), 10000), 'version 1 ran with its env')
111
112 // 2026-10-14, favela-gateway 0.3.0 on stortron: the release added a role in defaults.env, the update
113 // was staged, and the respawned child ran the new code with GATEWAY_ROLES from 0.2.1 — the supervisor
114 // resolved the env from the live manifest BEFORE applyStaged() swapped the new one in.
115 await stage(store, '2', marker)
116 const ran = await until(() => runs(marker).some((l) => l.startsWith('2:')), 10000)
117 t.ok(ran, 'version 2 ran')
118 t.ok(runs(marker).includes('2:env-2'), 'with version 2\'s manifest env — got ' + JSON.stringify(runs(marker).filter((l) => l.startsWith('2:'))))
119 if (!runs(marker).includes('2:env-2')) t.comment('supervisor log: ' + supLog.split('\n').filter((l) => /staged|applied|env/.test(l)).slice(-4).join(' | '))
120})