diff --git a/README.md b/README.md index 2a1b238..bbfdd64 100644 --- a/README.md +++ b/README.md @@ -83,6 +83,11 @@ expected entrypoints. Bootstrap uses detached checkouts, rejects substituted or dirty pre-existing directories, runs `npm ci --ignore-scripts` for MandateBound, then runs its explicit build command. +Each decide, act, and prove child is bounded by `AAS_CHILD_TIMEOUT_MS` +(default 30000). A hung child fails the stage instead of blocking the run. +Empty `AAS_CHILD_TIMEOUT_MS` and `AAS_GUI_PORT` values keep those defaults; +invalid integers are rejected. + Each invocation writes one atomic bundle under `.out/runs//`: - `manifest.json`: stage status and component provenance @@ -97,8 +102,9 @@ failed or skipped stage cannot leave an older stage artifact looking current. Run `npm run gui` and open the printed loopback URL. The GUI calls the same orchestrator, displays stage and provenance state, and downloads a JSON export of the selected run bundle. `npm run gui:smoke` checks the server without -starting a long-running process. The server binds only to `127.0.0.1`, requires -the exact loopback Host and same-origin boundary, and uses POST for a run. +starting a long-running process. The server binds only to `127.0.0.1` on port +8787 by default (`AAS_GUI_PORT` selects another loopback port), requires the +exact loopback Host and same-origin boundary, and uses POST for a run. ## Tests diff --git a/bin/aas-gui.mjs b/bin/aas-gui.mjs index 1adcfa1..ffc3f78 100644 --- a/bin/aas-gui.mjs +++ b/bin/aas-gui.mjs @@ -4,7 +4,7 @@ import { createServer } from "node:http"; import { readFileSync } from "node:fs"; import { isAbsolute, join } from "node:path"; import { pathToFileURL } from "node:url"; -import { DEFAULT_PATHS, runDemo } from "./aas.mjs"; +import { DEFAULT_GUI_PORT, DEFAULT_PATHS, resolveGuiPort, runDemo } from "./aas.mjs"; export function renderPage() { return ` @@ -149,7 +149,7 @@ export function createGuiServer({ }); } -export async function startGui({ port = 8787, host = "127.0.0.1", ...options } = {}) { +export async function startGui({ port = DEFAULT_GUI_PORT, host = "127.0.0.1", ...options } = {}) { if (host !== "127.0.0.1") throw new TypeError("GUI host must be 127.0.0.1."); const server = createGuiServer(options); await new Promise((resolve) => server.listen(port, host, resolve)); @@ -166,7 +166,7 @@ async function main() { process.stdout.write("GUI smoke test passed.\n"); return; } - await startGui({ port: Number(process.env.AAS_GUI_PORT ?? "8787") }); + await startGui({ port: resolveGuiPort() }); } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { diff --git a/bin/aas.mjs b/bin/aas.mjs index c9606b7..d9eb79b 100644 --- a/bin/aas.mjs +++ b/bin/aas.mjs @@ -107,6 +107,11 @@ const DEMO_VALUE_OPTIONS = new Set(["--response", "--fault"]); const STDERR_LIMIT = 800; /** Child stdout is capped so a runaway tool cannot inflate the run bundle. */ export const CHILD_JSON_LIMIT = 1024 * 1024; +/** Fail closed if a decide/act/prove child hangs. Override with AAS_CHILD_TIMEOUT_MS. */ +export const DEFAULT_CHILD_TIMEOUT_MS = 30_000; +export const CHILD_TIMEOUT_MAX_MS = 600_000; +/** Loopback port for `aas-gui`. Override with AAS_GUI_PORT. */ +export const DEFAULT_GUI_PORT = 8787; const DEMO_FAULTS = new Set(["none", "duplicate"]); const PROVE_SCENARIOS = new Set([ "principal", @@ -123,8 +128,48 @@ export const DIAGNOSTIC = Object.freeze({ CHILD_SPAWN: "AAS_CHILD_SPAWN", CHILD_EXIT: "AAS_CHILD_EXIT", CHILD_JSON: "AAS_CHILD_JSON", + CHILD_TIMEOUT: "AAS_CHILD_TIMEOUT", }); +/** + * Parse an optional integer environment value. + * Unset or blank values use `fallback`. Other non-integers fail closed. + * + * @param {string} name + * @param {string|undefined} raw + * @param {{fallback: number, min: number, max: number}} bounds + * @returns {number} + */ +export function parseEnvInteger(name, raw, { fallback, min, max }) { + if (raw === undefined) return fallback; + const trimmed = String(raw).trim(); + if (trimmed === "") return fallback; + if (!/^[0-9]+$/.test(trimmed)) { + throw new Error(`${name} must be an integer between ${min} and ${max}.`); + } + const value = Number(trimmed); + if (!Number.isInteger(value) || value < min || value > max) { + throw new Error(`${name} must be an integer between ${min} and ${max}.`); + } + return value; +} + +export function resolveChildTimeoutMs(env = process.env) { + return parseEnvInteger("AAS_CHILD_TIMEOUT_MS", env.AAS_CHILD_TIMEOUT_MS, { + fallback: DEFAULT_CHILD_TIMEOUT_MS, + min: 1, + max: CHILD_TIMEOUT_MAX_MS, + }); +} + +export function resolveGuiPort(env = process.env) { + return parseEnvInteger("AAS_GUI_PORT", env.AAS_GUI_PORT, { + fallback: DEFAULT_GUI_PORT, + min: 1, + max: 65535, + }); +} + export class UsageError extends Error { constructor(message) { super(message); @@ -205,6 +250,10 @@ Missing child tools fail closed with a bootstrap hint. Each run is written to .out/runs/. The .out/latest.json pointer identifies the most recent complete bundle. +Environment: + AAS_CHILD_TIMEOUT_MS Child process timeout in milliseconds (default: 30000) + AAS_GUI_PORT Loopback port for the local GUI (default: 8787) + Exit codes: 0 completed run (including fail-closed policy denial) 1 stage or environment error @@ -222,10 +271,13 @@ function missingChildTool(label) { /** @returns {ChildResult} */ export function runCapture(command, args, opts = {}) { + const { timeout = resolveChildTimeoutMs(), ...rest } = opts; const result = spawnSync(command, args, { encoding: "utf8", shell: false, - ...opts, + maxBuffer: CHILD_JSON_LIMIT, + ...rest, + timeout, }); return { status: result.status ?? 1, @@ -254,11 +306,18 @@ function attachChildDiagnostics(error, { stage, code, stderr }) { } function childProcessError(label, result) { + const timedOut = result.error?.code === "ETIMEDOUT"; const spawn = Boolean(result.error); - const code = spawn ? DIAGNOSTIC.CHILD_SPAWN : DIAGNOSTIC.CHILD_EXIT; - const base = spawn - ? `${label} child process error (${result.error.code ?? "spawn-error"})` - : `${label} child process exited with status ${result.status}`; + const code = timedOut + ? DIAGNOSTIC.CHILD_TIMEOUT + : spawn + ? DIAGNOSTIC.CHILD_SPAWN + : DIAGNOSTIC.CHILD_EXIT; + const base = timedOut + ? `${label} child process timed out` + : spawn + ? `${label} child process error (${result.error.code ?? "spawn-error"})` + : `${label} child process exited with status ${result.status}`; const detail = clipChildStderr(result.stderr, 200).replace(/\s+/g, " "); const error = new Error(detail ? `${base}: ${detail}` : base); return attachChildDiagnostics(error, { stage: label, code, stderr: result.stderr }); @@ -413,6 +472,7 @@ export function runDecide( { env }, ); if (result.error) { + if (result.error.code === "ETIMEDOUT") throw childProcessError("decide", result); lastError = result.error; continue; } @@ -668,6 +728,9 @@ export function printHuman(report, bundleDir = null) { */ export async function runDemo(args = [], options = {}) { validateDemoArgs(args); + const childTimeoutMs = options.childTimeoutMs ?? resolveChildTimeoutMs(); + const runner = options.runner ?? ((command, args, opts = {}) => + runCapture(command, args, { timeout: childTimeoutMs, ...opts })); const paths = { ...DEFAULT_PATHS, ...(options.paths ?? {}), @@ -725,7 +788,7 @@ export async function runDemo(args = [], options = {}) { const decide = await (options.runDecideFn ?? runDecide)(responsePath, { depsDir: paths.deps, fixturesDir: paths.fixtures, - runner: options.runner, + runner, }); const decideStderr = clipChildStderr(decide.stderr); stages.decide = stageRecord(decide.ok ? "passed" : "failed", { @@ -764,7 +827,7 @@ export async function runDemo(args = [], options = {}) { let proveStarted = false; try { actStarted = true; - const act = await (options.runActFn ?? runAct)(fault, { depsDir: paths.deps, runner: options.runner }); + const act = await (options.runActFn ?? runAct)(fault, { depsDir: paths.deps, runner }); const outcome = act.raw?.outcome ?? null; stages.act = stageRecord("passed", { raw: act.raw }); report.stages.act = { @@ -785,7 +848,7 @@ export async function runDemo(args = [], options = {}) { } const scenario = "operator"; proveStarted = true; - const prove = await (options.runProveFn ?? runProve)(scenario, { depsDir: paths.deps, runner: options.runner }); + const prove = await (options.runProveFn ?? runProve)(scenario, { depsDir: paths.deps, runner }); const proveStderr = clipChildStderr(prove.stderr); stages.prove = stageRecord(prove.ok ? "passed" : "failed", { raw: prove.raw, diff --git a/test/stack.test.mjs b/test/stack.test.mjs index bc2f67d..818dab8 100644 --- a/test/stack.test.mjs +++ b/test/stack.test.mjs @@ -7,6 +7,9 @@ import { fileURLToPath } from "node:url"; import { loadComponentLock, inspectDependencyDirectory, npmInvocation } from "../scripts/bootstrap.mjs"; import { CHILD_JSON_LIMIT, + CHILD_TIMEOUT_MAX_MS, + DEFAULT_CHILD_TIMEOUT_MS, + DEFAULT_GUI_PORT, DIAGNOSTIC, clipChildStderr, helpText, @@ -14,8 +17,11 @@ import { parseJsonOutput, persistRunBundle, printHuman, + resolveChildTimeoutMs, resolveComponentProvenance, + resolveGuiPort, runAct, + runCapture, runDecide, runDemo, runProve, @@ -245,6 +251,91 @@ test("prove still throws when a nonzero child has no JSON", () => { ); }); +async function withEnv(name, value, fn) { + const previous = process.env[name]; + if (value === undefined) delete process.env[name]; + else process.env[name] = value; + try { + return await fn(); + } finally { + if (previous === undefined) delete process.env[name]; + else process.env[name] = previous; + } +} + +test("child timeout and GUI port env values default, accept integers, and reject junk", () => { + assert.equal(DEFAULT_CHILD_TIMEOUT_MS, 30_000); + assert.equal(DEFAULT_GUI_PORT, 8787); + assert.equal(resolveChildTimeoutMs({}), DEFAULT_CHILD_TIMEOUT_MS); + assert.equal(resolveChildTimeoutMs({ AAS_CHILD_TIMEOUT_MS: "" }), DEFAULT_CHILD_TIMEOUT_MS); + assert.equal(resolveChildTimeoutMs({ AAS_CHILD_TIMEOUT_MS: " 5000 " }), 5000); + assert.equal(resolveGuiPort({}), DEFAULT_GUI_PORT); + assert.equal(resolveGuiPort({ AAS_GUI_PORT: "" }), DEFAULT_GUI_PORT); + assert.equal(resolveGuiPort({ AAS_GUI_PORT: "9090" }), 9090); + for (const raw of ["nope", "30.5", "-1", "0", String(CHILD_TIMEOUT_MAX_MS + 1)]) { + assert.throws(() => resolveChildTimeoutMs({ AAS_CHILD_TIMEOUT_MS: raw }), /AAS_CHILD_TIMEOUT_MS/); + } + for (const raw of ["abc", "8787.5", "0", "65536", "-8787"]) { + assert.throws(() => resolveGuiPort({ AAS_GUI_PORT: raw }), /AAS_GUI_PORT/); + } +}); + +test("invalid AAS_CHILD_TIMEOUT_MS fails closed before a demo run", async () => { + await withEnv("AAS_CHILD_TIMEOUT_MS", "nope", async () => { + await assert.rejects( + () => runDemo(["--response", "pass"], stubOptions(tempRoot())), + /AAS_CHILD_TIMEOUT_MS/, + ); + }); +}); + +test("runCapture applies a timeout to a hung child", () => { + const result = runCapture(process.execPath, ["-e", "setTimeout(() => {}, 5000)"], { timeout: 80 }); + assert.equal(result.error?.code, "ETIMEDOUT"); +}); + +test("timed-out children fail closed with AAS_CHILD_TIMEOUT", () => { + const runner = () => ({ + status: null, + stdout: "", + stderr: "", + error: Object.assign(new Error("spawnSync timed out"), { code: "ETIMEDOUT" }), + }); + assert.throws( + () => runAct("none", { runner }), + (error) => { + assert.match(error.message, /act child process timed out/); + assert.equal(error.code, DIAGNOSTIC.CHILD_TIMEOUT); + assert.equal(error.stage, "act"); + return true; + }, + ); +}); + +test("decide does not try another Python after a child timeout", () => { + let calls = 0; + assert.throws( + () => runDecide("unused", { + runner: () => { + calls += 1; + return { + status: null, + stdout: "", + stderr: "", + error: Object.assign(new Error("spawnSync timed out"), { code: "ETIMEDOUT" }), + }; + }, + }), + (error) => { + assert.equal(error.code, DIAGNOSTIC.CHILD_TIMEOUT); + assert.equal(error.stage, "decide"); + assert.match(error.message, /decide child process timed out/); + return true; + }, + ); + assert.equal(calls, 1); +}); + test("act names the child and preserves stderr on a spawn failure", () => { assert.throws( () => runAct("none", { @@ -654,6 +745,8 @@ test("CLI help covers usage, help flags, and exit codes", () => { assert.match(text, /aas demo \[--response pass\|fail\]/); assert.match(text, /-h, --help/); assert.match(text, /simulate --scenario operator/); + assert.match(text, /AAS_CHILD_TIMEOUT_MS/); + assert.match(text, /AAS_GUI_PORT/); assert.match(text, /Exit codes:/); });