/**
* Atomic materialize-and-swap — reusable across the pgh CLI and pgh-runtime.
*
* Build the new tree into a scratch dir, then swap it into place. This lets an
* UPDATE happen over an existing install without the plain materializer's
* "already exists — remove before re-install" throw, and without destroying
* the live install mid-materialize: the long materialize happens entirely in a
* scratch dir (live install untouched); only a tiny two-rename window touches
* the live path, and any still-running process keeps its old inodes. A failed
* materialize leaves the existing install completely intact.
*
* NOTE: app.js (the CLI) still carries its own private copy of this logic.
* This module is the reusable extraction so pgh-runtime's installRelease can
* inject it as an update-safe materializer. DRY follow-up: have app.js import
* from here too.
*/
import fs from '#node:fs'
import { spawnSync } from '#node:child_process'
import path from '#node:path'
const { rmSync, existsSync } = fs
// ── In-use protection ─────────────────────────────────────────────────────
//
// "Any still-running process keeps its old inodes" (above) is NOT enough:
// - Godot export templates chdir() into the executable's dir at startup, so
// deleting the old tree leaves the game with no cwd (getcwd() null ->
// user:// writes fail; hyper-tps' shader cache was never saved);
// - Godot reopens its .pck BY PATH for every resource load and seeks to
// offsets read at startup, so after a swap to another version every later
// load reads the wrong bytes.
// Both the launcher's `pgh app update` and pgh-sidecar's autoupdate swapped
// under a running game (hyper-games #88).
//
// So: a running app holds a SHARED flock(1) on <apps>/<name>.lock — its
// launcher takes it (`exec 9>>lock; flock -s 9; exec bare ...`, inherited by
// the app and its children, released by the kernel on exit or crash) and the
// supervisor wraps each child the same way. The swap itself runs inside
// `flock -n -x <lock>`: if anyone holds the lock, the new tree is STAGED as
// <name>.next and the live tree is left alone; applyStaged() swaps it in once
// nobody does (the launcher calls it before starting the app, the supervisor
// before each respawn). flock(1), not fs-native-extensions: on Linux that
// takes OFD record locks, which do not conflict with flock(2) locks at all.
// Without flock(1) (macOS, Windows) the swap is the plain rename pair, as
// before; Windows refuses to rename a running app's dir on its own.
export const STAGED_SUFFIX = '.next'
export function lockPathFor (store, name) { return store.appPath(name) + '.lock' }
export function stagedPathFor (store, name) { return store.appPath(name + STAGED_SUFFIX) }
let _hasFlock = null
export function hasFlock () {
if (_hasFlock === null) {
// cwd: '/' — see the note on swapUnlessInUse: never inherit the caller's cwd.
try { _hasFlock = spawnSync('flock', ['--version'], { cwd: '/' }).status === 0 } catch { _hasFlock = false }
}
return _hasFlock
}
const BUSY = 75 // flock -E: "the lock is held" — distinct from the swap's own failures
// $1 = live path, $2 = the new tree. rm the leftover .old FIRST so `mv live old`
// renames rather than moving into it; roll back if the second rename fails.
const SWAP_SH = 'o="$1.old"; rm -rf "$o"; had=0; ' +
'if [ -e "$1" ] || [ -L "$1" ]; then mv "$1" "$o" || exit 3; had=1; fi; ' +
'if ! mv "$2" "$1"; then [ "$had" = 1 ] && mv "$o" "$1"; exit 4; fi'
// Swap `newPath` into `finalPath` unless the app is running.
// @returns 'swapped' | 'busy'
function swapUnlessInUse (lockPath, finalPath, newPath) {
if (!hasFlock()) {
const oldPath = finalPath + '.old'
try { rmSync(oldPath, { recursive: true, force: true }) } catch {}
const hadExisting = existsSync(finalPath) || safeLstat(finalPath)
if (hadExisting) fs.renameSync(finalPath, oldPath)
try {
fs.renameSync(newPath, finalPath)
} catch (err) {
if (hadExisting) { try { fs.renameSync(oldPath, finalPath) } catch {} } // roll back
throw err
}
return 'swapped'
}
fs.writeFileSync(lockPath, '', { flag: 'a' })
// ⛔ An explicit cwd, never the caller's. The supervisor runs with cwd = the app's install dir, and
// the swap that applies an update deletes exactly that dir, so on the NEXT apply an inherited cwd is
// a dead inode: the spawn fails with ENOENT, the supervisor logs "staged-update check failed" and
// respawns the OLD tree. Every OTA after the first ran old code (stortron 2026-10-13, 8 supervisors;
// test/supervisor-staged-after-swap.test.js). The lock's own directory always exists here.
const r = spawnSync('flock', ['-n', '-E', String(BUSY), '-x', lockPath, 'sh', '-c', SWAP_SH, 'sh', finalPath, newPath],
{ cwd: path.dirname(lockPath) })
if (r.status === BUSY) return 'busy'
if (r.status !== 0) {
throw new Error('install swap failed (status ' + r.status + '): ' + String(r.stderr || '').trim())
}
return 'swapped'
}
// Re-key a tree's refs from one app name to another (atomic replace).
function moveRefs (store, from, to, refs) {
store.setAppRefs(to, [...refs])
try { store.clearAppRefs(from) } catch {}
}
function refsOf (store, name) {
const out = []
for (const [hash, apps] of Object.entries(store.listAllRefs())) if (apps.includes(name)) out.push(hash)
return out
}
function dropStaged (store, name) {
try { rmSync(stagedPathFor(store, name), { recursive: true, force: true }) } catch {}
try { store.clearAppRefs(name + STAGED_SUFFIX) } catch {}
}
function safeLstat (p) {
try { fs.lstatSync(p); return true } catch { return false }
}
/**
* @param {object} store TreeStore (appPath / setAppRefs / clearAppRefs)
* @param {object} materializer object with .materialize({ store, appName, rootTreeHash, deps, host })
* @param {object} args { installName, rootTreeHash, deps, host }
* @returns {{ installPath, finalPath, staged, rootTarget, depCount }} — installPath is
* where the new tree LANDED (the staged <name>.next when the app was running),
* finalPath the live path a launcher points at.
*/
export function materializeAtomic (store, materializer, { installName, rootTreeHash, deps, host }) {
const finalPath = store.appPath(installName)
const scratchName = installName + '.upd'
const scratchPath = store.appPath(scratchName)
const cleanScratch = () => {
try { rmSync(scratchPath, { recursive: true, force: true }) } catch {}
try { store.clearAppRefs(scratchName) } catch {}
}
cleanScratch() // drop any stale scratch from a prior interrupted run
let mat
try {
mat = materializer.materialize({ store, appName: scratchName, rootTreeHash, deps, host })
} catch (err) {
cleanScratch() // live install untouched
throw err
}
let swap
try {
swap = swapUnlessInUse(lockPathFor(store, installName), finalPath, scratchPath)
} catch (err) {
cleanScratch() // live install untouched (rolled back inside the swap)
throw err
}
if (swap === 'busy') {
// The app is running: keep the new tree beside it as <name>.next (replacing
// any older staged one) and leave the live tree alone.
const stagedName = installName + STAGED_SUFFIX
dropStaged(store, installName)
fs.renameSync(scratchPath, store.appPath(stagedName))
moveRefs(store, scratchName, stagedName, mat.referenced || [])
return { installPath: store.appPath(stagedName), finalPath, staged: true, rootTarget: mat.rootTarget, depCount: mat.depCount }
}
// Re-key refs scratch → real name as an atomic replace (drops the prior
// version's stale refs), then drop the old dir. A staged tree from an earlier
// in-use update is older than what just went live: drop it too.
moveRefs(store, scratchName, installName, mat.referenced || [])
try { rmSync(finalPath + '.old', { recursive: true, force: true }) } catch {}
dropStaged(store, installName)
return { installPath: finalPath, finalPath, staged: false, rootTarget: mat.rootTarget, depCount: mat.depCount }
}
/**
* Swap a staged update (<name>.next, left by an install that found the app
* running) into place — only if nobody holds the app's lock.
* @returns {{ applied: boolean, reason?: 'none' | 'in-use', installPath?: string }}
*/
export function applyStaged (store, name) {
const stagedName = name + STAGED_SUFFIX
const staged = store.appPath(stagedName)
if (!existsSync(staged)) return { applied: false, reason: 'none' }
const finalPath = store.appPath(name)
if (swapUnlessInUse(lockPathFor(store, name), finalPath, staged) === 'busy') {
return { applied: false, reason: 'in-use' }
}
moveRefs(store, stagedName, name, refsOf(store, stagedName))
try { rmSync(finalPath + '.old', { recursive: true, force: true }) } catch {}
return { applied: true, installPath: finalPath }
}
/**
* Adapt a base materializer into a function matching pgh-runtime
* installRelease's `opts.materialize({ store, appName, rootTreeHash, deps,
* host })` contract, performing an atomic scratch-build + swap (update-safe).
*
* installRelease(store, release, fetcher, {
* appName, materialize: atomicMaterializer({ materialize: nodeMaterialize })
* })
*
* @param {object} baseMaterializer object with .materialize(...) (e.g. pgh's node materializer)
* @returns {function} materialize-shaped fn for installRelease
*/
export function atomicMaterializer (baseMaterializer) {
return (args) => materializeAtomic(args.store, baseMaterializer, {
installName: args.appName,
rootTreeHash: args.rootTreeHash,
deps: args.deps,
host: args.host
})
}