// Regression: a staged update must run with the env ITS OWN manifest declares.
//
// The supervisor re-resolves the child env every spawn (child-env.js) — but it did so BEFORE applying a
// staged update, so it read the manifest of the release being replaced. Every staged OTA (the normal
// path while an app runs) therefore started the new code with the previous release's defaults.env, and a
// release that added or changed an env default took effect only one update later.
import test from 'brittle'
import process from '#node:process'
import fs from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { spawn, spawnSync } from '#node:child_process'
import { fileURLToPath } from '#node:url'
import { TreeStore } from '../lib/store.js'
import { materializeAtomic, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js'
const SUPERVISOR = fileURLToPath(new URL('../lib/supervisor.js', import.meta.url))
const NODE_MODULES = fileURLToPath(new URL('../node_modules', import.meta.url))
const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0
// An app tree for `version` whose INSTALL MANIFEST declares `defaults.env.WHICH = 'env-' + version`, as a
// real release's manifest declares its env. The entry records "<code version>:<env it was given>".
function appMaterializer (version, marker) {
return {
materialize ({ store, appName }) {
const dir = store.appPath(appName)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(join(dir, 'package.json'), JSON.stringify({
type: 'module',
imports: {
'#node:fs': { bare: 'bare-fs', default: 'fs' },
'#node:process': { bare: 'bare-process', default: 'process' }
}
}))
fs.writeFileSync(join(dir, '.pgh-install.json'), JSON.stringify({ name: appName, version, defaults: { env: { WHICH: 'env-' + version } } }))
fs.symlinkSync(NODE_MODULES, join(dir, 'node_modules'))
fs.writeFileSync(join(dir, 'entry.js'),
'import fs from "#node:fs"\n' +
'import process from "#node:process"\n' +
'fs.appendFileSync(' + JSON.stringify(marker) + ', ' + JSON.stringify(version + ':') + ' + process.env.WHICH + "\\n")\n' +
'setTimeout(() => process.exit(75), 300)\n')
return { referenced: [], rootTarget: dir, depCount: 0 }
}
}
}
// Hold the app's shared lock the way a running app does, so an install STAGES.
function holdShared (lockPath) {
return new Promise((resolve, reject) => {
fs.writeFileSync(lockPath, '', { flag: 'a' })
const p = spawn('sh', ['-c', 'exec 9>>"$0"; flock -s 9; echo held; exec sleep 60', lockPath])
p.stdout.on('data', (d) => { if (String(d).includes('held')) resolve(p) })
p.on('error', reject)
})
}
async function stage (store, version, marker) {
const holder = await holdShared(lockPathFor(store, 'app'))
try {
const r = materializeAtomic(store, appMaterializer(version, marker), { installName: 'app' })
if (!r.staged) throw new Error('setup: version ' + version + ' was not staged')
} finally {
holder.kill('SIGKILL')
}
}
const runs = (marker) => (fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').split('\n').filter(Boolean) : [])
async function until (fn, ms) {
const end = Date.now() + ms
while (Date.now() < end) { if (fn()) return true; await new Promise((r) => setTimeout(r, 100)) }
return false
}
test('⛔ a STAGED update runs with ITS OWN manifest env, not the previous release\'s', { skip: !HAS_FLOCK }, async (t) => {
const root = fs.mkdtempSync(join(tmpdir(), 'sup-staged-env-'))
const storeRoot = join(root, 'store')
const store = new TreeStore({ root: storeRoot })
store.init()
const marker = join(root, 'runs.txt')
materializeAtomic(store, appMaterializer('1', marker), { installName: 'app' })
const live = store.appPath('app')
const sup = spawn(process.execPath, [SUPERVISOR], {
cwd: live,
stdio: ['ignore', 'ignore', 'pipe'],
env: {
...process.env,
XDG_RUNTIME_DIR: join(root, 'run'),
PGH_STORE_ROOT: storeRoot,
PGH_SUP_NAME: 'app',
PGH_SUP_ENTRYPOINT: join(live, 'entry.js'),
PGH_SUP_RUNTIME: 'node',
PGH_SUP_RUNTIME_PATH: process.execPath,
PGH_SUP_ARGS_JSON: '[]',
PGH_SUP_CHILD_ENV: JSON.stringify({ ...process.env, WHICH: 'env-1' })
}
})
let supLog = ''
sup.stderr.on('data', (d) => { supLog += d })
t.teardown(async () => {
try { sup.kill('SIGTERM') } catch {}
await new Promise((r) => setTimeout(r, 500))
try { sup.kill('SIGKILL') } catch {}
fs.rmSync(root, { recursive: true, force: true })
})
t.ok(await until(() => runs(marker).includes('1:env-1'), 10000), 'version 1 ran with its env')
// 2026-10-14, favela-gateway 0.3.0 on stortron: the release added a role in defaults.env, the update
// was staged, and the respawned child ran the new code with GATEWAY_ROLES from 0.2.1 — the supervisor
// resolved the env from the live manifest BEFORE applyStaged() swapped the new one in.
await stage(store, '2', marker)
const ran = await until(() => runs(marker).some((l) => l.startsWith('2:')), 10000)
t.ok(ran, 'version 2 ran')
t.ok(runs(marker).includes('2:env-2'), 'with version 2\'s manifest env — got ' + JSON.stringify(runs(marker).filter((l) => l.startsWith('2:'))))
if (!runs(marker).includes('2:env-2')) t.comment('supervisor log: ' + supLog.split('\n').filter((l) => /staged|applied|env/.test(l)).slice(-4).join(' | '))
})