diff --git a/packages/cli/tests/launch-timeout.test.ts b/packages/cli/tests/launch-timeout.test.ts new file mode 100644 index 000000000..580a56ee4 --- /dev/null +++ b/packages/cli/tests/launch-timeout.test.ts @@ -0,0 +1,61 @@ +/** + * The harness deadline in `@executablemd/test-support/launch`. + * + * `runCli` bounds every suite's `xmd` subprocess, and its deadline expires + * under host contention rather than by the child's own doing — so when it + * does, the failure it raises is the only record the suite gets of that run. + * This proves the record carries what a diagnosis needs: the deadline, and + * whatever the child wrote on each channel before the run was abandoned. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { ensure, scoped } from "effection"; +import type { Operation } from "effection"; +import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { tmpdir } from "node:os"; +import { cliRuntime, runCli } from "@executablemd/test-support/launch"; + +// The deadline must expire for the failure to exist, so this test always costs +// its whole limit — kept as small as the slowest host launch leaves safe. +const LIMIT = cliRuntime() === "node" ? 20_000 : 10_000; + +/** Says its name on each channel, then outlives the deadline. */ +const LINGERING = [ + "# Lingering", + "", + "```bash exec", + "printf 'lingering-out'; printf 'lingering-err' >&2; sleep 120", + "```", + "", +].join("\n"); + +/** A directory holding the document, with no repository behind it. */ +function useDocument(body: (dir: string) => Operation): Operation { + return scoped(function* () { + const dir = join(tmpdir(), `xmd-deadline-${randomUUID()}`); + yield* ensure(() => rm(dir, { recursive: true, force: true })); + yield* ensureDir(dir); + yield* writeTextFile(join(dir, "lingering.md"), LINGERING); + return yield* body(dir); + }); +} + +describe("Harness deadline — an abandoned xmd run keeps its account", () => { + it("the failure names the deadline and carries both channels", function* () { + yield* useDocument(function* (dir) { + let failure: Error | undefined; + try { + yield* runCli(["run", "lingering.md"], { cwd: dir, timeout: LIMIT }).join(); + } catch (error) { + failure = error as Error; + } + + expect(failure?.message).toContain(`timed out after ${LIMIT}ms`); + expect(failure?.message).toContain("lingering-out"); + expect(failure?.message).toContain("lingering-err"); + }); + }); +}); diff --git a/packages/test-support/launch.ts b/packages/test-support/launch.ts index c5b48f0b9..3fa6b528e 100644 --- a/packages/test-support/launch.ts +++ b/packages/test-support/launch.ts @@ -1,7 +1,9 @@ import { exec } from "@effectionx/process"; import type { ProcessResult } from "@effectionx/process"; import { timebox } from "@effectionx/timebox"; +import { spawn } from "effection"; import type { Operation } from "effection"; +import { loadavg } from "node:os"; import { join } from "node:path"; import process from "node:process"; import { fileURLToPath } from "node:url"; @@ -83,33 +85,83 @@ export interface CliRun { */ export function runCli(args: string[], options: CliRunOptions = {}): CliRun { return { - join: () => bounded(args, options, (run) => run.join()), - expect: () => bounded(args, options, (run) => run.expect()), + join: () => bounded(args, options, "join"), + expect: () => bounded(args, options, "expect"), }; } +/** Everything the child wrote on one channel before its run settled. */ +interface PartialOutput { + stdout: string; + stderr: string; +} + function* bounded( args: string[], options: CliRunOptions, - settle: (run: ReturnType) => Operation, + mode: "join" | "expect", ): Operation { const limit = options.timeout ?? DEFAULT_TIMEOUT; + const cli = cliCommand(args); + // Accumulated outside the deadline, so a run the deadline abandons still has + // an account: a timeout that reports nothing but its duration cannot say + // whether the child hung before its first line or after its last. + const partial: PartialOutput = { stdout: "", stderr: "" }; const result = yield* timebox(limit, function* () { - const cli = cliCommand(args); - return yield* settle( - exec(cli.command, { - arguments: cli.arguments, - cwd: options.cwd, - env: cliEnv(options), - }), - ); + const child = yield* exec(cli.command, { + arguments: cli.arguments, + cwd: options.cwd, + env: cliEnv(options), + }); + yield* spawn(function* () { + const output = yield* child.stdout; + for (let next = yield* output.next(); !next.done; next = yield* output.next()) { + partial.stdout += text(next.value); + } + }); + yield* spawn(function* () { + const output = yield* child.stderr; + for (let next = yield* output.next(); !next.done; next = yield* output.next()) { + partial.stderr += text(next.value); + } + }); + const status = yield* mode === "expect" ? child.expect() : child.join(); + return { ...status, stdout: partial.stdout, stderr: partial.stderr }; }); if (result.timeout) { - throw new Error(`xmd ${args.join(" ")} timed out after ${limit}ms`); + throw new Error(abandonedReport(args, limit, partial)); } return result.value; } +// Chunks decode independently, exactly as capture concatenates them. +function text(bytes: Uint8Array): string { + return new TextDecoder().decode(bytes); +} + +/** + * A timeout is a host observation, not a CLI outcome, so the report carries + * what a diagnosis needs from the host: the machine's load — these deadlines + * expire under contention, not by the child's own doing — and whatever each + * channel received before the run was abandoned. + */ +function abandonedReport(args: string[], limit: number, partial: PartialOutput): string { + const load = loadavg() + .map((average) => average.toFixed(1)) + .join(", "); + return [ + `xmd ${args.join(" ")} timed out after ${limit}ms (load average ${load})`, + channel("stdout", partial.stdout), + channel("stderr", partial.stderr), + ].join("\n"); +} + +function channel(name: string, content: string): string { + return content.length === 0 + ? `--- ${name} before the deadline: nothing ---` + : `--- ${name} before the deadline ---\n${content}`; +} + function cliEnv(options: CliRunOptions): Record { const env: Record = {}; const names = options.inheritEnv ? Object.keys(process.env) : INHERITED;