From 6a042081cf2b7cbdf62584cfb49fae0a35622a20 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 19:58:39 -0700 Subject: [PATCH 01/12] chore: claim local command gate quest From 108fe1a408e55442fcf86c1ce2519502af5750a8 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 20:25:06 -0700 Subject: [PATCH 02/12] feat: preview Linux command admission with owned systemd scopes --- .github/workflows/check.yml | 9 ++ README.md | 30 ++++ quest/m0/local-gate.md | 25 ++++ src/cli/gate.ts | 289 ++++++++++++++++++++++++++++++++++++ src/cli/main.ts | 30 +++- test/fixtures/gate-run.ts | 9 ++ test/gate.systemd.test.ts | 198 ++++++++++++++++++++++++ test/gate.test.ts | 48 ++++++ 8 files changed, 637 insertions(+), 1 deletion(-) create mode 100644 src/cli/gate.ts create mode 100644 test/fixtures/gate-run.ts create mode 100644 test/gate.systemd.test.ts create mode 100644 test/gate.test.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index c0e16df7..42d717aa 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -30,6 +30,15 @@ jobs: run: nix develop --command just check - name: Test run: nix develop --command just test + - name: Enable the systemd user manager for gate integration tests + shell: bash + run: | + sudo loginctl enable-linger "$USER" + sudo systemctl start "user@$(id -u).service" + echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV" + echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV" + - name: Test real Linux gate ownership + run: nix develop --command env QUEST_GATE_SYSTEMD_TESTS=1 bunx vitest run test/gate.systemd.test.ts - name: Check Nix package run: nix flake check diff --git a/README.md b/README.md index a26d4718..e2688f2d 100644 --- a/README.md +++ b/README.md @@ -174,6 +174,36 @@ There is no deadline unless `--timeout ` is supplied. Activity exits 0, timeout exits 3, invalid arguments exit 2, and other failures exit 1; interrupt with Ctrl-C to stop waiting. +## Gate expensive commands (Linux preview) + +`quest gate [args...]` admits expensive phases to shared host slots: + +```sh +quest gate bun run test +quest gate cargo build --release +``` + +The preview requires Linux, unified cgroup v2 with writable `cgroup.kill`, and +`systemd-run` / `systemctl` (systemd 254+) connected to a systemd user manager. Other platforms +fail explicitly. Ordinary Quest commands remain available on every supported +platform. Repository entrypoints do not invoke this preview automatically yet. + +All repositories and worktrees belonging to a user share the same slots. The +limit defaults to the available CPU count; set `jobs` in +`~/.config/quest/gate.json` to change it, for example `{"jobs": 2}`. Use the same +host configuration for all callers, and change it only when gated commands are +idle. A nested invocation shares its existing kernel scope. Internal compiler +or test worker parallelism remains the command's responsibility. + +Arguments, working directory, environment and terminal streams pass through to +the executable. Waiting happens before it starts. Cancellation kills its exact +cgroup; if the gate crashes, surviving descendants retain their slot until they +finish. Commands that deliberately move themselves into other cgroups are +outside this cooperative admission boundary. The gate watches the kernel cgroup lifecycle for waiting, with no PID recovery guesses, polling or extra daemon. +There is no fairness guarantee: a queued command may wait on the first occupied +slot while another becomes available. This preview makes no measured claim of +improved whole-machine responsiveness. + ## Work with your agents Open this repository in Claude Code or Codex. The binary carries the skills; diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index ea43672f..1b3b0e24 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -27,6 +27,31 @@ remain useful without multiplying full-machine builds and test stacks. - Update CLI help and existing usage docs inline. Keep the shared primitive outside repository-specific policy and independent of the hosted runner. +### Implementation status + +- The draft implements a Linux/systemd user-scope backend, transparent CLI + invocation, configurable admission, nested scope reuse, exact cgroup-handle + cancellation, and event-driven contention waiting. Kernel scopes retain slots + through supervisor crashes and detached descendants. +- The quest remains unfinished. Automatic repository entrypoint adoption needs + a platform policy: recommended next step is explicit Linux support first, + retaining a clear unsupported error on macOS and Windows until native ownership + backends are justified. Do not install an unconditional gate in cross-platform + entrypoints or silently run an unsupported gate ungated. +- The pinned Bun exposes `--no-orphans`, but its best-effort registration and + bounded PID-tree walks need further validation before relying on it for exact + ownership. Windows ownership can use native Job Objects; portable Node + process groups do not provide the required crash and descendant guarantees. A broader backend choice + and its packaging remain open. No native helper is added in this draft. +- A 10-sample local bundled-CLI probe measured median startup of 35.5 ms for + `--version` and 72.5 ms for a gated `/bin/true` (about 37 ms additional). + This is an admission-cost probe on the current loaded host, not a whole-machine + responsiveness result. Representative build/test contention remains unmeasured. +- Shared configuration must remain consistent while slots are active. Safe + configuration changes across active callers and representative build/test + contention measurements remain unfinished. Fairness is not promised by this + simple admission implementation. + ## Related - [Recoverable worktrees](/quest/m0/worktree.md) - disk admission and checkout lifetime diff --git a/src/cli/gate.ts b/src/cli/gate.ts new file mode 100644 index 00000000..2904e92f --- /dev/null +++ b/src/cli/gate.ts @@ -0,0 +1,289 @@ +// Host-wide command admission. systemd owns scope lifetimes, including orphans. +import { spawn } from "node:child_process"; +import { closeSync, openSync, readFileSync, watch, writeSync } from "node:fs"; +import { availableParallelism, constants, homedir } from "node:os"; +import { join } from "node:path"; +import type { Readable, Writable } from "node:stream"; + +export interface GateBackend { + /** null means the atomic scope claim lost to another caller. */ + tryRun(slot: number, command: string[], signal: AbortSignal): Promise; + /** Block on a unit's lifecycle, rather than polling PID files or load. */ + wait(slot: number, signal: AbortSignal): Promise; +} + +export function gateJobs(config: unknown, fallback = availableParallelism()): number { + if (typeof config !== "object" || config === null || Array.isArray(config)) { + throw new Error("gate configuration must be an object with a jobs property"); + } + const configured = (config as { jobs?: unknown }).jobs; + const jobs = configured === undefined ? fallback : configured; + if (typeof jobs !== "number" || !Number.isSafeInteger(jobs) || jobs < 1) { + throw new Error("gate jobs must be a positive safe integer"); + } + return jobs; +} + +export async function admit( + command: string[], + jobs: number, + backend: GateBackend, + signal: AbortSignal, +): Promise { + for (;;) { + signal.throwIfAborted(); + for (let slot = 0; slot < jobs; slot++) { + signal.throwIfAborted(); + const result = await backend.tryRun(slot, command, signal); + if (result !== null) return result; + } + // Waiting on the first occupied slot is deliberately simple: no fairness + // promise, adaptive scheduler, busy polling, or admission timeout. + await backend.wait(0, signal); + } +} + +const unit = (slot: number) => `quest-gate-${slot}.scope`; +const scopePattern = /(?:^|\/)quest-gate-\d+\.scope(?:\/|$)/; + +/** No shell parses the command. This shell only restores stderr and locale. */ +const handshake = "cat /proc/self/cgroup >&4 || exit 125; read -r admitted <&5 || exit 125; exec 4>&- 5<&-; "; +const forward = + 'if [ "$1" = set ]; then export LC_ALL="$2"; else unset LC_ALL; fi; ' + 'shift 2; exec 2>&3 3>&-; exec "$@"'; + +interface Result { + code: number; + stderr: string; + stdout: string; +} + +function execute( + command: string, + args: string[], + signal: AbortSignal, + options: { forward?: boolean; scope?: string } = {}, +): Promise { + return new Promise((resolve, reject) => { + // Set up the stderr channels in the child shell, before replacing fd 2. + // Numeric fd 2 in Bun's extra stdio would duplicate the new diagnostic + // pipe. Reopening /proc/self/fd/2 also fails for socket-backed streams. + const child = spawn( + options.forward ? "/bin/sh" : command, + options.forward + ? ["-c", 'exec 6>&2; exec 2>&3; exec 3>&6 6>&-; exec "$@"', "quest-gate", command, ...args] + : args, + { + env: { ...process.env, LC_ALL: "C" }, + stdio: options.scope + ? ["inherit", "inherit", "inherit", "pipe", "pipe", "pipe"] + : options.forward + ? ["inherit", "inherit", "inherit", "pipe"] + : ["ignore", "pipe", "pipe"], + }, + ); + let stderr = ""; + let stdout = ""; + child.stdout?.setEncoding("utf8"); + child.stdout?.on("data", (data: string) => (stdout += data)); + const diagnostics = options.forward ? (child.stdio[3] as Readable) : child.stderr; + diagnostics?.setEncoding("utf8"); + diagnostics?.on("data", (data: string) => (stderr += data)); + let killHandle: number | undefined; + let ownershipError: Error | undefined; + const cancel = () => { + if (killHandle !== undefined) { + // The open kernel handle pins this exact cgroup. A reusable unit + // name (or PID) must never target a later command after an exit race. + try { + writeSync(killHandle, "1"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENODEV") ownershipError = error as Error; + } + } else child.kill("SIGTERM"); + }; + if (options.scope) { + const ownership = child.stdio[4]; + let cgroup = ""; + ownership?.on("data", (data: Buffer) => { + cgroup += data.toString(); + if (!cgroup.endsWith("\n")) return; + try { + const path = cgroup.trim().match(/^0::(\/[^\n]+)$/)?.[1]; + if (!path || !path.endsWith(`/${options.scope}`)) + throw new Error("gate requires unified cgroup v2 ownership"); + killHandle = openSync(join("/sys/fs/cgroup", path, "cgroup.kill"), "w"); + if (signal.aborted) cancel(); + else { + const admission = (child.stdio as unknown as (Writable | null)[])[5]; + admission?.on("error", (error: NodeJS.ErrnoException) => { + if (error.code !== "EPIPE") ownershipError = error; + }); + admission?.end("admitted\n"); + } + } catch (error) { + ownershipError = new Error( + `gate ownership unavailable: ${error instanceof Error ? error.message : error}`, + ); + child.kill("SIGKILL"); + } + }); + } + + signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); + child.once("error", (error) => { + signal.removeEventListener("abort", cancel); + reject(error); + }); + child.once("close", async (code, killed) => { + signal.removeEventListener("abort", cancel); + try { + if (killHandle !== undefined) closeSync(killHandle); + if (ownershipError) throw ownershipError; + resolve({ code: code ?? (killed ? 128 + constants.signals[killed] : 1), stderr, stdout }); + } catch (error) { + reject(error); + } + }); + }); +} + +export class SystemdGate implements GateBackend { + /** The kernel scope, never an inherited environment claim. */ + nested(): boolean { + return scopePattern.test(readFileSync("/proc/self/cgroup", "utf8").trim()); + } + + async tryRun(slot: number, command: string[], signal: AbortSignal): Promise { + const name = unit(slot); + const result = await execute( + "systemd-run", + [ + "--user", + "--scope", + "--quiet", + "--collect", + `--unit=${name}`, + "--expand-environment=no", + "--", + "/bin/sh", + "-c", + handshake + forward, + "quest-gate", + process.env.LC_ALL === undefined ? "unset" : "set", + process.env.LC_ALL ?? "", + ...command, + ], + signal, + { forward: true, scope: name }, + ); + // Only systemd-run's own stderr reaches this pipe. The command's stderr + // was restored to the original terminal/pipe through descriptor 3. + if ( + result.code !== 0 && + (result.stderr.includes(`Unit ${name} already exists`) || + result.stderr.includes(`Unit ${name} was already loaded or has a fragment file`)) + ) + return null; + if (result.stderr.trim()) throw new Error(`gate scope failed: ${result.stderr.trim()}`); + return result.code; + } + + async wait(slot: number, signal: AbortSignal): Promise { + const result = await execute( + "systemctl", + ["--user", "show", "--property=ControlGroup", "--value", unit(slot)], + signal, + ); + signal.throwIfAborted(); + if (result.code !== 0) { + if (result.stderr.includes("not found") || result.stderr.includes("not loaded")) return; + throw new Error(`gate wait failed: ${result.stderr.trim() || result.code}`); + } + const cgroup = result.stdout.trim(); + if (!cgroup) + throw new Error( + "gate scope has no cgroup; remove an obsolete manually configured quest-gate unit before continuing", + ); + await new Promise((resolve, reject) => { + let watcher: ReturnType | undefined; + const finish = (error?: unknown) => { + watcher?.close(); + signal.removeEventListener("abort", aborted); + if (error) reject(error); + else resolve(); + }; + const aborted = () => finish(signal.reason); + try { + const path = join("/sys/fs/cgroup", cgroup, "cgroup.events"); + watcher = watch(path, (event) => { + // Wait for the kernel cgroup to be removed, not merely empty: + // an empty scope may still be awaiting systemd collection. + if (event === "rename") finish(); + }); + watcher.on("error", finish); + signal.addEventListener("abort", aborted, { once: true }); + if (signal.aborted) aborted(); + // Registration precedes the read, covering deletion during setup. + readFileSync(path); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") finish(); + else finish(error); + } + }); + } +} + +/** Run from the CLI. Unsupported ownership must never silently run ungated. */ +export async function gate(command: string[]): Promise { + if (process.platform !== "linux") + throw new Error( + "gate requires Linux with a systemd user manager; this platform's process ownership is unsupported", + ); + const controller = new AbortController(); + let interrupted = 0; + const interrupt = (name: "SIGINT" | "SIGTERM" | "SIGHUP") => { + interrupted = 128 + constants.signals[name]; + controller.abort(); + }; + const handlers = { + SIGINT: () => interrupt("SIGINT"), + SIGTERM: () => interrupt("SIGTERM"), + SIGHUP: () => interrupt("SIGHUP"), + }; + for (const [name, handler] of Object.entries(handlers)) process.on(name, handler); + try { + const backend = new SystemdGate(); + if (backend.nested()) { + // A nested invocation executes in the already admitted kernel scope. + const result = await execute( + "/bin/sh", + [ + "-c", + forward, + "quest-gate", + process.env.LC_ALL === undefined ? "unset" : "set", + process.env.LC_ALL ?? "", + ...command, + ], + controller.signal, + { forward: true }, + ); + return interrupted || result.code; + } + let config: unknown = {}; + const path = join(homedir(), ".config", "quest", "gate.json"); + try { + config = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const result = await admit(command, gateJobs(config), backend, controller.signal); + return interrupted || result; + } catch (error) { + if (interrupted) return interrupted; + throw error; + } finally { + for (const [name, handler] of Object.entries(handlers)) process.off(name, handler); + } +} diff --git a/src/cli/main.ts b/src/cli/main.ts index a9fabce4..73cd6d7f 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -7,6 +7,7 @@ import { Command, CommanderError, InvalidArgumentError, Option } from "commander import { description, version } from "../../package.json"; import { blockers, check, formatFinding, label, lookup, ready, renderBlocker } from "../core"; +import { gate } from "./gate"; import { init, uninstall } from "./setup"; import { GUIDE, SKILLS, skill } from "./skills"; import { type Terminal, Theme } from "./theme"; @@ -52,6 +53,23 @@ export function main(argv: string[], out: Output): number | Promise { .exitOverride(); const root = () => program.opts<{ root: string }>().root; + program + .command("gate") + .summary("Admit an expensive command to a shared host slot (Linux/systemd)") + .description( + "Run a command in a user systemd scope; nested calls share their existing slot.\n\n" + + 'Reads ~/.config/quest/gate.json: {"jobs": 2}; defaults to the CPU count. ' + + "All callers must use the same host configuration. Unsupported platforms fail explicitly.", + ) + .argument("", "Executable to run") + .argument("[args...]", "Arguments forwarded verbatim") + .action((command: string, args: string[]) => { + pending = gate([command, ...args]).catch((error: unknown) => { + warn(error instanceof Error ? error.message : String(error)); + return 1; + }); + }); + program .command("check") .description("Report every structural mistake in the tree; exit non-zero if any") @@ -182,7 +200,7 @@ export function main(argv: string[], out: Output): number | Promise { }); try { - program.parse(argv, { from: "user" }); + program.parse(gateBoundary(argv), { from: "user" }); return pending ?? code; } catch (error) { if (error instanceof CommanderError) { @@ -226,3 +244,13 @@ function positiveSeconds(value: string): number { } return seconds; } + +/** Stop option parsing before a gated executable, keeping its own flags intact. */ +function gateBoundary(argv: string[]): string[] { + let index = 0; + while (argv[index] === "--root" || argv[index]?.startsWith("--root=")) { + index += argv[index] === "--root" ? 2 : 1; + } + if (argv[index] !== "gate" || !argv[index + 1] || ["--", "-h", "--help"].includes(argv[index + 1])) return argv; + return [...argv.slice(0, index + 1), "--", ...argv.slice(index + 1)]; +} diff --git a/test/fixtures/gate-run.ts b/test/fixtures/gate-run.ts new file mode 100644 index 00000000..b7520f58 --- /dev/null +++ b/test/fixtures/gate-run.ts @@ -0,0 +1,9 @@ +import { admit, SystemdGate } from "../../src/cli/gate"; +const controller = new AbortController(); +process.on("SIGTERM", () => controller.abort()); +try { + process.exitCode = await admit(JSON.parse(process.argv[2]), 1, new SystemdGate(), controller.signal); +} catch (error) { + if (controller.signal.aborted) process.exitCode = 143; + else throw error; +} diff --git a/test/gate.systemd.test.ts b/test/gate.systemd.test.ts new file mode 100644 index 00000000..f1b26bd7 --- /dev/null +++ b/test/gate.systemd.test.ts @@ -0,0 +1,198 @@ +// Opt in explicitly: these tests require a real Linux systemd user manager. +import { spawn, type ChildProcess } from "node:child_process"; +import { once } from "node:events"; +import { closeSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs"; +import { join, resolve } from "node:path"; +import { describe, expect, test } from "vitest"; +import { SystemdGate } from "../src/cli/gate"; +import { tempDir } from "./fixture"; + +const root = resolve(import.meta.dirname, ".."); +const cli = join(root, "src/cli/bin.ts"); +const runner = join(root, "test/fixtures/gate-run.ts"); +const enabled = process.env.QUEST_GATE_SYSTEMD_TESTS === "1"; +const signal = () => new AbortController().signal; + +function child(file: string, args: string[], cwd = root) { + const process = spawn("bun", [`--config=${join(root, "bunfig.toml")}`, file, ...args], { + cwd, + stdio: ["pipe", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + process.stdout.setEncoding("utf8"); + process.stderr.setEncoding("utf8"); + process.stdout.on("data", (data: string) => (stdout += data)); + process.stderr.on("data", (data: string) => (stderr += data)); + const exited = once(process, "exit"); + const closed = once(process, "close").then(([code, killed]) => ({ code, killed, stdout, stderr })); + return { process, exited, closed }; +} + +function line(process: ChildProcess, expected: string): Promise { + return new Promise((resolve, reject) => { + let text = ""; + const data = (chunk: Buffer) => { + text += chunk.toString(); + if (!text.includes(expected)) return; + process.stdout!.off("data", data); + process.off("close", closed); + resolve(); + }; + const closed = () => reject(new Error(`command exited before ${expected}: ${text}`)); + process.stdout!.on("data", data); + process.once("close", closed); + }); +} + +const hold = (file: string) => ` + const { watch, existsSync } = require('node:fs'); + const path = ${JSON.stringify(file)}; + const watcher = watch(require('node:path').dirname(path), () => { + if (existsSync(path)) watcher.close(); + }); + if (existsSync(path)) watcher.close(); + console.log('ready'); +`; + +describe.skipIf(!enabled)("real systemd scope ownership", () => { + test("forwards arguments, locale, stdin and exact exit codes across directories", async () => { + const locale = process.env.LC_ALL ?? null; + const spawned = child( + cli, + [ + "gate", + "bun", + "-e", + "process.stdin.once('data', data => { console.log(JSON.stringify([process.argv.slice(1), String(data), process.env.LC_ALL])); process.exitCode = 23; });", + "--", + "a $b", + "--help", + "--root", + "other", + ], + tempDir(), + ); + spawned.process.stdin.end("hello"); + const result = await spawned.closed; + expect(result.code, JSON.stringify(result)).toBe(23); + expect(result.stderr).toBe(""); + expect(JSON.parse(result.stdout)).toEqual([["a $b", "--help", "--root", "other"], "hello", locale]); + }); + + test("preserves stdin, stdout and stderr as terminal descriptors", async () => { + const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; + const command = ["bun", cli, "gate", "/bin/sh", "-c", "test -t 0 && test -t 1 && test -t 2 && echo terminal"] + .map(quote) + .join(" "); + const terminal = spawn("script", ["--quiet", "--return", "--command", command, "/dev/null"], { + cwd: root, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + terminal.stdout.on("data", (data: Buffer) => (stdout += data.toString())); + const [code] = await once(terminal, "close"); + expect(code).toBe(0); + expect(stdout).toContain("terminal"); + }); + + test("preserves existing output when stderr is redirected to a file", async () => { + const file = join(tempDir(), "stderr"); + const descriptor = openSync(file, "w"); + writeSync(descriptor, "prefix\n"); + const command = spawn("bun", [cli, "gate", "bun", "-e", "console.error('stderr')"], { + cwd: root, + stdio: ["ignore", "ignore", descriptor], + }); + closeSync(descriptor); + const [code] = await once(command, "close"); + expect(code).toBe(0); + expect(readFileSync(file, "utf8")).toBe("prefix\nstderr\n"); + }); + + test("nested calls keep their existing kernel scope", async () => { + const script = `console.log(require('node:fs').readFileSync('/proc/self/cgroup', 'utf8').trim()); require('node:child_process').spawnSync('bun', [${JSON.stringify(cli)}, 'gate', 'cat', '/proc/self/cgroup'], {stdio: 'inherit'});`; + const spawned = child(runner, [JSON.stringify(["bun", "-e", script])]); + const result = await spawned.closed; + expect(result.code).toBe(0); + const scopes = result.stdout.trim().split("\n"); + expect(scopes).toHaveLength(2); + expect(scopes[0]).toMatch(/\/quest-gate-0\.scope$/); + expect(scopes[1]).toBe(scopes[0]); + }); + + test("contention waits across directories without launching a second command", async () => { + const release = join(tempDir(), "release"); + const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); + try { + await line(first.process, "ready"); + expect( + await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), + ).toBeNull(); + const second = child(runner, [JSON.stringify(["bun", "-e", "console.log('second')"])], tempDir()); + writeFileSync(release, ""); + expect((await first.closed).code).toBe(0); + expect(await second.closed).toMatchObject({ code: 0, stdout: "second\n", stderr: "" }); + } finally { + writeFileSync(release, ""); + await first.closed; + } + }); + + test("a crashed supervisor leaves its descendant scope occupied", async () => { + const release = join(tempDir(), "release"); + const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); + try { + await line(first.process, "ready"); + first.process.kill("SIGKILL"); + await first.exited; + expect( + await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), + ).toBeNull(); + } finally { + writeFileSync(release, ""); + await first.closed; + } + }); + + test("detached descendants retain admission after the command exits", async () => { + const release = join(tempDir(), "release"); + const first = child(runner, [ + JSON.stringify(["/bin/sh", "-c", '"$@" &', "quest-orphan", "bun", "-e", hold(release)]), + ]); + try { + await line(first.process, "ready"); + expect( + await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), + ).toBeNull(); + } finally { + writeFileSync(release, ""); + await first.closed; + } + }); + + test("cancelling a waiter leaves the occupied command alone", async () => { + const release = join(tempDir(), "release"); + const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); + try { + await line(first.process, "ready"); + const controller = new AbortController(); + const waiting = new SystemdGate().wait(0, controller.signal); + controller.abort(); + await expect(waiting).rejects.toThrow(); + expect( + await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), + ).toBeNull(); + } finally { + writeFileSync(release, ""); + await first.closed; + } + }); + + test("cancellation kills the exact owned cgroup", async () => { + const spawned = child(cli, ["gate", "bun", "-e", "console.log('ready'); setInterval(() => {}, 1000);"]); + await line(spawned.process, "ready"); + spawned.process.kill("SIGTERM"); + expect((await spawned.closed).code).toBe(143); + }); +}); diff --git a/test/gate.test.ts b/test/gate.test.ts new file mode 100644 index 00000000..b070b3ae --- /dev/null +++ b/test/gate.test.ts @@ -0,0 +1,48 @@ +import { expect, test } from "vitest"; +import { admit, gateJobs, type GateBackend } from "../src/cli/gate"; +import { run } from "./fixture"; + +test("gate configuration defaults to CPUs and rejects unsafe limits", () => { + expect(gateJobs({}, 6)).toBe(6); + expect(gateJobs({ jobs: 2 }, 6)).toBe(2); + for (const config of [null, [], { jobs: null }, { jobs: 0 }, { jobs: 1.5 }, { jobs: "2" }, { jobs: Infinity }]) { + expect(() => gateJobs(config)).toThrow(); + } +}); + +test("admission checks slots before waiting and preserves exact exit codes", async () => { + const calls: string[] = []; + let released = false; + const backend: GateBackend = { + tryRun: async (slot, command) => { + calls.push(`try ${slot}`); + expect(command).toEqual(["compiler", "a $b", "--help"]); + return released ? 23 : null; + }, + wait: async (slot) => { + calls.push(`wait ${slot}`); + released = true; + }, + }; + expect(await admit(["compiler", "a $b", "--help"], 2, backend, new AbortController().signal)).toBe(23); + expect(calls).toEqual(["try 0", "try 1", "wait 0", "try 0"]); +}); + +test("a waiting cancellation never launches the command", async () => { + const controller = new AbortController(); + let calls = 0; + const backend: GateBackend = { + tryRun: async () => { + calls++; + return null; + }, + wait: async () => controller.abort(new Error("cancelled")), + }; + await expect(admit(["compiler"], 1, backend, controller.signal)).rejects.toThrow("cancelled"); + expect(calls).toBe(1); +}); + +test("gate has command help and requires an executable", () => { + expect(run("gate", "--help").stdout).toContain("Arguments forwarded verbatim"); + expect(run("gate").code).toBe(2); +}); From 603b3a016525dfe77375370fd1163fa8d2dca110 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 21:57:51 -0700 Subject: [PATCH 03/12] fix(gate): retry admission when the occupied slot was already collected systemctl show exits 0 with an empty ControlGroup for a unit that no longer exists, so a scope exiting between a lost claim and the wait raised a spurious 'obsolete unit' error. Only a unit file now blocks a slot; an empty cgroup returns to admission. Also gate checks rather than agents on host load: drop the agent load-average rule from the spawn and complete skills, document that configuration changes are safe while commands run, and record the open admission and platform decisions in the quest. Co-Authored-By: Claude Opus 5.5 --- README.md | 9 +++++---- assets/skills/complete.md | 1 - assets/skills/spawn.md | 1 - quest/m0/local-gate.md | 38 ++++++++++++++++++++------------------ src/cli/gate.ts | 24 ++++++++++++++---------- src/cli/main.ts | 2 +- test/gate.systemd.test.ts | 5 +++++ 7 files changed, 45 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index e2688f2d..1677800c 100644 --- a/README.md +++ b/README.md @@ -190,10 +190,11 @@ platform. Repository entrypoints do not invoke this preview automatically yet. All repositories and worktrees belonging to a user share the same slots. The limit defaults to the available CPU count; set `jobs` in -`~/.config/quest/gate.json` to change it, for example `{"jobs": 2}`. Use the same -host configuration for all callers, and change it only when gated commands are -idle. A nested invocation shares its existing kernel scope. Internal compiler -or test worker parallelism remains the command's responsibility. +`~/.config/quest/gate.json` to change it, for example `{"jobs": 2}`. Each +admission reads the file when it starts waiting; after lowering the limit, +commands already running in higher slots finish undisturbed. A nested invocation +shares its existing kernel scope. Internal compiler or test worker parallelism +remains the command's responsibility. Arguments, working directory, environment and terminal streams pass through to the executable. Waiting happens before it starts. Cancellation kills its exact diff --git a/assets/skills/complete.md b/assets/skills/complete.md index f13e8ab6..2c2d8528 100644 --- a/assets/skills/complete.md +++ b/assets/skills/complete.md @@ -26,7 +26,6 @@ Each sub-agent blocks on its own waits and reports back only when done or blocke For GitHub review or merge activity, it uses `quest wait` and resumes with the returned cursor as described by `quest skill merge`, rather than polling GitHub. Limit the number of active agents to the CPU core count. -Other sessions share this machine: hold new agents while the load average exceeds the core count. Tell each merge sub-agent that you watch its merge queue, so it reports once auto-merge is enabled. Wait on each such PR with `quest wait` until it merges. diff --git a/assets/skills/spawn.md b/assets/skills/spawn.md index ba5565a0..b7a2aef4 100644 --- a/assets/skills/spawn.md +++ b/assets/skills/spawn.md @@ -24,7 +24,6 @@ Start each quest in a background sub-agent with its own isolated worktree, since Each agent reports back only when done or blocked. Limit the number of active agents to the physical CPU core count. -Other sessions share this machine: hold new agents while the load average exceeds the core count. When done, each agent opens a PR, a draft if decisions or unfinished work remain. Its report lists every open decision and suggested follow-up with a recommendation. diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index 1b3b0e24..f7be6b82 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -33,24 +33,26 @@ remain useful without multiplying full-machine builds and test stacks. invocation, configurable admission, nested scope reuse, exact cgroup-handle cancellation, and event-driven contention waiting. Kernel scopes retain slots through supervisor crashes and detached descendants. -- The quest remains unfinished. Automatic repository entrypoint adoption needs - a platform policy: recommended next step is explicit Linux support first, - retaining a clear unsupported error on macOS and Windows until native ownership - backends are justified. Do not install an unconditional gate in cross-platform - entrypoints or silently run an unsupported gate ungated. -- The pinned Bun exposes `--no-orphans`, but its best-effort registration and - bounded PID-tree walks need further validation before relying on it for exact - ownership. Windows ownership can use native Job Objects; portable Node - process groups do not provide the required crash and descendant guarantees. A broader backend choice - and its packaging remain open. No native helper is added in this draft. -- A 10-sample local bundled-CLI probe measured median startup of 35.5 ms for - `--version` and 72.5 ms for a gated `/bin/true` (about 37 ms additional). - This is an admission-cost probe on the current loaded host, not a whole-machine - responsiveness result. Representative build/test contention remains unmeasured. -- Shared configuration must remain consistent while slots are active. Safe - configuration changes across active callers and representative build/test - contention measurements remain unfinished. Fairness is not promised by this - simple admission implementation. +- Decided 2026-10-15 (user): gate checks and CI-like suites on host load, not + agents. The agent-level load-average rule was removed from the spawn and + complete skills. +- Configuration changes are safe while commands run: each admission reads the + limit when it starts waiting, and commands in higher slots finish undisturbed + after the limit is lowered. +- Open: whether admission uses fixed per-user slots, the host load average + (which includes other users and sessions), or both. The systemd ownership + backend is only needed for slots; [Recoverable worktrees](/quest/m0/worktree.md) + needs the same exact descendant ownership and should reuse it either way. +- Open: the platform policy for automatic entrypoint adoption (`just check`, + `just test`). Explicit Linux support is recommended first, keeping a clear + unsupported error on macOS and Windows until native backends are justified. + Do not silently run an unsupported gate ungated. The pinned Bun's + `--no-orphans` does not establish exact ownership by itself (best-effort + registration, bounded PID-tree walks); Windows could use Job Objects. +- Unfinished: representative build/test contention measurements, once the + admission policy and adopted entrypoints are settled. A 10-sample probe + measured about 37 ms of admission overhead for a gated `/bin/true`. + Fairness is not promised. ## Related diff --git a/src/cli/gate.ts b/src/cli/gate.ts index 2904e92f..5341e40b 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -192,19 +192,23 @@ export class SystemdGate implements GateBackend { async wait(slot: number, signal: AbortSignal): Promise { const result = await execute( "systemctl", - ["--user", "show", "--property=ControlGroup", "--value", unit(slot)], + ["--user", "show", "--property=FragmentPath", "--property=ControlGroup", unit(slot)], signal, ); signal.throwIfAborted(); - if (result.code !== 0) { - if (result.stderr.includes("not found") || result.stderr.includes("not loaded")) return; - throw new Error(`gate wait failed: ${result.stderr.trim() || result.code}`); - } - const cgroup = result.stdout.trim(); - if (!cgroup) - throw new Error( - "gate scope has no cgroup; remove an obsolete manually configured quest-gate unit before continuing", - ); + if (result.code !== 0) throw new Error(`gate wait failed: ${result.stderr.trim() || result.code}`); + const properties = new Map( + result.stdout + .trim() + .split("\n") + .map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]), + ); + // A unit file would make every transient claim on this slot fail. + if (properties.get("FragmentPath")) + throw new Error(`gate slot is blocked by ${properties.get("FragmentPath")}; remove that unit file`); + // The occupant already exited (or awaits collection): retry admission. + const cgroup = properties.get("ControlGroup"); + if (!cgroup) return; await new Promise((resolve, reject) => { let watcher: ReturnType | undefined; const finish = (error?: unknown) => { diff --git a/src/cli/main.ts b/src/cli/main.ts index 73cd6d7f..d487e39f 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -59,7 +59,7 @@ export function main(argv: string[], out: Output): number | Promise { .description( "Run a command in a user systemd scope; nested calls share their existing slot.\n\n" + 'Reads ~/.config/quest/gate.json: {"jobs": 2}; defaults to the CPU count. ' + - "All callers must use the same host configuration. Unsupported platforms fail explicitly.", + "Unsupported platforms fail explicitly.", ) .argument("", "Executable to run") .argument("[args...]", "Arguments forwarded verbatim") diff --git a/test/gate.systemd.test.ts b/test/gate.systemd.test.ts index f1b26bd7..67e78098 100644 --- a/test/gate.systemd.test.ts +++ b/test/gate.systemd.test.ts @@ -189,6 +189,11 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { } }); + test("waiting on a slot that was already collected returns to admission", async () => { + // The occupying scope can exit between a lost claim and the wait. + await expect(new SystemdGate().wait(999, signal())).resolves.toBeUndefined(); + }); + test("cancellation kills the exact owned cgroup", async () => { const spawned = child(cli, ["gate", "bun", "-e", "console.log('ready'); setInterval(() => {}, 1000);"]); await line(spawned.process, "ready"); From de7319113268353872201bbfdd5bd4a948e1e7ec Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 22:23:50 -0700 Subject: [PATCH 04/12] feat(gate): admit by load average everywhere and gate just check/test Every platform now waits while the 1-minute load average exceeds the limit (the core count by default), admitting at most one waiter per 5-second window so a falling load does not release every waiter at once. Linux with a systemd user manager adds the existing scope slots; macOS, Windows, and Linux without a manager use load admission alone instead of failing. Nested calls run in place. The check, test, worker-check, and worker-test recipes run through the gate, and CI enables the user manager before checking so they exercise slots. The quest keeps only the representative contention measurement. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 10 +- README.md | 47 ++++---- justfile | 15 +++ quest/m0/README.md | 2 +- quest/m0/local-gate.md | 72 +++++------- src/cli/gate.ts | 225 ++++++++++++++++++++++++++++-------- src/cli/main.ts | 9 +- test/fixtures/gate-run.ts | 12 +- test/gate.systemd.test.ts | 16 ++- test/gate.test.ts | 87 ++++++++++++-- 10 files changed, 359 insertions(+), 136 deletions(-) diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 42d717aa..d3e39158 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -26,17 +26,17 @@ jobs: determinate: false - name: Install dependencies run: nix develop --command just install - - name: Check - run: nix develop --command just check - - name: Test - run: nix develop --command just test - - name: Enable the systemd user manager for gate integration tests + - name: Enable the systemd user manager so checks run in gate slots shell: bash run: | sudo loginctl enable-linger "$USER" sudo systemctl start "user@$(id -u).service" echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV" echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV" + - name: Check + run: nix develop --command just check + - name: Test + run: nix develop --command just test - name: Test real Linux gate ownership run: nix develop --command env QUEST_GATE_SYSTEMD_TESTS=1 bunx vitest run test/gate.systemd.test.ts - name: Check Nix package diff --git a/README.md b/README.md index 3ea8f8a3..7c48349d 100644 --- a/README.md +++ b/README.md @@ -198,36 +198,43 @@ There is no deadline unless `--timeout ` is supplied. Activity exits 0, timeout exits 3, invalid arguments exit 2, and other failures exit 1; interrupt with Ctrl-C to stop waiting. -## Gate expensive commands (Linux preview) +## Gate expensive commands -`quest gate [args...]` admits expensive phases to shared host slots: +`quest gate [args...]` holds an expensive command until the host has +room for it, across all of a user's repositories and worktrees: ```sh quest gate bun run test quest gate cargo build --release ``` -The preview requires Linux, unified cgroup v2 with writable `cgroup.kill`, and -`systemd-run` / `systemctl` (systemd 254+) connected to a systemd user manager. Other platforms -fail explicitly. Ordinary Quest commands remain available on every supported -platform. Repository entrypoints do not invoke this preview automatically yet. +Every platform waits while the 1-minute load average exceeds the limit, which +counts work from every session and user. The load average lags new work, so the +gate admits at most one waiting command every 5 seconds rather than releasing +every waiter at once. Windows reports no load average, so admission there is +immediate. -All repositories and worktrees belonging to a user share the same slots. The -limit defaults to the available CPU count; set `jobs` in -`~/.config/quest/gate.json` to change it, for example `{"jobs": 2}`. Each -admission reads the file when it starts waiting; after lowering the limit, -commands already running in higher slots finish undisturbed. A nested invocation -shares its existing kernel scope. Internal compiler or test worker parallelism -remains the command's responsibility. +On Linux with unified cgroup v2, writable `cgroup.kill`, and a systemd 254+ user +manager, each command also takes one of a fixed number of slots in a user scope. +The slot stays taken until the command's last descendant exits, even if the +gate crashes, and cancellation kills that exact cgroup. Without a user manager, +Linux uses load admission alone, like macOS and Windows. + +Both limits default to the CPU count; change them in +`~/.config/quest/gate.json`, for example `{"jobs": 2, "load": 8}`. Each +admission reads the file when it starts waiting; after lowering `jobs`, +commands already running in higher slots finish undisturbed. A nested +invocation runs immediately inside the admitted command. Internal compiler or +test worker parallelism remains the command's responsibility. + +This repository's `just check`, `just test`, `just worker-check`, and +`just worker-test` run through the gate. Arguments, working directory, environment and terminal streams pass through to -the executable. Waiting happens before it starts. Cancellation kills its exact -cgroup; if the gate crashes, surviving descendants retain their slot until they -finish. Commands that deliberately move themselves into other cgroups are -outside this cooperative admission boundary. The gate watches the kernel cgroup lifecycle for waiting, with no PID recovery guesses, polling or extra daemon. -There is no fairness guarantee: a queued command may wait on the first occupied -slot while another becomes available. This preview makes no measured claim of -improved whole-machine responsiveness. +the executable, and waiting happens before it starts. Commands that move +themselves into other cgroups are outside the slot boundary. There is no +fairness guarantee: a queued command may wait on the first occupied slot while +another becomes available. ## Work with your agents diff --git a/justfile b/justfile index abd7636b..dceb7ee9 100644 --- a/justfile +++ b/justfile @@ -8,8 +8,14 @@ default: install: bun install --frozen-lockfile +# Expensive recipes wait for host room through `quest gate`; nested calls run at once. +gate := "bun run --silent quest gate just" + # Check code, repository tooling, and the quest tree. check: + {{gate}} _check + +_check: bun run check bun run --silent quest check bun run --silent quest --root examples/export check @@ -21,6 +27,9 @@ check: # Run tests, including the shell hooks and the lifecycle of both builds. test: + {{gate}} _test + +_test: bun run test bash .claude/hooks/direnv.test.sh bash scripts/install-check.test.sh @@ -52,10 +61,16 @@ run *args: # Check generated binding types, TypeScript, formatting, and deployment bundle. worker-check: + {{gate}} _worker-check + +_worker-check: bun run --cwd cloudflare check # Run Worker integration tests in the Cloudflare runtime. worker-test: + {{gate}} _worker-test + +_worker-test: bun run --cwd cloudflare test # Apply auth migrations locally and start the Worker. diff --git a/quest/m0/README.md b/quest/m0/README.md index 0afb2c34..a3f2cea3 100644 --- a/quest/m0/README.md +++ b/quest/m0/README.md @@ -35,7 +35,7 @@ ownership-manifest design: ## Required -- [Expensive command gate](/quest/m0/local-gate.md) - shared CPU admission across worktrees and repositories +- [Gate contention measurements](/quest/m0/local-gate.md) - evidence that gated checks stay responsive under shared load - [Recoverable worktrees](/quest/m0/worktree.md) - disk admission, checkpoints, release, and restore - [Agent lifecycle integration](/quest/m0/worktree-agents.md) - reclaim completed and suspended checkouts through their harness diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index f7be6b82..1604b197 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -1,4 +1,4 @@ -# [M] Bound expensive commands with quest gate +# [S] Measure quest gate under representative contention ## Goal @@ -6,55 +6,43 @@ on one host, shared across repositories and worktrees. Concurrent agent sessions remain useful without multiplying full-machine builds and test stacks. +The gate and its automatic use by `just check` / `just test` have landed. What +remains is measuring representative build/test contention with and without the +gate, for example how many concurrent Worker suites hit their 5-second timeouts, +before claiming a responsiveness improvement. + ## Plan - Decided 2026-10-15: gate expensive command phases, not agent lifetimes. Use a simple configurable concurrency limit, with a CPU-count default. Repository entrypoints invoke the gate automatically; ordinary reasoning and I/O do not occupy a CPU slot. No adaptive scheduler or new daemon is needed for this scope. -- Wait before launching the command, its stack, or its test timeout clocks. - Preserve arguments, terminal I/O, exit status, and cancellation. Nested gated - calls must not deadlock or silently multiply the admitted concurrency. -- A slot remains owned while expensive descendants run. Cover interruption, - crashes, concurrent acquisition, and recovery without relying on stale PID - guesses. Unsupported ownership semantics fail explicitly. -- Reuse existing compiler scheduling such as mbx; this is admission for whole - expensive phases, not a replacement compiler scheduler. Internal worker - concurrency remains a separate consumer responsibility. -- Exercise contention across processes and repositories, cancellation, nesting, - and exact exit propagation with small fixtures. Measure admission overhead - and representative contention before claiming a responsiveness improvement. -- Update CLI help and existing usage docs inline. Keep the shared primitive - outside repository-specific policy and independent of the hosted runner. - -### Implementation status - -- The draft implements a Linux/systemd user-scope backend, transparent CLI - invocation, configurable admission, nested scope reuse, exact cgroup-handle - cancellation, and event-driven contention waiting. Kernel scopes retain slots - through supervisor crashes and detached descendants. - Decided 2026-10-15 (user): gate checks and CI-like suites on host load, not - agents. The agent-level load-average rule was removed from the spawn and - complete skills. -- Configuration changes are safe while commands run: each admission reads the - limit when it starts waiting, and commands in higher slots finish undisturbed - after the limit is lowered. -- Open: whether admission uses fixed per-user slots, the host load average - (which includes other users and sessions), or both. The systemd ownership - backend is only needed for slots; [Recoverable worktrees](/quest/m0/worktree.md) - needs the same exact descendant ownership and should reuse it either way. -- Open: the platform policy for automatic entrypoint adoption (`just check`, - `just test`). Explicit Linux support is recommended first, keeping a clear - unsupported error on macOS and Windows until native backends are justified. - Do not silently run an unsupported gate ungated. The pinned Bun's - `--no-orphans` does not establish exact ownership by itself (best-effort - registration, bounded PID-tree walks); Windows could use Job Objects. -- Unfinished: representative build/test contention measurements, once the - admission policy and adopted entrypoints are settled. A 10-sample probe - measured about 37 ms of admission overhead for a gated `/bin/true`. - Fairness is not promised. + agents, because load from every session and user is what starves test timers. + The agent-level load-average rule was removed from the spawn and complete skills. +- Decided 2026-10-15 (user): admission uses both limits. Slots cap concurrency + where the platform can own descendants exactly; admission also waits while the + 1-minute load average exceeds the core count, which sees work the slots cannot. +- Decided 2026-10-15 (user): the load gate runs on every platform, and Linux adds + systemd scope slots on top. macOS, Windows, and Linux without a user manager + get load-only admission rather than an unsupported error. Windows reports no + load average, so admission there is immediate. +- Decided 2026-10-15 (user): Bun's `--no-orphans` stays a research option. Its + best-effort registration and bounded PID-tree walks do not establish exact + ownership by themselves. +- Decided 2026-10-15: at most one admission per 5-second window across all + callers, claimed by exclusively creating a per-window file. The load average + lags new work and is sampled every 5 seconds, so waiters that all see it fall + must not start together. Nothing is held across the command, so a crash + leaves no stale lock. +- Decided 2026-10-15: lowering `jobs` while commands run is safe: each admission + reads the limits when it starts waiting, and higher slots finish undisturbed. +- Nested gated calls run immediately: the kernel scope identifies them on Linux, + and an inherited marker does where admission was by load alone. +- A 10-sample probe measured about 37 ms of admission overhead for a gated + `/bin/true`. Fairness is not promised. ## Related -- [Recoverable worktrees](/quest/m0/worktree.md) - disk admission and checkout lifetime +- [Recoverable worktrees](/quest/m0/worktree.md) - should reuse the systemd scope backend for exact descendant ownership - [Own-PC runner](/quest/m0/kix/runner.md) - reuse local command supervision diff --git a/src/cli/gate.ts b/src/cli/gate.ts index 5341e40b..fa82b538 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -1,48 +1,165 @@ -// Host-wide command admission. systemd owns scope lifetimes, including orphans. +// Host-wide command admission. Every platform waits for the load average; on +// Linux, systemd scope slots also cap concurrency and own orphans' lifetimes. import { spawn } from "node:child_process"; -import { closeSync, openSync, readFileSync, watch, writeSync } from "node:fs"; -import { availableParallelism, constants, homedir } from "node:os"; +import { + closeSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, + rmSync, + watch, + writeSync, +} from "node:fs"; +import { availableParallelism, constants, homedir, loadavg } from "node:os"; import { join } from "node:path"; import type { Readable, Writable } from "node:stream"; +import { setTimeout as sleep } from "node:timers/promises"; export interface GateBackend { - /** null means the atomic scope claim lost to another caller. */ + /** True when this process already runs inside an admitted command. */ + nested(): boolean; + /** null means the atomic slot claim lost to another caller. */ tryRun(slot: number, command: string[], signal: AbortSignal): Promise; - /** Block on a unit's lifecycle, rather than polling PID files or load. */ + /** Block on a slot's lifecycle, rather than polling PID files. */ wait(slot: number, signal: AbortSignal): Promise; } -export function gateJobs(config: unknown, fallback = availableParallelism()): number { +/** The machine-wide inputs to load admission, replaceable in tests. */ +export interface Host { + /** The 1-minute load average. */ + load(): number; + now(): number; + /** Atomically claim an admission window; false when another caller took it. */ + claim(window: number): boolean; + sleep(ms: number, signal: AbortSignal): Promise; +} + +export interface Limits { + /** Concurrent slots, where the platform can own them. */ + jobs: number; + /** The highest 1-minute load average that still admits a command. */ + load: number; +} + +export type Waiting = "load" | "slots"; + +/** + * At most one admission per window across all callers. The load average lags + * new work, so waiters that all see it fall must not start together. + */ +export const SETTLE_MS = 5000; + +export function gateLimits(config: unknown, cores = availableParallelism()): Limits { if (typeof config !== "object" || config === null || Array.isArray(config)) { - throw new Error("gate configuration must be an object with a jobs property"); + throw new Error("gate configuration must be an object"); } - const configured = (config as { jobs?: unknown }).jobs; - const jobs = configured === undefined ? fallback : configured; + const { jobs = cores, load = cores } = config as { jobs?: unknown; load?: unknown }; if (typeof jobs !== "number" || !Number.isSafeInteger(jobs) || jobs < 1) { throw new Error("gate jobs must be a positive safe integer"); } - return jobs; + if (typeof load !== "number" || !Number.isFinite(load) || load <= 0) { + throw new Error("gate load must be a positive number"); + } + return { jobs, load }; +} + +/** Claims are empty files, named by window, in one per-user directory. */ +export function fileHost(directory: string): Host { + return { + load: () => loadavg()[0], + now: Date.now, + claim(window) { + mkdirSync(directory, { recursive: true }); + try { + closeSync(openSync(join(directory, String(window)), "wx")); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + throw error; + } + for (const name of readdirSync(directory)) { + if (Number(name) < window) rmSync(join(directory, name), { force: true }); + } + return true; + }, + sleep: (ms, signal) => sleep(ms, undefined, { signal }), + }; +} + +/** Wait until the load admits a command and this caller wins the current window. */ +async function settle(host: Host, limit: number, signal: AbortSignal, waiting?: (reason: Waiting) => void) { + for (;;) { + signal.throwIfAborted(); + const now = host.now(); + const window = Math.floor(now / SETTLE_MS); + if (host.load() > limit) waiting?.("load"); + else if (host.claim(window)) return; + // Waiters wake together at the boundary, and exactly one claims the window. + await host.sleep((window + 1) * SETTLE_MS - now, signal); + } } export async function admit( command: string[], - jobs: number, + limits: Limits, backend: GateBackend, + host: Host, signal: AbortSignal, + waiting?: (reason: Waiting) => void, ): Promise { for (;;) { - signal.throwIfAborted(); - for (let slot = 0; slot < jobs; slot++) { + await settle(host, limits.load, signal, waiting); + for (let slot = 0; slot < limits.jobs; slot++) { signal.throwIfAborted(); const result = await backend.tryRun(slot, command, signal); if (result !== null) return result; } // Waiting on the first occupied slot is deliberately simple: no fairness // promise, adaptive scheduler, busy polling, or admission timeout. + waiting?.("slots"); await backend.wait(0, signal); } } +/** Marks a command admitted by load alone, where no kernel scope can. */ +const ADMITTED = "QUEST_GATE_ADMITTED"; + +/** Run a command in place, with inherited streams and its exact exit status. */ +function run(command: string[], signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const child = spawn(command[0], command.slice(1), { + env: { ...process.env, [ADMITTED]: "1" }, + stdio: "inherit", + }); + const cancel = () => child.kill("SIGTERM"); + signal.addEventListener("abort", cancel, { once: true }); + child.once("error", (error) => { + signal.removeEventListener("abort", cancel); + reject(error); + }); + child.once("close", (code, killed) => { + signal.removeEventListener("abort", cancel); + resolve(code ?? (killed ? 128 + constants.signals[killed] : 1)); + }); + }); +} + +/** Load-only admission, where the platform cannot own descendants exactly. */ +export class DirectGate implements GateBackend { + nested(): boolean { + return process.env[ADMITTED] === "1"; + } + + tryRun(_slot: number, command: string[], signal: AbortSignal): Promise { + return run(command, signal); + } + + wait(): Promise { + return Promise.reject(new Error("load-only admission has no slots to wait on")); + } +} + const unit = (slot: number) => `quest-gate-${slot}.scope`; const scopePattern = /(?:^|\/)quest-gate-\d+\.scope(?:\/|$)/; @@ -61,31 +178,26 @@ function execute( command: string, args: string[], signal: AbortSignal, - options: { forward?: boolean; scope?: string } = {}, + /** The scope that runs the command with the caller's streams. */ + scope?: string, ): Promise { return new Promise((resolve, reject) => { // Set up the stderr channels in the child shell, before replacing fd 2. // Numeric fd 2 in Bun's extra stdio would duplicate the new diagnostic // pipe. Reopening /proc/self/fd/2 also fails for socket-backed streams. const child = spawn( - options.forward ? "/bin/sh" : command, - options.forward - ? ["-c", 'exec 6>&2; exec 2>&3; exec 3>&6 6>&-; exec "$@"', "quest-gate", command, ...args] - : args, + scope ? "/bin/sh" : command, + scope ? ["-c", 'exec 6>&2; exec 2>&3; exec 3>&6 6>&-; exec "$@"', "quest-gate", command, ...args] : args, { env: { ...process.env, LC_ALL: "C" }, - stdio: options.scope - ? ["inherit", "inherit", "inherit", "pipe", "pipe", "pipe"] - : options.forward - ? ["inherit", "inherit", "inherit", "pipe"] - : ["ignore", "pipe", "pipe"], + stdio: scope ? ["inherit", "inherit", "inherit", "pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"], }, ); let stderr = ""; let stdout = ""; child.stdout?.setEncoding("utf8"); child.stdout?.on("data", (data: string) => (stdout += data)); - const diagnostics = options.forward ? (child.stdio[3] as Readable) : child.stderr; + const diagnostics = scope ? (child.stdio[3] as Readable) : child.stderr; diagnostics?.setEncoding("utf8"); diagnostics?.on("data", (data: string) => (stderr += data)); let killHandle: number | undefined; @@ -101,7 +213,7 @@ function execute( } } else child.kill("SIGTERM"); }; - if (options.scope) { + if (scope) { const ownership = child.stdio[4]; let cgroup = ""; ownership?.on("data", (data: Buffer) => { @@ -109,7 +221,7 @@ function execute( if (!cgroup.endsWith("\n")) return; try { const path = cgroup.trim().match(/^0::(\/[^\n]+)$/)?.[1]; - if (!path || !path.endsWith(`/${options.scope}`)) + if (!path || !path.endsWith(`/${scope}`)) throw new Error("gate requires unified cgroup v2 ownership"); killHandle = openSync(join("/sys/fs/cgroup", path, "cgroup.kill"), "w"); if (signal.aborted) cancel(); @@ -175,7 +287,7 @@ export class SystemdGate implements GateBackend { ...command, ], signal, - { forward: true, scope: name }, + name, ); // Only systemd-run's own stderr reaches this pipe. The command's stderr // was restored to the original terminal/pipe through descriptor 3. @@ -238,12 +350,24 @@ export class SystemdGate implements GateBackend { } } -/** Run from the CLI. Unsupported ownership must never silently run ungated. */ +/** A user manager on unified cgroups provides exact slot ownership. */ +function systemd(): boolean { + const runtime = process.env.XDG_RUNTIME_DIR; + return ( + process.platform === "linux" && + runtime !== undefined && + existsSync(join(runtime, "systemd", "private")) && + existsSync("/sys/fs/cgroup/cgroup.controllers") + ); +} + +const notices: Record = { + load: "quest gate: waiting for the load average to fall", + slots: "quest gate: waiting for a free slot", +}; + +/** Run from the CLI. */ export async function gate(command: string[]): Promise { - if (process.platform !== "linux") - throw new Error( - "gate requires Linux with a systemd user manager; this platform's process ownership is unsupported", - ); const controller = new AbortController(); let interrupted = 0; const interrupt = (name: "SIGINT" | "SIGTERM" | "SIGHUP") => { @@ -257,32 +381,31 @@ export async function gate(command: string[]): Promise { }; for (const [name, handler] of Object.entries(handlers)) process.on(name, handler); try { - const backend = new SystemdGate(); + const backend = systemd() ? new SystemdGate() : new DirectGate(); + // A nested invocation executes inside the already admitted command. if (backend.nested()) { - // A nested invocation executes in the already admitted kernel scope. - const result = await execute( - "/bin/sh", - [ - "-c", - forward, - "quest-gate", - process.env.LC_ALL === undefined ? "unset" : "set", - process.env.LC_ALL ?? "", - ...command, - ], - controller.signal, - { forward: true }, - ); - return interrupted || result.code; + const result = await run(command, controller.signal); + return interrupted || result; } let config: unknown = {}; - const path = join(homedir(), ".config", "quest", "gate.json"); try { - config = JSON.parse(readFileSync(path, "utf8")); + config = JSON.parse(readFileSync(join(homedir(), ".config", "quest", "gate.json"), "utf8")); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } - const result = await admit(command, gateJobs(config), backend, controller.signal); + const shown = new Set(); + const result = await admit( + command, + gateLimits(config), + backend, + fileHost(join(homedir(), ".cache", "quest", "gate")), + controller.signal, + (reason) => { + if (shown.has(reason)) return; + shown.add(reason); + process.stderr.write(`${notices[reason]}\n`); + }, + ); return interrupted || result; } catch (error) { if (interrupted) return interrupted; diff --git a/src/cli/main.ts b/src/cli/main.ts index 20f789a3..6af75cb2 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -55,11 +55,12 @@ export function main(argv: string[], out: Output): number | Promise { program .command("gate") - .summary("Admit an expensive command to a shared host slot (Linux/systemd)") + .summary("Run an expensive command once the host has room for it") .description( - "Run a command in a user systemd scope; nested calls share their existing slot.\n\n" + - 'Reads ~/.config/quest/gate.json: {"jobs": 2}; defaults to the CPU count. ' + - "Unsupported platforms fail explicitly.", + "Wait while the 1-minute load average exceeds the limit, admitting one waiting command every " + + "5 seconds. On Linux with a systemd user manager, the command also takes one of a fixed number " + + "of slots in a user scope until its last descendant exits. Nested calls run immediately.\n\n" + + 'Reads ~/.config/quest/gate.json, for example {"jobs": 2, "load": 8}; both default to the CPU count.', ) .argument("", "Executable to run") .argument("[args...]", "Arguments forwarded verbatim") diff --git a/test/fixtures/gate-run.ts b/test/fixtures/gate-run.ts index b7520f58..d46d17fa 100644 --- a/test/fixtures/gate-run.ts +++ b/test/fixtures/gate-run.ts @@ -1,8 +1,16 @@ -import { admit, SystemdGate } from "../../src/cli/gate"; +import { admit, type Host, SystemdGate } from "../../src/cli/gate"; const controller = new AbortController(); process.on("SIGTERM", () => controller.abort()); +// These fixtures exercise slot ownership, so the host load always admits. +const idle: Host = { load: () => 0, now: Date.now, claim: () => true, sleep: async () => {} }; try { - process.exitCode = await admit(JSON.parse(process.argv[2]), 1, new SystemdGate(), controller.signal); + process.exitCode = await admit( + JSON.parse(process.argv[2]), + { jobs: 1, load: 1 }, + new SystemdGate(), + idle, + controller.signal, + ); } catch (error) { if (controller.signal.aborted) process.exitCode = 143; else throw error; diff --git a/test/gate.systemd.test.ts b/test/gate.systemd.test.ts index 67e78098..5eddb69c 100644 --- a/test/gate.systemd.test.ts +++ b/test/gate.systemd.test.ts @@ -1,9 +1,10 @@ // Opt in explicitly: these tests require a real Linux systemd user manager. import { spawn, type ChildProcess } from "node:child_process"; import { once } from "node:events"; -import { closeSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs"; +import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs"; +import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; -import { describe, expect, test } from "vitest"; +import { afterAll, describe, expect, test } from "vitest"; import { SystemdGate } from "../src/cli/gate"; import { tempDir } from "./fixture"; @@ -13,9 +14,18 @@ const runner = join(root, "test/fixtures/gate-run.ts"); const enabled = process.env.QUEST_GATE_SYSTEMD_TESTS === "1"; const signal = () => new AbortController().signal; +// Load admission is unit-tested; a private home keeps it from holding these +// slot tests on a busy host, and keeps their admission claims separate. +const home = mkdtempSync(join(tmpdir(), "quest-gate-home-")); +mkdirSync(join(home, ".config", "quest"), { recursive: true }); +writeFileSync(join(home, ".config", "quest", "gate.json"), JSON.stringify({ load: 1e9 })); +const env = { ...process.env, HOME: home }; +afterAll(() => rmSync(home, { recursive: true, force: true })); + function child(file: string, args: string[], cwd = root) { const process = spawn("bun", [`--config=${join(root, "bunfig.toml")}`, file, ...args], { cwd, + env, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; @@ -87,6 +97,7 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { .join(" "); const terminal = spawn("script", ["--quiet", "--return", "--command", command, "/dev/null"], { cwd: root, + env, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -102,6 +113,7 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { writeSync(descriptor, "prefix\n"); const command = spawn("bun", [cli, "gate", "bun", "-e", "console.error('stderr')"], { cwd: root, + env, stdio: ["ignore", "ignore", descriptor], }); closeSync(descriptor); diff --git a/test/gate.test.ts b/test/gate.test.ts index b070b3ae..56c03d2f 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -1,12 +1,27 @@ +import { existsSync } from "node:fs"; +import { join } from "node:path"; import { expect, test } from "vitest"; -import { admit, gateJobs, type GateBackend } from "../src/cli/gate"; -import { run } from "./fixture"; +import { admit, DirectGate, fileHost, gateLimits, type GateBackend, type Host, SETTLE_MS } from "../src/cli/gate"; +import { run, tempDir } from "./fixture"; + +const idle: Host = { load: () => 0, now: () => 0, claim: () => true, sleep: async () => {} }; test("gate configuration defaults to CPUs and rejects unsafe limits", () => { - expect(gateJobs({}, 6)).toBe(6); - expect(gateJobs({ jobs: 2 }, 6)).toBe(2); - for (const config of [null, [], { jobs: null }, { jobs: 0 }, { jobs: 1.5 }, { jobs: "2" }, { jobs: Infinity }]) { - expect(() => gateJobs(config)).toThrow(); + expect(gateLimits({}, 6)).toEqual({ jobs: 6, load: 6 }); + expect(gateLimits({ jobs: 2, load: 1.5 }, 6)).toEqual({ jobs: 2, load: 1.5 }); + for (const config of [ + null, + [], + { jobs: null }, + { jobs: 0 }, + { jobs: 1.5 }, + { jobs: "2" }, + { jobs: Infinity }, + { load: 0 }, + { load: "2" }, + { load: Infinity }, + ]) { + expect(() => gateLimits(config)).toThrow(); } }); @@ -14,6 +29,7 @@ test("admission checks slots before waiting and preserves exact exit codes", asy const calls: string[] = []; let released = false; const backend: GateBackend = { + nested: () => false, tryRun: async (slot, command) => { calls.push(`try ${slot}`); expect(command).toEqual(["compiler", "a $b", "--help"]); @@ -24,24 +40,77 @@ test("admission checks slots before waiting and preserves exact exit codes", asy released = true; }, }; - expect(await admit(["compiler", "a $b", "--help"], 2, backend, new AbortController().signal)).toBe(23); - expect(calls).toEqual(["try 0", "try 1", "wait 0", "try 0"]); + const limits = { jobs: 2, load: 1 }; + const waiting = (reason: string) => calls.push(reason); + expect( + await admit(["compiler", "a $b", "--help"], limits, backend, idle, new AbortController().signal, waiting), + ).toBe(23); + expect(calls).toEqual(["try 0", "try 1", "slots", "wait 0", "try 0"]); +}); + +test("a high load waits, then admits one caller per settle window", async () => { + const events: string[] = []; + const taken = new Set([3]); // Another waiter won the first window after the load fell. + let now = 2 * SETTLE_MS + 500; + let load = 9; + const host: Host = { + load: () => load, + now: () => now, + claim: (window) => { + events.push(`claim ${window}`); + if (taken.has(window)) return false; + taken.add(window); + return true; + }, + sleep: async (ms) => { + events.push(`sleep ${ms}`); + now += ms; + load = 3; + }, + }; + const backend: GateBackend = { + nested: () => false, + tryRun: async () => (events.push("run"), 0), + wait: () => Promise.reject(new Error("unreachable")), + }; + const signal = new AbortController().signal; + expect(await admit(["x"], { jobs: 1, load: 4 }, backend, host, signal, (reason) => events.push(reason))).toBe(0); + expect(events).toEqual(["load", `sleep ${SETTLE_MS - 500}`, "claim 3", `sleep ${SETTLE_MS}`, "claim 4", "run"]); +}); + +test("a window admits one claim and later claims clear older windows", () => { + const directory = join(tempDir(), "claims"); + const host = fileHost(directory); + expect(host.claim(5)).toBe(true); + expect(host.claim(5)).toBe(false); + expect(host.claim(6)).toBe(true); + expect(existsSync(join(directory, "5"))).toBe(false); + expect(host.load()).toBeGreaterThanOrEqual(0); }); test("a waiting cancellation never launches the command", async () => { const controller = new AbortController(); let calls = 0; const backend: GateBackend = { + nested: () => false, tryRun: async () => { calls++; return null; }, wait: async () => controller.abort(new Error("cancelled")), }; - await expect(admit(["compiler"], 1, backend, controller.signal)).rejects.toThrow("cancelled"); + await expect(admit(["compiler"], { jobs: 1, load: 1 }, backend, idle, controller.signal)).rejects.toThrow( + "cancelled", + ); expect(calls).toBe(1); }); +test("load-only admission runs the command in place and marks it nested", async () => { + const gate = new DirectGate(); + const script = "process.exit(process.env.QUEST_GATE_ADMITTED === '1' ? 7 : 1)"; + expect(await gate.tryRun(0, [process.execPath, "-e", script], new AbortController().signal)).toBe(7); +}); + test("gate has command help and requires an executable", () => { expect(run("gate", "--help").stdout).toContain("Arguments forwarded verbatim"); expect(run("gate").code).toBe(2); From b337d15cc6ef3f9caad35a9d0ac576e67bfc77cc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Thu, 15 Oct 2026 22:33:59 -0700 Subject: [PATCH 05/12] feat(gate): derive admission limits from host CPU and memory Drop ~/.config/quest/gate.json. The load limit is the usable core count; each command is budgeted 4 GiB, so Linux gets min(cores, RAM/4 GiB) slots, and admission also waits while available memory is below min(4 GiB, RAM/4). RAM honors a container memory limit. macOS skips the memory signal (it counts reclaimable cache as used). Systemd ownership tests now run through the idle-host fixture so a busy host cannot hold them, and the CLI's flag boundary gets a unit test. Co-Authored-By: Claude Opus 5.5 --- README.md | 33 +++++++-------- quest/m0/local-gate.md | 22 ++++++---- src/cli/gate.ts | 65 +++++++++++++++++------------ src/cli/main.ts | 11 ++--- test/fixtures/gate-run.ts | 9 ++-- test/gate.systemd.test.ts | 49 ++++++++++------------ test/gate.test.ts | 86 ++++++++++++++++++++++++++------------- 7 files changed, 159 insertions(+), 116 deletions(-) diff --git a/README.md b/README.md index 7c48349d..feafa165 100644 --- a/README.md +++ b/README.md @@ -208,24 +208,25 @@ quest gate bun run test quest gate cargo build --release ``` -Every platform waits while the 1-minute load average exceeds the limit, which -counts work from every session and user. The load average lags new work, so the -gate admits at most one waiting command every 5 seconds rather than releasing -every waiter at once. Windows reports no load average, so admission there is -immediate. +Admission is sized from the host, with no configuration. Every platform waits +while the 1-minute load average exceeds the usable core count (honoring CPU +affinity), or while available memory is below one command's budget: 4 GiB, or a +quarter of RAM on smaller hosts, where RAM is capped by any container memory +limit. Load and free memory count work from every session and user. The load +average lags new work, so the gate admits at most one waiting command every 5 +seconds rather than releasing every waiter at once. Windows reports no load +average and macOS counts reclaimable cache as used memory, so those signals are +skipped there. On Linux with unified cgroup v2, writable `cgroup.kill`, and a systemd 254+ user -manager, each command also takes one of a fixed number of slots in a user scope. -The slot stays taken until the command's last descendant exits, even if the -gate crashes, and cancellation kills that exact cgroup. Without a user manager, -Linux uses load admission alone, like macOS and Windows. - -Both limits default to the CPU count; change them in -`~/.config/quest/gate.json`, for example `{"jobs": 2, "load": 8}`. Each -admission reads the file when it starts waiting; after lowering `jobs`, -commands already running in higher slots finish undisturbed. A nested -invocation runs immediately inside the admitted command. Internal compiler or -test worker parallelism remains the command's responsibility. +manager, each command also takes one of `min(cores, RAM / 4 GiB)` slots in a +user scope. The slot stays taken until the command's last descendant exits, +even if the gate crashes, and cancellation kills that exact cgroup. Without a +user manager, Linux uses load and memory admission alone, like macOS and +Windows. + +A nested invocation runs immediately inside the admitted command. Internal +compiler or test worker parallelism remains the command's responsibility. This repository's `just check`, `just test`, `just worker-check`, and `just worker-test` run through the gate. diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index 1604b197..1a274eb4 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -14,19 +14,18 @@ before claiming a responsiveness improvement. ## Plan - Decided 2026-10-15: gate expensive command phases, not agent lifetimes. Use a - simple configurable concurrency limit, with a CPU-count default. Repository + simple concurrency limit derived from the host. Repository entrypoints invoke the gate automatically; ordinary reasoning and I/O do not occupy a CPU slot. No adaptive scheduler or new daemon is needed for this scope. - Decided 2026-10-15 (user): gate checks and CI-like suites on host load, not agents, because load from every session and user is what starves test timers. The agent-level load-average rule was removed from the spawn and complete skills. -- Decided 2026-10-15 (user): admission uses both limits. Slots cap concurrency - where the platform can own descendants exactly; admission also waits while the +- Decided 2026-10-15 (user): admission uses both. Slots cap concurrency where + the platform can own descendants exactly; admission also waits while the 1-minute load average exceeds the core count, which sees work the slots cannot. - Decided 2026-10-15 (user): the load gate runs on every platform, and Linux adds systemd scope slots on top. macOS, Windows, and Linux without a user manager - get load-only admission rather than an unsupported error. Windows reports no - load average, so admission there is immediate. + get load and memory admission alone rather than an unsupported error. - Decided 2026-10-15 (user): Bun's `--no-orphans` stays a research option. Its best-effort registration and bounded PID-tree walks do not establish exact ownership by themselves. @@ -35,10 +34,17 @@ before claiming a responsiveness improvement. lags new work and is sampled every 5 seconds, so waiters that all see it fall must not start together. Nothing is held across the command, so a crash leaves no stale lock. -- Decided 2026-10-15: lowering `jobs` while commands run is safe: each admission - reads the limits when it starts waiting, and higher slots finish undisturbed. +- Decided 2026-10-15 (user): limits come from host specs, with no configuration + file. The load limit is the usable core count (`availableParallelism`, which + honors CPU affinity): one runnable thread per core. Each + command is budgeted 4 GiB, a typical build or test stack, so Linux gets + `min(cores, RAM / 4 GiB)` slots (at least one), with RAM capped by a cgroup + memory limit. Admission also waits while available memory is below + `min(4 GiB, RAM / 4)`, so a small host still runs one command. macOS counts + reclaimable cache as used memory, so only its load is gated; Windows reports + no load average, so only its memory is. - Nested gated calls run immediately: the kernel scope identifies them on Linux, - and an inherited marker does where admission was by load alone. + and an inherited marker does where admission had no kernel scope. - A 10-sample probe measured about 37 ms of admission overhead for a gated `/bin/true`. Fairness is not promised. diff --git a/src/cli/gate.ts b/src/cli/gate.ts index fa82b538..fbe2b7b7 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -1,5 +1,6 @@ -// Host-wide command admission. Every platform waits for the load average; on -// Linux, systemd scope slots also cap concurrency and own orphans' lifetimes. +// Host-wide command admission, sized from the host itself. Every platform waits +// for CPU load and free memory; on Linux, systemd scope slots also cap +// concurrency and own orphans' lifetimes. import { spawn } from "node:child_process"; import { closeSync, @@ -12,7 +13,7 @@ import { watch, writeSync, } from "node:fs"; -import { availableParallelism, constants, homedir, loadavg } from "node:os"; +import { availableParallelism, constants, freemem, homedir, loadavg, totalmem } from "node:os"; import { join } from "node:path"; import type { Readable, Writable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; @@ -30,6 +31,8 @@ export interface GateBackend { export interface Host { /** The 1-minute load average. */ load(): number; + /** Bytes available to a new command without swapping. */ + memory(): number; now(): number; /** Atomically claim an admission window; false when another caller took it. */ claim(window: number): boolean; @@ -41,9 +44,11 @@ export interface Limits { jobs: number; /** The highest 1-minute load average that still admits a command. */ load: number; + /** The least available memory, in bytes, that still admits a command. */ + memory: number; } -export type Waiting = "load" | "slots"; +export type Waiting = "load" | "memory" | "slots"; /** * At most one admission per window across all callers. The load average lags @@ -51,24 +56,34 @@ export type Waiting = "load" | "slots"; */ export const SETTLE_MS = 5000; -export function gateLimits(config: unknown, cores = availableParallelism()): Limits { - if (typeof config !== "object" || config === null || Array.isArray(config)) { - throw new Error("gate configuration must be an object"); - } - const { jobs = cores, load = cores } = config as { jobs?: unknown; load?: unknown }; - if (typeof jobs !== "number" || !Number.isSafeInteger(jobs) || jobs < 1) { - throw new Error("gate jobs must be a positive safe integer"); - } - if (typeof load !== "number" || !Number.isFinite(load) || load <= 0) { - throw new Error("gate load must be a positive number"); - } - return { jobs, load }; +/** The memory one gated build or test stack is assumed to need. */ +export const COMMAND_MEMORY = 4 * 2 ** 30; + +/** Usable memory: physical RAM, or a smaller cgroup/container limit. */ +function usableMemory(): number { + const constrained = process.constrainedMemory?.() ?? 0; + return constrained > 0 ? Math.min(totalmem(), constrained) : totalmem(); +} + +/** + * Derive the limits from the host: no configuration. The load limit is one + * runnable thread per usable core; each slot and each admission reserves one + * command's memory, though never more than a quarter of a small host's RAM. + */ +export function hostLimits(cores = availableParallelism(), memory = usableMemory()): Limits { + return { + jobs: Math.max(1, Math.min(cores, Math.floor(memory / COMMAND_MEMORY))), + load: cores, + memory: Math.min(COMMAND_MEMORY, memory / 4), + }; } /** Claims are empty files, named by window, in one per-user directory. */ export function fileHost(directory: string): Host { return { load: () => loadavg()[0], + // macOS counts reclaimable cache as used, so only its load is gated. + memory: () => (process.platform === "darwin" ? Infinity : freemem()), now: Date.now, claim(window) { mkdirSync(directory, { recursive: true }); @@ -87,13 +102,14 @@ export function fileHost(directory: string): Host { }; } -/** Wait until the load admits a command and this caller wins the current window. */ -async function settle(host: Host, limit: number, signal: AbortSignal, waiting?: (reason: Waiting) => void) { +/** Wait until the host has room for a command and this caller wins the current window. */ +async function settle(host: Host, limits: Limits, signal: AbortSignal, waiting?: (reason: Waiting) => void) { for (;;) { signal.throwIfAborted(); const now = host.now(); const window = Math.floor(now / SETTLE_MS); - if (host.load() > limit) waiting?.("load"); + if (host.load() > limits.load) waiting?.("load"); + else if (host.memory() < limits.memory) waiting?.("memory"); else if (host.claim(window)) return; // Waiters wake together at the boundary, and exactly one claims the window. await host.sleep((window + 1) * SETTLE_MS - now, signal); @@ -109,7 +125,7 @@ export async function admit( waiting?: (reason: Waiting) => void, ): Promise { for (;;) { - await settle(host, limits.load, signal, waiting); + await settle(host, limits, signal, waiting); for (let slot = 0; slot < limits.jobs; slot++) { signal.throwIfAborted(); const result = await backend.tryRun(slot, command, signal); @@ -363,6 +379,7 @@ function systemd(): boolean { const notices: Record = { load: "quest gate: waiting for the load average to fall", + memory: "quest gate: waiting for free memory", slots: "quest gate: waiting for a free slot", }; @@ -387,16 +404,10 @@ export async function gate(command: string[]): Promise { const result = await run(command, controller.signal); return interrupted || result; } - let config: unknown = {}; - try { - config = JSON.parse(readFileSync(join(homedir(), ".config", "quest", "gate.json"), "utf8")); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; - } const shown = new Set(); const result = await admit( command, - gateLimits(config), + hostLimits(), backend, fileHost(join(homedir(), ".cache", "quest", "gate")), controller.signal, diff --git a/src/cli/main.ts b/src/cli/main.ts index 6af75cb2..275564fb 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -57,10 +57,11 @@ export function main(argv: string[], out: Output): number | Promise { .command("gate") .summary("Run an expensive command once the host has room for it") .description( - "Wait while the 1-minute load average exceeds the limit, admitting one waiting command every " + - "5 seconds. On Linux with a systemd user manager, the command also takes one of a fixed number " + - "of slots in a user scope until its last descendant exits. Nested calls run immediately.\n\n" + - 'Reads ~/.config/quest/gate.json, for example {"jobs": 2, "load": 8}; both default to the CPU count.', + "Wait while the 1-minute load average exceeds the core count or available memory is below " + + "min(4 GiB, RAM/4), admitting one waiting command every 5 seconds. On Linux with a systemd " + + "user manager, the command also takes one of min(cores, RAM/4 GiB) slots in a user scope until " + + "its last descendant exits. Nested calls run immediately. Limits come from the host; there is " + + "no configuration.", ) .argument("", "Executable to run") .argument("[args...]", "Arguments forwarded verbatim") @@ -275,7 +276,7 @@ function positiveSeconds(value: string): number { } /** Stop option parsing before a gated executable, keeping its own flags intact. */ -function gateBoundary(argv: string[]): string[] { +export function gateBoundary(argv: string[]): string[] { let index = 0; while (argv[index] === "--root" || argv[index]?.startsWith("--root=")) { index += argv[index] === "--root" ? 2 : 1; diff --git a/test/fixtures/gate-run.ts b/test/fixtures/gate-run.ts index d46d17fa..599f4776 100644 --- a/test/fixtures/gate-run.ts +++ b/test/fixtures/gate-run.ts @@ -1,16 +1,17 @@ import { admit, type Host, SystemdGate } from "../../src/cli/gate"; const controller = new AbortController(); process.on("SIGTERM", () => controller.abort()); -// These fixtures exercise slot ownership, so the host load always admits. -const idle: Host = { load: () => 0, now: Date.now, claim: () => true, sleep: async () => {} }; +// These fixtures exercise slot ownership, so the host always has room. +const idle: Host = { load: () => 0, memory: () => Infinity, now: Date.now, claim: () => true, sleep: async () => {} }; try { - process.exitCode = await admit( + const code = await admit( JSON.parse(process.argv[2]), - { jobs: 1, load: 1 }, + { jobs: 1, load: 1, memory: 0 }, new SystemdGate(), idle, controller.signal, ); + process.exitCode = controller.signal.aborted ? 143 : code; } catch (error) { if (controller.signal.aborted) process.exitCode = 143; else throw error; diff --git a/test/gate.systemd.test.ts b/test/gate.systemd.test.ts index 5eddb69c..28b32732 100644 --- a/test/gate.systemd.test.ts +++ b/test/gate.systemd.test.ts @@ -1,10 +1,9 @@ // Opt in explicitly: these tests require a real Linux systemd user manager. import { spawn, type ChildProcess } from "node:child_process"; import { once } from "node:events"; -import { closeSync, mkdirSync, mkdtempSync, openSync, readFileSync, rmSync, writeFileSync, writeSync } from "node:fs"; -import { tmpdir } from "node:os"; +import { closeSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs"; import { join, resolve } from "node:path"; -import { afterAll, describe, expect, test } from "vitest"; +import { describe, expect, test } from "vitest"; import { SystemdGate } from "../src/cli/gate"; import { tempDir } from "./fixture"; @@ -14,18 +13,12 @@ const runner = join(root, "test/fixtures/gate-run.ts"); const enabled = process.env.QUEST_GATE_SYSTEMD_TESTS === "1"; const signal = () => new AbortController().signal; -// Load admission is unit-tested; a private home keeps it from holding these -// slot tests on a busy host, and keeps their admission claims separate. -const home = mkdtempSync(join(tmpdir(), "quest-gate-home-")); -mkdirSync(join(home, ".config", "quest"), { recursive: true }); -writeFileSync(join(home, ".config", "quest", "gate.json"), JSON.stringify({ load: 1e9 })); -const env = { ...process.env, HOME: home }; -afterAll(() => rmSync(home, { recursive: true, force: true })); +// Commands run through the fixture runner, which admits on an idle host: load +// and memory admission are unit-tested, and must not hold these on a busy host. function child(file: string, args: string[], cwd = root) { const process = spawn("bun", [`--config=${join(root, "bunfig.toml")}`, file, ...args], { cwd, - env, stdio: ["pipe", "pipe", "pipe"], }); let stdout = ""; @@ -69,17 +62,18 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { test("forwards arguments, locale, stdin and exact exit codes across directories", async () => { const locale = process.env.LC_ALL ?? null; const spawned = child( - cli, + runner, [ - "gate", - "bun", - "-e", - "process.stdin.once('data', data => { console.log(JSON.stringify([process.argv.slice(1), String(data), process.env.LC_ALL])); process.exitCode = 23; });", - "--", - "a $b", - "--help", - "--root", - "other", + JSON.stringify([ + "bun", + "-e", + "process.stdin.once('data', data => { console.log(JSON.stringify([process.argv.slice(1), String(data), process.env.LC_ALL])); process.exitCode = 23; });", + "--", + "a $b", + "--help", + "--root", + "other", + ]), ], tempDir(), ); @@ -92,12 +86,10 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { test("preserves stdin, stdout and stderr as terminal descriptors", async () => { const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; - const command = ["bun", cli, "gate", "/bin/sh", "-c", "test -t 0 && test -t 1 && test -t 2 && echo terminal"] - .map(quote) - .join(" "); + const gated = JSON.stringify(["/bin/sh", "-c", "test -t 0 && test -t 1 && test -t 2 && echo terminal"]); + const command = ["bun", runner, gated].map(quote).join(" "); const terminal = spawn("script", ["--quiet", "--return", "--command", command, "/dev/null"], { cwd: root, - env, stdio: ["ignore", "pipe", "pipe"], }); let stdout = ""; @@ -111,9 +103,8 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { const file = join(tempDir(), "stderr"); const descriptor = openSync(file, "w"); writeSync(descriptor, "prefix\n"); - const command = spawn("bun", [cli, "gate", "bun", "-e", "console.error('stderr')"], { + const command = spawn("bun", [runner, JSON.stringify(["bun", "-e", "console.error('stderr')"])], { cwd: root, - env, stdio: ["ignore", "ignore", descriptor], }); closeSync(descriptor); @@ -207,7 +198,9 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { }); test("cancellation kills the exact owned cgroup", async () => { - const spawned = child(cli, ["gate", "bun", "-e", "console.log('ready'); setInterval(() => {}, 1000);"]); + const spawned = child(runner, [ + JSON.stringify(["bun", "-e", "console.log('ready'); setInterval(() => {}, 1000);"]), + ]); await line(spawned.process, "ready"); spawned.process.kill("SIGTERM"); expect((await spawned.closed).code).toBe(143); diff --git a/test/gate.test.ts b/test/gate.test.ts index 56c03d2f..c7368fa0 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -1,28 +1,30 @@ import { existsSync } from "node:fs"; import { join } from "node:path"; import { expect, test } from "vitest"; -import { admit, DirectGate, fileHost, gateLimits, type GateBackend, type Host, SETTLE_MS } from "../src/cli/gate"; +import { + admit, + COMMAND_MEMORY, + DirectGate, + fileHost, + type GateBackend, + type Host, + hostLimits, + SETTLE_MS, +} from "../src/cli/gate"; +import { gateBoundary } from "../src/cli/main"; import { run, tempDir } from "./fixture"; -const idle: Host = { load: () => 0, now: () => 0, claim: () => true, sleep: async () => {} }; +const idle: Host = { load: () => 0, memory: () => Infinity, now: () => 0, claim: () => true, sleep: async () => {} }; +const GiB = 2 ** 30; -test("gate configuration defaults to CPUs and rejects unsafe limits", () => { - expect(gateLimits({}, 6)).toEqual({ jobs: 6, load: 6 }); - expect(gateLimits({ jobs: 2, load: 1.5 }, 6)).toEqual({ jobs: 2, load: 1.5 }); - for (const config of [ - null, - [], - { jobs: null }, - { jobs: 0 }, - { jobs: 1.5 }, - { jobs: "2" }, - { jobs: Infinity }, - { load: 0 }, - { load: "2" }, - { load: Infinity }, - ]) { - expect(() => gateLimits(config)).toThrow(); - } +test("limits derive from the host's cores and memory", () => { + // A slot per command's memory, capped at the cores; load at one per core. + expect(hostLimits(24, 64 * GiB)).toEqual({ jobs: 16, load: 24, memory: COMMAND_MEMORY }); + expect(hostLimits(8, 16 * GiB)).toEqual({ jobs: 4, load: 8, memory: COMMAND_MEMORY }); + expect(hostLimits(2, 64 * GiB)).toMatchObject({ jobs: 2, load: 2 }); + // A small host still runs one command, and reserves a quarter of its RAM. + expect(hostLimits(4, 2 * GiB)).toEqual({ jobs: 1, load: 4, memory: GiB / 2 }); + expect(hostLimits().jobs).toBeGreaterThanOrEqual(1); }); test("admission checks slots before waiting and preserves exact exit codes", async () => { @@ -40,7 +42,7 @@ test("admission checks slots before waiting and preserves exact exit codes", asy released = true; }, }; - const limits = { jobs: 2, load: 1 }; + const limits = { jobs: 2, load: 1, memory: 1 }; const waiting = (reason: string) => calls.push(reason); expect( await admit(["compiler", "a $b", "--help"], limits, backend, idle, new AbortController().signal, waiting), @@ -48,13 +50,15 @@ test("admission checks slots before waiting and preserves exact exit codes", asy expect(calls).toEqual(["try 0", "try 1", "slots", "wait 0", "try 0"]); }); -test("a high load waits, then admits one caller per settle window", async () => { +test("load and memory pressure wait, then admit one caller per settle window", async () => { const events: string[] = []; - const taken = new Set([3]); // Another waiter won the first window after the load fell. + const taken = new Set([4]); // Another waiter won the first window once the host had room. let now = 2 * SETTLE_MS + 500; let load = 9; + let memory = 0; const host: Host = { load: () => load, + memory: () => memory, now: () => now, claim: (window) => { events.push(`claim ${window}`); @@ -65,7 +69,8 @@ test("a high load waits, then admits one caller per settle window", async () => sleep: async (ms) => { events.push(`sleep ${ms}`); now += ms; - load = 3; + if (load > 4) load = 3; + else memory = GiB; }, }; const backend: GateBackend = { @@ -74,8 +79,18 @@ test("a high load waits, then admits one caller per settle window", async () => wait: () => Promise.reject(new Error("unreachable")), }; const signal = new AbortController().signal; - expect(await admit(["x"], { jobs: 1, load: 4 }, backend, host, signal, (reason) => events.push(reason))).toBe(0); - expect(events).toEqual(["load", `sleep ${SETTLE_MS - 500}`, "claim 3", `sleep ${SETTLE_MS}`, "claim 4", "run"]); + const limits = { jobs: 1, load: 4, memory: GiB }; + expect(await admit(["x"], limits, backend, host, signal, (reason) => events.push(reason))).toBe(0); + expect(events).toEqual([ + "load", + `sleep ${SETTLE_MS - 500}`, + "memory", + `sleep ${SETTLE_MS}`, + "claim 4", + `sleep ${SETTLE_MS}`, + "claim 5", + "run", + ]); }); test("a window admits one claim and later claims clear older windows", () => { @@ -86,6 +101,7 @@ test("a window admits one claim and later claims clear older windows", () => { expect(host.claim(6)).toBe(true); expect(existsSync(join(directory, "5"))).toBe(false); expect(host.load()).toBeGreaterThanOrEqual(0); + expect(host.memory()).toBeGreaterThan(0); }); test("a waiting cancellation never launches the command", async () => { @@ -99,9 +115,9 @@ test("a waiting cancellation never launches the command", async () => { }, wait: async () => controller.abort(new Error("cancelled")), }; - await expect(admit(["compiler"], { jobs: 1, load: 1 }, backend, idle, controller.signal)).rejects.toThrow( - "cancelled", - ); + await expect( + admit(["compiler"], { jobs: 1, load: 1, memory: 1 }, backend, idle, controller.signal), + ).rejects.toThrow("cancelled"); expect(calls).toBe(1); }); @@ -111,6 +127,20 @@ test("load-only admission runs the command in place and marks it nested", async expect(await gate.tryRun(0, [process.execPath, "-e", script], new AbortController().signal)).toBe(7); }); +test("the gated command keeps its own flags, even ones Quest also parses", () => { + expect(gateBoundary(["gate", "bun", "--help", "--root", "x"])).toEqual([ + "gate", + "--", + "bun", + "--help", + "--root", + "x", + ]); + expect(gateBoundary(["--root", "r", "gate", "ls"])).toEqual(["--root", "r", "gate", "--", "ls"]); + expect(gateBoundary(["gate", "--help"])).toEqual(["gate", "--help"]); + expect(gateBoundary(["check"])).toEqual(["check"]); +}); + test("gate has command help and requires an executable", () => { expect(run("gate", "--help").stdout).toContain("Arguments forwarded verbatim"); expect(run("gate").code).toBe(2); From 0ade9d977d54c6f48152410532c58e9ea78db3d0 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Fri, 16 Oct 2026 10:52:57 -0700 Subject: [PATCH 06/12] fix: close gate admission and ownership races --- README.md | 11 +-- src/cli/gate.ts | 133 +++++++++++++++++++++++++++++++++---- test/gate.platform.test.ts | 41 ++++++++++++ test/gate.systemd.test.ts | 77 ++++++++++++++++++++- test/gate.test.ts | 99 ++++++++++++++++++++++++++- 5 files changed, 338 insertions(+), 23 deletions(-) create mode 100644 test/gate.platform.test.ts diff --git a/README.md b/README.md index feafa165..953c90c2 100644 --- a/README.md +++ b/README.md @@ -212,7 +212,9 @@ Admission is sized from the host, with no configuration. Every platform waits while the 1-minute load average exceeds the usable core count (honoring CPU affinity), or while available memory is below one command's budget: 4 GiB, or a quarter of RAM on smaller hosts, where RAM is capped by any container memory -limit. Load and free memory count work from every session and user. The load +limit. On Linux, available memory includes reclaimable host cache and is bounded +by the remaining budget of each enclosing memory cgroup. Load and memory count +work from every session and user. The load average lags new work, so the gate admits at most one waiting command every 5 seconds rather than releasing every waiter at once. Windows reports no load average and macOS counts reclaimable cache as used memory, so those signals are @@ -221,9 +223,10 @@ skipped there. On Linux with unified cgroup v2, writable `cgroup.kill`, and a systemd 254+ user manager, each command also takes one of `min(cores, RAM / 4 GiB)` slots in a user scope. The slot stays taken until the command's last descendant exits, -even if the gate crashes, and cancellation kills that exact cgroup. Without a -user manager, Linux uses load and memory admission alone, like macOS and -Windows. +even if the gate crashes, and cancellation kills that exact cgroup. The gate +checks the client and manager versions and probes writable ownership before +starting the command. Linux uses load and memory admission alone when those +prerequisites are unavailable, like macOS and Windows. A nested invocation runs immediately inside the admitted command. Internal compiler or test worker parallelism remains the command's responsibility. diff --git a/src/cli/gate.ts b/src/cli/gate.ts index fbe2b7b7..33050f3c 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -2,9 +2,12 @@ // for CPU load and free memory; on Linux, systemd scope slots also cap // concurrency and own orphans' lifetimes. import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { closeSync, + constants as fsConstants, existsSync, + fstatSync, mkdirSync, openSync, readdirSync, @@ -13,8 +16,8 @@ import { watch, writeSync, } from "node:fs"; -import { availableParallelism, constants, freemem, homedir, loadavg, totalmem } from "node:os"; -import { join } from "node:path"; +import { availableParallelism, constants, homedir, loadavg, totalmem } from "node:os"; +import { dirname, join } from "node:path"; import type { Readable, Writable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; @@ -61,6 +64,7 @@ export const COMMAND_MEMORY = 4 * 2 ** 30; /** Usable memory: physical RAM, or a smaller cgroup/container limit. */ function usableMemory(): number { + if (process.platform === "linux") return linuxMemory().total; const constrained = process.constrainedMemory?.() ?? 0; return constrained > 0 ? Math.min(totalmem(), constrained) : totalmem(); } @@ -78,14 +82,62 @@ export function hostLimits(cores = availableParallelism(), memory = usableMemory }; } +/** Count reclaimable host cache and the headroom of every enclosing memory cgroup. */ +export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total: number; available: number } { + const meminfo = readFileSync(join(proc, "meminfo"), "utf8"); + const bytes = (key: string) => { + const value = meminfo.match(new RegExp(`^${key}:\\s+(\\d+) kB$`, "m"))?.[1]; + if (value === undefined) throw new Error(`gate cannot read Linux ${key}`); + return Number(value) * 1024; + }; + let total = bytes("MemTotal"); + let available = bytes("MemAvailable"); + for (const line of readFileSync(join(proc, "self/cgroup"), "utf8").trim().split("\n")) { + const [, controllers, path] = line.match(/^\d+:([^:]*):(.*)$/) ?? []; + if (controllers === undefined || path === undefined) throw new Error("gate cannot read Linux memory cgroups"); + const unified = controllers === ""; + if (!unified && !controllers?.split(",").includes("memory")) continue; + const root = unified ? cgroups : join(cgroups, "memory"); + const usage = unified ? "memory.current" : "memory.usage_in_bytes"; + const limits = unified + ? ["memory.max", "memory.high"] + : ["memory.limit_in_bytes", "memory.soft_limit_in_bytes"]; + // Child cgroups inherit their parents' limits and shared memory usage. + for (let group = join(root, path); ; group = dirname(group)) { + for (const file of limits) { + try { + const limit = Number(readFileSync(join(group, file), "utf8").trim()); + if (!Number.isFinite(limit) || limit <= 0) continue; // "max", or an unset v1 soft limit. + const current = Number(readFileSync(join(group, usage), "utf8").trim()); + if (!Number.isFinite(current) || current < 0) + throw new Error("gate cannot read Linux cgroup memory usage"); + total = Math.min(total, limit); + available = Math.min(available, Math.max(0, limit - current)); + } catch (error) { + // The memory controller may not be enabled in every ancestor. + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + if (group === root || group === dirname(group)) break; + } + } + return { total, available }; +} + /** Claims are empty files, named by window, in one per-user directory. */ -export function fileHost(directory: string): Host { +export function fileHost(directory: string, now = Date.now): Host { return { load: () => loadavg()[0], // macOS counts reclaimable cache as used, so only its load is gated. - memory: () => (process.platform === "darwin" ? Infinity : freemem()), - now: Date.now, + memory: () => + process.platform === "darwin" + ? Infinity + : process.platform === "linux" + ? linuxMemory().available + : process.availableMemory(), + now, claim(window) { + if (window !== Math.floor(now() / SETTLE_MS)) return false; mkdirSync(directory, { recursive: true }); try { closeSync(openSync(join(directory, String(window)), "wx")); @@ -93,6 +145,9 @@ export function fileHost(directory: string): Host { if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; throw error; } + // A descheduled caller may have created an old claim after cleanup. + // It must not admit work from that expired sample. + if (window !== Math.floor(now() / SETTLE_MS)) return false; for (const name of readdirSync(directory)) { if (Number(name) < window) rmSync(join(directory, name), { force: true }); } @@ -150,6 +205,7 @@ function run(command: string[], signal: AbortSignal): Promise { }); const cancel = () => child.kill("SIGTERM"); signal.addEventListener("abort", cancel, { once: true }); + if (signal.aborted) cancel(); child.once("error", (error) => { signal.removeEventListener("abort", cancel); reject(error); @@ -180,7 +236,13 @@ const unit = (slot: number) => `quest-gate-${slot}.scope`; const scopePattern = /(?:^|\/)quest-gate-\d+\.scope(?:\/|$)/; /** No shell parses the command. This shell only restores stderr and locale. */ -const handshake = "cat /proc/self/cgroup >&4 || exit 125; read -r admitted <&5 || exit 125; exec 4>&- 5<&-; "; +export const scopeHandshake = + "IFS=: read -r hierarchy controllers path "/sys/fs/cgroup$path/cgroup.kill" || exit 125; ' + + 'while read -r key value; do if [ "$key" = ino: ]; then inode=$value; fi; done &4; ' + + "read -r admitted <&5 || exit 125; exec 4>&- 5<&- 7>&-; "; const forward = 'if [ "$1" = set ]; then export LC_ALL="$2"; else unset LC_ALL; fi; ' + 'shift 2; exec 2>&3 3>&-; exec "$@"'; @@ -190,6 +252,18 @@ interface Result { stdout: string; } +/** Validate the shell's kernel identity before this handle can be used to cancel. */ +export function openOwnership(path: string, inode: bigint): number { + const handle = openSync(path, fsConstants.O_WRONLY); + try { + if (fstatSync(handle, { bigint: true }).ino !== inode) throw new Error("gate scope exited before admission"); + return handle; + } catch (error) { + closeSync(handle); + throw error; + } +} + function execute( command: string, args: string[], @@ -236,10 +310,13 @@ function execute( cgroup += data.toString(); if (!cgroup.endsWith("\n")) return; try { - const path = cgroup.trim().match(/^0::(\/[^\n]+)$/)?.[1]; + const identity = cgroup.match(/^(\/[^\t\n]+)\t(\d+)\n$/); + const path = identity?.[1]; if (!path || !path.endsWith(`/${scope}`)) throw new Error("gate requires unified cgroup v2 ownership"); - killHandle = openSync(join("/sys/fs/cgroup", path, "cgroup.kill"), "w"); + // The slot path can already name a successor if the shell died. + // Linux kernfs inodes include the generation on 64-bit hosts. + killHandle = openOwnership(join("/sys/fs/cgroup", path, "cgroup.kill"), BigInt(identity![2])); if (signal.aborted) cancel(); else { const admission = (child.stdio as unknown as (Writable | null)[])[5]; @@ -284,6 +361,10 @@ export class SystemdGate implements GateBackend { async tryRun(slot: number, command: string[], signal: AbortSignal): Promise { const name = unit(slot); + return this.runScope(name, command, signal); + } + + async runScope(name: string, command: string[], signal: AbortSignal): Promise { const result = await execute( "systemd-run", [ @@ -296,7 +377,7 @@ export class SystemdGate implements GateBackend { "--", "/bin/sh", "-c", - handshake + forward, + scopeHandshake + forward, "quest-gate", process.env.LC_ALL === undefined ? "unset" : "set", process.env.LC_ALL ?? "", @@ -366,15 +447,34 @@ export class SystemdGate implements GateBackend { } } -/** A user manager on unified cgroups provides exact slot ownership. */ -function systemd(): boolean { +/** Probe without starting the user's command; unsupported hosts use direct admission. */ +export async function systemd(signal: AbortSignal): Promise { const runtime = process.env.XDG_RUNTIME_DIR; - return ( + if (!( process.platform === "linux" && runtime !== undefined && existsSync(join(runtime, "systemd", "private")) && existsSync("/sys/fs/cgroup/cgroup.controllers") - ); + )) + return false; + try { + const client = await execute("systemd-run", ["--version"], signal); + if (client.code !== 0 || Number(client.stdout.match(/^systemd (\d+)/)?.[1] ?? 0) < 254) return false; + const manager = await execute("systemctl", ["--user", "show", "--property=Version", "--value"], signal); + if (manager.code !== 0 || Number(manager.stdout.match(/^(\d+)/)?.[1] ?? 0) < 254) return false; + // This exercises the manager, kernel support, and actual scope permissions, + // including opening cgroup.kill in both processes before the handshake. + return ( + (await new SystemdGate().runScope( + `quest-gate-probe-${randomUUID()}.scope`, + ["/bin/sh", "-c", ":"], + signal, + )) === 0 + ); + } catch { + signal.throwIfAborted(); + return false; + } } const notices: Record = { @@ -398,7 +498,12 @@ export async function gate(command: string[]): Promise { }; for (const [name, handler] of Object.entries(handlers)) process.on(name, handler); try { - const backend = systemd() ? new SystemdGate() : new DirectGate(); + // Kernel ownership already proves admission; a nested call must not need + // another manager probe (or fall back into host pressure admission). + if (process.platform === "linux" && new SystemdGate().nested()) { + return interrupted || (await run(command, controller.signal)); + } + const backend = (await systemd(controller.signal)) ? new SystemdGate() : new DirectGate(); // A nested invocation executes inside the already admitted command. if (backend.nested()) { const result = await run(command, controller.signal); diff --git a/test/gate.platform.test.ts b/test/gate.platform.test.ts new file mode 100644 index 00000000..5d3d7945 --- /dev/null +++ b/test/gate.platform.test.ts @@ -0,0 +1,41 @@ +import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { afterEach, expect, test, vi } from "vitest"; +import { gate, systemd } from "../src/cli/gate"; +import { tempDir } from "./fixture"; + +afterEach(() => vi.unstubAllEnvs()); + +test.skipIf(process.platform !== "linux").each([ + { client: 253, manager: 261, failure: "old client", probe: false }, + { client: 261, manager: 253, failure: "old manager", probe: false }, + { client: 254, manager: 254, failure: "cgroup.kill: No such file or directory", probe: true }, + { client: 261, manager: 261, failure: "cgroup.kill: Permission denied", probe: true }, +])("falls back before the user command when systemd lacks $failure", async ({ client, manager, failure, probe }) => { + const runtime = tempDir(); + mkdirSync(join(runtime, "systemd")); + writeFileSync(join(runtime, "systemd/private"), ""); + const bin = tempDir(); + const log = join(bin, "calls"); + writeFileSync( + join(bin, "systemd-run"), + `#!/bin/sh +printf '%s\\n' "$*" >> '${log}' +if [ "$1" = --version ]; then printf 'systemd ${client}\\n'; exit 0; fi +printf '%s\\n' '${failure}' >&2 +exit 125 +`, + { mode: 0o755 }, + ); + writeFileSync(join(bin, "systemctl"), `#!/bin/sh\nprintf '${manager}\\n'\n`, { mode: 0o755 }); + vi.stubEnv("XDG_RUNTIME_DIR", runtime); + vi.stubEnv("PATH", bin); + // The command is already admitted by the portable backend. This keeps the + // fallback regression independent of real host load and admission windows. + vi.stubEnv("QUEST_GATE_ADMITTED", "1"); + expect(await systemd(new AbortController().signal)).toBe(false); + expect(await gate([process.execPath, "-e", "process.exit(23)"])).toBe(23); + const calls = readFileSync(log, "utf8"); + expect(calls.includes("--scope")).toBe(probe); + if (probe) expect(calls).toContain("quest-gate-probe-"); +}); diff --git a/test/gate.systemd.test.ts b/test/gate.systemd.test.ts index 28b32732..53595408 100644 --- a/test/gate.systemd.test.ts +++ b/test/gate.systemd.test.ts @@ -1,10 +1,10 @@ // Opt in explicitly: these tests require a real Linux systemd user manager. -import { spawn, type ChildProcess } from "node:child_process"; +import { spawn, spawnSync, type ChildProcess } from "node:child_process"; import { once } from "node:events"; import { closeSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs"; import { join, resolve } from "node:path"; import { describe, expect, test } from "vitest"; -import { SystemdGate } from "../src/cli/gate"; +import { openOwnership, scopeHandshake, systemd, SystemdGate } from "../src/cli/gate"; import { tempDir } from "./fixture"; const root = resolve(import.meta.dirname, ".."); @@ -59,6 +59,40 @@ const hold = (file: string) => ` `; describe.skipIf(!enabled)("real systemd scope ownership", () => { + test("the disposable prerequisite scope supports exact ownership", async () => { + expect(await systemd(signal())).toBe(true); + }); + + test("memory admission respects a real scope budget instead of abundant host memory", async () => { + const script = `import {linuxMemory, hostLimits} from ${JSON.stringify(join(root, "src/cli/gate.ts"))}; console.log(JSON.stringify({memory: linuxMemory(), limits: hostLimits()}));`; + const command = spawn( + "systemd-run", + [ + "--user", + "--scope", + "--quiet", + "--collect", + "--expand-environment=no", + "--property=MemoryMax=256M", + "bun", + "-e", + script, + ], + { cwd: root, stdio: ["ignore", "pipe", "pipe"] }, + ); + let stdout = ""; + let stderr = ""; + command.stdout.on("data", (data: Buffer) => (stdout += data.toString())); + command.stderr.on("data", (data: Buffer) => (stderr += data.toString())); + const [code] = await once(command, "close"); + expect(code, stderr).toBe(0); + const result = JSON.parse(stdout); + expect(result.memory.total).toBe(256 * 2 ** 20); + expect(result.memory.available).toBeGreaterThan(0); + expect(result.memory.available).toBeLessThan(result.memory.total); + expect(result.limits.memory).toBe(64 * 2 ** 20); + }); + test("forwards arguments, locale, stdin and exact exit codes across directories", async () => { const locale = process.env.LC_ALL ?? null; const spawned = child( @@ -205,4 +239,43 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { spawned.process.kill("SIGTERM"); expect((await spawned.closed).code).toBe(143); }); + + test("a dead handshake cannot acquire ownership of a successor in its reused slot", async () => { + const original = spawn( + "systemd-run", + [ + "--user", + "--scope", + "--quiet", + "--collect", + "--expand-environment=no", + "--unit=quest-gate-0.scope", + "/bin/sh", + "-c", + scopeHandshake + "exit 0", + ], + { stdio: ["ignore", "ignore", "pipe", "ignore", "pipe", "pipe"] }, + ); + const closed = once(original, "close"); + const [record] = await once(original.stdio[4]!, "data"); + const [path, inode] = String(record).trim().split("\t"); + // Hold the buffered record without acknowledging it. Kill and collect + // the original scope, exactly the pre-admission interleaving in review. + const killed = spawnSync("systemctl", ["--user", "kill", "--signal=SIGKILL", "quest-gate-0.scope"]); + expect(killed.status, killed.stderr.toString()).toBe(0); + await closed; + await new SystemdGate().wait(0, signal()); + const release = join(tempDir(), "release"); + const successor = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); + try { + await line(successor.process, "ready"); + expect(() => openOwnership(join("/sys/fs/cgroup", path, "cgroup.kill"), BigInt(inode))).toThrow( + "exited before admission", + ); + expect(await new SystemdGate().tryRun(0, ["/bin/sh", "-c", ":"], signal())).toBeNull(); + } finally { + writeFileSync(release, ""); + await successor.closed; + } + }); }); diff --git a/test/gate.test.ts b/test/gate.test.ts index c7368fa0..b0fd4303 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -1,4 +1,13 @@ -import { existsSync } from "node:fs"; +import { + closeSync, + existsSync, + mkdirSync, + readFileSync, + renameSync, + statSync, + writeFileSync, + writeSync, +} from "node:fs"; import { join } from "node:path"; import { expect, test } from "vitest"; import { @@ -9,6 +18,8 @@ import { type GateBackend, type Host, hostLimits, + linuxMemory, + openOwnership, SETTLE_MS, } from "../src/cli/gate"; import { gateBoundary } from "../src/cli/main"; @@ -93,17 +104,88 @@ test("load and memory pressure wait, then admit one caller per settle window", a ]); }); -test("a window admits one claim and later claims clear older windows", () => { +test("a window admits one claim and cleanup never admits an expired sample again", () => { const directory = join(tempDir(), "claims"); - const host = fileHost(directory); + let now = 5 * SETTLE_MS; + const host = fileHost(directory, () => now); expect(host.claim(5)).toBe(true); expect(host.claim(5)).toBe(false); + now = 6 * SETTLE_MS; expect(host.claim(6)).toBe(true); expect(existsSync(join(directory, "5"))).toBe(false); + // A waiter sampled 5 before descheduling, then resumes after cleanup. + expect(host.claim(5)).toBe(false); + expect(existsSync(join(directory, "5"))).toBe(false); expect(host.load()).toBeGreaterThanOrEqual(0); expect(host.memory()).toBeGreaterThan(0); }); +test("a claim that crosses rollover during filesystem work does not admit", () => { + const directory = join(tempDir(), "claims"); + const successor = fileHost(directory, () => 6 * SETTLE_MS); + let calls = 0; + const delayed = fileHost(directory, () => { + if (++calls === 1) return 5 * SETTLE_MS; + expect(successor.claim(6)).toBe(true); + return 6 * SETTLE_MS; + }); + expect(delayed.claim(5)).toBe(false); + expect(successor.claim(6)).toBe(false); +}); + +test.each(["v1", "v2"])("Linux memory counts reclaimable cache and bounds %s container headroom", (version) => { + const proc = tempDir(); + const cgroups = tempDir(); + mkdirSync(join(proc, "self")); + writeFileSync( + join(proc, "meminfo"), + `MemTotal: ${(64 * GiB) / 1024} kB\nMemFree: 1024 kB\nMemAvailable: ${(24 * GiB) / 1024} kB\n`, + ); + const unified = version === "v2"; + writeFileSync(join(proc, "self/cgroup"), unified ? "0::/container/child\n" : "3:memory:/container/child\n"); + const root = unified ? cgroups : join(cgroups, "memory"); + const container = join(root, "container"); + const child = join(container, "child"); + mkdirSync(child, { recursive: true }); + const limit = unified ? "memory.max" : "memory.limit_in_bytes"; + const usage = unified ? "memory.current" : "memory.usage_in_bytes"; + writeFileSync(join(child, limit), unified ? "max" : String(128 * GiB)); + writeFileSync(join(child, usage), String(GiB)); + expect(linuxMemory(proc, cgroups)).toEqual({ total: 64 * GiB, available: 24 * GiB }); + // The child is unlimited, but shares an almost-full 8 GiB parent budget. + writeFileSync(join(container, limit), String(8 * GiB)); + writeFileSync(join(container, usage), String(8 * GiB - GiB / 4)); + expect(linuxMemory(proc, cgroups)).toEqual({ total: 8 * GiB, available: GiB / 4 }); + writeFileSync(join(container, usage), String(9 * GiB)); + expect(linuxMemory(proc, cgroups).available).toBe(0); +}); + +test("a scope path reused before handshake acknowledgment cannot acquire cancellation ownership", () => { + const path = join(tempDir(), "cgroup.kill"); + writeFileSync(path, "original"); + // The original shell reported this identity, then exited; the slot was reused. + const original = statSync(path, { bigint: true }).ino; + renameSync(path, `${path}.old`); + writeFileSync(path, "successor"); + expect(() => openOwnership(path, original)).toThrow("exited before admission"); + expect(readFileSync(path, "utf8")).toBe("successor"); +}); + +test("cancellation ownership stays pinned after its slot path is reused", () => { + const path = join(tempDir(), "cgroup.kill"); + writeFileSync(path, "original"); + const handle = openOwnership(path, statSync(path, { bigint: true }).ino); + try { + renameSync(path, `${path}.old`); + writeFileSync(path, "successor"); + writeSync(handle, "1"); + expect(readFileSync(`${path}.old`, "utf8")).toBe("1riginal"); + expect(readFileSync(path, "utf8")).toBe("successor"); + } finally { + closeSync(handle); + } +}); + test("a waiting cancellation never launches the command", async () => { const controller = new AbortController(); let calls = 0; @@ -127,6 +209,17 @@ test("load-only admission runs the command in place and marks it nested", async expect(await gate.tryRun(0, [process.execPath, "-e", script], new AbortController().signal)).toBe(7); }); +test("load-only admission cancels a signal aborted before listener registration", async () => { + const controller = new AbortController(); + controller.abort(); + const result = await new DirectGate().tryRun( + 0, + [process.execPath, "-e", "setInterval(() => {}, 1000)"], + controller.signal, + ); + expect(result).not.toBe(0); +}); + test("the gated command keeps its own flags, even ones Quest also parses", () => { expect(gateBoundary(["gate", "bun", "--help", "--root", "x"])).toEqual([ "gate", From 568430703f3dd2e379d1531421376d2dc91cb557 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 11:57:26 -0700 Subject: [PATCH 07/12] fix(gate): wait on live slots and keep admission host-local A waiter on an occupied systemd slot failed instead of waiting: a live transient scope reports its own FragmentPath, which was read as a blocking unit file, and cgroup removal raises no inotify event, so the watcher waiting for "rename" never resolved under Bun. Block only on a non-transient fragment, and resolve once the cgroup empties or vanishes. Also address the open Codex findings: subtract inactive file cache from cgroup usage, compare the host-wide load average with the host CPU count, and keep admission claims in $XDG_RUNTIME_DIR or a per-host cache directory. Co-Authored-By: Claude Opus 5.5 --- README.md | 14 +++--- quest/m0/local-gate.md | 13 +++--- src/cli/gate.ts | 92 +++++++++++++++++++++++++++++---------- src/cli/main.ts | 2 +- test/gate.systemd.test.ts | 68 +++++++++++++++++++++-------- test/gate.test.ts | 26 +++++++++-- 6 files changed, 159 insertions(+), 56 deletions(-) diff --git a/README.md b/README.md index d885baeb..5d50d872 100644 --- a/README.md +++ b/README.md @@ -215,14 +215,14 @@ quest gate cargo build --release ``` Admission is sized from the host, with no configuration. Every platform waits -while the 1-minute load average exceeds the usable core count (honoring CPU -affinity), or while available memory is below one command's budget: 4 GiB, or a -quarter of RAM on smaller hosts, where RAM is capped by any container memory -limit. On Linux, available memory includes reclaimable host cache and is bounded +while the host-wide 1-minute load average exceeds the host's CPU count, or while +available memory is below one command's budget: 4 GiB, or a quarter of RAM on +smaller hosts, where RAM is capped by any container memory limit. On Linux, +available memory includes reclaimable host and cgroup file cache and is bounded by the remaining budget of each enclosing memory cgroup. Load and memory count -work from every session and user. The load -average lags new work, so the gate admits at most one waiting command every 5 -seconds rather than releasing every waiter at once. Windows reports no load +work from every session and user. The load average lags new work, so the gate +admits at most one waiting command every 5 seconds per host rather than +releasing every waiter at once. Windows reports no load average and macOS counts reclaimable cache as used memory, so those signals are skipped there. diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index 1a274eb4..885b9db5 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -33,14 +33,17 @@ before claiming a responsiveness improvement. callers, claimed by exclusively creating a per-window file. The load average lags new work and is sampled every 5 seconds, so waiters that all see it fall must not start together. Nothing is held across the command, so a crash - leaves no stale lock. + leaves no stale lock. Claims live in `$XDG_RUNTIME_DIR`, or a per-hostname + cache directory, because a home directory may be shared between hosts. - Decided 2026-10-15 (user): limits come from host specs, with no configuration - file. The load limit is the usable core count (`availableParallelism`, which - honors CPU affinity): one runnable thread per core. Each + file. The load limit is the host's CPU count, because the load average counts + every CPU, including ones outside this process's affinity (revised + 2026-10-21 from the usable core count). Each command is budgeted 4 GiB, a typical build or test stack, so Linux gets - `min(cores, RAM / 4 GiB)` slots (at least one), with RAM capped by a cgroup + `min(usable cores, RAM / 4 GiB)` slots (at least one), with RAM capped by a cgroup memory limit. Admission also waits while available memory is below - `min(4 GiB, RAM / 4)`, so a small host still runs one command. macOS counts + `min(4 GiB, RAM / 4)`, so a small host still runs one command. Cgroup + headroom subtracts inactive file cache from usage, since it is reclaimable. macOS counts reclaimable cache as used memory, so only its load is gated; Windows reports no load average, so only its memory is. - Nested gated calls run immediately: the kernel scope identifies them on Linux, diff --git a/src/cli/gate.ts b/src/cli/gate.ts index 33050f3c..a3690f0e 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -16,7 +16,7 @@ import { watch, writeSync, } from "node:fs"; -import { availableParallelism, constants, homedir, loadavg, totalmem } from "node:os"; +import { availableParallelism, constants, cpus, homedir, hostname, loadavg, totalmem } from "node:os"; import { dirname, join } from "node:path"; import type { Readable, Writable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; @@ -70,18 +70,35 @@ function usableMemory(): number { } /** - * Derive the limits from the host: no configuration. The load limit is one - * runnable thread per usable core; each slot and each admission reserves one - * command's memory, though never more than a quarter of a small host's RAM. + * Derive the limits from the host: no configuration. Slots never exceed the + * cores this process may use. The load average counts runnable threads on every + * CPU, including ones outside this process's affinity, so its limit is one per + * host CPU. Each slot and each admission reserves one command's memory, though + * never more than a quarter of a small host's RAM. */ -export function hostLimits(cores = availableParallelism(), memory = usableMemory()): Limits { +export function hostLimits( + cores = availableParallelism(), + memory = usableMemory(), + processors = Math.max(cores, cpus().length), +): Limits { return { jobs: Math.max(1, Math.min(cores, Math.floor(memory / COMMAND_MEMORY))), - load: cores, + load: processors, memory: Math.min(COMMAND_MEMORY, memory / 4), }; } +/** A memory.stat counter, or 0 where the file or key is absent. */ +function cgroupStat(group: string, key: string): number { + try { + const stat = readFileSync(join(group, "memory.stat"), "utf8"); + return Number(stat.match(new RegExp(`^${key} (\\d+)$`, "m"))?.[1] ?? 0); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return 0; + throw error; + } +} + /** Count reclaimable host cache and the headroom of every enclosing memory cgroup. */ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total: number; available: number } { const meminfo = readFileSync(join(proc, "meminfo"), "utf8"); @@ -99,6 +116,9 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total if (!unified && !controllers?.split(",").includes("memory")) continue; const root = unified ? cgroups : join(cgroups, "memory"); const usage = unified ? "memory.current" : "memory.usage_in_bytes"; + // Usage includes inactive file cache, which the kernel reclaims without + // swapping; subtract it, as container runtimes do for their working set. + const cache = unified ? "inactive_file" : "total_inactive_file"; const limits = unified ? ["memory.max", "memory.high"] : ["memory.limit_in_bytes", "memory.soft_limit_in_bytes"]; @@ -111,8 +131,9 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total const current = Number(readFileSync(join(group, usage), "utf8").trim()); if (!Number.isFinite(current) || current < 0) throw new Error("gate cannot read Linux cgroup memory usage"); + const used = Math.max(0, current - cgroupStat(group, cache)); total = Math.min(total, limit); - available = Math.min(available, Math.max(0, limit - current)); + available = Math.min(available, Math.max(0, limit - used)); } catch (error) { // The memory controller may not be enabled in every ancestor. if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; @@ -124,7 +145,18 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total return { total, available }; } -/** Claims are empty files, named by window, in one per-user directory. */ +/** + * Claims belong to this host, since a home directory may be shared between + * machines: the user's runtime directory where one exists, otherwise a + * per-host cache directory. + */ +export function claimDirectory(env = process.env, home = homedir(), host = hostname()): string { + return env.XDG_RUNTIME_DIR + ? join(env.XDG_RUNTIME_DIR, "quest", "gate") + : join(home, ".cache", "quest", "gate", host); +} + +/** Claims are empty files, named by window, in one per-user, per-host directory. */ export function fileHost(directory: string, now = Date.now): Host { return { load: () => loadavg()[0], @@ -401,7 +433,14 @@ export class SystemdGate implements GateBackend { async wait(slot: number, signal: AbortSignal): Promise { const result = await execute( "systemctl", - ["--user", "show", "--property=FragmentPath", "--property=ControlGroup", unit(slot)], + [ + "--user", + "show", + "--property=FragmentPath", + "--property=Transient", + "--property=ControlGroup", + unit(slot), + ], signal, ); signal.throwIfAborted(); @@ -412,8 +451,9 @@ export class SystemdGate implements GateBackend { .split("\n") .map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]), ); - // A unit file would make every transient claim on this slot fail. - if (properties.get("FragmentPath")) + // A persistent unit file would make every transient claim on this slot + // fail. A live occupant's own transient unit also reports a fragment. + if (properties.get("FragmentPath") && properties.get("Transient") !== "yes") throw new Error(`gate slot is blocked by ${properties.get("FragmentPath")}; remove that unit file`); // The occupant already exited (or awaits collection): retry admission. const cgroup = properties.get("ControlGroup"); @@ -427,22 +467,30 @@ export class SystemdGate implements GateBackend { else resolve(); }; const aborted = () => finish(signal.reason); + const path = join("/sys/fs/cgroup", cgroup, "cgroup.events"); + // The kernel notifies when the last process leaves, but not when the + // cgroup is removed. An empty scope still awaiting systemd collection + // makes the next claim lose, so admission waits here again. + const vacated = () => { + try { + if (/^populated 0$/m.test(readFileSync(path, "utf8"))) finish(); + } catch (error) { + // ENODEV: the cgroup was removed between open and read. + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT" || code === "ENODEV") finish(); + else finish(error); + } + }; try { - const path = join("/sys/fs/cgroup", cgroup, "cgroup.events"); - watcher = watch(path, (event) => { - // Wait for the kernel cgroup to be removed, not merely empty: - // an empty scope may still be awaiting systemd collection. - if (event === "rename") finish(); - }); + watcher = watch(path, vacated); watcher.on("error", finish); signal.addEventListener("abort", aborted, { once: true }); if (signal.aborted) aborted(); - // Registration precedes the read, covering deletion during setup. - readFileSync(path); } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") finish(); - else finish(error); + if ((error as NodeJS.ErrnoException).code !== "ENOENT") return finish(error); } + // Registration precedes the read, covering an exit during setup. + vacated(); }); } } @@ -514,7 +562,7 @@ export async function gate(command: string[]): Promise { command, hostLimits(), backend, - fileHost(join(homedir(), ".cache", "quest", "gate")), + fileHost(claimDirectory()), controller.signal, (reason) => { if (shown.has(reason)) return; diff --git a/src/cli/main.ts b/src/cli/main.ts index 66669938..cd2bd696 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -57,7 +57,7 @@ export function main(argv: string[], out: Output): number | Promise { .command("gate") .summary("Run an expensive command once the host has room for it") .description( - "Wait while the 1-minute load average exceeds the core count or available memory is below " + + "Wait while the 1-minute load average exceeds the host's CPU count or available memory is below " + "min(4 GiB, RAM/4), admitting one waiting command every 5 seconds. On Linux with a systemd " + "user manager, the command also takes one of min(cores, RAM/4 GiB) slots in a user scope until " + "its last descendant exits. Nested calls run immediately. Limits come from the host; there is " + diff --git a/test/gate.systemd.test.ts b/test/gate.systemd.test.ts index 53595408..fdb5e72f 100644 --- a/test/gate.systemd.test.ts +++ b/test/gate.systemd.test.ts @@ -226,6 +226,24 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { } }); + test("waiting on a live occupant resolves once its scope is removed", async () => { + const release = join(tempDir(), "release"); + const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); + try { + await line(first.process, "ready"); + // A live transient scope reports its own fragment; that is not a blocking unit file. + let resolved = false; + const waiting = new SystemdGate().wait(0, signal()).then(() => (resolved = true)); + await new Promise((resolve) => setTimeout(resolve, 200)); + expect(resolved).toBe(false); + writeFileSync(release, ""); + await waiting; + } finally { + writeFileSync(release, ""); + await first.closed; + } + }); + test("waiting on a slot that was already collected returns to admission", async () => { // The occupying scope can exit between a lost claim and the wait. await expect(new SystemdGate().wait(999, signal())).resolves.toBeUndefined(); @@ -241,23 +259,39 @@ describe.skipIf(!enabled)("real systemd scope ownership", () => { }); test("a dead handshake cannot acquire ownership of a successor in its reused slot", async () => { - const original = spawn( - "systemd-run", - [ - "--user", - "--scope", - "--quiet", - "--collect", - "--expand-environment=no", - "--unit=quest-gate-0.scope", - "/bin/sh", - "-c", - scopeHandshake + "exit 0", - ], - { stdio: ["ignore", "ignore", "pipe", "ignore", "pipe", "pipe"] }, - ); - const closed = once(original, "close"); - const [record] = await once(original.stdio[4]!, "data"); + // Claim slot 0 like admission does: an earlier test's emptied scope may + // still await collection, so a lost claim waits on the slot and retries. + const handshake = () => { + const original = spawn( + "systemd-run", + [ + "--user", + "--scope", + "--quiet", + "--collect", + "--expand-environment=no", + "--unit=quest-gate-0.scope", + "/bin/sh", + "-c", + scopeHandshake + "exit 0", + ], + { stdio: ["ignore", "ignore", "pipe", "ignore", "pipe", "pipe"] }, + ); + const closed = once(original, "close"); + const record = Promise.race([ + once(original.stdio[4]!, "data").then(([data]) => String(data)), + closed.then(() => null), + ]); + return { closed, record }; + }; + let claim = handshake(); + let record = await claim.record; + while (record === null) { + await new SystemdGate().wait(0, signal()); + claim = handshake(); + record = await claim.record; + } + const { closed } = claim; const [path, inode] = String(record).trim().split("\t"); // Hold the buffered record without acknowledging it. Kill and collect // the original scope, exactly the pre-admission interleaving in review. diff --git a/test/gate.test.ts b/test/gate.test.ts index b0fd4303..763483a9 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -13,6 +13,7 @@ import { expect, test } from "vitest"; import { admit, COMMAND_MEMORY, + claimDirectory, DirectGate, fileHost, type GateBackend, @@ -30,12 +31,15 @@ const GiB = 2 ** 30; test("limits derive from the host's cores and memory", () => { // A slot per command's memory, capped at the cores; load at one per core. - expect(hostLimits(24, 64 * GiB)).toEqual({ jobs: 16, load: 24, memory: COMMAND_MEMORY }); - expect(hostLimits(8, 16 * GiB)).toEqual({ jobs: 4, load: 8, memory: COMMAND_MEMORY }); - expect(hostLimits(2, 64 * GiB)).toMatchObject({ jobs: 2, load: 2 }); + expect(hostLimits(24, 64 * GiB, 24)).toEqual({ jobs: 16, load: 24, memory: COMMAND_MEMORY }); + expect(hostLimits(8, 16 * GiB, 8)).toEqual({ jobs: 4, load: 8, memory: COMMAND_MEMORY }); + // The host-wide load average is compared with every host CPU, not the + // process's affinity subset, so an idle assigned CPU is not held back. + expect(hostLimits(2, 64 * GiB, 32)).toMatchObject({ jobs: 2, load: 32 }); // A small host still runs one command, and reserves a quarter of its RAM. - expect(hostLimits(4, 2 * GiB)).toEqual({ jobs: 1, load: 4, memory: GiB / 2 }); + expect(hostLimits(4, 2 * GiB, 4)).toEqual({ jobs: 1, load: 4, memory: GiB / 2 }); expect(hostLimits().jobs).toBeGreaterThanOrEqual(1); + expect(hostLimits().load).toBeGreaterThanOrEqual(hostLimits().jobs); }); test("admission checks slots before waiting and preserves exact exit codes", async () => { @@ -149,6 +153,7 @@ test.each(["v1", "v2"])("Linux memory counts reclaimable cache and bounds %s con mkdirSync(child, { recursive: true }); const limit = unified ? "memory.max" : "memory.limit_in_bytes"; const usage = unified ? "memory.current" : "memory.usage_in_bytes"; + const cache = unified ? "inactive_file" : "total_inactive_file"; writeFileSync(join(child, limit), unified ? "max" : String(128 * GiB)); writeFileSync(join(child, usage), String(GiB)); expect(linuxMemory(proc, cgroups)).toEqual({ total: 64 * GiB, available: 24 * GiB }); @@ -156,10 +161,23 @@ test.each(["v1", "v2"])("Linux memory counts reclaimable cache and bounds %s con writeFileSync(join(container, limit), String(8 * GiB)); writeFileSync(join(container, usage), String(8 * GiB - GiB / 4)); expect(linuxMemory(proc, cgroups)).toEqual({ total: 8 * GiB, available: GiB / 4 }); + // Inactive file cache inside the budget is reclaimable, so it counts as available. + writeFileSync(join(container, "memory.stat"), `active_file 1\n${cache} ${6 * GiB}\nanon 2\n`); + expect(linuxMemory(proc, cgroups)).toEqual({ total: 8 * GiB, available: 6 * GiB + GiB / 4 }); + writeFileSync(join(container, "memory.stat"), `${cache} 0\n`); writeFileSync(join(container, usage), String(9 * GiB)); expect(linuxMemory(proc, cgroups).available).toBe(0); }); +test("admission claims stay on this host", () => { + expect(claimDirectory({ XDG_RUNTIME_DIR: "/run/user/1000" }, "/home/u", "a")).toBe( + join("/run/user/1000", "quest", "gate"), + ); + // A home directory shared between machines keeps each host's claims apart. + expect(claimDirectory({}, "/home/u", "a")).toBe(join("/home/u", ".cache", "quest", "gate", "a")); + expect(claimDirectory({}, "/home/u", "b")).not.toBe(claimDirectory({}, "/home/u", "a")); +}); + test("a scope path reused before handshake acknowledgment cannot acquire cancellation ownership", () => { const path = join(tempDir(), "cgroup.kill"); writeFileSync(path, "original"); From fea6a08e29827f053f241a45852d7be82d93be31 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:13:09 -0700 Subject: [PATCH 08/12] refactor(gate): admit on load and memory only Drop the Linux systemd slot backend: scopes, the slot handshake, cgroup.kill inode pinning, prerequisite probes, the opt-in systemd suite, and the CI linger step. Exact descendant ownership moves to the worktree process-ownership quest; the backend is recoverable from 5684307. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/check.yml | 9 - README.md | 20 +- quest/m0/local-gate.md | 62 +++--- quest/m0/worktree.md | 8 +- src/cli/gate.ts | 384 +++--------------------------------- src/cli/main.ts | 6 +- test/fixtures/gate-run.ts | 18 -- test/gate.platform.test.ts | 41 ---- test/gate.systemd.test.ts | 315 ----------------------------- test/gate.test.ts | 131 +++--------- 10 files changed, 98 insertions(+), 896 deletions(-) delete mode 100644 test/fixtures/gate-run.ts delete mode 100644 test/gate.platform.test.ts delete mode 100644 test/gate.systemd.test.ts diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index d3e39158..c0e16df7 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -26,19 +26,10 @@ jobs: determinate: false - name: Install dependencies run: nix develop --command just install - - name: Enable the systemd user manager so checks run in gate slots - shell: bash - run: | - sudo loginctl enable-linger "$USER" - sudo systemctl start "user@$(id -u).service" - echo "XDG_RUNTIME_DIR=/run/user/$(id -u)" >> "$GITHUB_ENV" - echo "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/$(id -u)/bus" >> "$GITHUB_ENV" - name: Check run: nix develop --command just check - name: Test run: nix develop --command just test - - name: Test real Linux gate ownership - run: nix develop --command env QUEST_GATE_SYSTEMD_TESTS=1 bunx vitest run test/gate.systemd.test.ts - name: Check Nix package run: nix flake check diff --git a/README.md b/README.md index 5d50d872..8131fae4 100644 --- a/README.md +++ b/README.md @@ -226,25 +226,17 @@ releasing every waiter at once. Windows reports no load average and macOS counts reclaimable cache as used memory, so those signals are skipped there. -On Linux with unified cgroup v2, writable `cgroup.kill`, and a systemd 254+ user -manager, each command also takes one of `min(cores, RAM / 4 GiB)` slots in a -user scope. The slot stays taken until the command's last descendant exits, -even if the gate crashes, and cancellation kills that exact cgroup. The gate -checks the client and manager versions and probes writable ownership before -starting the command. Linux uses load and memory admission alone when those -prerequisites are unavailable, like macOS and Windows. - -A nested invocation runs immediately inside the admitted command. Internal -compiler or test worker parallelism remains the command's responsibility. +Admission is the gate's only job: it holds no slot or lock while the command +runs and does not track the command's descendants. A nested invocation runs +immediately inside the admitted command. Internal compiler or test worker +parallelism remains the command's responsibility. This repository's `just check`, `just test`, `just worker-check`, and `just worker-test` run through the gate. Arguments, working directory, environment and terminal streams pass through to -the executable, and waiting happens before it starts. Commands that move -themselves into other cgroups are outside the slot boundary. There is no -fairness guarantee: a queued command may wait on the first occupied slot while -another becomes available. +the executable, and waiting happens before it starts. There is no fairness +guarantee or timeout: a command waits as long as the host stays busy. ## Work with your agents diff --git a/quest/m0/local-gate.md b/quest/m0/local-gate.md index 885b9db5..ffd080bb 100644 --- a/quest/m0/local-gate.md +++ b/quest/m0/local-gate.md @@ -2,30 +2,32 @@ ## Goal -`quest gate ` admits a bounded number of expensive commands for one user -on one host, shared across repositories and worktrees. Concurrent agent sessions -remain useful without multiplying full-machine builds and test stacks. +`quest gate ` holds expensive commands for one user on one host until the +host has CPU and memory room, shared across repositories and worktrees. +Concurrent agent sessions remain useful without multiplying full-machine builds +and test stacks. -The gate and its automatic use by `just check` / `just test` have landed. What -remains is measuring representative build/test contention with and without the -gate, for example how many concurrent Worker suites hit their 5-second timeouts, -before claiming a responsiveness improvement. +Load and memory admission and its automatic use by `just check` / `just test` +have landed. What remains is measuring representative build/test contention +with and without the gate, for example how many concurrent Worker suites hit +their 5-second timeouts, before claiming a responsiveness improvement. ## Plan -- Decided 2026-10-15: gate expensive command phases, not agent lifetimes. Use a - simple concurrency limit derived from the host. Repository - entrypoints invoke the gate automatically; ordinary reasoning and I/O do not - occupy a CPU slot. No adaptive scheduler or new daemon is needed for this scope. +- Decided 2026-10-15: gate expensive command phases, not agent lifetimes. + Repository entrypoints invoke the gate automatically; ordinary reasoning and + I/O are not gated. No adaptive scheduler or new daemon is needed for this scope. - Decided 2026-10-15 (user): gate checks and CI-like suites on host load, not agents, because load from every session and user is what starves test timers. The agent-level load-average rule was removed from the spawn and complete skills. -- Decided 2026-10-15 (user): admission uses both. Slots cap concurrency where - the platform can own descendants exactly; admission also waits while the - 1-minute load average exceeds the core count, which sees work the slots cannot. -- Decided 2026-10-15 (user): the load gate runs on every platform, and Linux adds - systemd scope slots on top. macOS, Windows, and Linux without a user manager - get load and memory admission alone rather than an unsupported error. +- Decided 2026-10-21 (user): admission is load and memory only, on every + platform. This supersedes the 2026-10-15 decisions to add Linux systemd scope + slots on top: every review round on the scope handshake, `cgroup.kill` inode + pinning, and manager probes found another race, for little admission benefit. + Exact descendant ownership and orphan cleanup move to the + [worktree process-ownership quest](/quest/m0/worktree.md). The removed + backend, its probes, and its opt-in systemd suite are recoverable from commit + 5684307 in [kixelated/quest#128](https://github.com/kixelated/quest/pull/128). - Decided 2026-10-15 (user): Bun's `--no-orphans` stays a research option. Its best-effort registration and bounded PID-tree walks do not establish exact ownership by themselves. @@ -38,20 +40,18 @@ before claiming a responsiveness improvement. - Decided 2026-10-15 (user): limits come from host specs, with no configuration file. The load limit is the host's CPU count, because the load average counts every CPU, including ones outside this process's affinity (revised - 2026-10-21 from the usable core count). Each - command is budgeted 4 GiB, a typical build or test stack, so Linux gets - `min(usable cores, RAM / 4 GiB)` slots (at least one), with RAM capped by a cgroup - memory limit. Admission also waits while available memory is below - `min(4 GiB, RAM / 4)`, so a small host still runs one command. Cgroup - headroom subtracts inactive file cache from usage, since it is reclaimable. macOS counts - reclaimable cache as used memory, so only its load is gated; Windows reports - no load average, so only its memory is. -- Nested gated calls run immediately: the kernel scope identifies them on Linux, - and an inherited marker does where admission had no kernel scope. -- A 10-sample probe measured about 37 ms of admission overhead for a gated - `/bin/true`. Fairness is not promised. + 2026-10-21 from the usable core count). Admission waits while available + memory is below `min(4 GiB, RAM / 4)`: 4 GiB is a typical build or test + stack, and a small host still runs one command. RAM is capped by a cgroup + memory limit, and cgroup headroom subtracts inactive file cache from usage, + since it is reclaimable. macOS counts reclaimable cache as used memory, so + only its load is gated; Windows reports no load average, so only its memory is. +- Nested gated calls run immediately, identified by a marker the gate sets in + the admitted command's environment. Fairness is not promised. +- Measure admission overhead alongside contention; the earlier 37 ms figure + included the removed systemd probe. ## Related -- [Recoverable worktrees](/quest/m0/worktree.md) - should reuse the systemd scope backend for exact descendant ownership -- [Own-PC runner](/quest/m0/kix/runner.md) - reuse local command supervision +- [Recoverable worktrees](/quest/m0/worktree.md) - owns command descendants, with its own process-ownership backend +- [Own-PC runner](/quest/m0/kix/runner.md) - can gate expensive job phases on host room diff --git a/quest/m0/worktree.md b/quest/m0/worktree.md index a8384906..442a3829 100644 --- a/quest/m0/worktree.md +++ b/quest/m0/worktree.md @@ -31,6 +31,12 @@ Agent count is unrestricted by this command; expensive work uses `quest gate`. Reuse the owning tool for managed worktrees and report failed cleanup as a retained allocation, not success. Do not steal live locks or remove shared or current checkouts underneath their users. +- Decided 2026-10-21 (user): this quest brings its own process-ownership + backend; `quest gate` admits on load and memory only and owns no + descendants. The Linux systemd scope backend (scopes, slot handshake, + `cgroup.kill` inode pinning, prerequisite probes, opt-in systemd tests) was + dropped from the gate and is restorable from commit 5684307 in + [kixelated/quest#128](https://github.com/kixelated/quest/pull/128). - Preserve branches/recovery refs independently of checkout retention. A squash-merged branch may not be an ancestor of main; exact PR-head evidence matters when deciding whether a published result is finished. @@ -40,5 +46,5 @@ Agent count is unrestricted by this command; expensive work uses `quest gate`. ## Related -- [Expensive command gate](/quest/m0/local-gate.md) - independent CPU admission +- [Expensive command gate](/quest/m0/local-gate.md) - independent CPU and memory admission - [Agent lifecycle integration](/quest/m0/worktree-agents.md) - native agents and workflow adoption diff --git a/src/cli/gate.ts b/src/cli/gate.ts index a3690f0e..46a3750f 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -1,35 +1,12 @@ -// Host-wide command admission, sized from the host itself. Every platform waits -// for CPU load and free memory; on Linux, systemd scope slots also cap -// concurrency and own orphans' lifetimes. +// Host-wide command admission, sized from the host itself: every platform waits +// for CPU load and free memory before starting the command. The gate owns no +// descendants and holds nothing while the command runs. import { spawn } from "node:child_process"; -import { randomUUID } from "node:crypto"; -import { - closeSync, - constants as fsConstants, - existsSync, - fstatSync, - mkdirSync, - openSync, - readdirSync, - readFileSync, - rmSync, - watch, - writeSync, -} from "node:fs"; +import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { availableParallelism, constants, cpus, homedir, hostname, loadavg, totalmem } from "node:os"; import { dirname, join } from "node:path"; -import type { Readable, Writable } from "node:stream"; import { setTimeout as sleep } from "node:timers/promises"; -export interface GateBackend { - /** True when this process already runs inside an admitted command. */ - nested(): boolean; - /** null means the atomic slot claim lost to another caller. */ - tryRun(slot: number, command: string[], signal: AbortSignal): Promise; - /** Block on a slot's lifecycle, rather than polling PID files. */ - wait(slot: number, signal: AbortSignal): Promise; -} - /** The machine-wide inputs to load admission, replaceable in tests. */ export interface Host { /** The 1-minute load average. */ @@ -43,15 +20,13 @@ export interface Host { } export interface Limits { - /** Concurrent slots, where the platform can own them. */ - jobs: number; /** The highest 1-minute load average that still admits a command. */ load: number; /** The least available memory, in bytes, that still admits a command. */ memory: number; } -export type Waiting = "load" | "memory" | "slots"; +export type Waiting = "load" | "memory"; /** * At most one admission per window across all callers. The load average lags @@ -70,19 +45,16 @@ function usableMemory(): number { } /** - * Derive the limits from the host: no configuration. Slots never exceed the - * cores this process may use. The load average counts runnable threads on every - * CPU, including ones outside this process's affinity, so its limit is one per - * host CPU. Each slot and each admission reserves one command's memory, though - * never more than a quarter of a small host's RAM. + * Derive the limits from the host: no configuration. The load average counts + * runnable threads on every CPU, including ones outside this process's + * affinity, so its limit is one per host CPU. Each admission reserves one + * command's memory, though never more than a quarter of a small host's RAM. */ export function hostLimits( - cores = availableParallelism(), + processors = Math.max(availableParallelism(), cpus().length), memory = usableMemory(), - processors = Math.max(cores, cpus().length), ): Limits { return { - jobs: Math.max(1, Math.min(cores, Math.floor(memory / COMMAND_MEMORY))), load: processors, memory: Math.min(COMMAND_MEMORY, memory / 4), }; @@ -189,8 +161,11 @@ export function fileHost(directory: string, now = Date.now): Host { }; } -/** Wait until the host has room for a command and this caller wins the current window. */ -async function settle(host: Host, limits: Limits, signal: AbortSignal, waiting?: (reason: Waiting) => void) { +/** + * Wait until the host has room for a command and this caller wins the current + * window. No fairness promise, adaptive scheduler, or admission timeout. + */ +export async function admit(limits: Limits, host: Host, signal: AbortSignal, waiting?: (reason: Waiting) => void) { for (;;) { signal.throwIfAborted(); const now = host.now(); @@ -203,33 +178,11 @@ async function settle(host: Host, limits: Limits, signal: AbortSignal, waiting?: } } -export async function admit( - command: string[], - limits: Limits, - backend: GateBackend, - host: Host, - signal: AbortSignal, - waiting?: (reason: Waiting) => void, -): Promise { - for (;;) { - await settle(host, limits, signal, waiting); - for (let slot = 0; slot < limits.jobs; slot++) { - signal.throwIfAborted(); - const result = await backend.tryRun(slot, command, signal); - if (result !== null) return result; - } - // Waiting on the first occupied slot is deliberately simple: no fairness - // promise, adaptive scheduler, busy polling, or admission timeout. - waiting?.("slots"); - await backend.wait(0, signal); - } -} - -/** Marks a command admitted by load alone, where no kernel scope can. */ +/** Marks an admitted command, so nested gated calls inside it run at once. */ const ADMITTED = "QUEST_GATE_ADMITTED"; /** Run a command in place, with inherited streams and its exact exit status. */ -function run(command: string[], signal: AbortSignal): Promise { +export function run(command: string[], signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const child = spawn(command[0], command.slice(1), { env: { ...process.env, [ADMITTED]: "1" }, @@ -249,286 +202,9 @@ function run(command: string[], signal: AbortSignal): Promise { }); } -/** Load-only admission, where the platform cannot own descendants exactly. */ -export class DirectGate implements GateBackend { - nested(): boolean { - return process.env[ADMITTED] === "1"; - } - - tryRun(_slot: number, command: string[], signal: AbortSignal): Promise { - return run(command, signal); - } - - wait(): Promise { - return Promise.reject(new Error("load-only admission has no slots to wait on")); - } -} - -const unit = (slot: number) => `quest-gate-${slot}.scope`; -const scopePattern = /(?:^|\/)quest-gate-\d+\.scope(?:\/|$)/; - -/** No shell parses the command. This shell only restores stderr and locale. */ -export const scopeHandshake = - "IFS=: read -r hierarchy controllers path "/sys/fs/cgroup$path/cgroup.kill" || exit 125; ' + - 'while read -r key value; do if [ "$key" = ino: ]; then inode=$value; fi; done &4; ' + - "read -r admitted <&5 || exit 125; exec 4>&- 5<&- 7>&-; "; -const forward = - 'if [ "$1" = set ]; then export LC_ALL="$2"; else unset LC_ALL; fi; ' + 'shift 2; exec 2>&3 3>&-; exec "$@"'; - -interface Result { - code: number; - stderr: string; - stdout: string; -} - -/** Validate the shell's kernel identity before this handle can be used to cancel. */ -export function openOwnership(path: string, inode: bigint): number { - const handle = openSync(path, fsConstants.O_WRONLY); - try { - if (fstatSync(handle, { bigint: true }).ino !== inode) throw new Error("gate scope exited before admission"); - return handle; - } catch (error) { - closeSync(handle); - throw error; - } -} - -function execute( - command: string, - args: string[], - signal: AbortSignal, - /** The scope that runs the command with the caller's streams. */ - scope?: string, -): Promise { - return new Promise((resolve, reject) => { - // Set up the stderr channels in the child shell, before replacing fd 2. - // Numeric fd 2 in Bun's extra stdio would duplicate the new diagnostic - // pipe. Reopening /proc/self/fd/2 also fails for socket-backed streams. - const child = spawn( - scope ? "/bin/sh" : command, - scope ? ["-c", 'exec 6>&2; exec 2>&3; exec 3>&6 6>&-; exec "$@"', "quest-gate", command, ...args] : args, - { - env: { ...process.env, LC_ALL: "C" }, - stdio: scope ? ["inherit", "inherit", "inherit", "pipe", "pipe", "pipe"] : ["ignore", "pipe", "pipe"], - }, - ); - let stderr = ""; - let stdout = ""; - child.stdout?.setEncoding("utf8"); - child.stdout?.on("data", (data: string) => (stdout += data)); - const diagnostics = scope ? (child.stdio[3] as Readable) : child.stderr; - diagnostics?.setEncoding("utf8"); - diagnostics?.on("data", (data: string) => (stderr += data)); - let killHandle: number | undefined; - let ownershipError: Error | undefined; - const cancel = () => { - if (killHandle !== undefined) { - // The open kernel handle pins this exact cgroup. A reusable unit - // name (or PID) must never target a later command after an exit race. - try { - writeSync(killHandle, "1"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENODEV") ownershipError = error as Error; - } - } else child.kill("SIGTERM"); - }; - if (scope) { - const ownership = child.stdio[4]; - let cgroup = ""; - ownership?.on("data", (data: Buffer) => { - cgroup += data.toString(); - if (!cgroup.endsWith("\n")) return; - try { - const identity = cgroup.match(/^(\/[^\t\n]+)\t(\d+)\n$/); - const path = identity?.[1]; - if (!path || !path.endsWith(`/${scope}`)) - throw new Error("gate requires unified cgroup v2 ownership"); - // The slot path can already name a successor if the shell died. - // Linux kernfs inodes include the generation on 64-bit hosts. - killHandle = openOwnership(join("/sys/fs/cgroup", path, "cgroup.kill"), BigInt(identity![2])); - if (signal.aborted) cancel(); - else { - const admission = (child.stdio as unknown as (Writable | null)[])[5]; - admission?.on("error", (error: NodeJS.ErrnoException) => { - if (error.code !== "EPIPE") ownershipError = error; - }); - admission?.end("admitted\n"); - } - } catch (error) { - ownershipError = new Error( - `gate ownership unavailable: ${error instanceof Error ? error.message : error}`, - ); - child.kill("SIGKILL"); - } - }); - } - - signal.addEventListener("abort", cancel, { once: true }); - if (signal.aborted) cancel(); - child.once("error", (error) => { - signal.removeEventListener("abort", cancel); - reject(error); - }); - child.once("close", async (code, killed) => { - signal.removeEventListener("abort", cancel); - try { - if (killHandle !== undefined) closeSync(killHandle); - if (ownershipError) throw ownershipError; - resolve({ code: code ?? (killed ? 128 + constants.signals[killed] : 1), stderr, stdout }); - } catch (error) { - reject(error); - } - }); - }); -} - -export class SystemdGate implements GateBackend { - /** The kernel scope, never an inherited environment claim. */ - nested(): boolean { - return scopePattern.test(readFileSync("/proc/self/cgroup", "utf8").trim()); - } - - async tryRun(slot: number, command: string[], signal: AbortSignal): Promise { - const name = unit(slot); - return this.runScope(name, command, signal); - } - - async runScope(name: string, command: string[], signal: AbortSignal): Promise { - const result = await execute( - "systemd-run", - [ - "--user", - "--scope", - "--quiet", - "--collect", - `--unit=${name}`, - "--expand-environment=no", - "--", - "/bin/sh", - "-c", - scopeHandshake + forward, - "quest-gate", - process.env.LC_ALL === undefined ? "unset" : "set", - process.env.LC_ALL ?? "", - ...command, - ], - signal, - name, - ); - // Only systemd-run's own stderr reaches this pipe. The command's stderr - // was restored to the original terminal/pipe through descriptor 3. - if ( - result.code !== 0 && - (result.stderr.includes(`Unit ${name} already exists`) || - result.stderr.includes(`Unit ${name} was already loaded or has a fragment file`)) - ) - return null; - if (result.stderr.trim()) throw new Error(`gate scope failed: ${result.stderr.trim()}`); - return result.code; - } - - async wait(slot: number, signal: AbortSignal): Promise { - const result = await execute( - "systemctl", - [ - "--user", - "show", - "--property=FragmentPath", - "--property=Transient", - "--property=ControlGroup", - unit(slot), - ], - signal, - ); - signal.throwIfAborted(); - if (result.code !== 0) throw new Error(`gate wait failed: ${result.stderr.trim() || result.code}`); - const properties = new Map( - result.stdout - .trim() - .split("\n") - .map((line) => [line.slice(0, line.indexOf("=")), line.slice(line.indexOf("=") + 1)]), - ); - // A persistent unit file would make every transient claim on this slot - // fail. A live occupant's own transient unit also reports a fragment. - if (properties.get("FragmentPath") && properties.get("Transient") !== "yes") - throw new Error(`gate slot is blocked by ${properties.get("FragmentPath")}; remove that unit file`); - // The occupant already exited (or awaits collection): retry admission. - const cgroup = properties.get("ControlGroup"); - if (!cgroup) return; - await new Promise((resolve, reject) => { - let watcher: ReturnType | undefined; - const finish = (error?: unknown) => { - watcher?.close(); - signal.removeEventListener("abort", aborted); - if (error) reject(error); - else resolve(); - }; - const aborted = () => finish(signal.reason); - const path = join("/sys/fs/cgroup", cgroup, "cgroup.events"); - // The kernel notifies when the last process leaves, but not when the - // cgroup is removed. An empty scope still awaiting systemd collection - // makes the next claim lose, so admission waits here again. - const vacated = () => { - try { - if (/^populated 0$/m.test(readFileSync(path, "utf8"))) finish(); - } catch (error) { - // ENODEV: the cgroup was removed between open and read. - const code = (error as NodeJS.ErrnoException).code; - if (code === "ENOENT" || code === "ENODEV") finish(); - else finish(error); - } - }; - try { - watcher = watch(path, vacated); - watcher.on("error", finish); - signal.addEventListener("abort", aborted, { once: true }); - if (signal.aborted) aborted(); - } catch (error) { - if ((error as NodeJS.ErrnoException).code !== "ENOENT") return finish(error); - } - // Registration precedes the read, covering an exit during setup. - vacated(); - }); - } -} - -/** Probe without starting the user's command; unsupported hosts use direct admission. */ -export async function systemd(signal: AbortSignal): Promise { - const runtime = process.env.XDG_RUNTIME_DIR; - if (!( - process.platform === "linux" && - runtime !== undefined && - existsSync(join(runtime, "systemd", "private")) && - existsSync("/sys/fs/cgroup/cgroup.controllers") - )) - return false; - try { - const client = await execute("systemd-run", ["--version"], signal); - if (client.code !== 0 || Number(client.stdout.match(/^systemd (\d+)/)?.[1] ?? 0) < 254) return false; - const manager = await execute("systemctl", ["--user", "show", "--property=Version", "--value"], signal); - if (manager.code !== 0 || Number(manager.stdout.match(/^(\d+)/)?.[1] ?? 0) < 254) return false; - // This exercises the manager, kernel support, and actual scope permissions, - // including opening cgroup.kill in both processes before the handshake. - return ( - (await new SystemdGate().runScope( - `quest-gate-probe-${randomUUID()}.scope`, - ["/bin/sh", "-c", ":"], - signal, - )) === 0 - ); - } catch { - signal.throwIfAborted(); - return false; - } -} - const notices: Record = { load: "quest gate: waiting for the load average to fall", memory: "quest gate: waiting for free memory", - slots: "quest gate: waiting for a free slot", }; /** Run from the CLI. */ @@ -546,30 +222,16 @@ export async function gate(command: string[]): Promise { }; for (const [name, handler] of Object.entries(handlers)) process.on(name, handler); try { - // Kernel ownership already proves admission; a nested call must not need - // another manager probe (or fall back into host pressure admission). - if (process.platform === "linux" && new SystemdGate().nested()) { - return interrupted || (await run(command, controller.signal)); - } - const backend = (await systemd(controller.signal)) ? new SystemdGate() : new DirectGate(); // A nested invocation executes inside the already admitted command. - if (backend.nested()) { - const result = await run(command, controller.signal); - return interrupted || result; - } - const shown = new Set(); - const result = await admit( - command, - hostLimits(), - backend, - fileHost(claimDirectory()), - controller.signal, - (reason) => { + if (process.env[ADMITTED] !== "1") { + const shown = new Set(); + await admit(hostLimits(), fileHost(claimDirectory()), controller.signal, (reason) => { if (shown.has(reason)) return; shown.add(reason); process.stderr.write(`${notices[reason]}\n`); - }, - ); + }); + } + const result = await run(command, controller.signal); return interrupted || result; } catch (error) { if (interrupted) return interrupted; diff --git a/src/cli/main.ts b/src/cli/main.ts index cd2bd696..fc44120f 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -58,10 +58,8 @@ export function main(argv: string[], out: Output): number | Promise { .summary("Run an expensive command once the host has room for it") .description( "Wait while the 1-minute load average exceeds the host's CPU count or available memory is below " + - "min(4 GiB, RAM/4), admitting one waiting command every 5 seconds. On Linux with a systemd " + - "user manager, the command also takes one of min(cores, RAM/4 GiB) slots in a user scope until " + - "its last descendant exits. Nested calls run immediately. Limits come from the host; there is " + - "no configuration.", + "min(4 GiB, RAM/4), admitting one waiting command every 5 seconds. Nested calls run immediately. " + + "Limits come from the host; there is no configuration.", ) .argument("", "Executable to run") .argument("[args...]", "Arguments forwarded verbatim") diff --git a/test/fixtures/gate-run.ts b/test/fixtures/gate-run.ts deleted file mode 100644 index 599f4776..00000000 --- a/test/fixtures/gate-run.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { admit, type Host, SystemdGate } from "../../src/cli/gate"; -const controller = new AbortController(); -process.on("SIGTERM", () => controller.abort()); -// These fixtures exercise slot ownership, so the host always has room. -const idle: Host = { load: () => 0, memory: () => Infinity, now: Date.now, claim: () => true, sleep: async () => {} }; -try { - const code = await admit( - JSON.parse(process.argv[2]), - { jobs: 1, load: 1, memory: 0 }, - new SystemdGate(), - idle, - controller.signal, - ); - process.exitCode = controller.signal.aborted ? 143 : code; -} catch (error) { - if (controller.signal.aborted) process.exitCode = 143; - else throw error; -} diff --git a/test/gate.platform.test.ts b/test/gate.platform.test.ts deleted file mode 100644 index 5d3d7945..00000000 --- a/test/gate.platform.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; -import { join } from "node:path"; -import { afterEach, expect, test, vi } from "vitest"; -import { gate, systemd } from "../src/cli/gate"; -import { tempDir } from "./fixture"; - -afterEach(() => vi.unstubAllEnvs()); - -test.skipIf(process.platform !== "linux").each([ - { client: 253, manager: 261, failure: "old client", probe: false }, - { client: 261, manager: 253, failure: "old manager", probe: false }, - { client: 254, manager: 254, failure: "cgroup.kill: No such file or directory", probe: true }, - { client: 261, manager: 261, failure: "cgroup.kill: Permission denied", probe: true }, -])("falls back before the user command when systemd lacks $failure", async ({ client, manager, failure, probe }) => { - const runtime = tempDir(); - mkdirSync(join(runtime, "systemd")); - writeFileSync(join(runtime, "systemd/private"), ""); - const bin = tempDir(); - const log = join(bin, "calls"); - writeFileSync( - join(bin, "systemd-run"), - `#!/bin/sh -printf '%s\\n' "$*" >> '${log}' -if [ "$1" = --version ]; then printf 'systemd ${client}\\n'; exit 0; fi -printf '%s\\n' '${failure}' >&2 -exit 125 -`, - { mode: 0o755 }, - ); - writeFileSync(join(bin, "systemctl"), `#!/bin/sh\nprintf '${manager}\\n'\n`, { mode: 0o755 }); - vi.stubEnv("XDG_RUNTIME_DIR", runtime); - vi.stubEnv("PATH", bin); - // The command is already admitted by the portable backend. This keeps the - // fallback regression independent of real host load and admission windows. - vi.stubEnv("QUEST_GATE_ADMITTED", "1"); - expect(await systemd(new AbortController().signal)).toBe(false); - expect(await gate([process.execPath, "-e", "process.exit(23)"])).toBe(23); - const calls = readFileSync(log, "utf8"); - expect(calls.includes("--scope")).toBe(probe); - if (probe) expect(calls).toContain("quest-gate-probe-"); -}); diff --git a/test/gate.systemd.test.ts b/test/gate.systemd.test.ts deleted file mode 100644 index fdb5e72f..00000000 --- a/test/gate.systemd.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -// Opt in explicitly: these tests require a real Linux systemd user manager. -import { spawn, spawnSync, type ChildProcess } from "node:child_process"; -import { once } from "node:events"; -import { closeSync, openSync, readFileSync, writeFileSync, writeSync } from "node:fs"; -import { join, resolve } from "node:path"; -import { describe, expect, test } from "vitest"; -import { openOwnership, scopeHandshake, systemd, SystemdGate } from "../src/cli/gate"; -import { tempDir } from "./fixture"; - -const root = resolve(import.meta.dirname, ".."); -const cli = join(root, "src/cli/bin.ts"); -const runner = join(root, "test/fixtures/gate-run.ts"); -const enabled = process.env.QUEST_GATE_SYSTEMD_TESTS === "1"; -const signal = () => new AbortController().signal; - -// Commands run through the fixture runner, which admits on an idle host: load -// and memory admission are unit-tested, and must not hold these on a busy host. - -function child(file: string, args: string[], cwd = root) { - const process = spawn("bun", [`--config=${join(root, "bunfig.toml")}`, file, ...args], { - cwd, - stdio: ["pipe", "pipe", "pipe"], - }); - let stdout = ""; - let stderr = ""; - process.stdout.setEncoding("utf8"); - process.stderr.setEncoding("utf8"); - process.stdout.on("data", (data: string) => (stdout += data)); - process.stderr.on("data", (data: string) => (stderr += data)); - const exited = once(process, "exit"); - const closed = once(process, "close").then(([code, killed]) => ({ code, killed, stdout, stderr })); - return { process, exited, closed }; -} - -function line(process: ChildProcess, expected: string): Promise { - return new Promise((resolve, reject) => { - let text = ""; - const data = (chunk: Buffer) => { - text += chunk.toString(); - if (!text.includes(expected)) return; - process.stdout!.off("data", data); - process.off("close", closed); - resolve(); - }; - const closed = () => reject(new Error(`command exited before ${expected}: ${text}`)); - process.stdout!.on("data", data); - process.once("close", closed); - }); -} - -const hold = (file: string) => ` - const { watch, existsSync } = require('node:fs'); - const path = ${JSON.stringify(file)}; - const watcher = watch(require('node:path').dirname(path), () => { - if (existsSync(path)) watcher.close(); - }); - if (existsSync(path)) watcher.close(); - console.log('ready'); -`; - -describe.skipIf(!enabled)("real systemd scope ownership", () => { - test("the disposable prerequisite scope supports exact ownership", async () => { - expect(await systemd(signal())).toBe(true); - }); - - test("memory admission respects a real scope budget instead of abundant host memory", async () => { - const script = `import {linuxMemory, hostLimits} from ${JSON.stringify(join(root, "src/cli/gate.ts"))}; console.log(JSON.stringify({memory: linuxMemory(), limits: hostLimits()}));`; - const command = spawn( - "systemd-run", - [ - "--user", - "--scope", - "--quiet", - "--collect", - "--expand-environment=no", - "--property=MemoryMax=256M", - "bun", - "-e", - script, - ], - { cwd: root, stdio: ["ignore", "pipe", "pipe"] }, - ); - let stdout = ""; - let stderr = ""; - command.stdout.on("data", (data: Buffer) => (stdout += data.toString())); - command.stderr.on("data", (data: Buffer) => (stderr += data.toString())); - const [code] = await once(command, "close"); - expect(code, stderr).toBe(0); - const result = JSON.parse(stdout); - expect(result.memory.total).toBe(256 * 2 ** 20); - expect(result.memory.available).toBeGreaterThan(0); - expect(result.memory.available).toBeLessThan(result.memory.total); - expect(result.limits.memory).toBe(64 * 2 ** 20); - }); - - test("forwards arguments, locale, stdin and exact exit codes across directories", async () => { - const locale = process.env.LC_ALL ?? null; - const spawned = child( - runner, - [ - JSON.stringify([ - "bun", - "-e", - "process.stdin.once('data', data => { console.log(JSON.stringify([process.argv.slice(1), String(data), process.env.LC_ALL])); process.exitCode = 23; });", - "--", - "a $b", - "--help", - "--root", - "other", - ]), - ], - tempDir(), - ); - spawned.process.stdin.end("hello"); - const result = await spawned.closed; - expect(result.code, JSON.stringify(result)).toBe(23); - expect(result.stderr).toBe(""); - expect(JSON.parse(result.stdout)).toEqual([["a $b", "--help", "--root", "other"], "hello", locale]); - }); - - test("preserves stdin, stdout and stderr as terminal descriptors", async () => { - const quote = (value: string) => "'" + value.replaceAll("'", "'\\''") + "'"; - const gated = JSON.stringify(["/bin/sh", "-c", "test -t 0 && test -t 1 && test -t 2 && echo terminal"]); - const command = ["bun", runner, gated].map(quote).join(" "); - const terminal = spawn("script", ["--quiet", "--return", "--command", command, "/dev/null"], { - cwd: root, - stdio: ["ignore", "pipe", "pipe"], - }); - let stdout = ""; - terminal.stdout.on("data", (data: Buffer) => (stdout += data.toString())); - const [code] = await once(terminal, "close"); - expect(code).toBe(0); - expect(stdout).toContain("terminal"); - }); - - test("preserves existing output when stderr is redirected to a file", async () => { - const file = join(tempDir(), "stderr"); - const descriptor = openSync(file, "w"); - writeSync(descriptor, "prefix\n"); - const command = spawn("bun", [runner, JSON.stringify(["bun", "-e", "console.error('stderr')"])], { - cwd: root, - stdio: ["ignore", "ignore", descriptor], - }); - closeSync(descriptor); - const [code] = await once(command, "close"); - expect(code).toBe(0); - expect(readFileSync(file, "utf8")).toBe("prefix\nstderr\n"); - }); - - test("nested calls keep their existing kernel scope", async () => { - const script = `console.log(require('node:fs').readFileSync('/proc/self/cgroup', 'utf8').trim()); require('node:child_process').spawnSync('bun', [${JSON.stringify(cli)}, 'gate', 'cat', '/proc/self/cgroup'], {stdio: 'inherit'});`; - const spawned = child(runner, [JSON.stringify(["bun", "-e", script])]); - const result = await spawned.closed; - expect(result.code).toBe(0); - const scopes = result.stdout.trim().split("\n"); - expect(scopes).toHaveLength(2); - expect(scopes[0]).toMatch(/\/quest-gate-0\.scope$/); - expect(scopes[1]).toBe(scopes[0]); - }); - - test("contention waits across directories without launching a second command", async () => { - const release = join(tempDir(), "release"); - const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); - try { - await line(first.process, "ready"); - expect( - await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), - ).toBeNull(); - const second = child(runner, [JSON.stringify(["bun", "-e", "console.log('second')"])], tempDir()); - writeFileSync(release, ""); - expect((await first.closed).code).toBe(0); - expect(await second.closed).toMatchObject({ code: 0, stdout: "second\n", stderr: "" }); - } finally { - writeFileSync(release, ""); - await first.closed; - } - }); - - test("a crashed supervisor leaves its descendant scope occupied", async () => { - const release = join(tempDir(), "release"); - const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); - try { - await line(first.process, "ready"); - first.process.kill("SIGKILL"); - await first.exited; - expect( - await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), - ).toBeNull(); - } finally { - writeFileSync(release, ""); - await first.closed; - } - }); - - test("detached descendants retain admission after the command exits", async () => { - const release = join(tempDir(), "release"); - const first = child(runner, [ - JSON.stringify(["/bin/sh", "-c", '"$@" &', "quest-orphan", "bun", "-e", hold(release)]), - ]); - try { - await line(first.process, "ready"); - expect( - await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), - ).toBeNull(); - } finally { - writeFileSync(release, ""); - await first.closed; - } - }); - - test("cancelling a waiter leaves the occupied command alone", async () => { - const release = join(tempDir(), "release"); - const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); - try { - await line(first.process, "ready"); - const controller = new AbortController(); - const waiting = new SystemdGate().wait(0, controller.signal); - controller.abort(); - await expect(waiting).rejects.toThrow(); - expect( - await new SystemdGate().tryRun(0, ["bun", "-e", "throw new Error('must not start')"], signal()), - ).toBeNull(); - } finally { - writeFileSync(release, ""); - await first.closed; - } - }); - - test("waiting on a live occupant resolves once its scope is removed", async () => { - const release = join(tempDir(), "release"); - const first = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); - try { - await line(first.process, "ready"); - // A live transient scope reports its own fragment; that is not a blocking unit file. - let resolved = false; - const waiting = new SystemdGate().wait(0, signal()).then(() => (resolved = true)); - await new Promise((resolve) => setTimeout(resolve, 200)); - expect(resolved).toBe(false); - writeFileSync(release, ""); - await waiting; - } finally { - writeFileSync(release, ""); - await first.closed; - } - }); - - test("waiting on a slot that was already collected returns to admission", async () => { - // The occupying scope can exit between a lost claim and the wait. - await expect(new SystemdGate().wait(999, signal())).resolves.toBeUndefined(); - }); - - test("cancellation kills the exact owned cgroup", async () => { - const spawned = child(runner, [ - JSON.stringify(["bun", "-e", "console.log('ready'); setInterval(() => {}, 1000);"]), - ]); - await line(spawned.process, "ready"); - spawned.process.kill("SIGTERM"); - expect((await spawned.closed).code).toBe(143); - }); - - test("a dead handshake cannot acquire ownership of a successor in its reused slot", async () => { - // Claim slot 0 like admission does: an earlier test's emptied scope may - // still await collection, so a lost claim waits on the slot and retries. - const handshake = () => { - const original = spawn( - "systemd-run", - [ - "--user", - "--scope", - "--quiet", - "--collect", - "--expand-environment=no", - "--unit=quest-gate-0.scope", - "/bin/sh", - "-c", - scopeHandshake + "exit 0", - ], - { stdio: ["ignore", "ignore", "pipe", "ignore", "pipe", "pipe"] }, - ); - const closed = once(original, "close"); - const record = Promise.race([ - once(original.stdio[4]!, "data").then(([data]) => String(data)), - closed.then(() => null), - ]); - return { closed, record }; - }; - let claim = handshake(); - let record = await claim.record; - while (record === null) { - await new SystemdGate().wait(0, signal()); - claim = handshake(); - record = await claim.record; - } - const { closed } = claim; - const [path, inode] = String(record).trim().split("\t"); - // Hold the buffered record without acknowledging it. Kill and collect - // the original scope, exactly the pre-admission interleaving in review. - const killed = spawnSync("systemctl", ["--user", "kill", "--signal=SIGKILL", "quest-gate-0.scope"]); - expect(killed.status, killed.stderr.toString()).toBe(0); - await closed; - await new SystemdGate().wait(0, signal()); - const release = join(tempDir(), "release"); - const successor = child(runner, [JSON.stringify(["bun", "-e", hold(release)])]); - try { - await line(successor.process, "ready"); - expect(() => openOwnership(join("/sys/fs/cgroup", path, "cgroup.kill"), BigInt(inode))).toThrow( - "exited before admission", - ); - expect(await new SystemdGate().tryRun(0, ["/bin/sh", "-c", ":"], signal())).toBeNull(); - } finally { - writeFileSync(release, ""); - await successor.closed; - } - }); -}); diff --git a/test/gate.test.ts b/test/gate.test.ts index 763483a9..02ed64ef 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -1,68 +1,32 @@ -import { - closeSync, - existsSync, - mkdirSync, - readFileSync, - renameSync, - statSync, - writeFileSync, - writeSync, -} from "node:fs"; +import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; -import { expect, test } from "vitest"; +import { afterEach, expect, test, vi } from "vitest"; import { admit, COMMAND_MEMORY, claimDirectory, - DirectGate, fileHost, - type GateBackend, + gate, type Host, hostLimits, linuxMemory, - openOwnership, + run as runCommand, SETTLE_MS, } from "../src/cli/gate"; import { gateBoundary } from "../src/cli/main"; import { run, tempDir } from "./fixture"; -const idle: Host = { load: () => 0, memory: () => Infinity, now: () => 0, claim: () => true, sleep: async () => {} }; const GiB = 2 ** 30; -test("limits derive from the host's cores and memory", () => { - // A slot per command's memory, capped at the cores; load at one per core. - expect(hostLimits(24, 64 * GiB, 24)).toEqual({ jobs: 16, load: 24, memory: COMMAND_MEMORY }); - expect(hostLimits(8, 16 * GiB, 8)).toEqual({ jobs: 4, load: 8, memory: COMMAND_MEMORY }); +afterEach(() => vi.unstubAllEnvs()); + +test("limits derive from the host's CPUs and memory", () => { // The host-wide load average is compared with every host CPU, not the // process's affinity subset, so an idle assigned CPU is not held back. - expect(hostLimits(2, 64 * GiB, 32)).toMatchObject({ jobs: 2, load: 32 }); + expect(hostLimits(24, 64 * GiB)).toEqual({ load: 24, memory: COMMAND_MEMORY }); // A small host still runs one command, and reserves a quarter of its RAM. - expect(hostLimits(4, 2 * GiB, 4)).toEqual({ jobs: 1, load: 4, memory: GiB / 2 }); - expect(hostLimits().jobs).toBeGreaterThanOrEqual(1); - expect(hostLimits().load).toBeGreaterThanOrEqual(hostLimits().jobs); -}); - -test("admission checks slots before waiting and preserves exact exit codes", async () => { - const calls: string[] = []; - let released = false; - const backend: GateBackend = { - nested: () => false, - tryRun: async (slot, command) => { - calls.push(`try ${slot}`); - expect(command).toEqual(["compiler", "a $b", "--help"]); - return released ? 23 : null; - }, - wait: async (slot) => { - calls.push(`wait ${slot}`); - released = true; - }, - }; - const limits = { jobs: 2, load: 1, memory: 1 }; - const waiting = (reason: string) => calls.push(reason); - expect( - await admit(["compiler", "a $b", "--help"], limits, backend, idle, new AbortController().signal, waiting), - ).toBe(23); - expect(calls).toEqual(["try 0", "try 1", "slots", "wait 0", "try 0"]); + expect(hostLimits(4, 2 * GiB)).toEqual({ load: 4, memory: GiB / 2 }); + expect(hostLimits().load).toBeGreaterThanOrEqual(1); }); test("load and memory pressure wait, then admit one caller per settle window", async () => { @@ -88,14 +52,8 @@ test("load and memory pressure wait, then admit one caller per settle window", a else memory = GiB; }, }; - const backend: GateBackend = { - nested: () => false, - tryRun: async () => (events.push("run"), 0), - wait: () => Promise.reject(new Error("unreachable")), - }; const signal = new AbortController().signal; - const limits = { jobs: 1, load: 4, memory: GiB }; - expect(await admit(["x"], limits, backend, host, signal, (reason) => events.push(reason))).toBe(0); + await admit({ load: 4, memory: GiB }, host, signal, (reason) => events.push(reason)); expect(events).toEqual([ "load", `sleep ${SETTLE_MS - 500}`, @@ -104,7 +62,6 @@ test("load and memory pressure wait, then admit one caller per settle window", a "claim 4", `sleep ${SETTLE_MS}`, "claim 5", - "run", ]); }); @@ -178,63 +135,33 @@ test("admission claims stay on this host", () => { expect(claimDirectory({}, "/home/u", "b")).not.toBe(claimDirectory({}, "/home/u", "a")); }); -test("a scope path reused before handshake acknowledgment cannot acquire cancellation ownership", () => { - const path = join(tempDir(), "cgroup.kill"); - writeFileSync(path, "original"); - // The original shell reported this identity, then exited; the slot was reused. - const original = statSync(path, { bigint: true }).ino; - renameSync(path, `${path}.old`); - writeFileSync(path, "successor"); - expect(() => openOwnership(path, original)).toThrow("exited before admission"); - expect(readFileSync(path, "utf8")).toBe("successor"); -}); - -test("cancellation ownership stays pinned after its slot path is reused", () => { - const path = join(tempDir(), "cgroup.kill"); - writeFileSync(path, "original"); - const handle = openOwnership(path, statSync(path, { bigint: true }).ino); - try { - renameSync(path, `${path}.old`); - writeFileSync(path, "successor"); - writeSync(handle, "1"); - expect(readFileSync(`${path}.old`, "utf8")).toBe("1riginal"); - expect(readFileSync(path, "utf8")).toBe("successor"); - } finally { - closeSync(handle); - } -}); - -test("a waiting cancellation never launches the command", async () => { +test("cancelling a waiting caller rejects admission", async () => { const controller = new AbortController(); - let calls = 0; - const backend: GateBackend = { - nested: () => false, - tryRun: async () => { - calls++; - return null; - }, - wait: async () => controller.abort(new Error("cancelled")), + const busy: Host = { + load: () => 9, + memory: () => Infinity, + now: () => 0, + claim: () => true, + sleep: async () => controller.abort(new Error("cancelled")), }; - await expect( - admit(["compiler"], { jobs: 1, load: 1, memory: 1 }, backend, idle, controller.signal), - ).rejects.toThrow("cancelled"); - expect(calls).toBe(1); + await expect(admit({ load: 1, memory: 1 }, busy, controller.signal)).rejects.toThrow("cancelled"); }); -test("load-only admission runs the command in place and marks it nested", async () => { - const gate = new DirectGate(); +test("an admitted command runs in place and is marked nested", async () => { const script = "process.exit(process.env.QUEST_GATE_ADMITTED === '1' ? 7 : 1)"; - expect(await gate.tryRun(0, [process.execPath, "-e", script], new AbortController().signal)).toBe(7); + expect(await runCommand([process.execPath, "-e", script], new AbortController().signal)).toBe(7); +}); + +test("a nested gate runs at once and keeps the exact exit status", async () => { + // A gated recipe that calls another one must not wait for admission twice. + vi.stubEnv("QUEST_GATE_ADMITTED", "1"); + expect(await gate([process.execPath, "-e", "process.exit(23)"])).toBe(23); }); -test("load-only admission cancels a signal aborted before listener registration", async () => { +test("a command cancelled before listener registration still stops", async () => { const controller = new AbortController(); controller.abort(); - const result = await new DirectGate().tryRun( - 0, - [process.execPath, "-e", "setInterval(() => {}, 1000)"], - controller.signal, - ); + const result = await runCommand([process.execPath, "-e", "setInterval(() => {}, 1000)"], controller.signal); expect(result).not.toBe(0); }); From e98f6f30a2f90865db56118b9a4ab7ee95a57962 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 12:45:26 -0700 Subject: [PATCH 09/12] fix(gate): keep global options after gate and walk only mounted cgroups `quest gate --version` and `quest gate --root ` now parse Quest's global options instead of treating them as the executable. The memory cgroup walk stays inside the mount: a membership path absent below a bind-mounted container cgroup still ends at the mount root, where the container's limit lives; a `/` path no longer walks above the mount, and a cgroup outside the namespace (`/..`) is skipped. Co-Authored-By: Claude Opus 5.5 --- src/cli/gate.ts | 16 +++++++++++----- src/cli/main.ts | 22 +++++++++++++++------- test/gate.test.ts | 32 +++++++++++++++++++++++++++++++- 3 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/cli/gate.ts b/src/cli/gate.ts index 46a3750f..57f60da6 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -4,7 +4,7 @@ import { spawn } from "node:child_process"; import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { availableParallelism, constants, cpus, homedir, hostname, loadavg, totalmem } from "node:os"; -import { dirname, join } from "node:path"; +import { dirname, join, relative, resolve } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; /** The machine-wide inputs to load admission, replaceable in tests. */ @@ -86,7 +86,7 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total if (controllers === undefined || path === undefined) throw new Error("gate cannot read Linux memory cgroups"); const unified = controllers === ""; if (!unified && !controllers?.split(",").includes("memory")) continue; - const root = unified ? cgroups : join(cgroups, "memory"); + const root = resolve(cgroups, unified ? "" : "memory"); const usage = unified ? "memory.current" : "memory.usage_in_bytes"; // Usage includes inactive file cache, which the kernel reclaims without // swapping; subtract it, as container runtimes do for their working set. @@ -94,8 +94,14 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total const limits = unified ? ["memory.max", "memory.high"] : ["memory.limit_in_bytes", "memory.soft_limit_in_bytes"]; - // Child cgroups inherit their parents' limits and shared memory usage. - for (let group = join(root, path); ; group = dirname(group)) { + // Child cgroups inherit their parents' limits and shared memory usage. The + // walk ends at the mount point, which a container without a cgroup + // namespace bind-mounts from its own cgroup: its membership path may not + // exist below the mount, but the container's limit still applies there. + const start = resolve(root, `.${path}`); + // A cgroup namespace shows a cgroup outside it as `/..`, which is not mounted. + if (/^\.\.(\/|$)/.test(relative(root, start))) continue; + for (let group = start; ; group = dirname(group)) { for (const file of limits) { try { const limit = Number(readFileSync(join(group, file), "utf8").trim()); @@ -111,7 +117,7 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } - if (group === root || group === dirname(group)) break; + if (group === root) break; } } return { total, available }; diff --git a/src/cli/main.ts b/src/cli/main.ts index f08a4d85..174088ec 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -307,14 +307,22 @@ function positiveSeconds(value: string): number { return seconds; } -/** Stop option parsing before a gated executable, keeping its own flags intact. */ +/** + * Stop option parsing before a gated executable, keeping its own flags intact. + * Quest's global options still apply on either side of `gate`. + */ export function gateBoundary(argv: string[]): string[] { - let index = 0; - while (argv[index] === "--root" || argv[index]?.startsWith("--root=")) { - index += argv[index] === "--root" ? 2 : 1; - } - if (argv[index] !== "gate" || !argv[index + 1] || ["--", "-h", "--help"].includes(argv[index + 1])) return argv; - return [...argv.slice(0, index + 1), "--", ...argv.slice(index + 1)]; + const skipRoot = (index: number) => { + while (argv[index] === "--root" || argv[index]?.startsWith("--root=")) { + index += argv[index] === "--root" ? 2 : 1; + } + return index; + }; + const index = skipRoot(0); + if (argv[index] !== "gate") return argv; + const command = skipRoot(index + 1); + if (!argv[command] || ["--", "-h", "--help", "-V", "--version"].includes(argv[command])) return argv; + return [...argv.slice(0, command), "--", ...argv.slice(command)]; } /** A result limit must be a positive, safe integer. */ diff --git a/test/gate.test.ts b/test/gate.test.ts index 02ed64ef..d6d7d33d 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -13,7 +13,7 @@ import { run as runCommand, SETTLE_MS, } from "../src/cli/gate"; -import { gateBoundary } from "../src/cli/main"; +import { gateBoundary, main } from "../src/cli/main"; import { run, tempDir } from "./fixture"; const GiB = 2 ** 30; @@ -126,6 +126,25 @@ test.each(["v1", "v2"])("Linux memory counts reclaimable cache and bounds %s con expect(linuxMemory(proc, cgroups).available).toBe(0); }); +test("Linux memory reads the container cgroup mounted as the root", () => { + const proc = tempDir(); + const cgroups = tempDir(); + mkdirSync(join(proc, "self")); + writeFileSync(join(proc, "meminfo"), `MemTotal: ${(64 * GiB) / 1024} kB\nMemAvailable: ${(24 * GiB) / 1024} kB\n`); + writeFileSync(join(cgroups, "memory.max"), String(8 * GiB)); + writeFileSync(join(cgroups, "memory.current"), String(2 * GiB)); + const limited = { total: 8 * GiB, available: 6 * GiB }; + // With a cgroup namespace, the container's own cgroup is `/`. + writeFileSync(join(proc, "self/cgroup"), "0::/\n"); + expect(linuxMemory(proc, cgroups)).toEqual(limited); + // Without one, the host path is absent below the bind-mounted container cgroup. + writeFileSync(join(proc, "self/cgroup"), "0::/system.slice/docker-abc.scope\n"); + expect(linuxMemory(proc, cgroups)).toEqual(limited); + // A cgroup outside the namespace is not mounted, so nothing visible bounds it. + writeFileSync(join(proc, "self/cgroup"), "0::/../sibling\n"); + expect(linuxMemory(proc, cgroups)).toEqual({ total: 64 * GiB, available: 24 * GiB }); +}); + test("admission claims stay on this host", () => { expect(claimDirectory({ XDG_RUNTIME_DIR: "/run/user/1000" }, "/home/u", "a")).toBe( join("/run/user/1000", "quest", "gate"), @@ -177,6 +196,17 @@ test("the gated command keeps its own flags, even ones Quest also parses", () => expect(gateBoundary(["--root", "r", "gate", "ls"])).toEqual(["--root", "r", "gate", "--", "ls"]); expect(gateBoundary(["gate", "--help"])).toEqual(["gate", "--help"]); expect(gateBoundary(["check"])).toEqual(["check"]); + // Quest's own global options may also follow the subcommand. + expect(gateBoundary(["gate", "--root", "r", "ls"])).toEqual(["gate", "--root", "r", "--", "ls"]); + expect(gateBoundary(["gate", "--root=r", "ls"])).toEqual(["gate", "--root=r", "--", "ls"]); + expect(gateBoundary(["gate", "--version"])).toEqual(["gate", "--version"]); +}); + +test("global options after gate keep working", async () => { + expect(run("gate", "--version").stdout).toMatch(/^quest /); + vi.stubEnv("QUEST_GATE_ADMITTED", "1"); + const out = { stdout: () => {}, stderr: () => {} }; + expect(await main(["gate", "--root", ".", process.execPath, "-e", "process.exit(5)"], out)).toBe(5); }); test("gate has command help and requires an executable", () => { From c6fafd2ec58322fcd3c17b097afe0c45d800236e Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:02:56 -0700 Subject: [PATCH 10/12] fix(gate): detect cgroup paths outside the mount on every separator The `/..` check matched only `/`, so on Windows the test's escaping path walked past the mount to the drive root and looped there. Compare against the platform separator and keep the filesystem-root stop as a backstop. Co-Authored-By: Claude Opus 5.5 --- src/cli/gate.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/cli/gate.ts b/src/cli/gate.ts index 57f60da6..e9a9bf86 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -4,7 +4,7 @@ import { spawn } from "node:child_process"; import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { availableParallelism, constants, cpus, homedir, hostname, loadavg, totalmem } from "node:os"; -import { dirname, join, relative, resolve } from "node:path"; +import { dirname, join, relative, resolve, sep } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; /** The machine-wide inputs to load admission, replaceable in tests. */ @@ -100,7 +100,8 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total // exist below the mount, but the container's limit still applies there. const start = resolve(root, `.${path}`); // A cgroup namespace shows a cgroup outside it as `/..`, which is not mounted. - if (/^\.\.(\/|$)/.test(relative(root, start))) continue; + const inside = relative(root, start); + if (inside === ".." || inside.startsWith(`..${sep}`)) continue; for (let group = start; ; group = dirname(group)) { for (const file of limits) { try { @@ -117,7 +118,7 @@ export function linuxMemory(proc = "/proc", cgroups = "/sys/fs/cgroup"): { total if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; } } - if (group === root) break; + if (group === root || group === dirname(group)) break; } } return { total, available }; From e1444082a49f8732bfc4c10a9fcae053dbd9fdbb Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:14:52 -0700 Subject: [PATCH 11/12] fix(gate): keep admissions a full window apart Claims were fixed 5-second buckets, so one caller could claim window N at 4,999 ms and another window N+1 at 5,000 ms. A claim is now refused while the previous window's claim exists. Only an admitted claim removes older ones, and none can be admitted while a newer claim exists, so cleanup cannot delete that claim before the check. Refused claims are removed so they never hold back the following window. Co-Authored-By: Claude Opus 5.5 --- src/cli/gate.ts | 22 +++++++++++++++------- test/gate.test.ts | 27 +++++++++++++++++++++++---- 2 files changed, 38 insertions(+), 11 deletions(-) diff --git a/src/cli/gate.ts b/src/cli/gate.ts index e9a9bf86..426b28d6 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -2,7 +2,7 @@ // for CPU load and free memory before starting the command. The gate owns no // descendants and holds nothing while the command runs. import { spawn } from "node:child_process"; -import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync } from "node:fs"; import { availableParallelism, constants, cpus, homedir, hostname, loadavg, totalmem } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { setTimeout as sleep } from "node:timers/promises"; @@ -29,8 +29,8 @@ export interface Limits { export type Waiting = "load" | "memory"; /** - * At most one admission per window across all callers. The load average lags - * new work, so waiters that all see it fall must not start together. + * Admissions across all callers are at least this far apart. The load average + * lags new work, so waiters that all see it fall must not start together. */ export const SETTLE_MS = 5000; @@ -150,15 +150,23 @@ export function fileHost(directory: string, now = Date.now): Host { claim(window) { if (window !== Math.floor(now() / SETTLE_MS)) return false; mkdirSync(directory, { recursive: true }); + const file = join(directory, String(window)); try { - closeSync(openSync(join(directory, String(window)), "wx")); + closeSync(openSync(file, "wx")); } catch (error) { if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; throw error; } - // A descheduled caller may have created an old claim after cleanup. - // It must not admit work from that expired sample. - if (window !== Math.floor(now() / SETTLE_MS)) return false; + // The previous window's claim may have admitted a command moments ago, + // so admissions stay a full window apart. Only an admitted claim removes + // older ones, and none can be admitted while this one exists, so that + // claim is still here. A descheduled caller may also have created an old + // claim after cleanup; it must not admit work from that expired sample. + // A refused claim is removed, so it never holds back the next window. + if (existsSync(join(directory, String(window - 1))) || window !== Math.floor(now() / SETTLE_MS)) { + rmSync(file, { force: true }); + return false; + } for (const name of readdirSync(directory)) { if (Number(name) < window) rmSync(join(directory, name), { force: true }); } diff --git a/test/gate.test.ts b/test/gate.test.ts index d6d7d33d..572b9d82 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -71,8 +71,8 @@ test("a window admits one claim and cleanup never admits an expired sample again const host = fileHost(directory, () => now); expect(host.claim(5)).toBe(true); expect(host.claim(5)).toBe(false); - now = 6 * SETTLE_MS; - expect(host.claim(6)).toBe(true); + now = 7 * SETTLE_MS; + expect(host.claim(7)).toBe(true); expect(existsSync(join(directory, "5"))).toBe(false); // A waiter sampled 5 before descheduling, then resumes after cleanup. expect(host.claim(5)).toBe(false); @@ -87,11 +87,30 @@ test("a claim that crosses rollover during filesystem work does not admit", () = let calls = 0; const delayed = fileHost(directory, () => { if (++calls === 1) return 5 * SETTLE_MS; - expect(successor.claim(6)).toBe(true); + // The pending claim may still admit, so the next window must wait for it. + expect(successor.claim(6)).toBe(false); return 6 * SETTLE_MS; }); expect(delayed.claim(5)).toBe(false); - expect(successor.claim(6)).toBe(false); + // The expired claim admitted nothing and is gone, so the next window is free. + expect(existsSync(join(directory, "5"))).toBe(false); + expect(successor.claim(6)).toBe(true); +}); + +test("admissions stay a full window apart across a window boundary", () => { + const directory = join(tempDir(), "claims"); + let now = 5 * SETTLE_MS - 1; + const host = fileHost(directory, () => now); + expect(host.claim(4)).toBe(true); + // A waiter at the next boundary, 1 ms later, must not start alongside it. + now = 5 * SETTLE_MS; + expect(host.claim(5)).toBe(false); + // The refused claim is removed: it admitted nothing, so it holds back no one. + expect(existsSync(join(directory, "5"))).toBe(false); + expect(host.claim(5)).toBe(false); + now = 6 * SETTLE_MS; + expect(host.claim(6)).toBe(true); + expect(existsSync(join(directory, "4"))).toBe(false); }); test.each(["v1", "v2"])("Linux memory counts reclaimable cache and bounds %s container headroom", (version) => { From af7dfe0b9bec5eee10f71e0dc92731245f87fe59 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Wed, 21 Oct 2026 13:33:37 -0700 Subject: [PATCH 12/12] fix(gate): forward the received signal to the command An interrupted gate always sent SIGTERM, so a foreground command could get the terminal's SIGINT and then a SIGTERM that cut its cleanup short. The gate now forwards the signal it received. Co-Authored-By: Claude Opus 5.5 --- src/cli/gate.ts | 15 ++++++++++++--- test/gate.test.ts | 9 +++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/src/cli/gate.ts b/src/cli/gate.ts index 426b28d6..f4d17a97 100644 --- a/src/cli/gate.ts +++ b/src/cli/gate.ts @@ -196,14 +196,23 @@ export async function admit(limits: Limits, host: Host, signal: AbortSignal, wai /** Marks an admitted command, so nested gated calls inside it run at once. */ const ADMITTED = "QUEST_GATE_ADMITTED"; -/** Run a command in place, with inherited streams and its exact exit status. */ +/** + * Run a command in place, with inherited streams and its exact exit status. + * Aborting with a signal name forwards that signal, so the command's own + * handling of it runs; any other reason sends SIGTERM. + */ export function run(command: string[], signal: AbortSignal): Promise { return new Promise((resolve, reject) => { const child = spawn(command[0], command.slice(1), { env: { ...process.env, [ADMITTED]: "1" }, stdio: "inherit", }); - const cancel = () => child.kill("SIGTERM"); + const cancel = () => + child.kill( + typeof signal.reason === "string" && signal.reason in constants.signals + ? (signal.reason as NodeJS.Signals) + : "SIGTERM", + ); signal.addEventListener("abort", cancel, { once: true }); if (signal.aborted) cancel(); child.once("error", (error) => { @@ -228,7 +237,7 @@ export async function gate(command: string[]): Promise { let interrupted = 0; const interrupt = (name: "SIGINT" | "SIGTERM" | "SIGHUP") => { interrupted = 128 + constants.signals[name]; - controller.abort(); + controller.abort(name); }; const handlers = { SIGINT: () => interrupt("SIGINT"), diff --git a/test/gate.test.ts b/test/gate.test.ts index 572b9d82..bc70af9a 100644 --- a/test/gate.test.ts +++ b/test/gate.test.ts @@ -1,4 +1,5 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { constants } from "node:os"; import { join } from "node:path"; import { afterEach, expect, test, vi } from "vitest"; import { @@ -203,6 +204,14 @@ test("a command cancelled before listener registration still stops", async () => expect(result).not.toBe(0); }); +// Windows has no POSIX signals to forward; kill() always terminates there. +test.skipIf(process.platform === "win32")("cancellation forwards the received signal to the command", async () => { + const controller = new AbortController(); + controller.abort("SIGINT"); + const result = await runCommand([process.execPath, "-e", "setInterval(() => {}, 1000)"], controller.signal); + expect(result).toBe(128 + constants.signals.SIGINT); +}); + test("the gated command keeps its own flags, even ones Quest also parses", () => { expect(gateBoundary(["gate", "bun", "--help", "--root", "x"])).toEqual([ "gate",