Skip to content

Commit e66f43e

Browse files
authored
fix(hooks): suppress unhandled stdout/stderr stream errors in gmail watcher (#100519)
* fix(hooks): suppress unhandled stdout/stderr stream errors in gmail watcher * proof(gmail-watcher): add real behavior proof script for stream error catch * proof(gmail-watcher): replace wrapper with real stream error proof * style: apply oxfmt to changed files
1 parent 7d939b6 commit e66f43e

3 files changed

Lines changed: 135 additions & 0 deletions

File tree

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
// Real behavior proof: `spawnGogServe` handles real stdout/stderr stream
2+
// error events without crashing the gateway.
3+
//
4+
// The proof prepends a fake `gog` binary to PATH so `startGmailWatcher` can
5+
// reach `spawnGogServe`, then patches `child_process.spawn` so the serve child
6+
// is a real process whose streams emit real `error` events after the fix's
7+
// listeners are attached. With the fix the watcher still starts; without the
8+
// stream error listeners the unhandled errors would terminate the process.
9+
10+
import fs from "node:fs/promises";
11+
import { createRequire } from "node:module";
12+
import os from "node:os";
13+
import path from "node:path";
14+
import { fileURLToPath } from "node:url";
15+
16+
const repoRoot = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
17+
18+
const require = createRequire(import.meta.url);
19+
const childProcess = require("node:child_process") as typeof import("node:child_process");
20+
const originalSpawn = childProcess.spawn;
21+
22+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-proof-gog-"));
23+
const fakeGog = path.join(tmpDir, "gog");
24+
25+
// Fake gog that succeeds for `watch start` and sleeps for `watch serve`.
26+
await fs.writeFile(
27+
fakeGog,
28+
`#!/bin/sh
29+
if [ "$1" = "gmail" ] && [ "$2" = "watch" ]; then
30+
if [ "$3" = "start" ]; then
31+
echo "watch started"
32+
exit 0
33+
fi
34+
if [ "$3" = "serve" ]; then
35+
while true; do sleep 1; done
36+
fi
37+
fi
38+
echo "unknown command" >&2
39+
exit 1
40+
`,
41+
"utf8",
42+
);
43+
await fs.chmod(fakeGog, 0o755);
44+
45+
process.env.PATH = `${tmpDir}${path.delimiter}${process.env.PATH ?? ""}`;
46+
47+
// Patch spawn so the serve child is a real process whose streams we can make
48+
// emit error events after startGmailWatcher attaches listeners.
49+
childProcess.spawn = (...args: Parameters<typeof originalSpawn>) => {
50+
const child = originalSpawn.apply(childProcess, args);
51+
const cmd = args[0] ?? "";
52+
const argv = args[1] as string[] | undefined;
53+
if (cmd === "gog" || argv?.[0] === "gmail") {
54+
setTimeout(() => {
55+
child.stdout?.emit("error", new Error("gog stdout read failed"));
56+
child.stderr?.emit("error", new Error("gog stderr read failed"));
57+
}, 100);
58+
}
59+
return child;
60+
};
61+
62+
const { startGmailWatcher, stopGmailWatcher } = await import(
63+
path.join(repoRoot, "src/hooks/gmail-watcher.js")
64+
);
65+
66+
const config = {
67+
hooks: {
68+
enabled: true,
69+
token: "hook-token",
70+
gmail: {
71+
account: "[email protected]",
72+
topic: "projects/demo/topics/gmail",
73+
pushToken: "push-token",
74+
renewEveryMinutes: 1,
75+
tailscale: { mode: "off" },
76+
},
77+
},
78+
};
79+
80+
console.log("=== Proof: gmail-watcher stream error catch ===\n");
81+
82+
try {
83+
const result = await startGmailWatcher(config);
84+
if (result.started) {
85+
console.log("Watcher started successfully.");
86+
console.log("\nPASS: stream errors were caught and startGmailWatcher still resolved.");
87+
} else {
88+
console.log(`\nFAIL: watcher did not start: ${result.reason ?? "unknown"}`);
89+
process.exitCode = 1;
90+
}
91+
} catch (err) {
92+
console.error("\nFAIL: startGmailWatcher rejected with:");
93+
console.error(err);
94+
process.exitCode = 1;
95+
} finally {
96+
try {
97+
await stopGmailWatcher();
98+
} catch {
99+
// ignore
100+
}
101+
await fs.rm(tmpDir, { recursive: true, force: true });
102+
}

src/hooks/gmail-watcher.test.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,4 +362,31 @@ describe("startGmailWatcher", () => {
362362
vi.useRealTimers();
363363
}
364364
});
365+
366+
it("swallows stdout and stderr stream errors without crashing", async () => {
367+
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
368+
let stdout: EventEmitter | undefined;
369+
let stderr: EventEmitter | undefined;
370+
mocks.spawn.mockImplementation(() => {
371+
const child = new EventEmitter();
372+
stdout = new EventEmitter();
373+
stderr = new EventEmitter();
374+
const mockedChild = Object.assign(child, {
375+
stdout,
376+
stderr,
377+
kill: vi.fn(() => {
378+
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
379+
return true;
380+
}),
381+
killed: false,
382+
});
383+
queueMicrotask(() => {
384+
stdout?.emit("error", new Error("stdout read failed"));
385+
stderr?.emit("error", new Error("stderr read failed"));
386+
});
387+
return mockedChild;
388+
});
389+
390+
await expect(startGmailWatcher(createGmailConfig())).resolves.toEqual({ started: true });
391+
});
365392
});

src/hooks/gmail-watcher.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,13 +79,19 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
7979
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
8080
});
8181

82+
child.stdout?.on("error", (err) => {
83+
log.error(`gog stdout error: ${String(err)}`);
84+
});
8285
child.stdout?.on("data", (data: Buffer) => {
8386
const line = data.toString().trim();
8487
if (line) {
8588
log.info(`[gog] ${line}`);
8689
}
8790
});
8891

92+
child.stderr?.on("error", (err) => {
93+
log.error(`gog stderr error: ${String(err)}`);
94+
});
8995
child.stderr?.on("data", (data: Buffer) => {
9096
const line = data.toString().trim();
9197
if (!line) {

0 commit comments

Comments
 (0)