From e66f43e27616d3b87eebfdfc0ac70d17c799c652 Mon Sep 17 00:00:00 2001 From: cxbAsDev Date: Mon, 6 Jul 2026 14:58:07 +0800 Subject: [PATCH] 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 --- scripts/proof/gmail-watcher-stream-errors.mts | 102 ++++++++++++++++++ src/hooks/gmail-watcher.test.ts | 27 +++++ src/hooks/gmail-watcher.ts | 6 ++ 3 files changed, 135 insertions(+) create mode 100644 scripts/proof/gmail-watcher-stream-errors.mts diff --git a/scripts/proof/gmail-watcher-stream-errors.mts b/scripts/proof/gmail-watcher-stream-errors.mts new file mode 100644 index 000000000000..0afa2d47f871 --- /dev/null +++ b/scripts/proof/gmail-watcher-stream-errors.mts @@ -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) => { + 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 }); +} diff --git a/src/hooks/gmail-watcher.test.ts b/src/hooks/gmail-watcher.test.ts index 746ddb55f4a0..052f03a4b02a 100644 --- a/src/hooks/gmail-watcher.test.ts +++ b/src/hooks/gmail-watcher.test.ts @@ -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 }); + }); }); diff --git a/src/hooks/gmail-watcher.ts b/src/hooks/gmail-watcher.ts index 3e9f97df60ba..6e58dfa14f25 100644 --- a/src/hooks/gmail-watcher.ts +++ b/src/hooks/gmail-watcher.ts @@ -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) {