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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
23 changes: 18 additions & 5 deletions src/flows/executors/shell.ts
Original file line number Diff line number Diff line change
@@ -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 ?? [])}`;
}
Expand Down Expand Up @@ -94,10 +110,7 @@ export async function runShellAction(spec: ShellActionExecution): Promise<ShellA
});
});

if (spec.stdin != null) {
child.stdin.write(spec.stdin);
}
child.stdin.end();
writeShellStdin(child, spec.stdin);

if (spec.timeoutMs != null && spec.timeoutMs > 0) {
timeout = setTimeout(() => {
Expand Down
50 changes: 50 additions & 0 deletions test/flows-shell.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"');
});
Expand Down Expand Up @@ -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);
});