OverviewFilesCommitsIssuesDocsReleasesPeers

pgh commits / 61d2ef2

0.1.157: never swap a running app's install — stage it, apply at next start
The update swap (live -> .old, scratch -> live, delete .old) was "safe for a running process because it keeps its old inodes". It is not: a Godot export template chdir()s into its own dir, so the delete leaves the game with no cwd (getcwd null -> user:// writes fail; hyper-tps' shader cache was never saved on ashatron), and Godot reopens its .pck BY PATH for every resource load, seeking to offsets read at startup. Both the hyper-tps launcher's `pgh app update` and pgh-sidecar's autoupdate swapped under the running game (hyper-games #88). In-use contract: - registry-install launchers hold a SHARED flock(1) on <apps>/<name>.lock for the app's lifetime (fd 9, inherited, released by the kernel on exit/crash); the supervisor wraps each child the same way (exec keeps its pid); - the swap runs inside `flock -n -x`: lock held -> the new tree is STAGED as <name>.next (with its manifest) and the live tree is left alone; - `pgh app apply-staged <name>` swaps it in when nobody holds the lock; the launcher calls it before starting the app (PGH_NO_APPLY=1: no recursion via pgh's own launcher), the supervisor before every (re)spawn, so an exit-75 OTA respawn applies it; - flock(1), not fs-native-extensions: on Linux that takes OFD locks, which do not conflict with flock(2). No flock(1) -> the old plain rename swap. - app.js' private copy of materializeAtomic is gone (one implementation, shared with pgh-runtime's OTA); `pgh app list` skips .upd/.old/.next and shows a staged column. Tests: install-in-use (red before: the live tree went to v2 under a held lock), launcher-in-use (a real generated launcher holds the lock; staged tree applied through pgh with PGH_NO_APPLY=1). npm test 347/347. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
61d2ef2aff69ecd1b3857ab0074cf898dacb1764
by plankatron <fleeky@gmail.com> · 2026-10-02T01:41:02.000Z

Changed files (9) +491 -89

lib/app-state.js +6 -0
……@@ -311,9 +311,15 @@ export function expandEnv (s, env) {
311311 return s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => (env[k] != null ? env[k] : '${' + k + '}'))
312312}
313313
314// Install-machinery siblings of an app dir, never apps themselves: the update
315// scratch (.upd), the swap's leftover (.old) and a STAGED update waiting for
316// the app to stop (.next — it has a manifest; see materializers/atomic.js).
317const INSTALL_SIBLING = /\.(upd|old|next)$/
318
314319export function listAppNames () {
315320 try {
316321 return fs.readdirSync(appsDir()).filter(n => {
322 if (INSTALL_SIBLING.test(n)) return false
317323 try { return fs.existsSync(installManifestFile(n)) } catch { return false }
318324 })
319325 } catch { return [] }
lib/commands/app.js +41 -63
……@@ -14,7 +14,8 @@ import { Client } from 'hyper-svc'
1414
1515import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../store.js'
1616import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js'
17import { installComplete, resolveBundledRuntime } from '../runtime-factory.js'
17import { installComplete, resolveBundledRuntime, launcherScript, livePath } from '../runtime-factory.js'
18import { materializeAtomic, applyStaged } from '../materializers/atomic.js'
1819import { ReleaseRegistry } from '../release-registry.js'
1920import { resolveRelease as sharedResolveRelease } from '../resolve-release.js'
2021import { NetworkFetcher } from '../network-fetcher.js'
……@@ -101,55 +102,9 @@ function writeLauncher (launcher, body) {
101102// other fell back to the bare NAME, and the other is the one finalizeInstall uses. That drift is what
102103// wrote ashatron a PATH-dependent `exec bare ...` launcher. One implementation, no parity to maintain.
103104
104// Atomic materialize-and-swap. Build the new tree into a scratch dir, then swap
105// it into place. This lets pgh update ITSELF safely: the old "rm installPath;
106// materialize in place" destroyed the running CLI's own tree (its
107// bin/<name>.bare.js, lib/, node_modules/bare-fs) for the WHOLE materialize
108// duration, so a lazily-required module — or the materialize itself — could
109// fault mid-way and leave a corrupt, half-written install. Here the long
110// materialize happens entirely in a scratch dir (the live install untouched);
111// only a tiny two-rename window touches the live path, and the running process
112// keeps its old inodes throughout. A failed materialize leaves the existing
113// install completely intact.
114function materializeAtomic (store, materializer, { installName, rootTreeHash, deps, host }) {
115 const finalPath = store.appPath(installName)
116 const scratchName = installName + '.upd'
117 const scratchPath = store.appPath(scratchName)
118 const cleanScratch = () => {
119 try { rmSync(scratchPath, { recursive: true, force: true }) } catch {}
120 try { store.clearAppRefs(scratchName) } catch {}
121 }
122 cleanScratch() // drop any stale scratch from a prior interrupted run
123
124 let mat
125 try {
126 mat = materializer.materialize({ store, appName: scratchName, rootTreeHash, deps, host })
127 } catch (err) {
128 cleanScratch() // live install untouched
129 throw err
130 }
131
132 const oldPath = finalPath + '.old'
133 try { rmSync(oldPath, { recursive: true, force: true }) } catch {}
134 const hadExisting = existsSync(finalPath) || safeLstat(finalPath)
135 if (hadExisting) fs.renameSync(finalPath, oldPath)
136 try {
137 fs.renameSync(scratchPath, finalPath)
138 } catch (err) {
139 if (hadExisting) { try { fs.renameSync(oldPath, finalPath) } catch {} } // roll back
140 cleanScratch()
141 throw err
142 }
143
144 // Re-key refs scratch → real name as an ATOMIC REPLACE (drops the prior
145 // version's stale refs, like in-place materialize did), then drop the old
146 // dir — open inodes survive for any still-running process.
147 store.setAppRefs(installName, [...(mat.referenced || [])])
148 try { store.clearAppRefs(scratchName) } catch {}
149 try { rmSync(oldPath, { recursive: true, force: true }) } catch {}
150
151 return { installPath: finalPath, rootTarget: mat.rootTarget, depCount: mat.depCount }
152}
105// Atomic materialize-and-swap (and, when the app is running, STAGE instead of
106// swap) lives in ../materializers/atomic.js — this file used to carry a private
107// copy of it; there is one implementation now, shared with pgh-runtime's OTA.
153108
154109async function resolveBareName (name) {
155110 let c = null
……@@ -270,18 +225,31 @@ export async function run (client, args) {
270225 return await installViaRegistry(pgh, ['install', upgradeUrl, '--name', name])
271226 }
272227
228 // Swap in an update that was STAGED because the app was running (see
229 // materializers/atomic.js). Called by the app's launcher before it starts
230 // and by the supervisor before each respawn; refuses while the app runs.
231 if (sub === 'apply-staged') {
232 const name = args[1]
233 if (!name) throw new Error('usage: pgh app apply-staged <name>')
234 const store = new TreeStore()
235 store.init()
236 return { name, ...applyStaged(store, name) }
237 }
238
273239 if (sub === 'list' || sub === 'ls') {
274240 // Include both regular installs (have package.json) and adopted
275241 // dev installs (.pgh-install.json only). listAppNames filters on
276242 // the install manifest, which is the canonical liveness signal.
277243 return listAppNames().map(name => {
278244 const m = readInstallManifest(name) || {}
245 const st = readInstallManifest(name + '.next') // a staged update (app was running)
279246 return {
280247 name,
281248 path: m.sourcePath || join(APPS_DIR, name),
282249 launcher: existsSync(join(BIN_DIR, name)),
283250 adopted: !!m.adopted,
284 version: m.version
251 version: m.version,
252 staged: (st && st.version) || ''
285253 }
286254 })
287255 }
……@@ -721,6 +689,7 @@ async function installViaRegistry (pgh, args) {
721689 host
722690 })
723691 console.log(' install: ' + mat0.installPath + ' (runtime=' + runtime0 + ', deps=' + mat0.depCount + ')')
692 if (mat0.staged) console.log(' staged: ' + installName + ' is running — this version applies at its next launch')
724693
725694 try {
726695 const installedPkgPgh0 = safeReadPkgPgh(mat0.installPath) || {}
……@@ -736,7 +705,7 @@ async function installViaRegistry (pgh, args) {
736705 treeHash: entry.treeHash,
737706 runtime: runtime0,
738707 installedAt: new Date().toISOString(),
739 sourcePath: mat0.installPath,
708 sourcePath: mat0.finalPath,
740709 entrypoint: entrypointRel0,
741710 defaults: installedPkgPgh0.defaults || {},
742711 args: Array.isArray(installedPkgPgh0.args) ? installedPkgPgh0.args : [],
……@@ -758,10 +727,13 @@ async function installViaRegistry (pgh, args) {
758727 fs.mkdirSync(BIN_DIR, { recursive: true })
759728 const launcher0 = path.join(BIN_DIR, binName0)
760729 const interp0 = runtime0 === 'bare' ? 'bare' : runtime0 === 'godot' ? 'bare' : 'node'
761 writeLauncher(
762 launcher0,
763 '#!/bin/sh\nexec ' + resolveBundledRuntime(mat0.installPath, interp0, detectHost()) + ' ' + path.join(mat0.installPath, entrypoint0) + ' "$@"\n'
764 )
730 writeLauncher(launcher0, launcherScript({
731 runtimePath: livePath(resolveBundledRuntime(mat0.installPath, interp0, detectHost()), mat0.installPath, mat0.finalPath),
732 entryAbs: path.join(mat0.finalPath, entrypoint0),
733 name: installName,
734 store: treeStore,
735 binDir: BIN_DIR
736 }))
765737 console.log(' launcher: ' + launcher0 + ' (' + interp0 + ')')
766738
767739 try {
……@@ -775,7 +747,8 @@ async function installViaRegistry (pgh, args) {
775747 installed: installName,
776748 version: entry.version,
777749 treeHash: entry.treeHash,
778 path: mat0.installPath,
750 path: mat0.finalPath,
751 staged: !!mat0.staged,
779752 launcher: launcher0,
780753 runtime: runtime0,
781754 depCount: mat0.depCount,
……@@ -972,6 +945,7 @@ async function installViaRegistry (pgh, args) {
972945 host
973946 })
974947 console.log(' install: ' + mat.installPath + ' (runtime=' + runtime + ', deps=' + mat.depCount + ')')
948 if (mat.staged) console.log(' staged: ' + installName + ' is running — this version applies at its next launch')
975949 __mark('materialized')
976950
977951 // Write install manifest — lets the app read its own tree hash at
……@@ -996,7 +970,7 @@ async function installViaRegistry (pgh, args) {
996970 treeHash: entry.treeHash,
997971 runtime,
998972 installedAt: new Date().toISOString(),
999 sourcePath: mat.installPath,
973 sourcePath: mat.finalPath,
1000974 entrypoint: entrypointRel,
1001975 defaults: installedPkgPgh.defaults || {},
1002976 args: Array.isArray(installedPkgPgh.args) ? installedPkgPgh.args : [],
……@@ -1010,7 +984,7 @@ async function installViaRegistry (pgh, args) {
1010984 }
1011985
1012986 // Launcher — uses pgh section's bin/entrypoint if provided, else index.js.
1013 const pghSection = safeReadPkgPgh(installPath)
987 const pghSection = safeReadPkgPgh(mat.installPath) // the new tree, staged or live
1014988 // Explicit --name → launcher uses that name (avoids clobbering another app's
1015989 // launcher); default install uses pkg.pgh.bin. (See the local-closure path.)
1016990 const binName = renameIdx !== -1 ? installName : ((pghSection && pghSection.bin) || installName)
……@@ -1023,10 +997,13 @@ async function installViaRegistry (pgh, args) {
1023997 // `node` throws TypeError on first native binding. Godot is implicit-bare
1024998 // (its launcher boots a bare daemon, not godot itself).
1025999 const interp = runtime === 'bare' ? 'bare' : runtime === 'godot' ? 'bare' : 'node'
1026 writeLauncher(
1027 launcher,
1028 '#!/bin/sh\nexec ' + resolveBundledRuntime(installPath, interp, detectHost()) + ' ' + path.join(installPath, entrypoint) + ' "$@"\n'
1029 )
1000 writeLauncher(launcher, launcherScript({
1001 runtimePath: livePath(resolveBundledRuntime(mat.installPath, interp, detectHost()), mat.installPath, installPath),
1002 entryAbs: path.join(installPath, entrypoint),
1003 name: installName,
1004 store: treeStore,
1005 binDir: BIN_DIR
1006 }))
10301007 console.log(' launcher: ' + launcher + ' (' + interp + ')')
10311008 __mark('launcher-written')
10321009
……@@ -1055,6 +1032,7 @@ async function installViaRegistry (pgh, args) {
10551032 version: entry.version,
10561033 treeHash: entry.treeHash,
10571034 path: installPath,
1035 staged: !!mat.staged,
10581036 launcher,
10591037 runtime,
10601038 depCount: mat.depCount
lib/materializers/atomic.js +124 -13
……@@ -16,9 +16,92 @@
1616 */
1717
1818import fs from '#node:fs'
19import { spawnSync } from '#node:child_process'
1920
2021const { rmSync, existsSync } = fs
2122
23// ── In-use protection ─────────────────────────────────────────────────────
24//
25// "Any still-running process keeps its old inodes" (above) is NOT enough:
26// - Godot export templates chdir() into the executable's dir at startup, so
27// deleting the old tree leaves the game with no cwd (getcwd() null ->
28// user:// writes fail; hyper-tps' shader cache was never saved);
29// - Godot reopens its .pck BY PATH for every resource load and seeks to
30// offsets read at startup, so after a swap to another version every later
31// load reads the wrong bytes.
32// Both the launcher's `pgh app update` and pgh-sidecar's autoupdate swapped
33// under a running game (hyper-games #88).
34//
35// So: a running app holds a SHARED flock(1) on <apps>/<name>.lock — its
36// launcher takes it (`exec 9>>lock; flock -s 9; exec bare ...`, inherited by
37// the app and its children, released by the kernel on exit or crash) and the
38// supervisor wraps each child the same way. The swap itself runs inside
39// `flock -n -x <lock>`: if anyone holds the lock, the new tree is STAGED as
40// <name>.next and the live tree is left alone; applyStaged() swaps it in once
41// nobody does (the launcher calls it before starting the app, the supervisor
42// before each respawn). flock(1), not fs-native-extensions: on Linux that
43// takes OFD record locks, which do not conflict with flock(2) locks at all.
44// Without flock(1) (macOS, Windows) the swap is the plain rename pair, as
45// before; Windows refuses to rename a running app's dir on its own.
46export const STAGED_SUFFIX = '.next'
47export function lockPathFor (store, name) { return store.appPath(name) + '.lock' }
48export function stagedPathFor (store, name) { return store.appPath(name + STAGED_SUFFIX) }
49
50let _hasFlock = null
51export function hasFlock () {
52 if (_hasFlock === null) {
53 try { _hasFlock = spawnSync('flock', ['--version']).status === 0 } catch { _hasFlock = false }
54 }
55 return _hasFlock
56}
57
58const BUSY = 75 // flock -E: "the lock is held" — distinct from the swap's own failures
59// $1 = live path, $2 = the new tree. rm the leftover .old FIRST so `mv live old`
60// renames rather than moving into it; roll back if the second rename fails.
61const SWAP_SH = 'o="$1.old"; rm -rf "$o"; had=0; ' +
62 'if [ -e "$1" ] || [ -L "$1" ]; then mv "$1" "$o" || exit 3; had=1; fi; ' +
63 'if ! mv "$2" "$1"; then [ "$had" = 1 ] && mv "$o" "$1"; exit 4; fi'
64
65// Swap `newPath` into `finalPath` unless the app is running.
66// @returns 'swapped' | 'busy'
67function swapUnlessInUse (lockPath, finalPath, newPath) {
68 if (!hasFlock()) {
69 const oldPath = finalPath + '.old'
70 try { rmSync(oldPath, { recursive: true, force: true }) } catch {}
71 const hadExisting = existsSync(finalPath) || safeLstat(finalPath)
72 if (hadExisting) fs.renameSync(finalPath, oldPath)
73 try {
74 fs.renameSync(newPath, finalPath)
75 } catch (err) {
76 if (hadExisting) { try { fs.renameSync(oldPath, finalPath) } catch {} } // roll back
77 throw err
78 }
79 return 'swapped'
80 }
81 fs.writeFileSync(lockPath, '', { flag: 'a' })
82 const r = spawnSync('flock', ['-n', '-E', String(BUSY), '-x', lockPath, 'sh', '-c', SWAP_SH, 'sh', finalPath, newPath])
83 if (r.status === BUSY) return 'busy'
84 if (r.status !== 0) {
85 throw new Error('install swap failed (status ' + r.status + '): ' + String(r.stderr || '').trim())
86 }
87 return 'swapped'
88}
89
90// Re-key a tree's refs from one app name to another (atomic replace).
91function moveRefs (store, from, to, refs) {
92 store.setAppRefs(to, [...refs])
93 try { store.clearAppRefs(from) } catch {}
94}
95function refsOf (store, name) {
96 const out = []
97 for (const [hash, apps] of Object.entries(store.listAllRefs())) if (apps.includes(name)) out.push(hash)
98 return out
99}
100function dropStaged (store, name) {
101 try { rmSync(stagedPathFor(store, name), { recursive: true, force: true }) } catch {}
102 try { store.clearAppRefs(name + STAGED_SUFFIX) } catch {}
103}
104
22105function safeLstat (p) {
23106 try { fs.lstatSync(p); return true } catch { return false }
24107}
……@@ -27,7 +110,9 @@ function safeLstat (p) {
27110 * @param {object} store TreeStore (appPath / setAppRefs / clearAppRefs)
28111 * @param {object} materializer object with .materialize({ store, appName, rootTreeHash, deps, host })
29112 * @param {object} args { installName, rootTreeHash, deps, host }
30 * @returns {{ installPath, rootTarget, depCount }}
113 * @returns {{ installPath, finalPath, staged, rootTarget, depCount }} — installPath is
114 * where the new tree LANDED (the staged <name>.next when the app was running),
115 * finalPath the live path a launcher points at.
31116 */
32117export function materializeAtomic (store, materializer, { installName, rootTreeHash, deps, host }) {
33118 const finalPath = store.appPath(installName)
……@@ -47,25 +132,50 @@ export function materializeAtomic (store, materializer, { installName, rootTreeH
47132 throw err
48133 }
49134
50 const oldPath = finalPath + '.old'
51 try { rmSync(oldPath, { recursive: true, force: true }) } catch {}
52 const hadExisting = existsSync(finalPath) || safeLstat(finalPath)
53 if (hadExisting) fs.renameSync(finalPath, oldPath)
135 let swap
54136 try {
55 fs.renameSync(scratchPath, finalPath)
137 swap = swapUnlessInUse(lockPathFor(store, installName), finalPath, scratchPath)
56138 } catch (err) {
57 if (hadExisting) { try { fs.renameSync(oldPath, finalPath) } catch {} } // roll back
58 cleanScratch()
139 cleanScratch() // live install untouched (rolled back inside the swap)
59140 throw err
60141 }
61142
143 if (swap === 'busy') {
144 // The app is running: keep the new tree beside it as <name>.next (replacing
145 // any older staged one) and leave the live tree alone.
146 const stagedName = installName + STAGED_SUFFIX
147 dropStaged(store, installName)
148 fs.renameSync(scratchPath, store.appPath(stagedName))
149 moveRefs(store, scratchName, stagedName, mat.referenced || [])
150 return { installPath: store.appPath(stagedName), finalPath, staged: true, rootTarget: mat.rootTarget, depCount: mat.depCount }
151 }
152
62153 // Re-key refs scratch → real name as an atomic replace (drops the prior
63 // version's stale refs), then drop the old dir — open inodes survive.
64 store.setAppRefs(installName, [...(mat.referenced || [])])
65 try { store.clearAppRefs(scratchName) } catch {}
66 try { rmSync(oldPath, { recursive: true, force: true }) } catch {}
154 // version's stale refs), then drop the old dir. A staged tree from an earlier
155 // in-use update is older than what just went live: drop it too.
156 moveRefs(store, scratchName, installName, mat.referenced || [])
157 try { rmSync(finalPath + '.old', { recursive: true, force: true }) } catch {}
158 dropStaged(store, installName)
159
160 return { installPath: finalPath, finalPath, staged: false, rootTarget: mat.rootTarget, depCount: mat.depCount }
161}
67162
68 return { installPath: finalPath, rootTarget: mat.rootTarget, depCount: mat.depCount }
163/**
164 * Swap a staged update (<name>.next, left by an install that found the app
165 * running) into place — only if nobody holds the app's lock.
166 * @returns {{ applied: boolean, reason?: 'none' | 'in-use', installPath?: string }}
167 */
168export function applyStaged (store, name) {
169 const stagedName = name + STAGED_SUFFIX
170 const staged = store.appPath(stagedName)
171 if (!existsSync(staged)) return { applied: false, reason: 'none' }
172 const finalPath = store.appPath(name)
173 if (swapUnlessInUse(lockPathFor(store, name), finalPath, staged) === 'busy') {
174 return { applied: false, reason: 'in-use' }
175 }
176 moveRefs(store, stagedName, name, refsOf(store, stagedName))
177 try { rmSync(finalPath + '.old', { recursive: true, force: true }) } catch {}
178 return { applied: true, installPath: finalPath }
69179}
70180
71181/**
……@@ -88,3 +198,4 @@ export function atomicMaterializer (baseMaterializer) {
88198 host: args.host
89199 })
90200}
201
lib/runtime-factory.js +60 -10
……@@ -31,7 +31,7 @@ import { TreeStore } from './store.js'
3131import { NetworkFetcher } from './network-fetcher.js'
3232import { materialize as nodeMaterialize } from './materializers/node.js'
3333import { materialize as godotMaterialize } from './materializers/godot.js'
34import { atomicMaterializer } from './materializers/atomic.js'
34import { atomicMaterializer, lockPathFor, stagedPathFor } from './materializers/atomic.js'
3535import { PghRuntime, installRelease } from 'pgh-runtime'
3636import { makeGetLatestRelease } from './resolve-release.js'
3737
……@@ -151,12 +151,55 @@ function detectHost () {
151151 return host
152152}
153153
154function writeLauncher (launcherPath, runtimePath, entryAbs) {
154/**
155 * The launcher script for a registry install (`pgh app install|update`, OTA).
156 * Besides exec'ing the app it implements the IN-USE contract of
157 * materializers/atomic.js: while the app runs it holds a SHARED flock on
158 * <apps>/<name>.lock — fd 9, inherited by the app and its children, released
159 * by the kernel when they exit or crash — so an update stages its tree as
160 * <name>.next instead of swapping it under the running app; and a staged
161 * update is applied HERE, before the app starts, so it always starts on one
162 * complete tree.
163 * - apply-staged runs through pgh's own launcher with PGH_NO_APPLY=1, which
164 * skips both steps there: no recursion when pgh itself is the app, and no
165 * lock held by the very process trying to take it exclusively;
166 * - `: >>lock` first, because a failed `exec 9>>` would kill the shell;
167 * - -w 30: waits out an apply in progress, never blocks the start for good;
168 * - no flock(1) (macOS) -> the old two-line launcher behaviour.
169 * @param {{ runtimePath, entryAbs, name?, store?, binDir? }} o — without
170 * name+store (adopted dev installs, legacy paths) the plain launcher.
171 */
172export function launcherScript ({ runtimePath, entryAbs, name, store, binDir }) {
173 const run = 'exec ' + runtimePath + ' ' + entryAbs + ' "$@"\n'
174 if (!name || !store) return '#!/bin/sh\n' + run
175 const q = (s) => "'" + String(s).replace(/'/g, "'\\''") + "'"
176 const lock = q(lockPathFor(store, name))
177 const staged = q(stagedPathFor(store, name))
178 const pgh = q(path.join(binDir || defaultBinDir(), 'pgh'))
179 return '#!/bin/sh\n' +
180 '# pgh in-use lock: an update never swaps this app\'s tree while it runs (pgh lib/materializers/atomic.js)\n' +
181 'if [ -z "$PGH_NO_APPLY" ] && command -v flock >/dev/null 2>&1; then\n' +
182 ' if [ -d ' + staged + ' ] && [ -x ' + pgh + ' ]; then PGH_NO_APPLY=1 ' + pgh + ' app apply-staged ' + q(name) + ' >/dev/null 2>&1 || true; fi\n' +
183 ' if : >>' + lock + ' 2>/dev/null; then exec 9>>' + lock + '; flock -s -w 30 9 || true; fi\n' +
184 'fi\n' + run
185}
186
187function writeLauncher (launcherPath, runtimePath, entryAbs, lockOpts = {}) {
155188 fs.mkdirSync(path.dirname(launcherPath), { recursive: true })
156189 // Replace any existing file/symlink first — writing THROUGH a symlink into
157190 // the store would corrupt shared content (see app.js writeLauncher note).
158191 try { fs.unlinkSync(launcherPath) } catch {}
159 fs.writeFileSync(launcherPath, '#!/bin/sh\nexec ' + runtimePath + ' ' + entryAbs + ' "$@"\n', { mode: 0o755 })
192 fs.writeFileSync(launcherPath, launcherScript({ runtimePath, entryAbs, ...lockOpts }), { mode: 0o755 })
193}
194
195/**
196 * A path resolved inside a STAGED tree, rewritten to where it will live once
197 * applied (the launcher must never point into <name>.next).
198 */
199export function livePath (p, contentPath, installPath) {
200 return (contentPath !== installPath && typeof p === 'string' && p.startsWith(contentPath + path.sep))
201 ? installPath + p.slice(contentPath.length)
202 : p
160203}
161204
162205/**
……@@ -172,13 +215,19 @@ export function finalizeInstall ({ store, name, release = {}, host, binDir, runt
172215 binDir = binDir || defaultBinDir()
173216 host = host || detectHost()
174217 const installPath = store.appPath(name)
218 // An update that found the app running left its tree STAGED (atomic.js) — the
219 // new tree's package.json and manifest are there; the launcher still points
220 // at the live path. A direct swap drops any staged tree, so "staged exists"
221 // means the latest install is the staged one.
222 const stagedPath = stagedPathFor(store, name)
223 const contentPath = fs.existsSync(stagedPath) ? stagedPath : installPath
175224
176225 // Read the installed package.json's pgh block for entrypoint/runtime/etc.
177226 let pgh = {}
178 try { pgh = JSON.parse(fs.readFileSync(path.join(installPath, 'package.json'), 'utf8')).pgh || {} } catch {}
227 try { pgh = JSON.parse(fs.readFileSync(path.join(contentPath, 'package.json'), 'utf8')).pgh || {} } catch {}
179228 const entry = pgh.entrypoint ||
180 (fs.existsSync(path.join(installPath, 'index.js')) ? 'index.js' :
181 fs.existsSync(path.join(installPath, 'server.js')) ? 'server.js' : 'index.js')
229 (fs.existsSync(path.join(contentPath, 'index.js')) ? 'index.js' :
230 fs.existsSync(path.join(contentPath, 'server.js')) ? 'server.js' : 'index.js')
182231 const runtime = release.runtime || pgh.runtime || 'bare'
183232 // Explicit binName (e.g. when installed under --name) wins, so a custom-named
184233 // install can't clobber another app's launcher (parity with app.js). Else
……@@ -189,14 +238,15 @@ export function finalizeInstall ({ store, name, release = {}, host, binDir, runt
189238 // Launcher — prefer the per-install bundled bare runtime (parity with
190239 // app.js resolveBundledRuntime); else caller-provided, else interp on PATH.
191240 const interp = runtimeInterp(runtime)
192 const rtPath = runtimePath || resolveBundledRuntime(installPath, interp, host)
241 const rtPath = runtimePath || livePath(resolveBundledRuntime(contentPath, interp, host), contentPath, installPath)
193242 fs.mkdirSync(binDir, { recursive: true })
194243 const launcher = path.join(binDir, binName)
195 writeLauncher(launcher, rtPath, path.join(installPath, entry))
244 writeLauncher(launcher, rtPath, path.join(installPath, entry), { name, store, binDir })
196245
197246 // Full install manifest — byte-parity with installViaRegistry (app.js:622-636)
198 // so `pgh app list/start/boot` read the same fields.
199 const manifestPath = path.join(installPath, '.pgh-install.json')
247 // so `pgh app list/start/boot` read the same fields. Into the tree that will
248 // go live: the staged one when the update was staged.
249 const manifestPath = path.join(contentPath, '.pgh-install.json')
200250 fs.writeFileSync(manifestPath, JSON.stringify({
201251 name,
202252 publishedName: release.name || name,
lib/supervisor.js +25 -1
……@@ -27,6 +27,8 @@ import path from '#node:path'
2727import { spawn } from '#node:child_process'
2828import { writeState, writePid, clearPid, readInstallManifest, effectiveEnv } from './app-state.js'
2929import { resolveChildEnv as resolveEnv, envDelta } from './child-env.js'
30import { TreeStore } from './store.js'
31import { applyStaged, hasFlock, lockPathFor } from './materializers/atomic.js'
3032
3133const NAME = process.env.PGH_SUP_NAME
3234const ENTRYPOINT = process.env.PGH_SUP_ENTRYPOINT
……@@ -120,7 +122,29 @@ async function loop () {
120122 // Re-resolved every spawn, so an OTA respawn picks up the new tree's manifest env.
121123 const childEnv = childEnvFor()
122124 logEnvDelta(childEnv)
123 child = spawn(RUNTIME_PATH, [ENTRYPOINT, ...ARGS], {
125 // IN-USE CONTRACT (materializers/atomic.js): no child is running here, so an
126 // update that was STAGED while it ran goes live now — this is what makes an
127 // exit-75 OTA respawn, or any restart, pick it up. Then the child runs
128 // holding a SHARED flock on the app's lock, so an update arriving while it
129 // runs stages instead of swapping its tree. `exec` keeps the child's pid,
130 // so SIGTERM/SIGKILL above still reach the app itself.
131 let cmd = RUNTIME_PATH
132 let argv = [ENTRYPOINT, ...ARGS]
133 try {
134 const store = new TreeStore()
135 store.init()
136 const a = applyStaged(store, NAME)
137 if (a.applied) log('applied the staged update before spawning')
138 else if (a.reason === 'in-use') log('a staged update is waiting: something else holds ' + NAME + '\'s lock')
139 if (hasFlock()) {
140 cmd = 'sh'
141 argv = ['-c', 'if : >>"$0" 2>/dev/null; then exec 9>>"$0"; flock -s -w 30 9 || true; fi; exec "$@"',
142 lockPathFor(store, NAME), RUNTIME_PATH, ENTRYPOINT, ...ARGS]
143 }
144 } catch (err) {
145 log('staged-update check failed (spawning as before): ' + (err && err.message))
146 }
147 child = spawn(cmd, argv, {
124148 stdio: ['ignore', 'inherit', 'inherit'],
125149 env: childEnv,
126150 // Launch from the app dir, RE-RESOLVED every spawn. The app's OTA
package.json +1 -1
……@@ -1,6 +1,6 @@
11{
22 "name": "pgh",
3 "version": "0.1.156",
3 "version": "0.1.157",
44 "description": "pgh \u2014 P2P content-addressed package manager and app launcher",
55 "type": "module",
66 "main": "index.js",
test/install-in-use.test.js +123 -0
……@@ -0,0 +1,123 @@
1// A running app's install is never swapped out from under it.
2//
3// The swap (rename live -> .old, scratch -> live, delete .old) was claimed
4// safe for a running process because "it keeps its old inodes". It is not:
5// - Godot export templates chdir() into the executable's dir at startup
6// (main.cpp), so the delete leaves the game with NO cwd -> getcwd() null ->
7// DirAccess fails -> user:// writes fail (hyper-tps' shader cache never
8// saved on ashatron, every launch recompiled);
9// - Godot reopens its .pck BY PATH for every resource load
10// (file_access_pack.cpp) and seeks to offsets read at startup, so after a
11// swap to a different version every later load reads the wrong bytes.
12// Both the launcher's `pgh app update` and pgh-sidecar's autoupdate swapped
13// mid-game (hyper-games #88).
14//
15// Contract: a running app holds a SHARED flock(1) on <apps>/<name>.lock (its
16// launcher / the supervisor take it). An update that finds the lock held
17// STAGES the new tree as <name>.next and leaves the live tree alone; the
18// staged tree is applied (swapped in) only when nobody holds the lock.
19import test from 'brittle'
20import fs from '#node:fs'
21import path from '#node:path'
22import os from '#node:os'
23import { spawn, spawnSync } from '#node:child_process'
24
25import { TreeStore } from '../lib/store.js'
26import { materializeAtomic, applyStaged, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js'
27
28const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0
29
30function mkstore (t) {
31 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-inuse-'))
32 t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
33 const store = new TreeStore({ root })
34 store.init()
35 return store
36}
37
38// A materializer that writes VERSION into the tree it is asked to build.
39function fakeMaterializer (version) {
40 return {
41 materialize ({ store, appName }) {
42 const dir = store.appPath(appName)
43 fs.mkdirSync(dir, { recursive: true })
44 fs.writeFileSync(path.join(dir, 'VERSION'), version)
45 return { referenced: [], rootTarget: dir, depCount: 0 }
46 }
47 }
48}
49
50// Hold a shared lock EXACTLY the way the launcher does — one process that
51// locks an inherited fd and execs the app — so killing it releases the lock.
52// (`flock -s lock cmd` forks cmd as a child that inherits the lock; killing
53// flock would leave an orphan holding it.)
54function holdShared (lockPath) {
55 return new Promise((resolve, reject) => {
56 fs.writeFileSync(lockPath, '', { flag: 'a' })
57 const p = spawn('sh', ['-c', 'exec 9>>"$0"; flock -s 9; echo held; exec sleep 60', lockPath])
58 p.stdout.on('data', (d) => { if (String(d).includes('held')) resolve(p) })
59 p.on('error', reject)
60 })
61}
62
63const readVersion = (dir) => fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')
64
65test('update while the app runs: live tree untouched, new tree staged', { skip: !HAS_FLOCK }, async (t) => {
66 const store = mkstore(t)
67 materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
68 const live = store.appPath('app')
69 const holder = await holdShared(lockPathFor(store, 'app'))
70 t.teardown(() => holder.kill('SIGKILL'))
71
72 const r = materializeAtomic(store, fakeMaterializer('2'), { installName: 'app' })
73
74 t.is(readVersion(live), '1', 'the running app still sees version 1')
75 t.ok(r.staged, 'the update reports it was staged')
76 t.is(readVersion(stagedPathFor(store, 'app')), '2', 'version 2 waits as <name>.next')
77 t.is(r.installPath, stagedPathFor(store, 'app'), 'callers write the manifest into the staged tree')
78 t.is(r.finalPath, live, 'and still know the live path for the launcher')
79})
80
81test('a staged update is refused while the app runs and applied once it stops', { skip: !HAS_FLOCK }, async (t) => {
82 const store = mkstore(t)
83 materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
84 const holder = await holdShared(lockPathFor(store, 'app'))
85 materializeAtomic(store, fakeMaterializer('2'), { installName: 'app' })
86
87 const busy = applyStaged(store, 'app')
88 t.is(busy.applied, false, 'not applied while running')
89 t.is(busy.reason, 'in-use')
90 t.is(readVersion(store.appPath('app')), '1')
91
92 holder.kill('SIGKILL')
93 await new Promise((resolve) => holder.on('exit', resolve))
94
95 const done = applyStaged(store, 'app')
96 t.is(done.applied, true, 'applied once the lock is free')
97 t.is(readVersion(store.appPath('app')), '2', 'the live tree is version 2')
98 t.absent(fs.existsSync(stagedPathFor(store, 'app')), 'nothing left staged')
99 t.absent(fs.existsSync(store.appPath('app') + '.old'), 'no .old left behind')
100})
101
102test('update with nobody running swaps directly and drops a stale staged tree', { skip: !HAS_FLOCK }, async (t) => {
103 const store = mkstore(t)
104 materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
105 const holder = await holdShared(lockPathFor(store, 'app'))
106 materializeAtomic(store, fakeMaterializer('2'), { installName: 'app' })
107 holder.kill('SIGKILL')
108 await new Promise((resolve) => holder.on('exit', resolve))
109
110 const r = materializeAtomic(store, fakeMaterializer('3'), { installName: 'app' })
111 t.absent(r.staged)
112 t.is(readVersion(store.appPath('app')), '3', 'the newest version is live')
113 t.absent(fs.existsSync(stagedPathFor(store, 'app')), 'the older staged version is gone')
114})
115
116test('nothing staged: applyStaged is a no-op', { skip: !HAS_FLOCK }, async (t) => {
117 const store = mkstore(t)
118 materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
119 const r = applyStaged(store, 'app')
120 t.is(r.applied, false)
121 t.is(r.reason, 'none')
122 t.is(readVersion(store.appPath('app')), '1')
123})
test/launcher-in-use.test.js +110 -0
……@@ -0,0 +1,110 @@
1// The launcher side of the in-use contract (see install-in-use.test.js and
2// lib/materializers/atomic.js): a generated launcher runs the app holding a
3// SHARED flock on <apps>/<name>.lock, and applies a staged update — through
4// pgh's own launcher, with PGH_NO_APPLY=1 — before the app starts.
5import test from 'brittle'
6import fs from '#node:fs'
7import path from '#node:path'
8import os from '#node:os'
9import process from '#node:process'
10import { spawn, spawnSync } from '#node:child_process'
11
12import { TreeStore } from '../lib/store.js'
13import { materializeAtomic, stagedPathFor } from '../lib/materializers/atomic.js'
14import { launcherScript } from '../lib/runtime-factory.js'
15import { listAppNames } from '../lib/app-state.js'
16
17const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0
18
19function setup (t) {
20 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-launch-'))
21 t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
22 const store = new TreeStore({ root: path.join(root, 'store') })
23 store.init()
24 const binDir = path.join(root, 'bin')
25 fs.mkdirSync(binDir)
26 // A stand-in `pgh` that records how the launcher called it.
27 const calls = path.join(root, 'pgh-calls')
28 fs.writeFileSync(path.join(binDir, 'pgh'), '#!/bin/sh\necho "NO_APPLY=$PGH_NO_APPLY $*" >> \'' + calls + '\'\n', { mode: 0o755 })
29 const launcher = path.join(binDir, 'app')
30 fs.writeFileSync(launcher, launcherScript({
31 runtimePath: 'sh',
32 entryAbs: path.join(store.appPath('app'), 'run.sh'),
33 name: 'app',
34 store,
35 binDir
36 }), { mode: 0o755 })
37 return { store, launcher, calls }
38}
39
40// An app tree whose run.sh announces its version, then waits.
41function appTree (version) {
42 return {
43 materialize ({ store, appName }) {
44 const dir = store.appPath(appName)
45 fs.mkdirSync(dir, { recursive: true })
46 fs.writeFileSync(path.join(dir, 'VERSION'), version)
47 fs.writeFileSync(path.join(dir, 'run.sh'), 'echo "running $(cat "$(dirname "$0")/VERSION")"; exec sleep 60\n')
48 return { referenced: [], rootTarget: dir, depCount: 0 }
49 }
50 }
51}
52
53function launch (launcher, env = {}) {
54 return new Promise((resolve, reject) => {
55 const p = spawn(launcher, [], { env: { ...process.env, ...env } })
56 p.stdout.on('data', (d) => { if (String(d).includes('running')) resolve(p) })
57 p.on('error', reject)
58 p.on('exit', (code) => reject(new Error('launcher exited ' + code)))
59 })
60}
61
62const version = (store) => fs.readFileSync(path.join(store.appPath('app'), 'VERSION'), 'utf8')
63
64test('the running app holds the lock: an update stages, the live tree stays', { skip: !HAS_FLOCK }, async (t) => {
65 const { store, launcher } = setup(t)
66 materializeAtomic(store, appTree('1'), { installName: 'app' })
67 const app = await launch(launcher)
68 t.teardown(() => app.kill('SIGKILL'))
69
70 const r = materializeAtomic(store, appTree('2'), { installName: 'app' })
71 t.ok(r.staged, 'staged, because the launched app holds the lock')
72 t.is(version(store), '1', 'live tree untouched while it runs')
73})
74
75test('a staged update is applied through pgh before the app starts', { skip: !HAS_FLOCK }, async (t) => {
76 const { store, launcher, calls } = setup(t)
77 materializeAtomic(store, appTree('1'), { installName: 'app' })
78 fs.mkdirSync(stagedPathFor(store, 'app')) // something staged
79 const app = await launch(launcher)
80 t.teardown(() => app.kill('SIGKILL'))
81 t.is(fs.readFileSync(calls, 'utf8').trim(), 'NO_APPLY=1 app apply-staged app',
82 'pgh is asked to apply it, with PGH_NO_APPLY=1 (no recursion through pgh\'s own launcher)')
83})
84
85test('PGH_NO_APPLY: no apply, no lock (a maintenance invocation)', { skip: !HAS_FLOCK }, async (t) => {
86 const { store, launcher, calls } = setup(t)
87 materializeAtomic(store, appTree('1'), { installName: 'app' })
88 fs.mkdirSync(stagedPathFor(store, 'app'))
89 const app = await launch(launcher, { PGH_NO_APPLY: '1' })
90 t.teardown(() => app.kill('SIGKILL'))
91 t.absent(fs.existsSync(calls), 'pgh not called')
92 const r = materializeAtomic(store, appTree('2'), { installName: 'app' })
93 t.absent(r.staged, 'no lock held, so the update swaps directly')
94})
95
96test('a staged tree is not listed as an app', async (t) => {
97 const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-list-'))
98 t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
99 for (const n of ['app', 'app.next', 'app.upd', 'app.old']) {
100 fs.mkdirSync(path.join(root, 'apps', n), { recursive: true })
101 fs.writeFileSync(path.join(root, 'apps', n, '.pgh-install.json'), '{}')
102 }
103 const prev = process.env.PGH_STORE_ROOT
104 process.env.PGH_STORE_ROOT = root
105 try {
106 t.alike(listAppNames(), ['app'])
107 } finally {
108 process.env.PGH_STORE_ROOT = prev || '' // Bare's env refuses delete; '' falls back to the default root
109 }
110})
test/runtime-closure-install.test.js +1 -1
……@@ -249,7 +249,7 @@ test('installComplete parity: pkg.pgh-driven launcher + full install manifest',
249249 // with "exec: bare: not found" while the same launcher worked elsewhere. resolveBundledRuntime now
250250 // resolves an absolute runtime whenever one is reachable, so assert the INTENT (which interpreter) and
251251 // leave the fallback ORDER to test/launcher-runtime-path.test.js, which injects execPath to cover it.
252 const rt = body.split('\n')[1].split(' ')[1]
252 const rt = body.trim().split('\n').pop().split(' ')[1] // the exec line is LAST (the in-use lock lines precede it)
253253 t.is(path.basename(rt), 'bare', 'bare interp (absolute whenever a runtime can be resolved)')
254254 t.ok(body.includes(path.join(store.appPath('app'), 'main.js')), 'launcher execs pgh.entrypoint')
255255