Files
lobster/test/step_stdin_epipe.test.ts
f14e22d94a fix: workflow run crashes when a step exits before reading its stdin (#122)
* fix: workflow run crashes when a step exits before reading its stdin

A step command that exits during its startup (for example a shell script
failing a preamble check under set -e) before draining a large piped stdin
leaves the engine's pending stdin write to fail with EPIPE. The stdin socket
had no 'error' listener, so Node raised an unhandled 'error' event and the
entire lobster process crashed -- losing the approval gate, the resume token,
and the step's real exit code and stderr.

Ignore stdin write errors in both shell-step and stdlib exec spawns: the
close handler already reports the true failure. Regression test drives a
300KB stdout through a fast-exiting step and asserts the run rejects with
'workflow command failed (1)' instead of crashing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* simplify: condense EPIPE inline comments to essential context

The 5-line comment blocks explaining EPIPE behavior were excessive for
1-line handlers. The test comment describing the 300KB mechanism was
similarly verbose. Trim each to the non-obvious why only.

* simplify: remove EPIPE inline comments

The child.stdin.on('error', () => {}) pattern is self-explanatory to
Node.js developers; the rationale is fully documented in the PR body.

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: Peter Steinberger <steipete@gmail.com>
2026-08-13 12:22:15 -07:00

52 lines
1.5 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { promises as fsp } from "node:fs";
import os from "node:os";
import path from "node:path";
import { createDefaultRegistry } from "../src/commands/registry.js";
import { runWorkflowFile } from "../src/workflows/file.js";
async function runWorkflow(workflow: unknown) {
const tmpDir = await fsp.mkdtemp(path.join(os.tmpdir(), "lobster-stdin-epipe-"));
const stateDir = path.join(tmpDir, "state");
const filePath = path.join(tmpDir, "workflow.lobster");
await fsp.writeFile(filePath, JSON.stringify(workflow, null, 2), "utf8");
return runWorkflowFile({
filePath,
ctx: {
stdin: process.stdin,
stdout: process.stdout,
stderr: process.stderr,
env: { ...process.env, LOBSTER_STATE_DIR: stateDir },
mode: "tool",
registry: createDefaultRegistry(),
},
});
}
test("a step that exits before draining large stdin fails cleanly instead of crashing", async () => {
// 300KB exceeds the OS pipe buffer (64KB on Linux), so the write to the
// second step's stdin is still pending when that step exits without reading.
// Before the EPIPE guard, this crashed the engine instead of reporting the
// step failure.
await assert.rejects(
() =>
runWorkflow({
steps: [
{
id: "big",
command: "node -e \"process.stdout.write('x'.repeat(300000))\"",
},
{
id: "fast_fail",
command: 'node -e "process.exit(1)"',
stdin: "$big.stdout",
},
],
}),
/workflow command failed \(1\)/,
);
});