pi-mono/packages/coding-agent/test/exec-utf8-chunks.test.ts
Christian Klotz c466a35957
fix(coding-agent): cap pi.exec output at 256 MiB (#11546)
Closes #11360.

## Summary
- Cap combined stdout/stderr with `ExecOptions.maxBuffer`, defaulting to
the smaller of 256 MiB and the runtime string limit. Invalid explicit
limits reject before spawning.
- Terminate oversized commands and return `truncated: true`, `killed:
true`, and a nonzero exit code. Close output pipes so writing
descendants cannot keep the call pending, and discard incomplete UTF-8
suffixes rather than adding replacement characters.
- Fix SIGKILL escalation for children that ignore SIGTERM, and simplify
completion to `await` with one cleanup block and result.
- Document the API and add regressions for byte limits, shell
descendants, UTF-8 chunk boundaries, and 32-bit runtime limits.

`ExecResult.truncated` is required. Existing extensions reading
execution results continue working; typed mocks or wrappers constructing
results must add the field.

## Verification
- `npm run build` and the post-rebase `npm run build:offline`
- `npm run check`
- 39 focused execution and extension tests
- `./test.sh` passes after building the env daemon. Earlier runs hit
remote-cleanup and reftable-watcher timeouts; both passed in focused
reruns and the final full run.
2026-10-29 00:13:41 +02:00

53 lines
2 KiB
TypeScript

import type * as ChildProcessModule from "node:child_process";
import { ChildProcess, spawn } from "node:child_process";
import { PassThrough } from "node:stream";
import { setImmediate } from "node:timers/promises";
import { expect, it, vi } from "vitest";
import { execCommand } from "../src/core/exec.ts";
vi.mock("node:child_process", async (importOriginal) => ({
...(await importOriginal<typeof ChildProcessModule>()),
spawn: vi.fn(),
}));
// #11360: real streams control chunk boundaries without relying on OS pipe timing.
// Only process creation and termination are stubbed; decoding and process waiting are real.
it.each(["stdout", "stderr"] as const)(
"discards pending %s UTF-8 bytes when either stream exceeds the cap",
async (stream) => {
const child = new ChildProcess();
const stdout = new PassThrough();
const stderr = new PassThrough();
child.stdout = stdout;
child.stderr = stderr;
vi.mocked(spawn).mockReturnValueOnce(child);
vi.spyOn(child, "kill").mockImplementation(() => {
queueMicrotask(() => {
child.emit("exit", null, "SIGTERM");
child.emit("close", null, "SIGTERM");
});
return true;
});
const resultPromise = execCommand("test-command", [], process.cwd(), { maxBuffer: 3 });
const output = stream === "stdout" ? stdout : stderr;
const otherOutput = stream === "stdout" ? stderr : stdout;
// The decoder first returns "a" and buffers the first byte of €.
output.write(Buffer.from([0x61, 0xe2]));
await setImmediate();
// The other decoder also buffers the first byte of € before the shared cap is exceeded.
otherOutput.write(Buffer.from([0xe2]));
await setImmediate();
output.write(Buffer.from([0x82, 0xac]));
const result = await resultPromise;
expect(result).toMatchObject({
stdout: stream === "stdout" ? "a" : "",
stderr: stream === "stderr" ? "a" : "",
truncated: true,
killed: true,
});
expect(result.code).not.toBe(0);
expect(Buffer.byteLength(result.stdout) + Buffer.byteLength(result.stderr)).toBeLessThanOrEqual(3);
},
);