OverviewFilesCommitsIssuesDocsReleasesPeers

pgh / test / install-in-use.test.js

124 lines · javascript ref:
// A running app's install is never swapped out from under it.
//
// The swap (rename live -> .old, scratch -> live, delete .old) was claimed
// safe for a running process because "it keeps its old inodes". It is not:
//   - Godot export templates chdir() into the executable's dir at startup
//     (main.cpp), so the delete leaves the game with NO cwd -> getcwd() null ->
//     DirAccess fails -> user:// writes fail (hyper-tps' shader cache never
//     saved on ashatron, every launch recompiled);
//   - Godot reopens its .pck BY PATH for every resource load
//     (file_access_pack.cpp) and seeks to offsets read at startup, so after a
//     swap to a different version every later load reads the wrong bytes.
// Both the launcher's `pgh app update` and pgh-sidecar's autoupdate swapped
// mid-game (hyper-games #88).
//
// Contract: a running app holds a SHARED flock(1) on <apps>/<name>.lock (its
// launcher / the supervisor take it). An update that finds the lock held
// STAGES the new tree as <name>.next and leaves the live tree alone; the
// staged tree is applied (swapped in) only when nobody holds the lock.
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import { spawn, spawnSync } from '#node:child_process'

import { TreeStore } from '../lib/store.js'
import { materializeAtomic, applyStaged, lockPathFor, stagedPathFor } from '../lib/materializers/atomic.js'

const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0

function mkstore (t) {
  const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-inuse-'))
  t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
  const store = new TreeStore({ root })
  store.init()
  return store
}

// A materializer that writes VERSION into the tree it is asked to build.
function fakeMaterializer (version) {
  return {
    materialize ({ store, appName }) {
      const dir = store.appPath(appName)
      fs.mkdirSync(dir, { recursive: true })
      fs.writeFileSync(path.join(dir, 'VERSION'), version)
      return { referenced: [], rootTarget: dir, depCount: 0 }
    }
  }
}

// Hold a shared lock EXACTLY the way the launcher does — one process that
// locks an inherited fd and execs the app — so killing it releases the lock.
// (`flock -s lock cmd` forks cmd as a child that inherits the lock; killing
// flock would leave an orphan holding it.)
function holdShared (lockPath) {
  return new Promise((resolve, reject) => {
    fs.writeFileSync(lockPath, '', { flag: 'a' })
    const p = spawn('sh', ['-c', 'exec 9>>"$0"; flock -s 9; echo held; exec sleep 60', lockPath])
    p.stdout.on('data', (d) => { if (String(d).includes('held')) resolve(p) })
    p.on('error', reject)
  })
}

const readVersion = (dir) => fs.readFileSync(path.join(dir, 'VERSION'), 'utf8')

test('update while the app runs: live tree untouched, new tree staged', { skip: !HAS_FLOCK }, async (t) => {
  const store = mkstore(t)
  materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
  const live = store.appPath('app')
  const holder = await holdShared(lockPathFor(store, 'app'))
  t.teardown(() => holder.kill('SIGKILL'))

  const r = materializeAtomic(store, fakeMaterializer('2'), { installName: 'app' })

  t.is(readVersion(live), '1', 'the running app still sees version 1')
  t.ok(r.staged, 'the update reports it was staged')
  t.is(readVersion(stagedPathFor(store, 'app')), '2', 'version 2 waits as <name>.next')
  t.is(r.installPath, stagedPathFor(store, 'app'), 'callers write the manifest into the staged tree')
  t.is(r.finalPath, live, 'and still know the live path for the launcher')
})

test('a staged update is refused while the app runs and applied once it stops', { skip: !HAS_FLOCK }, async (t) => {
  const store = mkstore(t)
  materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
  const holder = await holdShared(lockPathFor(store, 'app'))
  materializeAtomic(store, fakeMaterializer('2'), { installName: 'app' })

  const busy = applyStaged(store, 'app')
  t.is(busy.applied, false, 'not applied while running')
  t.is(busy.reason, 'in-use')
  t.is(readVersion(store.appPath('app')), '1')

  holder.kill('SIGKILL')
  await new Promise((resolve) => holder.on('exit', resolve))

  const done = applyStaged(store, 'app')
  t.is(done.applied, true, 'applied once the lock is free')
  t.is(readVersion(store.appPath('app')), '2', 'the live tree is version 2')
  t.absent(fs.existsSync(stagedPathFor(store, 'app')), 'nothing left staged')
  t.absent(fs.existsSync(store.appPath('app') + '.old'), 'no .old left behind')
})

test('update with nobody running swaps directly and drops a stale staged tree', { skip: !HAS_FLOCK }, async (t) => {
  const store = mkstore(t)
  materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
  const holder = await holdShared(lockPathFor(store, 'app'))
  materializeAtomic(store, fakeMaterializer('2'), { installName: 'app' })
  holder.kill('SIGKILL')
  await new Promise((resolve) => holder.on('exit', resolve))

  const r = materializeAtomic(store, fakeMaterializer('3'), { installName: 'app' })
  t.absent(r.staged)
  t.is(readVersion(store.appPath('app')), '3', 'the newest version is live')
  t.absent(fs.existsSync(stagedPathFor(store, 'app')), 'the older staged version is gone')
})

test('nothing staged: applyStaged is a no-op', { skip: !HAS_FLOCK }, async (t) => {
  const store = mkstore(t)
  materializeAtomic(store, fakeMaterializer('1'), { installName: 'app' })
  const r = applyStaged(store, 'app')
  t.is(r.applied, false)
  t.is(r.reason, 'none')
  t.is(readVersion(store.appPath('app')), '1')
})