diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9273fc51..540e6aaa 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - RpcClient now forwards subprocess stderr to parent process in real-time ([#2805](https://github.com/badlogic/pi-mono/issues/2805)) +- Theme file watcher now handles async `fs.watch` error events instead of crashing the process ([#2791](https://github.com/badlogic/pi-mono/issues/2791)) ## [0.65.0] - 2026-04-03 diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index 48d0e72a..40f717cd 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -798,6 +798,14 @@ function startThemeWatcher(): void { } scheduleReload(); }); + themeWatcher.on("error", () => { + try { + themeWatcher?.close(); + } catch { + /* ignore */ + } + themeWatcher = undefined; + }); } catch (_error) { // Ignore errors starting watcher } diff --git a/packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts b/packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts new file mode 100644 index 00000000..3dfcb70a --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts @@ -0,0 +1,106 @@ +import { execFileSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +/** + * Regression test for https://github.com/badlogic/pi-mono/issues/2791 + * + * fs.watch() returns an FSWatcher (EventEmitter). If the watcher emits an + * 'error' event after creation and no error handler is attached, Node.js + * treats it as an uncaught exception and terminates the process. + * + * We test this by spawning a child process that: + * 1. Sets up a custom theme with the watcher enabled + * 2. Finds the FSWatcher via process._getActiveHandles() + * 3. Emits a synthetic 'error' event on it + * 4. If the watcher has no error handler -> crash (exit != 0) -> bug present + * 5. If the watcher has an error handler -> clean exit (exit 0) -> bug fixed + */ +describe("issue #2791 fs.watch error event crashes process", () => { + let tempRoot: string; + + beforeEach(() => { + tempRoot = mkdtempSync(join(tmpdir(), "pi-2791-")); + const agentDir = join(tempRoot, "agent"); + const themesDir = join(agentDir, "themes"); + mkdirSync(themesDir, { recursive: true }); + + // Copy dark.json as "custom-test" theme + const darkThemePath = join(__dirname, "../../../src/modes/interactive/theme/dark.json"); + const darkTheme = JSON.parse(readFileSync(darkThemePath, "utf-8")); + darkTheme.name = "custom-test"; + writeFileSync(join(themesDir, "custom-test.json"), JSON.stringify(darkTheme, null, 2)); + }); + + afterEach(() => { + rmSync(tempRoot, { recursive: true, force: true }); + }); + + it("process should survive an error event on the theme FSWatcher", () => { + const themeModulePath = join(__dirname, "../../../src/modes/interactive/theme/theme.js").replace(/\\/g, "/"); + const agentDir = join(tempRoot, "agent").replace(/\\/g, "/"); + + // Script that sets up the watcher and emits a synthetic error on it. + // If no .on('error') handler is attached, EventEmitter.emit('error') + // throws, which either crashes the process or gets caught by our try/catch. + const scriptPath = join(tempRoot, "test-watcher-error.mts"); + writeFileSync( + scriptPath, + ` +import { setTheme, stopThemeWatcher } from "${themeModulePath}"; + +process.env.PI_CODING_AGENT_DIR = "${agentDir}"; + +setTheme("custom-test", true); + +// Find the FSWatcher among active handles +const handles = (process as any)._getActiveHandles(); +const fsWatcher = handles.find((h: any) => h.constructor?.name === "FSWatcher"); + +if (!fsWatcher) { + process.stderr.write("no FSWatcher found among active handles\\n"); + process.exit(2); +} + +const errorListenerCount = fsWatcher.listenerCount("error"); +if (errorListenerCount === 0) { + process.stderr.write("BUG: FSWatcher has no error handler (issue #2791)\\n"); +} + +// Emitting 'error' on an EventEmitter with no error listener throws. +// This simulates an async OS error (e.g. ReadDirectoryChangesW invalidation). +try { + fsWatcher.emit("error", new Error("simulated OS watcher failure")); +} catch { + process.stderr.write("error event was unhandled and threw\\n"); + process.exit(1); +} + +stopThemeWatcher(); +process.exit(0); +`, + ); + + let _stdout = ""; + let stderr = ""; + let exitCode: number; + try { + _stdout = execFileSync("npx", ["tsx", scriptPath], { + timeout: 10000, + encoding: "utf-8", + env: { ...process.env, PI_CODING_AGENT_DIR: agentDir }, + stdio: ["pipe", "pipe", "pipe"], + }); + exitCode = 0; + } catch (err: unknown) { + const e = err as { status: number; stdout: string; stderr: string }; + _stdout = e.stdout ?? ""; + stderr = e.stderr ?? ""; + exitCode = e.status ?? 1; + } + + expect(exitCode, `Child crashed (exit ${exitCode}). stderr: ${stderr.trim()}`).toBe(0); + }); +});