OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / supervisor-staged-env.test.js

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