mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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:
parent
8a2ebd3854
commit
36be9c612e
1 changed files with 11 additions and 2 deletions
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue