OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / lib / materializers / atomic.js

210 lines · javascript ref:
/**
 * 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
  })
}