diff --git a/CHANGELOG.md b/CHANGELOG.md index 074b0330..3cbc7072 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Repo: https://github.com/openclaw/acpx ### Fixes - Flows: coalesce heartbeat writes while storage is busy so slow filesystems do not accumulate overlapping writes and stall running steps. +- Flows: keep the host alive when a shell action closes stdin before consuming its input. Thanks @SebTardif. ## 2026.8.28 (v0.13.2) diff --git a/src/flows/executors/shell.ts b/src/flows/executors/shell.ts index f4d322f7..2f91936e 100644 --- a/src/flows/executors/shell.ts +++ b/src/flows/executors/shell.ts @@ -1,7 +1,23 @@ -import { spawn } from "node:child_process"; +import { spawn, type ChildProcess } from "node:child_process"; import { TimeoutError } from "../../async-control.js"; import type { ShellActionExecution, ShellActionResult } from "../runtime.js"; +function writeShellStdin(child: ChildProcess, stdin: string | undefined): void { + const stream = child.stdin; + if (!stream) { + return; + } + stream.on("error", () => { + // A child may close its input early; its exit status remains authoritative. + }); + if (stdin != null && stream.writable && !stream.writableEnded) { + stream.write(stdin); + } + if (stream.writable && !stream.writableEnded) { + stream.end(); + } +} + export function formatShellActionSummary(spec: ShellActionExecution): string { return `shell: ${renderShellCommand(spec.command, spec.args ?? [])}`; } @@ -94,10 +110,7 @@ export async function runShellAction(spec: ShellActionExecution): Promise 0) { timeout = setTimeout(() => { diff --git a/test/flows-shell.test.ts b/test/flows-shell.test.ts index 485eef48..3761874b 100644 --- a/test/flows-shell.test.ts +++ b/test/flows-shell.test.ts @@ -1,4 +1,5 @@ import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; import test from "node:test"; import { TimeoutError } from "../src/async-control.js"; import { @@ -7,6 +8,32 @@ import { runShellAction, } from "../src/flows/executors/shell.js"; +function runHostScript(script: string): Promise<{ + exitCode: number | null; + stdout: string; + stderr: string; +}> { + return new Promise((resolve, reject) => { + const child = spawn(process.execPath, ["--input-type=module", "-e", script], { + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.once("error", reject); + child.once("close", (exitCode) => { + resolve({ exitCode, stdout, stderr }); + }); + }); +} + test("renderShellCommand quotes arguments consistently", () => { assert.equal(renderShellCommand("echo", ["hello", "two words"]), 'echo "hello" "two words"'); }); @@ -77,3 +104,26 @@ test("runShellAction rejects commands terminated by signal", async () => { /signal SIGTERM/, ); }); + +test("runShellAction does not crash the host when the child exits before reading stdin", async () => { + const moduleUrl = new URL("../src/flows/executors/shell.js", import.meta.url).href; + const host = await runHostScript(` + import { runShellAction } from ${JSON.stringify(moduleUrl)}; + const result = await runShellAction({ + command: process.execPath, + args: ["-e", "setImmediate(() => process.exit(0))"], + stdin: "x".repeat(1024 * 1024), + allowNonZeroExit: true, + }); + process.stdout.write(JSON.stringify({ + exitCode: result.exitCode, + signal: result.signal, + })); + `); + + assert.equal(host.exitCode, 0, host.stderr); + assert.doesNotMatch(host.stderr, /EPIPE|uncaughtException|Unhandled/); + const payload = JSON.parse(host.stdout) as { exitCode: number | null; signal: string | null }; + assert.equal(payload.exitCode, 0); + assert.equal(payload.signal, null); +});