From 0d6e3f790af468385ec718c3db1d55f73daaf027 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 19 Aug 2026 22:07:03 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=8D=20Report=20what=20an=20abandoned?= =?UTF-8?q?=20xmd=20run=20wrote=20before=20its=20deadline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A runCli deadline used to discard the child's output, so a suite-level timeout — the load-dependent flake shape the CLI suites see — left no record of where the run hung. The failure now carries both channels as received before the run was abandoned, and the host's load average, which is the condition such deadlines expire under. Claude-Session: https://claude.ai/code/session_01MNztk1fikXUnD2FW4yS6Kr --- packages/cli/tests/launch-timeout.test.ts | 61 ++++++++++++++++++ packages/test-support/launch.ts | 76 +++++++++++++++++++---- 2 files changed, 125 insertions(+), 12 deletions(-) create mode 100644 packages/cli/tests/launch-timeout.test.ts 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;