fix(coding-agent): decode pi.exec output across UTF-8 chunk boundaries (#11326)

`execCommand()` (the function behind `pi.exec()`) decoded each stdout
and stderr chunk on its own with `data.toString()`. When a multi-byte
UTF-8 character was split across two pipe chunks, each half was turned
into U+FFFD, so the result was corrupted. For example, `"你好"` came back
as `"���好"` while the command still exited with code 0.

This change gives stdout and stderr each their own
`StringDecoder("utf8")`. A decoder holds back incomplete bytes until the
rest of the character arrives. Both decoders are flushed with `end()`
before the promise resolves, on both the success path and the error
path. `waitForChildProcess()` can resolve without a stream `end` event,
so the decoders are flushed there and not in an `end` handler. The
return shape, exit codes, and kill behavior are unchanged.

**Testing**
- Added a regression test to `packages/coding-agent/test/exec.test.ts`.
The child process splits `"你好"` across two writes on both stdout and
stderr. The test fails without the fix and passes with it: `node
../../node_modules/vitest/dist/cli.js --run test/exec.test.ts` reports 3
passed.
- `npm run check` passes.
- Reproduction from the issue: a child that writes the first byte of
`"你好"` and then the rest 100 ms later. Before the fix the result was
`{"stdout":"���好",...}`. After the fix it is
`{"stdout":"你好","stderr":"","code":0,"killed":false}`.

Fixes #11312

Requested by @davidbrai

---------

Co-authored-by: triagent <triagent@localhost>
Co-authored-by: David Brailovsky <davidbrai@gmail.com>
This commit is contained in:
robopipi[bot] 2026-10-23 14:47:34 +02:00 • committed by GitHub
parent 8a2ebd3854
commit 36be9c612e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -3,6 +3,7 @@
*/
import { spawn } from "node:child_process";
import { StringDecoder } from "node:string_decoder";
import { waitForChildProcess } from "../utils/child-process.ts";
/**
@ -46,6 +47,10 @@ export async function execCommand(
let stdout = "";
let stderr = "";
// Decode across chunks: a multi-byte UTF-8 character can be split between two chunks,
// and decoding each chunk separately would corrupt it (earendil-works/pi#11312).
const stdoutDecoder = new StringDecoder("utf8");
const stderrDecoder = new StringDecoder("utf8");
let killed = false;
let timeoutId: NodeJS.Timeout | undefined;
@ -79,11 +84,11 @@ export async function execCommand(
}
proc.stdout?.on("data", (data) => {
stdout += data.toString();
stdout += stdoutDecoder.write(data);
});
proc.stderr?.on("data", (data) => {
stderr += data.toString();
stderr += stderrDecoder.write(data);
});
// Wait for process termination without hanging on inherited stdio handles
@ -91,6 +96,8 @@ export async function execCommand(
waitForChildProcess(proc)
.then((code) => {
if (timeoutId) clearTimeout(timeoutId);
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}
@ -98,6 +105,8 @@ export async function execCommand(
})
.catch((error: Error) => {
if (timeoutId) clearTimeout(timeoutId);
stdout += stdoutDecoder.end();
stderr += stderrDecoder.end();
if (options?.signal) {
options.signal.removeEventListener("abort", killProcess);
}