diff --git a/README.md b/README.md index ca1003a5..7462b9f7 100644 --- a/README.md +++ b/README.md @@ -1138,6 +1138,14 @@ time as if the raw load had room. A freshly booted hosted macOS runner is one such host: work outside the job holds its load above its CPU count for the whole job. Available memory is still required. +A running command's load can lull, as between a build's phases, though it will +soon take the whole host again. So where a systemd scope owns the command, its +gate samples the scope's CPU use every 5 seconds and records the most CPUs the +command kept busy over one sample in the last minute. While gated commands run, +the load counts as at least the sum of those records, and nothing starts while +they kept every CPU busy. macOS, Windows, and Linux without a systemd user +manager have no scope to measure, so the load average alone decides there. + A user's admissions on one host are recorded under `~/.cache/quest/admissions/`, whatever the caller's environment or login session. The home directory comes from the user database, not `$HOME`; diff --git a/src/cli/gate.ts b/src/cli/gate.ts index 172f4b7a..5395d373 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -10,14 +10,15 @@ import type { Readable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; import { type Cancellation, cancelled, follow, forwards, GROUP, interrupts, received, requested } from "./interrupt"; import { stubbed } from "./job"; -import { questScope, startTime, systemdScopes } from "./supervise"; +import { questScope, scopeGroup, startTime, systemdScopes } from "./supervise"; /** * One admitted command: a unique id, the wall-clock time it was admitted, and the process ID of * the gate that runs it, which holds a slot until that command ends, with when that gate started * (see `processStart`), so a reused process ID holds nothing. `queued` marks a caller still * waiting for its turn instead, as of `at`: it holds a place in the queue, the ledger's order of - * queued entries, and no slot. + * queued entries, and no slot. `cores` is the most CPUs the command kept busy over any + * SETTLE_MS of the last RECENT_MS, where its gate measures it (see `measure`). */ export interface Admission { id: string; @@ -25,6 +26,7 @@ export interface Admission { pid?: number; queued?: boolean; start?: string; + cores?: number; } /** One version of the host's admission ledger and queue. Versions only grow; 0 is the empty ledger. */ @@ -51,6 +53,8 @@ export interface Host { */ publish(base: Ledger, admissions: Admission[]): boolean; sleep(ms: number, signal: AbortSignal): Promise; + /** CPU microseconds the processes in systemd scope `unit` have used, or undefined while it does not exist. */ + usage(unit: string): number | undefined; } export interface Limits { @@ -252,7 +256,8 @@ function parse(text: string): Admission[] { Number.isFinite(entry?.at) && (entry.pid === undefined || (Number.isSafeInteger(entry.pid) && entry.pid > 0)) && (entry.queued === undefined || entry.queued === true) && - (entry.start === undefined || typeof entry.start === "string"), + (entry.start === undefined || typeof entry.start === "string") && + (entry.cores === undefined || (Number.isSafeInteger(entry.cores) && entry.cores >= 0)), ); return valid ? (value as Admission[]) : []; } @@ -311,6 +316,8 @@ function remove(file: string) { */ export function fileHost(directory: string, now = Date.now): Host { const path = (version: number) => join(directory, String(version)); + // A scope's cgroup never moves, so systemd is asked for it once. + const groups = new Map(); const latest = (): number => { let names: string[]; try { @@ -387,6 +394,22 @@ export function fileHost(directory: string, now = Date.now): Host { return true; }, sleep: (ms, signal) => sleep(ms, undefined, { signal }), + usage(unit) { + const group = groups.get(unit) ?? scopeGroup(unit); + if (group === undefined) return undefined; + groups.set(unit, group); + let stat: string; + try { + stat = readFileSync(join(group, "cpu.stat"), "utf8"); + } catch (error) { + // The scope ended, and systemd removed its cgroup. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + const usage = stat.match(/^usage_usec (\d+)$/m)?.[1]; + if (usage === undefined) throw new Error(`gate cannot read the CPU use of ${unit}`); + return Number(usage); + }, }; } @@ -405,6 +428,12 @@ export function fileHost(directory: string, now = Date.now): Host { * one, the load is work the gate cannot wait out, as on a freshly booted macOS * runner, so the raw load counts as having room. * + * A running command's load can lull, as between a build's phases, while it will + * soon take the whole host again. So where gates measure their commands (see + * `measure`), the load counts as at least the CPUs those commands kept busy at + * their busiest in the last minute, and nothing is admitted while they kept + * every CPU busy. + * * Callers are admitted first come, first served: each records its place in the * queue when it starts waiting, and counts every caller queued ahead of it as * one more recent admission, so it starts before them only while the host has @@ -417,7 +446,8 @@ export function fileHost(directory: string, now = Date.now): Host { * * Each caller decides against the newest ledger and publishes atomically, so * concurrent callers never exceed the rule and one always wins while it has - * room. No admission timeout except a quick caller's. + * room. No admission timeout except a quick caller's. Resolves with the + * admission's id. */ export async function admit( limits: Limits, @@ -425,7 +455,7 @@ export async function admit( signal: AbortSignal, waiting?: (reason: Waiting) => void, precedence: Precedence = {}, -) { +): Promise { const id = randomUUID(); // A place in the queue has an id of its own, so publishing the admission adds a new entry. const place = randomUUID(); @@ -480,10 +510,15 @@ export async function admit( // Recorded like any admission, so it still counts against the others' headroom. if (now >= deadline) return admitted(now); // Priority ignores load, whether or not a gated command runs; memory still applies. - const load = precedence.priority ? 0 : running.size > 0 ? host.load() : Math.min(host.load(), limits.load); + const used = precedence.priority ? 0 : [...running].reduce((sum, admission) => sum + (admission.cores ?? 0), 0); + const load = precedence.priority + ? 0 + : running.size > 0 + ? Math.max(host.load(), used) + : Math.min(host.load(), limits.load); const memory = host.memory(); let wait = SETTLE_MS; - if (load > limits.load) waiting?.("load"); + if (load > limits.load || used >= limits.load) waiting?.("load"); else if (memory < limits.memory) waiting?.("memory"); else { // Every caller queued ahead takes a slot before this one, so it starts first only while @@ -509,7 +544,7 @@ export async function admit( try { for (;;) { signal.throwIfAborted(); - if (await turn()) return; + if (await turn()) return id; } } finally { // A cancelled caller leaves the queue at once, rather than once its gate ends. @@ -526,6 +561,41 @@ export async function admit( } } +/** + * Until `signal` aborts, sample the CPU use of the admitted command in systemd scope `unit` every + * SETTLE_MS, and keep its admission's `cores` at the most CPUs it kept busy over one sample in the + * last RECENT_MS, rounded, so other callers count it at its busiest rather than at a lull. + */ +export async function measure(host: Host, admission: { id: string; unit: string }, signal: AbortSignal) { + const samples: { at: number; cores: number }[] = []; + // The command's scope starts with no CPU use, so a burst before the first sample still counts. + let previous = { at: host.now(), usage: 0 }; + let recorded = 0; + for (;;) { + try { + await host.sleep(SETTLE_MS, signal); + } catch (error) { + if (signal.aborted) return; + throw error; + } + const usage = host.usage(admission.unit); + const at = host.now(); + if (usage !== undefined) { + // A clock stepped backward yields no rate for its interval. + if (at > previous.at) samples.push({ at, cores: (usage - previous.usage) / ((at - previous.at) * 1000) }); + previous = { at, usage }; + } + while (samples.length > 0 && samples[0].at <= at - RECENT_MS) samples.shift(); + const cores = Math.round(Math.max(0, ...samples.map((sample) => sample.cores))); + for (let ledger = host.ledger(); cores !== recorded; ledger = host.ledger()) { + const mine = ledger.admissions.find(({ id }) => id === admission.id); + if (mine === undefined) throw new Error(`gate lost its admission ${admission.id} from the ledger`); + const updated = ledger.admissions.map((entry) => (entry === mine ? { ...mine, cores } : entry)); + if (host.publish(ledger, updated)) recorded = cores; + } + } +} + /** Marks an admitted command, so nested gated calls inside it run at once. */ const ADMITTED = "QUEST_GATE_ADMITTED"; @@ -587,20 +657,20 @@ export const inside: Owner = plain(true); * owner's kill stops the gate's scope too, and the gate forwards that owner's signals to it. */ const background = - (enclosing: string | null): Owner => + (unit: string, enclosing: string | null): Owner => (command, env, cancel) => - systemdScopes.run(`quest-gate-${randomUUID()}.scope`, process.cwd(), command, { + systemdScopes.run(unit, process.cwd(), command, { env, cancel, within: enclosing, slice: "background.slice", }); -/** Run the command in a background scope of its own, outside any Quest scope (see `background`). */ -export const scope: Owner = background(null); +/** Run the command in background scope `unit`, a new name, outside any Quest scope (see `background`). */ +export const scope = (unit: string): Owner => background(unit, null); -/** Run the command in a background scope bound to the Quest scope `enclosing` (see `background`). */ -export const bound = (enclosing: string): Owner => background(enclosing); +/** Run the command in background scope `unit`, a new name, bound to the Quest scope `enclosing` (see `background`). */ +export const bound = (unit: string, enclosing: string): Owner => background(unit, enclosing); /** * On Windows, run the command under a stub in a kill-on-close Job Object that forbids breakaway @@ -682,20 +752,22 @@ const TOKEN = 3; * The strongest owner this host offers. A nested call runs inside its admitted command, which an * enclosing Quest owner also signals when it is Quest's group or a Quest scope (see `inside`). A * call inside a Quest scope runs its command in a background scope bound to it. Linux owns descendants through a systemd scope, and settles for a process group, - * like macOS, without a systemd user manager. Windows owns them through a Job Object. + * like macOS, without a systemd user manager. Windows owns them through a Job Object. A systemd + * scope also lets the gate measure its command's CPU use (see `measure`), so `unit` names it. */ -export function owner(env = process.env, cgroup = "/proc/self/cgroup"): Owner { +export function owner(env = process.env, cgroup = "/proc/self/cgroup"): { run: Owner; unit?: string } { // A Quest scope around this process holds the command too, since it shares the cgroup or an ancestor. const enclosing = process.platform === "linux" ? questScope(cgroup) : undefined; - if (env[ADMITTED] === "1") return env[GROUP] === "1" || enclosing ? inside : direct; - if (process.platform === "win32") return job; - if (enclosing) return bound(enclosing.unit); - if (process.platform !== "linux") return group; + if (env[ADMITTED] === "1") return { run: env[GROUP] === "1" || enclosing ? inside : direct }; + if (process.platform === "win32") return { run: job }; + const unit = `quest-gate-${randomUUID()}.scope`; + if (enclosing) return { run: bound(unit, enclosing.unit), unit }; + if (process.platform !== "linux") return { run: group }; try { systemdScopes.check(); - return scope; + return { run: scope(unit), unit }; } catch { - return group; + return { run: group }; } } @@ -705,11 +777,16 @@ const notices: Record = { queue: "quest gate: waiting for commands queued earlier to start", }; -/** Run from the CLI. */ +/** + * Run from the CLI. A failure to measure the command is fatal even when the command succeeded, + * since every other gate on the host relies on that accounting; a cancelled command still + * reports its cancellation. + */ export async function gate(command: string[], options: { quick?: boolean } = {}): Promise { const cancel = interrupts(); try { - const run = owner(); + const { run, unit } = owner(); + let admitted: { host: Host; id: string } | undefined; // A nested invocation executes inside the already admitted command. if (process.env[ADMITTED] !== "1") { const shown = new Set(); @@ -723,14 +800,32 @@ export async function gate(command: string[], options: { quick?: boolean } = {}) priority: process.env[PRIORITY] === "1", quick: options.quick, }; - await admit(hostLimits(), fileHost(claimDirectory()), cancel.signal, notice, precedence); + const host = fileHost(claimDirectory()); + admitted = { host, id: await admit(hostLimits(), host, cancel.signal, notice, precedence) }; } catch (error) { if (cancel.signal.aborted) return cancelled(cancel.signal); throw error; } } - const result = await run(command, { ...process.env, [ADMITTED]: "1" }, cancel); - return cancel.signal.aborted ? cancelled(cancel.signal) : result; + const stop = new AbortController(); + // Held as a value until the command ends, so a failure never goes unhandled meanwhile. + const measuring = + admitted !== undefined && unit !== undefined + ? measure(admitted.host, { id: admitted.id, unit }, stop.signal).then( + () => undefined, + (error: unknown) => ({ error }), + ) + : undefined; + let result: number; + try { + result = await run(command, { ...process.env, [ADMITTED]: "1" }, cancel); + } finally { + stop.abort(); + } + const failed = await measuring; + if (cancel.signal.aborted) return cancelled(cancel.signal); + if (failed) throw failed.error; + return result; } finally { cancel.close(); } diff --git a/src/cli/main.ts b/src/cli/main.ts index cecf0b55..ad412698 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -129,7 +129,10 @@ export function main(argv: string[], out: Output): number | Promise { "Wait while the 1-minute load average exceeds the host's CPU count or available memory is below " + "min(4 GiB, RAM/4). Each admission in the last minute counts as one more unit of load and one " + "budget less memory: commands start at once while those still fit, and 5 seconds apart while " + - "only the raw measures do. The load limit applies only while a gated command still runs. Nested " + + "only the raw measures do. The load limit applies only while a gated command still runs. Where a " + + "systemd scope owns each running command, the load counts as at least the sum of their peak CPU " + + "use in the last minute, and nothing starts while those fill every CPU, so a lull between a " + + "command's phases does not free its CPUs. Nested " + "calls run immediately. Limits come from the host; there is " + "no configuration.\n\n" + `--quick waits at most ${QUICK_MS / 1000} seconds, then starts the command anyway; pass it only for ` + diff --git a/src/cli/supervise.ts b/src/cli/supervise.ts index 98c33423..ff8ed50f 100644 --- a/src/cli/supervise.ts +++ b/src/cli/supervise.ts @@ -207,7 +207,7 @@ function signalScope(unit: string, signal: NodeJS.Signals, done: (error?: string } /** The scope's cgroup directory, or undefined once systemd no longer knows the unit. */ -function scopeGroup(unit: string): string | undefined { +export function scopeGroup(unit: string): string | undefined { const group = execFileSync("systemctl", ["--user", "show", "--property=ControlGroup", "--value", unit], { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], diff --git a/test/gate.test.ts b/test/gate.test.ts index bc3b25ed..eb191d88 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -1,4 +1,5 @@ import { spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { once } from "node:events"; import { chmodSync, @@ -30,6 +31,7 @@ import { type Host, hostLimits, linuxMemory, + measure, type Owner, owner, processStart, @@ -210,6 +212,75 @@ test("a running admission keeps its slot past the minute, and a finished one exp expect(host.ledger()).toEqual({ version: 100, admissions: [] }); }); +test("a running command that kept every CPU busy holds the host through a lull in the load", async () => { + const events: string[] = []; + // Between a build's phases, the load average falls below the host's CPUs. + const state = { now: 0, load: 1, memory: Infinity }; + const host = testHost(tempDir(), state, events); + const build = { id: "build", at: -10 * RECENT_MS, ...me, cores: 4 }; + expect(host.publish(host.ledger(), [build])).toBe(true); + const sleep = host.sleep; + host.sleep = async (ms, signal) => { + await sleep(ms, signal); + // Its busiest minute now left one CPU free. + const ledger = host.ledger(); + host.publish( + ledger, + ledger.admissions.map((admission) => (admission.id === build.id ? { ...build, cores: 3 } : admission)), + ); + }; + await admit({ load: 4, memory: GiB }, host, signal, (reason) => events.push(reason)); + expect(events).toEqual(["load", `sleep ${SETTLE_MS}`]); + expect(host.ledger().admissions).toEqual([ + { ...build, cores: 3 }, + { id: expect.any(String), at: SETTLE_MS, ...me }, + ]); +}); + +/** Run `measure` over `busy`, the CPUs its command keeps busy over each SETTLE_MS, undefined before its scope exists; returns what it publishes. */ +async function measured(busy: (number | undefined)[]) { + const state = { now: 0, load: 0, memory: Infinity }; + const host = testHost(tempDir(), state); + host.publish(host.ledger(), [{ id: "build", at: 0, ...me }]); + let usage: number | undefined; + let total = 0; + host.usage = () => usage; + const published: string[] = []; + const publish = host.publish; + host.publish = (base, admissions) => { + published.push(`${admissions[0].cores} at ${state.now}`); + return publish(base, admissions); + }; + const stop = new AbortController(); + host.sleep = async (ms) => { + if (busy.length === 0) { + stop.abort(); + throw stop.signal.reason; + } + state.now += ms; + const cores = busy.shift(); + if (cores === undefined) return; + total += cores * ms * 1000; + usage = total; + }; + await measure(host, { id: "build", unit: "quest-gate-build.scope" }, stop.signal); + return { published, admissions: host.ledger().admissions }; +} + +test("a gate records the most CPUs its command kept busy in the last minute", async () => { + // Before systemd creates the scope, an idle first reading, a build on four CPUs, then its + // tests on one for over a minute. + const { published, admissions } = await measured([undefined, 0, 4, 4, ...Array(RECENT_MS / SETTLE_MS).fill(1)]); + // The build's last sample, at 20 s, holds its four CPUs for a minute. + expect(published).toEqual([`4 at ${3 * SETTLE_MS}`, `1 at ${4 * SETTLE_MS + RECENT_MS}`]); + expect(admissions).toEqual([{ id: "build", at: 0, ...me, cores: 1 }]); +}); + +test("a gate records its command's burst before the first sample", async () => { + const { published } = await measured([4, ...Array(RECENT_MS / SETTLE_MS).fill(0)]); + expect(published).toEqual([`4 at ${SETTLE_MS}`, `0 at ${SETTLE_MS + RECENT_MS}`]); +}); + test("a priority caller waits for memory, never for load, even while a gated command runs", async () => { const directory = tempDir(); const state = { now: 0, load: 99, memory: 0 }; @@ -733,15 +804,17 @@ test("a nested call, or one inside a Quest scope, leaves its command to the encl }); expect(questScope(terminal)).toBeUndefined(); expect(questScope(cgroup(`${unit}-not`))).toBeUndefined(); - if (process.platform === "win32") return expect(owner({ QUEST_GATE_ADMITTED: "1" })).toBe(direct); + if (process.platform === "win32") return expect(owner({ QUEST_GATE_ADMITTED: "1" }).run).toBe(direct); // The enclosing owner signals the command too unless it was not one of Quest's. - expect(owner({ QUEST_GATE_ADMITTED: "1" }, terminal)).toBe(direct); - expect(owner({ QUEST_GATE_ADMITTED: "1", QUEST_GATE_GROUP: "1" }, terminal)).toBe(inside); + expect(owner({ QUEST_GATE_ADMITTED: "1" }, terminal)).toEqual({ run: direct }); + expect(owner({ QUEST_GATE_ADMITTED: "1", QUEST_GATE_GROUP: "1" }, terminal)).toEqual({ run: inside }); if (process.platform !== "linux") return; - expect(owner({ QUEST_GATE_ADMITTED: "1" }, gated)).toBe(inside); - expect(owner({ QUEST_GATE_ADMITTED: "1" }, child)).toBe(inside); - // An unadmitted call runs beside the enclosing scope, not inside it. - expect([direct, inside, scope]).not.toContain(owner({}, worktree)); + expect(owner({ QUEST_GATE_ADMITTED: "1" }, gated)).toEqual({ run: inside }); + expect(owner({ QUEST_GATE_ADMITTED: "1" }, child)).toEqual({ run: inside }); + // An unadmitted call runs beside the enclosing scope, not inside it, in a scope the gate names. + const beside = owner({}, worktree); + expect([direct, inside]).not.toContain(beside.run); + expect(beside.unit).toMatch(/^quest-gate-[0-9a-f-]+\.scope$/); }); /** Gone, or a zombie its new parent has not reaped yet: either way it runs no code. */ @@ -770,10 +843,13 @@ function descendant(dir: string, rest: string, { leave = false } = {}) { }; } +/** A systemd scope owner with a new unit, as the gate names one. */ +const scoped: Owner = (command, env, cancel) => scope(`quest-gate-${randomUUID()}.scope`)(command, env, cancel); + const owners: [string, Owner][] = process.platform === "linux" ? [ - ["a systemd scope", scope], + ["a systemd scope", scoped], ["a process group", group], ] : process.platform === "win32" @@ -1016,9 +1092,22 @@ describe.skipIf(process.platform === "win32")("an outermost gate owning a proces describe.runIf(process.platform === "linux")("a systemd scope", () => { test("also stops a descendant that left its process group", { timeout: 30_000 }, async () => { const { command, pid } = descendant(tempDir(), "exit 3", { leave: true }); - expect(await scope(command, process.env, cancellation().cancel)).toBe(3); + expect(await scoped(command, process.env, cancellation().cancel)).toBe(3); await until(() => stopped(pid()), "the daemon to stop"); }); + + test("reports its CPU use to the gate until it ends", { timeout: 30_000 }, async () => { + const host = fileHost(tempDir()); + const unit = `quest-gate-${randomUUID()}.scope`; + const done = join(tempDir(), "done"); + expect(host.usage(unit)).toBeUndefined(); + const spin = ["/bin/sh", "-c", `while [ ! -e ${quote(done)} ]; do :; done`]; + const result = scope(unit)(spin, process.env, cancellation().cancel); + await until(() => (host.usage(unit) ?? 0) > 0, "the scope's CPU use to show"); + writeFileSync(done, ""); + expect(await result).toBe(0); + await until(() => host.usage(unit) === undefined, "systemd to remove the scope"); + }); }); /** @@ -1035,20 +1124,21 @@ async function insideScope(launch: (command: string[], env: NodeJS.ProcessEnv) = script, `import { spawnSync } from "node:child_process"; import { existsSync, readFileSync, writeFileSync } from "node:fs"; - import { direct, group, inside, job, owner, scope } from ${JSON.stringify(gate)}; + import { direct, group, inside, job, owner } from ${JSON.stringify(gate)}; const dir = ${JSON.stringify(dir)}; - const own = owner(process.env); - const kind = new Map([[direct, "direct"], [group, "group"], [inside, "inside"], [job, "job"], [scope, "scope"]]).get(own) ?? "bound"; + const { run: own, unit } = owner(process.env); + const kind = unit ? "scope" : new Map([[direct, "direct"], [group, "group"], [inside, "inside"], [job, "job"]]).get(own); let report = { kind }; if (${run}) { const never = new AbortController().signal; - const command = "cat /proc/self/cgroup > " + dir + "/cgroup; sleep 1000 & echo $! > " + dir + "/pid; exit 7"; + const command = "cat /proc/self/cgroup > " + dir + "/cgroup; systemctl --user show --property=BindsTo --value " + unit + " > " + dir + "/binds; sleep 1000 & echo $! > " + dir + "/pid; exit 7"; const status = await own(["/bin/sh", "-c", command], process.env, { signal: never, kill: never }); const path = readFileSync(dir + "/cgroup", "utf8").trim().slice(3); const pid = readFileSync(dir + "/pid", "utf8").trim(); const ps = spawnSync("ps", ["-o", "stat=", "-p", pid], { encoding: "utf8" }); const stopped = ps.status !== 0 || ps.stdout.trim().startsWith("Z"); - report = { kind, status, path, stopped, removed: !existsSync("/sys/fs/cgroup" + path) }; + const binds = readFileSync(dir + "/binds", "utf8").trim(); + report = { kind, status, path, binds, stopped, removed: !existsSync("/sys/fs/cgroup" + path) }; } writeFileSync(${JSON.stringify(result)}, JSON.stringify(report));`, ); @@ -1060,8 +1150,10 @@ async function insideScope(launch: (command: string[], env: NodeJS.ProcessEnv) = describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () => { test("runs its command in a background scope beside the enclosing one", { timeout: 30_000 }, async () => { - const report = await insideScope((command, env) => scope(command, env, cancellation().cancel), true); - expect(report).toMatchObject({ kind: "bound", status: 7, stopped: true, removed: true }); + const report = await insideScope((command, env) => scoped(command, env, cancellation().cancel), true); + expect(report).toMatchObject({ kind: "scope", status: 7, stopped: true, removed: true }); + // Bound to the enclosing scope, a gate's scope here, so that scope's owner stops it too. + expect(report.binds).toMatch(/^quest-gate-[0-9a-f-]+\.scope$/); // A sibling, so only the gated command yields the CPU, not the rest of the enclosing scope. expect(report.path).toMatch(/\/background\.slice\/quest-gate-[0-9a-f-]+\.scope$/); }); @@ -1077,7 +1169,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = `trap '' TERM; sleep 1000 & echo $! > ${quote(pid)}; : > ${quote(ready)}; wait`, ]; const { stop, kill, cancel } = cancellation(); - const result = scope(nestedGate(dir, command), outside(), cancel); + const result = scoped(nestedGate(dir, command), outside(), cancel); await until(() => existsSync(ready), "the command to start"); stop.abort("SIGTERM"); kill.abort(); @@ -1100,7 +1192,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = `import { existsSync } from "node:fs"; import { direct, inside, owner } from ${JSON.stringify(gate)}; import { cancelled, interrupts } from ${JSON.stringify(interrupt)}; - const own = owner(process.env); + const { run: own } = owner(process.env); if (own === direct || own === inside) process.exit(90); const signals = interrupts(); const stop = new AbortController(); @@ -1130,7 +1222,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = const dir = tempDir(); const leaf = signalled(dir, ["SIGTERM"], 200); const { stop, cancel } = cancellation(); - const result = scope(nestedGate(dir, phase(dir, leaf.script)), outside(), cancel); + const result = scoped(nestedGate(dir, phase(dir, leaf.script)), outside(), cancel); await created(leaf.ready); stop.abort("SIGTERM"); expect(await result).toBe(128 + constants.signals.SIGTERM); @@ -1145,7 +1237,7 @@ describe.runIf(process.platform === "linux")("a gate inside a Quest scope", () = const abort = join(dir, "abort"); // The shell waits on the leaf in the background, so only a signal to the whole cgroup reaches it. const command = ["/bin/sh", "-c", `${quote(process.execPath)} ${quote(leaf.script)} & wait`]; - const result = scope(nestedGate(dir, command, abort), outside(), cancellation().cancel); + const result = scoped(nestedGate(dir, command, abort), outside(), cancellation().cancel); await created(leaf.ready); writeFileSync(abort, ""); expect(await result).toBe(128 + constants.signals.SIGTERM); @@ -1267,7 +1359,7 @@ test("the checkout's Bun config hands a gated command no .env files from its dir describe.runIf(process.platform === "win32")("a job object", () => { test("is the gate's owner outside an admitted command", () => { - expect(owner({})).toBe(job); + expect(owner({})).toEqual({ run: job }); }); test("keeps the exact exit status and stops a detached descendant", { timeout: 30_000 }, async () => { diff --git a/test/worktree.run.test.ts b/test/worktree.run.test.ts index bf4d7706..5b7589cd 100644 --- a/test/worktree.run.test.ts +++ b/test/worktree.run.test.ts @@ -1,6 +1,7 @@ // Supervised runs against the host's real systemd user manager. On Linux these need 5.14+, systemd 254+ // and unified cgroup v2; a host without them fails here rather than skipping. import { execFileSync, spawn, spawnSync } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { once } from "node:events"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; @@ -173,7 +174,7 @@ describe.runIf(process.platform === "linux")("supervised runs under systemd", { writeFileSync(justfile, `run:\n ${recipe.map((word) => JSON.stringify(word)).join(" ")}\n`); const stop = new AbortController(); const kill = new AbortController(); - const result = scope( + const result = scope(`quest-gate-${randomUUID()}.scope`)( ["just", "--justfile", justfile, "--working-directory", dir, "run"], { ...process.env, QUEST_WORKTREE_HOME: home, QUEST_GATE_ADMITTED: "1" }, { signal: stop.signal, kill: kill.signal },