OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / c7591b1

0.1.161: staged updates apply on every OTA, not only the first
`pgh app start` runs the supervisor with cwd = the app's install dir, and the swap that applies a staged update deletes exactly that dir. applyStaged's `flock` spawnSync inherited the dead cwd, so on the next OTA it failed with ENOENT. The supervisor logged "staged-update check failed (spawning as before)" and respawned the OLD tree, without the flock wrapper, so later installs swapped live instead of staging. Found live 2026-10-13: ap-bridge 0.10.14 answered release-ready, exited 75, and came back as 0.10.13. 8 supervisors on stortron and tabertron sat in a deleted `.old` cwd. Both flock spawns now pass an explicit cwd: the lock's own directory for the swap, `/` for the version probe. test/supervisor-staged-after-swap.test.js runs the real supervisor with cwd = the app dir and stages two updates. Red on 0.1.160 with the production message; green now. Suite 357/357 (1007 asserts). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
c7591b177ea9b4f43473343d32356793cfffcc2e
by plankatron <fleeky@gmail.com> · 2026-10-13T02:34:49.000Z

Changed files (3) +140 -3

lib/materializers/atomic.js +10 -2
……@@ -17,6 +17,7 @@
1717
1818import fs from '#node:fs'
1919import { spawnSync } from '#node:child_process'
20import path from '#node:path'
2021
2122const { rmSync, existsSync } = fs
2223
……@@ -50,7 +51,8 @@ export function stagedPathFor (store, name) { return store.appPath(name + STAGED
5051let _hasFlock = null
5152export function hasFlock () {
5253 if (_hasFlock === null) {
53 try { _hasFlock = spawnSync('flock', ['--version']).status === 0 } catch { _hasFlock = false }
54 // cwd: '/' — see the note on swapUnlessInUse: never inherit the caller's cwd.
55 try { _hasFlock = spawnSync('flock', ['--version'], { cwd: '/' }).status === 0 } catch { _hasFlock = false }
5456 }
5557 return _hasFlock
5658}
……@@ -79,7 +81,13 @@ function swapUnlessInUse (lockPath, finalPath, newPath) {
7981 return 'swapped'
8082 }
8183 fs.writeFileSync(lockPath, '', { flag: 'a' })
82 const r = spawnSync('flock', ['-n', '-E', String(BUSY), '-x', lockPath, 'sh', '-c', SWAP_SH, 'sh', finalPath, newPath])
84 // ⛔ An explicit cwd, never the caller's. The supervisor runs with cwd = the app's install dir, and
85 // the swap that applies an update deletes exactly that dir, so on the NEXT apply an inherited cwd is
86 // a dead inode: the spawn fails with ENOENT, the supervisor logs "staged-update check failed" and
87 // respawns the OLD tree. Every OTA after the first ran old code (stortron 2026-10-13, 8 supervisors;
88 // test/supervisor-staged-after-swap.test.js). The lock's own directory always exists here.
89 const r = spawnSync('flock', ['-n', '-E', String(BUSY), '-x', lockPath, 'sh', '-c', SWAP_SH, 'sh', finalPath, newPath],
90 { cwd: path.dirname(lockPath) })
8391 if (r.status === BUSY) return 'busy'
8492 if (r.status !== 0) {
8593 throw new Error('install swap failed (status ' + r.status + '): ' + String(r.stderr || '').trim())
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.160",
3 "version": "0.1.161",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/supervisor-staged-after-swap.test.js +129 -0
……@@ -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
18import test from 'brittle'
19import process from '#node:process'
20import fs from '#node:fs'
21import { join } from '#node:path'
22import { tmpdir } from '#node:os'
23import { spawn, spawnSync } from '#node:child_process'
24import { fileURLToPath } from '#node:url'
25
26import { TreeStore } from '../lib/store.js'
27import { materializeAtomic, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js'
28
29const SUPERVISOR = fileURLToPath(new URL('../lib/supervisor.js', import.meta.url))
30const NODE_MODULES = fileURLToPath(new URL('../node_modules', import.meta.url))
31const 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.
34function 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.
58function 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
67async 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
77const runs = (marker) => (fs.existsSync(marker) ? fs.readFileSync(marker, 'utf8').split('\n').filter(Boolean) : [])
78async 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
84test('⛔ 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})