| … | … | @@ -0,0 +1,129 @@ |
| 1 | // Regression: a supervisor must keep applying staged updates after its FIRST apply. |
| 2 | // |
| 3 | // ⛔ THE INCIDENT (stortron, 2026-10-13). ap-bridge 0.10.14 was installed and staged as |
| 4 | // `ap-bridge.next`, the app answered release-ready and exited 75, and the supervisor logged |
| 5 | // [supervisor] staged-update check failed (spawning as before): no such file or directory |
| 6 | // then respawned 0.10.13. Every OTA after the first one silently ran the OLD code. |
| 7 | // |
| 8 | // Mechanism: `pgh app start` spawns the supervisor with cwd = the app's install dir. applyStaged() |
| 9 | // swaps that dir (live -> .old, .next -> live, rm .old), so the swap that applies an update DELETES |
| 10 | // the supervisor's cwd. The child spawn already passes an explicit cwd (supervisor-cwd-swap.test.js), |
| 11 | // but applyStaged's own `flock` spawnSync inherits the dead cwd, fails with ENOENT, and the catch |
| 12 | // falls back to spawning the old tree (without the flock wrapper, so later installs swap in place |
| 13 | // instead of staging). Found live on 8 supervisors across stortron and tabertron. |
| 14 | // |
| 15 | // This test runs the real supervisor with cwd = the app dir, applies one staged update (which |
| 16 | // deletes that cwd, as in production), stages another, and requires the next respawn to run it. |
| 17 | |
| 18 | import test from 'brittle' |
| 19 | import process from '#node:process' |
| 20 | import fs from '#node:fs' |
| 21 | import { join } from '#node:path' |
| 22 | import { tmpdir } from '#node:os' |
| 23 | import { spawn, spawnSync } from '#node:child_process' |
| 24 | import { fileURLToPath } from '#node:url' |
| 25 | |
| 26 | import { TreeStore } from '../lib/store.js' |
| 27 | import { materializeAtomic, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js' |
| 28 | |
| 29 | const SUPERVISOR = fileURLToPath(new URL('../lib/supervisor.js', import.meta.url)) |
| 30 | const NODE_MODULES = fileURLToPath(new URL('../node_modules', import.meta.url)) |
| 31 | const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0 |
| 32 | |
| 33 | // An app tree for `version`: it appends its VERSION to the marker, then asks for an OTA respawn. |
| 34 | function appMaterializer (version, marker) { |
| 35 | return { |
| 36 | materialize ({ store, appName }) { |
| 37 | const dir = store.appPath(appName) |
| 38 | fs.mkdirSync(dir, { recursive: true }) |
| 39 | fs.writeFileSync(join(dir, 'package.json'), JSON.stringify({ |
| 40 | type: 'module', |
| 41 | imports: { |
| 42 | '#node:fs': { bare: 'bare-fs', default: 'fs' }, |
| 43 | '#node:process': { bare: 'bare-process', default: 'process' } |
| 44 | } |
| 45 | })) |
| 46 | fs.symlinkSync(NODE_MODULES, join(dir, 'node_modules')) |
| 47 | fs.writeFileSync(join(dir, 'entry.js'), |
| 48 | 'import fs from "#node:fs"\n' + |
| 49 | 'import process from "#node:process"\n' + |
| 50 | 'fs.appendFileSync(' + JSON.stringify(marker) + ', ' + JSON.stringify(version + '\n') + ')\n' + |
| 51 | 'setTimeout(() => process.exit(75), 300)\n') |
| 52 | return { referenced: [], rootTarget: dir, depCount: 0 } |
| 53 | } |
| 54 | } |
| 55 | } |
| 56 | |
| 57 | // Hold the app's shared lock the way a running app does, so an install STAGES. |
| 58 | function holdShared (lockPath) { |
| 59 | return new Promise((resolve, reject) => { |
| 60 | fs.writeFileSync(lockPath, '', { flag: 'a' }) |
| 61 | const p = spawn('sh', ['-c', 'exec 9>>"$0"; flock -s 9; echo held; exec sleep 60', lockPath]) |
| 62 | p.stdout.on('data', (d) => { if (String(d).includes('held')) resolve(p) }) |
| 63 | p.on('error', reject) |
| 64 | }) |
| 65 | } |
| 66 | |
| 67 | async function stage (store, version, marker) { |
| 68 | const holder = await holdShared(lockPathFor(store, 'app')) |
| 69 | try { |
| 70 | const r = materializeAtomic(store, appMaterializer(version, marker), { installName: 'app' }) |
| 71 | if (!r.staged) throw new Error('setup: version ' + version + ' was not staged') |
| 72 | } finally { |
| 73 | holder.kill('SIGKILL') |
| 74 | } |
| 75 | } |
| 76 | |
| 77 | const runs = (marker) => (fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').split('\n').filter(Boolean) : []) |
| 78 | async function until (fn, ms) { |
| 79 | const end = Date.now() + ms |
| 80 | while (Date.now() < end) { if (fn()) return true; await new Promise((r) => setTimeout(r, 100)) } |
| 81 | return false |
| 82 | } |
| 83 | |
| 84 | test('⛔ a staged update applies on every OTA respawn, not only the first', { skip: !HAS_FLOCK }, async (t) => { |
| 85 | const root = fs.mkdtempSync(join(tmpdir(), 'sup-staged-')) |
| 86 | const storeRoot = join(root, 'store') |
| 87 | const store = new TreeStore({ root: storeRoot }) |
| 88 | store.init() |
| 89 | const marker = join(root, 'runs.txt') |
| 90 | |
| 91 | materializeAtomic(store, appMaterializer('1', marker), { installName: 'app' }) |
| 92 | const live = store.appPath('app') |
| 93 | |
| 94 | const sup = spawn(process.execPath, [SUPERVISOR], { |
| 95 | cwd: live, // exactly what `pgh app start` does (lib/commands/app.js: cwd: sourcePath) |
| 96 | stdio: ['ignore', 'ignore', 'pipe'], |
| 97 | env: { |
| 98 | ...process.env, |
| 99 | XDG_RUNTIME_DIR: join(root, 'run'), |
| 100 | PGH_STORE_ROOT: storeRoot, |
| 101 | PGH_SUP_NAME: 'app', |
| 102 | PGH_SUP_ENTRYPOINT: join(live, 'entry.js'), |
| 103 | PGH_SUP_RUNTIME: 'node', |
| 104 | PGH_SUP_RUNTIME_PATH: process.execPath, |
| 105 | PGH_SUP_ARGS_JSON: '[]', |
| 106 | PGH_SUP_CHILD_ENV: '{}' |
| 107 | } |
| 108 | }) |
| 109 | let supLog = '' |
| 110 | sup.stderr.on('data', (d) => { supLog += d }) |
| 111 | t.teardown(async () => { |
| 112 | try { sup.kill('SIGTERM') } catch {} |
| 113 | await new Promise((r) => setTimeout(r, 500)) |
| 114 | try { sup.kill('SIGKILL') } catch {} |
| 115 | fs.rmSync(root, { recursive: true, force: true }) |
| 116 | }) |
| 117 | |
| 118 | t.ok(await until(() => runs(marker).includes('1'), 10000), 'version 1 ran') |
| 119 | |
| 120 | await stage(store, '2', marker) |
| 121 | t.ok(await until(() => runs(marker).includes('2'), 10000), 'the first staged update applied (this part always worked)') |
| 122 | |
| 123 | await stage(store, '3', marker) |
| 124 | const third = await until(() => runs(marker).includes('3'), 10000) |
| 125 | t.ok(third, 'the SECOND staged update applied too — the first swap deleted the supervisor\'s cwd') |
| 126 | t.absent(/staged-update check failed/.test(supLog), 'and the supervisor never fell back to the old tree') |
| 127 | if (!third) t.comment('supervisor log: ' + supLog.split('\n').filter((l) => /staged|applied/.test(l)).slice(-3).join(' | ')) |
| 128 | t.absent(fs.existsSync(stagedPathFor(store, 'app')), 'nothing is left waiting in .next') |
| 129 | }) |