// Regression: supervisor must launch the child from the LIVE app dir even
// after the app's OTA swaps its install dir out from under the supervisor's
// cwd.
//
// Bug:
// `pgh app start` spawns the supervisor (a long-lived pgh process) with
// cwd = the app's install dir. pgh-runtime's OTA atomically renames that
// dir to a new inode, deleting the old one — so the supervisor's cwd
// becomes "(deleted)". The supervisor then spawned children with NO cwd,
// so each child inherited the dead directory and bare/node aborted with
// ENOENT (exit 134) on every respawn → permanent crash-loop, while the
// app code/tree were perfectly fine.
//
// Fix: supervisor.js spawns the child with cwd: dirname(ENTRYPOINT) — an
// absolute path string re-resolved at spawn time, always the live app dir.
//
// This test reproduces the strand: it lets the supervisor boot and run once
// from a separate cwd dir, then DELETES that cwd (the swap) and waits for the
// next respawn — asserting the child still runs, and from the app dir. Without
// the fix the respawn inherits the deleted cwd and never starts.
import test from 'brittle'
import process from '#node:process'
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync, rmSync, existsSync, readFileSync } from '#node:fs'
import { join } from '#node:path'
import { tmpdir } from '#node:os'
import { spawn } from '#node:child_process'
import { fileURLToPath } from '#node:url'
const SUPERVISOR = fileURLToPath(new URL('../lib/supervisor.js', import.meta.url))
// pgh's own node_modules — the fixture app symlinks this so that under Bare
// the child's `#node:fs` / `#node:process` imports resolve to bare-fs /
// bare-process. A standalone Bare script has no `process` global and can't
// resolve those modules without a node_modules on its lookup path; production
// app entrypoints get this from the installed pgh package, so the fixture
// reproduces that environment rather than running orphaned in a bare tmpdir.
const NODE_MODULES = fileURLToPath(new URL('../node_modules', import.meta.url))
test('supervisor launches child from live app dir when its own cwd was deleted (OTA swap)', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'sup-cwd-'))
const appDir = join(root, 'app')
mkdirSync(appDir, { recursive: true })
// Fixture app: mirror a real pgh app so the child can resolve the runtime
// shim imports. pgh is "type":"module" with an `imports` map that rewrites
// `#node:fs` -> bare-fs (Bare) / fs (Node); the child must live in a package
// that declares that map AND has a node_modules to resolve the bare-* target.
writeFileSync(join(appDir, 'package.json'), JSON.stringify({
type: 'module',
imports: {
'#node:fs': { bare: 'bare-fs', default: 'fs' },
'#node:process': { bare: 'bare-process', default: 'process' }
}
}))
symlinkSync(NODE_MODULES, join(appDir, 'node_modules'))
// child entrypoint (ESM): record its own cwd, then exit 0.
const cwdMarker = join(root, 'child-cwd.txt')
const entry = join(appDir, 'entry.js')
writeFileSync(entry,
'import fs from "#node:fs"\n' +
'import process from "#node:process"\n' +
'fs.writeFileSync(' + JSON.stringify(cwdMarker) + ', process.cwd())\n' +
'process.exit(0)\n')
// the supervisor's cwd — a separate dir we delete to simulate the swap.
const supCwd = join(root, 'stale-cwd')
mkdirSync(supCwd)
// keep supervisor state/log out of the real run/store roots.
const runRoot = join(root, 'run')
const env = {
...process.env,
XDG_RUNTIME_DIR: runRoot,
PGH_STORE_ROOT: join(root, 'store'),
PGH_SUP_NAME: 'sup-cwd-test',
PGH_SUP_ENTRYPOINT: entry,
PGH_SUP_RUNTIME: 'node',
PGH_SUP_RUNTIME_PATH: process.execPath,
PGH_SUP_ARGS_JSON: '[]',
// Minimal, explicit child env. The child resolves its modules from its own
// cwd + the node_modules symlink, so it needs nothing inherited. Passing
// the parent's full process.env breaks the child under Bare (the
// JSON-roundtripped env trips bare's module resolution), and this test is
// about cwd handling, not env propagation — so keep it minimal.
PGH_SUP_CHILD_ENV: '{}'
}
const sup = spawn(process.execPath, [SUPERVISOR], {
cwd: supCwd,
stdio: ['ignore', 'ignore', 'ignore'],
env
})
const waitForMarker = (deadlineMs) => new Promise((resolve) => {
const deadline = Date.now() + deadlineMs
const iv = setInterval(() => {
if (existsSync(cwdMarker)) { clearInterval(iv); resolve(readFileSync(cwdMarker, 'utf8')) }
else if (Date.now() > deadline) { clearInterval(iv); resolve(null) }
}, 100)
})
// 1. Let the supervisor boot with a valid cwd and run its first child. This
// mirrors production, where the OTA dir-swap happens while the supervisor
// is already running — NOT during its startup. (Deleting its cwd in the
// same tick it's spawned races bare's own process bootstrap, which needs
// a valid cwd to finish starting — an artifact, not the scenario we test.)
const firstCwd = await waitForMarker(8000)
t.ok(firstCwd !== null, 'supervisor booted and ran the child')
// 2. Now pull the rug: delete the supervisor's cwd (the OTA dir swap) and the
// marker, so only a fresh respawn AFTER the swap can recreate it.
rmSync(supCwd, { recursive: true, force: true })
rmSync(cwdMarker, { force: true })
// 3. The child exits 0 each run, so the supervisor respawns it on backoff.
// The respawn must launch from dirname(ENTRYPOINT) — the live app dir —
// even though the supervisor's own cwd is now a deleted inode.
const childCwd = await waitForMarker(10000)
try { sup.kill('SIGTERM') } catch {}
await new Promise(r => setTimeout(r, 300))
try { sup.kill('SIGKILL') } catch {}
t.ok(childCwd !== null, 'child started despite the supervisor cwd being deleted')
t.is(childCwd, appDir, 'child ran from the live app dir (dirname of entrypoint)')
rmSync(root, { recursive: true, force: true })
})