// The launcher side of the in-use contract (see install-in-use.test.js and
// lib/materializers/atomic.js): a generated launcher runs the app holding a
// SHARED flock on <apps>/<name>.lock, and applies a staged update — through
// pgh's own launcher, with PGH_NO_APPLY=1 — before the app starts.
import test from 'brittle'
import fs from '#node:fs'
import path from '#node:path'
import os from '#node:os'
import process from '#node:process'
import { spawn, spawnSync } from '#node:child_process'
import { TreeStore } from '../lib/store.js'
import { materializeAtomic, stagedPathFor } from '../lib/materializers/atomic.js'
import { launcherScript } from '../lib/runtime-factory.js'
import { listAppNames } from '../lib/app-state.js'
const HAS_FLOCK = spawnSync('flock', ['--version']).status === 0
function setup (t) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-launch-'))
t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
const store = new TreeStore({ root: path.join(root, 'store') })
store.init()
const binDir = path.join(root, 'bin')
fs.mkdirSync(binDir)
// A stand-in `pgh` that records how the launcher called it.
const calls = path.join(root, 'pgh-calls')
fs.writeFileSync(path.join(binDir, 'pgh'), '#!/bin/sh\necho "NO_APPLY=$PGH_NO_APPLY $*" >> \'' + calls + '\'\n', { mode: 0o755 })
const launcher = path.join(binDir, 'app')
fs.writeFileSync(launcher, launcherScript({
runtimePath: 'sh',
entryAbs: path.join(store.appPath('app'), 'run.sh'),
name: 'app',
store,
binDir
}), { mode: 0o755 })
return { store, launcher, calls }
}
// An app tree whose run.sh announces its version, then waits.
function appTree (version) {
return {
materialize ({ store, appName }) {
const dir = store.appPath(appName)
fs.mkdirSync(dir, { recursive: true })
fs.writeFileSync(path.join(dir, 'VERSION'), version)
fs.writeFileSync(path.join(dir, 'run.sh'), 'echo "running $(cat "$(dirname "$0")/VERSION")"; exec sleep 60\n')
return { referenced: [], rootTarget: dir, depCount: 0 }
}
}
}
function launch (launcher, env = {}) {
return new Promise((resolve, reject) => {
const p = spawn(launcher, [], { env: { ...process.env, ...env } })
p.stdout.on('data', (d) => { if (String(d).includes('running')) resolve(p) })
p.on('error', reject)
p.on('exit', (code) => reject(new Error('launcher exited ' + code)))
})
}
const version = (store) => fs.readFileSync(path.join(store.appPath('app'), 'VERSION'), 'utf8')
test('the running app holds the lock: an update stages, the live tree stays', { skip: !HAS_FLOCK }, async (t) => {
const { store, launcher } = setup(t)
materializeAtomic(store, appTree('1'), { installName: 'app' })
const app = await launch(launcher)
t.teardown(() => app.kill('SIGKILL'))
const r = materializeAtomic(store, appTree('2'), { installName: 'app' })
t.ok(r.staged, 'staged, because the launched app holds the lock')
t.is(version(store), '1', 'live tree untouched while it runs')
})
test('a staged update is applied through pgh before the app starts', { skip: !HAS_FLOCK }, async (t) => {
const { store, launcher, calls } = setup(t)
materializeAtomic(store, appTree('1'), { installName: 'app' })
fs.mkdirSync(stagedPathFor(store, 'app')) // something staged
const app = await launch(launcher)
t.teardown(() => app.kill('SIGKILL'))
t.is(fs.readFileSync(calls, 'utf8').trim(), 'NO_APPLY=1 app apply-staged app',
'pgh is asked to apply it, with PGH_NO_APPLY=1 (no recursion through pgh\'s own launcher)')
})
test('PGH_NO_APPLY: no apply, no lock (a maintenance invocation)', { skip: !HAS_FLOCK }, async (t) => {
const { store, launcher, calls } = setup(t)
materializeAtomic(store, appTree('1'), { installName: 'app' })
fs.mkdirSync(stagedPathFor(store, 'app'))
const app = await launch(launcher, { PGH_NO_APPLY: '1' })
t.teardown(() => app.kill('SIGKILL'))
t.absent(fs.existsSync(calls), 'pgh not called')
const r = materializeAtomic(store, appTree('2'), { installName: 'app' })
t.absent(r.staged, 'no lock held, so the update swaps directly')
})
test('a staged tree is not listed as an app', async (t) => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'pgh-list-'))
t.teardown(() => { try { fs.rmSync(root, { recursive: true, force: true }) } catch {} })
for (const n of ['app', 'app.next', 'app.upd', 'app.old']) {
fs.mkdirSync(path.join(root, 'apps', n), { recursive: true })
fs.writeFileSync(path.join(root, 'apps', n, '.pgh-install.json'), '{}')
}
const prev = process.env.PGH_STORE_ROOT
process.env.PGH_STORE_ROOT = root
try {
t.alike(listAppNames(), ['app'])
} finally {
process.env.PGH_STORE_ROOT = prev || '' // Bare's env refuses delete; '' falls back to the default root
}
})