Skip to content

Commit 118e5bd

Browse files
steipetecxbAsDev
andauthored
fix(agents): harden tool search child streams (#101295)
Co-authored-by: 陈宪彪0668000387 <[email protected]>
1 parent ae16704 commit 118e5bd

2 files changed

Lines changed: 97 additions & 1 deletion

File tree

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
// Regression tests for code-mode child stderr stream errors in Tool Search.
2+
import { spawn, type ChildProcess, type SpawnOptions } from "node:child_process";
3+
import { EventEmitter } from "node:events";
4+
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5+
6+
type MockSpawnChild = EventEmitter & {
7+
stderr?: EventEmitter & { setEncoding?: (enc: string) => void };
8+
send?: (message: unknown, callback?: (error?: Error | null) => void) => boolean;
9+
connected?: boolean;
10+
kill?: (signal?: string) => void;
11+
};
12+
13+
function createMockSpawnChild() {
14+
const child = new EventEmitter() as MockSpawnChild;
15+
const stderr = new EventEmitter() as MockSpawnChild["stderr"];
16+
stderr!.setEncoding = vi.fn();
17+
child.stderr = stderr;
18+
child.connected = true;
19+
child.kill = vi.fn();
20+
child.send = vi.fn(() => true);
21+
return { child, stderr };
22+
}
23+
24+
vi.mock("node:child_process", async () => {
25+
const { mockNodeBuiltinModule } = await import("openclaw/plugin-sdk/test-node-mocks");
26+
const spawnLocal = vi.fn(
27+
(_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => {
28+
const { child } = createMockSpawnChild();
29+
return child as unknown as ChildProcess;
30+
},
31+
);
32+
return mockNodeBuiltinModule(
33+
() => vi.importActual<typeof import("node:child_process")>("node:child_process"),
34+
{
35+
spawn: spawnLocal as unknown as typeof import("node:child_process").spawn,
36+
},
37+
);
38+
});
39+
40+
const spawnMock = vi.mocked(spawn);
41+
42+
let toolSearch: typeof import("./tool-search.js");
43+
44+
describe("tool-search code-mode stream errors", () => {
45+
beforeAll(async () => {
46+
toolSearch = await import("./tool-search.js");
47+
});
48+
49+
afterEach(() => {
50+
toolSearch.testing.setToolSearchCodeModeSupportedForTest(undefined);
51+
toolSearch.testing.setToolSearchMinCodeTimeoutMsForTest(undefined);
52+
});
53+
54+
it("rejects stderr errors and leaves the unused stdout unpiped", async () => {
55+
toolSearch.testing.setToolSearchCodeModeSupportedForTest(true);
56+
toolSearch.testing.setToolSearchMinCodeTimeoutMsForTest(1000);
57+
58+
let spawnedChild: MockSpawnChild | undefined;
59+
spawnMock.mockImplementationOnce(
60+
(_command: string, _args: readonly string[], _options: SpawnOptions): ChildProcess => {
61+
const { child, stderr } = createMockSpawnChild();
62+
spawnedChild = child;
63+
process.nextTick(() => {
64+
stderr?.emit("error", new Error("stderr read failed"));
65+
});
66+
return child as unknown as ChildProcess;
67+
},
68+
);
69+
70+
const runtime = new toolSearch.ToolSearchRuntime(
71+
{},
72+
toolSearch.testing.resolveToolSearchConfig({}),
73+
);
74+
75+
await expect(
76+
toolSearch.testing.runCodeModeChild({
77+
code: "return 1;",
78+
config: toolSearch.testing.resolveToolSearchConfig({}),
79+
logs: [],
80+
parentToolCallId: "call-stderr-error",
81+
runtime,
82+
}),
83+
).rejects.toThrow("stderr read failed");
84+
expect(spawnMock).toHaveBeenCalledOnce();
85+
expect(spawnMock.mock.calls[0]?.[2]).toMatchObject({
86+
stdio: ["ignore", "ignore", "pipe", "ipc"],
87+
});
88+
expect(spawnedChild?.kill).toHaveBeenCalledOnce();
89+
});
90+
});

src/agents/tool-search.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2111,7 +2111,9 @@ function runCodeModeChild(params: {
21112111
const child = spawn(process.execPath, buildCodeModeChildArgs(), {
21122112
cwd: os.tmpdir(),
21132113
env: {},
2114-
stdio: ["ignore", "pipe", "pipe", "ipc"],
2114+
// The worker returns logs/results over IPC and never writes stdout.
2115+
// Ignore it so an unused pipe cannot fill or surface unhandled errors.
2116+
stdio: ["ignore", "ignore", "pipe", "ipc"],
21152117
});
21162118
let stderrTail = "";
21172119
let settled = false;
@@ -2154,6 +2156,9 @@ function runCodeModeChild(params: {
21542156
child.stderr?.on("data", (chunk: string) => {
21552157
stderrTail = appendToolSearchCodeStderrTail(stderrTail, chunk);
21562158
});
2159+
child.stderr?.on("error", (error) => {
2160+
settle(() => reject(error));
2161+
});
21572162

21582163
child.on("error", (error) => {
21592164
settle(() => reject(error));
@@ -2357,5 +2362,6 @@ export const testing = {
23572362
applyToolSearchCatalog,
23582363
addClientToolsToToolSearchCatalog,
23592364
appendToolSearchCodeStderrTail,
2365+
runCodeModeChild,
23602366
};
23612367
export { testing as __testing };

0 commit comments

Comments
 (0)