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
This commit is contained in:
cxbAsDev
2026-07-06 14:58:07 +08:00
committed by GitHub
parent 7d939b6456
commit e66f43e276
3 changed files with 135 additions and 0 deletions

View File

@@ -0,0 +1,102 @@
// Real behavior proof: `spawnGogServe` handles real stdout/stderr stream
// error events without crashing the gateway.
//
// The proof prepends a fake `gog` binary to PATH so `startGmailWatcher` can
// reach `spawnGogServe`, then patches `child_process.spawn` so the serve child
// is a real process whose streams emit real `error` events after the fix's
// listeners are attached. With the fix the watcher still starts; without the
// stream error listeners the unhandled errors would terminate the process.
import fs from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.dirname(path.dirname(path.dirname(fileURLToPath(import.meta.url))));
const require = createRequire(import.meta.url);
const childProcess = require("node:child_process") as typeof import("node:child_process");
const originalSpawn = childProcess.spawn;
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-proof-gog-"));
const fakeGog = path.join(tmpDir, "gog");
// Fake gog that succeeds for `watch start` and sleeps for `watch serve`.
await fs.writeFile(
fakeGog,
`#!/bin/sh
if [ "$1" = "gmail" ] && [ "$2" = "watch" ]; then
if [ "$3" = "start" ]; then
echo "watch started"
exit 0
fi
if [ "$3" = "serve" ]; then
while true; do sleep 1; done
fi
fi
echo "unknown command" >&2
exit 1
`,
"utf8",
);
await fs.chmod(fakeGog, 0o755);
process.env.PATH = `${tmpDir}${path.delimiter}${process.env.PATH ?? ""}`;
// Patch spawn so the serve child is a real process whose streams we can make
// emit error events after startGmailWatcher attaches listeners.
childProcess.spawn = (...args: Parameters<typeof originalSpawn>) => {
const child = originalSpawn.apply(childProcess, args);
const cmd = args[0] ?? "";
const argv = args[1] as string[] | undefined;
if (cmd === "gog" || argv?.[0] === "gmail") {
setTimeout(() => {
child.stdout?.emit("error", new Error("gog stdout read failed"));
child.stderr?.emit("error", new Error("gog stderr read failed"));
}, 100);
}
return child;
};
const { startGmailWatcher, stopGmailWatcher } = await import(
path.join(repoRoot, "src/hooks/gmail-watcher.js")
);
const config = {
hooks: {
enabled: true,
token: "hook-token",
gmail: {
account: "me@example.com",
topic: "projects/demo/topics/gmail",
pushToken: "push-token",
renewEveryMinutes: 1,
tailscale: { mode: "off" },
},
},
};
console.log("=== Proof: gmail-watcher stream error catch ===\n");
try {
const result = await startGmailWatcher(config);
if (result.started) {
console.log("Watcher started successfully.");
console.log("\nPASS: stream errors were caught and startGmailWatcher still resolved.");
} else {
console.log(`\nFAIL: watcher did not start: ${result.reason ?? "unknown"}`);
process.exitCode = 1;
}
} catch (err) {
console.error("\nFAIL: startGmailWatcher rejected with:");
console.error(err);
process.exitCode = 1;
} finally {
try {
await stopGmailWatcher();
} catch {
// ignore
}
await fs.rm(tmpDir, { recursive: true, force: true });
}

View File

@@ -362,4 +362,31 @@ describe("startGmailWatcher", () => {
vi.useRealTimers();
}
});
it("swallows stdout and stderr stream errors without crashing", async () => {
mocks.runCommandWithTimeout.mockResolvedValue({ code: 0, stdout: "", stderr: "" });
let stdout: EventEmitter | undefined;
let stderr: EventEmitter | undefined;
mocks.spawn.mockImplementation(() => {
const child = new EventEmitter();
stdout = new EventEmitter();
stderr = new EventEmitter();
const mockedChild = Object.assign(child, {
stdout,
stderr,
kill: vi.fn(() => {
queueMicrotask(() => child.emit("exit", null, "SIGTERM"));
return true;
}),
killed: false,
});
queueMicrotask(() => {
stdout?.emit("error", new Error("stdout read failed"));
stderr?.emit("error", new Error("stderr read failed"));
});
return mockedChild;
});
await expect(startGmailWatcher(createGmailConfig())).resolves.toEqual({ started: true });
});
});

View File

@@ -79,6 +79,9 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
child.stdout?.on("error", (err) => {
log.error(`gog stdout error: ${String(err)}`);
});
child.stdout?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (line) {
@@ -86,6 +89,9 @@ function spawnGogServe(cfg: GmailHookRuntimeConfig): ChildProcess {
}
});
child.stderr?.on("error", (err) => {
log.error(`gog stderr error: ${String(err)}`);
});
child.stderr?.on("data", (data: Buffer) => {
const line = data.toString().trim();
if (!line) {