| … | … | @@ -311,9 +311,15 @@ export function expandEnv (s, env) { |
| 311 | 311 | return s.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => (env[k] != null ? env[k] : '${' + k + '}')) |
| 312 | 312 | } |
| 313 | 313 | |
| 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). | |
| 317 | const INSTALL_SIBLING = /\.(upd|old|next)$/ | |
| 318 | ||
| 314 | 319 | export function listAppNames () { |
| 315 | 320 | try { |
| 316 | 321 | return fs.readdirSync(appsDir()).filter(n => { |
| 322 | if (INSTALL_SIBLING.test(n)) return false | |
| 317 | 323 | try { return fs.existsSync(installManifestFile(n)) } catch { return false } |
| 318 | 324 | }) |
| 319 | 325 | } catch { return [] } |
| … | … | @@ -14,7 +14,8 @@ import { Client } from 'hyper-svc' |
| 14 | 14 | |
| 15 | 15 | import { TreeStore, DEFAULT_ROOT as DEFAULT_STORE_ROOT } from '../store.js' |
| 16 | 16 | import { installTree, linkApp, unlinkApp, swapApp } from '../tree-install.js' |
| 17 | import { installComplete, resolveBundledRuntime } from '../runtime-factory.js' | |
| 17 | import { installComplete, resolveBundledRuntime, launcherScript, livePath } from '../runtime-factory.js' | |
| 18 | import { materializeAtomic, applyStaged } from '../materializers/atomic.js' | |
| 18 | 19 | import { ReleaseRegistry } from '../release-registry.js' |
| 19 | 20 | import { resolveRelease as sharedResolveRelease } from '../resolve-release.js' |
| 20 | 21 | import { NetworkFetcher } from '../network-fetcher.js' |
| … | … | @@ -101,55 +102,9 @@ function writeLauncher (launcher, body) { |
| 101 | 102 | // other fell back to the bare NAME, and the other is the one finalizeInstall uses. That drift is what |
| 102 | 103 | // wrote ashatron a PATH-dependent `exec bare ...` launcher. One implementation, no parity to maintain. |
| 103 | 104 | |
| 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. | |
| 114 | function 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. | |
| 153 | 108 | |
| 154 | 109 | async function resolveBareName (name) { |
| 155 | 110 | let c = null |
| … | … | @@ -270,18 +225,31 @@ export async function run (client, args) { |
| 270 | 225 | return await installViaRegistry(pgh, ['install', upgradeUrl, '--name', name]) |
| 271 | 226 | } |
| 272 | 227 | |
| 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 | ||
| 273 | 239 | if (sub === 'list' || sub === 'ls') { |
| 274 | 240 | // Include both regular installs (have package.json) and adopted |
| 275 | 241 | // dev installs (.pgh-install.json only). listAppNames filters on |
| 276 | 242 | // the install manifest, which is the canonical liveness signal. |
| 277 | 243 | return listAppNames().map(name => { |
| 278 | 244 | const m = readInstallManifest(name) || {} |
| 245 | const st = readInstallManifest(name + '.next') // a staged update (app was running) | |
| 279 | 246 | return { |
| 280 | 247 | name, |
| 281 | 248 | path: m.sourcePath || join(APPS_DIR, name), |
| 282 | 249 | launcher: existsSync(join(BIN_DIR, name)), |
| 283 | 250 | adopted: !!m.adopted, |
| 284 | version: m.version | |
| 251 | version: m.version, | |
| 252 | staged: (st && st.version) || '' | |
| 285 | 253 | } |
| 286 | 254 | }) |
| 287 | 255 | } |
| … | … | @@ -721,6 +689,7 @@ async function installViaRegistry (pgh, args) { |
| 721 | 689 | host |
| 722 | 690 | }) |
| 723 | 691 | 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') | |
| 724 | 693 | |
| 725 | 694 | try { |
| 726 | 695 | const installedPkgPgh0 = safeReadPkgPgh(mat0.installPath) || {} |
| … | … | @@ -736,7 +705,7 @@ async function installViaRegistry (pgh, args) { |
| 736 | 705 | treeHash: entry.treeHash, |
| 737 | 706 | runtime: runtime0, |
| 738 | 707 | installedAt: new Date().toISOString(), |
| 739 | sourcePath: mat0.installPath, | |
| 708 | sourcePath: mat0.finalPath, | |
| 740 | 709 | entrypoint: entrypointRel0, |
| 741 | 710 | defaults: installedPkgPgh0.defaults || {}, |
| 742 | 711 | args: Array.isArray(installedPkgPgh0.args) ? installedPkgPgh0.args : [], |
| … | … | @@ -758,10 +727,13 @@ async function installViaRegistry (pgh, args) { |
| 758 | 727 | fs.mkdirSync(BIN_DIR, { recursive: true }) |
| 759 | 728 | const launcher0 = path.join(BIN_DIR, binName0) |
| 760 | 729 | 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 | })) | |
| 765 | 737 | console.log(' launcher: ' + launcher0 + ' (' + interp0 + ')') |
| 766 | 738 | |
| 767 | 739 | try { |
| … | … | @@ -775,7 +747,8 @@ async function installViaRegistry (pgh, args) { |
| 775 | 747 | installed: installName, |
| 776 | 748 | version: entry.version, |
| 777 | 749 | treeHash: entry.treeHash, |
| 778 | path: mat0.installPath, | |
| 750 | path: mat0.finalPath, | |
| 751 | staged: !!mat0.staged, | |
| 779 | 752 | launcher: launcher0, |
| 780 | 753 | runtime: runtime0, |
| 781 | 754 | depCount: mat0.depCount, |
| … | … | @@ -972,6 +945,7 @@ async function installViaRegistry (pgh, args) { |
| 972 | 945 | host |
| 973 | 946 | }) |
| 974 | 947 | 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') | |
| 975 | 949 | __mark('materialized') |
| 976 | 950 | |
| 977 | 951 | // Write install manifest — lets the app read its own tree hash at |
| … | … | @@ -996,7 +970,7 @@ async function installViaRegistry (pgh, args) { |
| 996 | 970 | treeHash: entry.treeHash, |
| 997 | 971 | runtime, |
| 998 | 972 | installedAt: new Date().toISOString(), |
| 999 | sourcePath: mat.installPath, | |
| 973 | sourcePath: mat.finalPath, | |
| 1000 | 974 | entrypoint: entrypointRel, |
| 1001 | 975 | defaults: installedPkgPgh.defaults || {}, |
| 1002 | 976 | args: Array.isArray(installedPkgPgh.args) ? installedPkgPgh.args : [], |
| … | … | @@ -1010,7 +984,7 @@ async function installViaRegistry (pgh, args) { |
| 1010 | 984 | } |
| 1011 | 985 | |
| 1012 | 986 | // 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 | |
| 1014 | 988 | // Explicit --name → launcher uses that name (avoids clobbering another app's |
| 1015 | 989 | // launcher); default install uses pkg.pgh.bin. (See the local-closure path.) |
| 1016 | 990 | const binName = renameIdx !== -1 ? installName : ((pghSection && pghSection.bin) || installName) |
| … | … | @@ -1023,10 +997,13 @@ async function installViaRegistry (pgh, args) { |
| 1023 | 997 | // `node` throws TypeError on first native binding. Godot is implicit-bare |
| 1024 | 998 | // (its launcher boots a bare daemon, not godot itself). |
| 1025 | 999 | 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 | })) | |
| 1030 | 1007 | console.log(' launcher: ' + launcher + ' (' + interp + ')') |
| 1031 | 1008 | __mark('launcher-written') |
| 1032 | 1009 | |
| … | … | @@ -1055,6 +1032,7 @@ async function installViaRegistry (pgh, args) { |
| 1055 | 1032 | version: entry.version, |
| 1056 | 1033 | treeHash: entry.treeHash, |
| 1057 | 1034 | path: installPath, |
| 1035 | staged: !!mat.staged, | |
| 1058 | 1036 | launcher, |
| 1059 | 1037 | runtime, |
| 1060 | 1038 | depCount: mat.depCount |
| … | … | @@ -16,9 +16,92 @@ |
| 16 | 16 | */ |
| 17 | 17 | |
| 18 | 18 | import fs from '#node:fs' |
| 19 | import { spawnSync } from '#node:child_process' | |
| 19 | 20 | |
| 20 | 21 | const { rmSync, existsSync } = fs |
| 21 | 22 | |
| 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. | |
| 46 | export const STAGED_SUFFIX = '.next' | |
| 47 | export function lockPathFor (store, name) { return store.appPath(name) + '.lock' } | |
| 48 | export function stagedPathFor (store, name) { return store.appPath(name + STAGED_SUFFIX) } | |
| 49 | ||
| 50 | let _hasFlock = null | |
| 51 | export function hasFlock () { | |
| 52 | if (_hasFlock === null) { | |
| 53 | try { _hasFlock = spawnSync('flock', ['--version']).status === 0 } catch { _hasFlock = false } | |
| 54 | } | |
| 55 | return _hasFlock | |
| 56 | } | |
| 57 | ||
| 58 | const 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. | |
| 61 | const 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' | |
| 67 | function 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). | |
| 91 | function moveRefs (store, from, to, refs) { | |
| 92 | store.setAppRefs(to, [...refs]) | |
| 93 | try { store.clearAppRefs(from) } catch {} | |
| 94 | } | |
| 95 | function 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 | } | |
| 100 | function dropStaged (store, name) { | |
| 101 | try { rmSync(stagedPathFor(store, name), { recursive: true, force: true }) } catch {} | |
| 102 | try { store.clearAppRefs(name + STAGED_SUFFIX) } catch {} | |
| 103 | } | |
| 104 | ||
| 22 | 105 | function safeLstat (p) { |
| 23 | 106 | try { fs.lstatSync(p); return true } catch { return false } |
| 24 | 107 | } |
| … | … | @@ -27,7 +110,9 @@ function safeLstat (p) { |
| 27 | 110 | * @param {object} store TreeStore (appPath / setAppRefs / clearAppRefs) |
| 28 | 111 | * @param {object} materializer object with .materialize({ store, appName, rootTreeHash, deps, host }) |
| 29 | 112 | * @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. | |
| 31 | 116 | */ |
| 32 | 117 | export function materializeAtomic (store, materializer, { installName, rootTreeHash, deps, host }) { |
| 33 | 118 | const finalPath = store.appPath(installName) |
| … | … | @@ -47,25 +132,50 @@ export function materializeAtomic (store, materializer, { installName, rootTreeH |
| 47 | 132 | throw err |
| 48 | 133 | } |
| 49 | 134 | |
| 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 | |
| 54 | 136 | try { |
| 55 | fs.renameSync(scratchPath, finalPath) | |
| 137 | swap = swapUnlessInUse(lockPathFor(store, installName), finalPath, scratchPath) | |
| 56 | 138 | } 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) | |
| 59 | 140 | throw err |
| 60 | 141 | } |
| 61 | 142 | |
| 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 | ||
| 62 | 153 | // 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 | } | |
| 67 | 162 | |
| 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 | */ | |
| 168 | export 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 } | |
| 69 | 179 | } |
| 70 | 180 | |
| 71 | 181 | /** |
| … | … | @@ -88,3 +198,4 @@ export function atomicMaterializer (baseMaterializer) { |
| 88 | 198 | host: args.host |
| 89 | 199 | }) |
| 90 | 200 | } |
| 201 |
| … | … | @@ -31,7 +31,7 @@ import { TreeStore } from './store.js' |
| 31 | 31 | import { NetworkFetcher } from './network-fetcher.js' |
| 32 | 32 | import { materialize as nodeMaterialize } from './materializers/node.js' |
| 33 | 33 | import { materialize as godotMaterialize } from './materializers/godot.js' |
| 34 | import { atomicMaterializer } from './materializers/atomic.js' | |
| 34 | import { atomicMaterializer, lockPathFor, stagedPathFor } from './materializers/atomic.js' | |
| 35 | 35 | import { PghRuntime, installRelease } from 'pgh-runtime' |
| 36 | 36 | import { makeGetLatestRelease } from './resolve-release.js' |
| 37 | 37 | |
| … | … | @@ -151,12 +151,55 @@ function detectHost () { |
| 151 | 151 | return host |
| 152 | 152 | } |
| 153 | 153 | |
| 154 | function 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 | */ | |
| 172 | export 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 | ||
| 187 | function writeLauncher (launcherPath, runtimePath, entryAbs, lockOpts = {}) { | |
| 155 | 188 | fs.mkdirSync(path.dirname(launcherPath), { recursive: true }) |
| 156 | 189 | // Replace any existing file/symlink first — writing THROUGH a symlink into |
| 157 | 190 | // the store would corrupt shared content (see app.js writeLauncher note). |
| 158 | 191 | 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 | */ | |
| 199 | export 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 | |
| 160 | 203 | } |
| 161 | 204 | |
| 162 | 205 | /** |
| … | … | @@ -172,13 +215,19 @@ export function finalizeInstall ({ store, name, release = {}, host, binDir, runt |
| 172 | 215 | binDir = binDir || defaultBinDir() |
| 173 | 216 | host = host || detectHost() |
| 174 | 217 | 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 | |
| 175 | 224 | |
| 176 | 225 | // Read the installed package.json's pgh block for entrypoint/runtime/etc. |
| 177 | 226 | 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 {} | |
| 179 | 228 | 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') | |
| 182 | 231 | const runtime = release.runtime || pgh.runtime || 'bare' |
| 183 | 232 | // Explicit binName (e.g. when installed under --name) wins, so a custom-named |
| 184 | 233 | // 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 |
| 189 | 238 | // Launcher — prefer the per-install bundled bare runtime (parity with |
| 190 | 239 | // app.js resolveBundledRuntime); else caller-provided, else interp on PATH. |
| 191 | 240 | const interp = runtimeInterp(runtime) |
| 192 | const rtPath = runtimePath || resolveBundledRuntime(installPath, interp, host) | |
| 241 | const rtPath = runtimePath || livePath(resolveBundledRuntime(contentPath, interp, host), contentPath, installPath) | |
| 193 | 242 | fs.mkdirSync(binDir, { recursive: true }) |
| 194 | 243 | 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 }) | |
| 196 | 245 | |
| 197 | 246 | // 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') | |
| 200 | 250 | fs.writeFileSync(manifestPath, JSON.stringify({ |
| 201 | 251 | name, |
| 202 | 252 | publishedName: release.name || name, |
| … | … | @@ -27,6 +27,8 @@ import path from '#node:path' |
| 27 | 27 | import { spawn } from '#node:child_process' |
| 28 | 28 | import { writeState, writePid, clearPid, readInstallManifest, effectiveEnv } from './app-state.js' |
| 29 | 29 | import { resolveChildEnv as resolveEnv, envDelta } from './child-env.js' |
| 30 | import { TreeStore } from './store.js' | |
| 31 | import { applyStaged, hasFlock, lockPathFor } from './materializers/atomic.js' | |
| 30 | 32 | |
| 31 | 33 | const NAME = process.env.PGH_SUP_NAME |
| 32 | 34 | const ENTRYPOINT = process.env.PGH_SUP_ENTRYPOINT |
| … | … | @@ -120,7 +122,29 @@ async function loop () { |
| 120 | 122 | // Re-resolved every spawn, so an OTA respawn picks up the new tree's manifest env. |
| 121 | 123 | const childEnv = childEnvFor() |
| 122 | 124 | 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, { | |
| 124 | 148 | stdio: ['ignore', 'inherit', 'inherit'], |
| 125 | 149 | env: childEnv, |
| 126 | 150 | // Launch from the app dir, RE-RESOLVED every spawn. The app's OTA |
| … | … | @@ -1,6 +1,6 @@ |
| 1 | 1 | { |
| 2 | 2 | "name": "pgh", |
| 3 | "version": "0.1.156", | |
| 3 | "version": "0.1.157", | |
| 4 | 4 | "description": "pgh \u2014 P2P content-addressed package manager and app launcher", |
| 5 | 5 | "type": "module", |
| 6 | 6 | "main": "index.js", |
| … | … | @@ -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. | |
| 19 | import test from 'brittle' | |
| 20 | import fs from '#node:fs' | |
| 21 | import path from '#node:path' | |
| 22 | import os from '#node:os' | |
| 23 | import { spawn, spawnSync } from '#node:child_process' | |
| 24 | ||
| 25 | import { TreeStore } from '../lib/store.js' | |
| 26 | import { materializeAtomic, applyStaged, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js' | |
| 27 | ||
| 28 | const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0 | |
| 29 | ||
| 30 | function 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. | |
| 39 | function 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.) | |
| 54 | function 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 | ||
| 63 | const readVersion = (dir) => fs.readFileSync(path.join(dir, 'VERSION'), 'utf8') | |
| 64 | ||
| 65 | test('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 | ||
| 81 | test('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 | ||
| 102 | test('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 | ||
| 116 | test('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 | }) |
| … | … | @@ -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. | |
| 5 | import test from 'brittle' | |
| 6 | import fs from '#node:fs' | |
| 7 | import path from '#node:path' | |
| 8 | import os from '#node:os' | |
| 9 | import process from '#node:process' | |
| 10 | import { spawn, spawnSync } from '#node:child_process' | |
| 11 | ||
| 12 | import { TreeStore } from '../lib/store.js' | |
| 13 | import { materializeAtomic, stagedPathFor } from '../lib/materializers/atomic.js' | |
| 14 | import { launcherScript } from '../lib/runtime-factory.js' | |
| 15 | import { listAppNames } from '../lib/app-state.js' | |
| 16 | ||
| 17 | const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0 | |
| 18 | ||
| 19 | function 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. | |
| 41 | function 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 | ||
| 53 | function 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 | ||
| 62 | const version = (store) => fs.readFileSync(path.join(store.appPath('app'), 'VERSION'), 'utf8') | |
| 63 | ||
| 64 | test('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 | ||
| 75 | test('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 | ||
| 85 | test('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 | ||
| 96 | test('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 | }) |
| … | … | @@ -249,7 +249,7 @@ test('installComplete parity: pkg.pgh-driven launcher + full install manifest', |
| 249 | 249 | // with "exec: bare: not found" while the same launcher worked elsewhere. resolveBundledRuntime now |
| 250 | 250 | // resolves an absolute runtime whenever one is reachable, so assert the INTENT (which interpreter) and |
| 251 | 251 | // 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) | |
| 253 | 253 | t.is(path.basename(rt), 'bare', 'bare interp (absolute whenever a runtime can be resolved)') |
| 254 | 254 | t.ok(body.includes(path.join(store.appPath('app'), 'main.js')), 'launcher execs pgh.entrypoint') |
| 255 | 255 |