OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / supervisor-cwd-swap.test.js

130 lines · javascript ref:
// 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 })
})