Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<run-id>/`:

- `manifest.json`: stage status and component provenance
Expand All @@ -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

Expand Down
6 changes: 3 additions & 3 deletions bin/aas-gui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<!doctype html>
Expand Down Expand Up @@ -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));
Expand All @@ -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) {
Expand Down
79 changes: 71 additions & 8 deletions bin/aas.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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);
Expand Down Expand Up @@ -205,6 +250,10 @@ Missing child tools fail closed with a bootstrap hint. Each run is written to
.out/runs/<run-id>. 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
Expand All @@ -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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Child timeout can hang forever

When a child ignores SIGTERM, spawnSync keeps waiting after timeout expires. One hung stage can still block the entire run indefinitely.

Suggested change
timeout,
timeout,
killSignal: "SIGKILL",
Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

});
return {
status: result.status ?? 1,
Expand Down Expand Up @@ -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 });
Expand Down Expand Up @@ -413,6 +472,7 @@ export function runDecide(
{ env },
);
if (result.error) {
if (result.error.code === "ETIMEDOUT") throw childProcessError("decide", result);
lastError = result.error;
continue;
}
Expand Down Expand Up @@ -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 ?? {}),
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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 = {
Expand All @@ -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,
Expand Down
93 changes: 93 additions & 0 deletions test/stack.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,21 @@ 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,
main,
parseJsonOutput,
persistRunBundle,
printHuman,
resolveChildTimeoutMs,
resolveComponentProvenance,
resolveGuiPort,
runAct,
runCapture,
runDecide,
runDemo,
runProve,
Expand Down Expand Up @@ -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", {
Expand Down Expand Up @@ -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:/);
});

Expand Down
Loading