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
61 changes: 61 additions & 0 deletions packages/cli/tests/launch-timeout.test.ts
Original file line number Diff line number Diff line change
@@ -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<T>(body: (dir: string) => Operation<T>): Operation<T> {
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");
});
});
});
76 changes: 64 additions & 12 deletions packages/test-support/launch.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<typeof exec>) => Operation<ProcessResult>,
mode: "join" | "expect",
): Operation<ProcessResult> {
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<ProcessResult>(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<string, string> {
const env: Record<string, string> = {};
const names = options.inheritEnv ? Object.keys(process.env) : INHERITED;
Expand Down
Loading