Added in-progress label to GitHub issue.
EditReviewed code and verified root cause independently.
UnderstandCreated small test to reproduce the bug.
BuildDrafted and committed fix for the issue.
EditPushed changes and confirmed issue closure.
VerifyAnalyze GitHub issue(s): https://github.com/badlogic/pi-mono/issues/2791
For each issue:
Add the inprogress label to the issue via GitHub CLI before analysis starts. If adding the label fails, report that explicitly and continue.
Read the issue in full, including all comments and linked issues/PRs.
Do not trust analysis written in the issue. Independently verify behavior and derive your own analysis from the code and execution path.
For bugs:
For feature requests:
Do NOT implement unless explicitly asked. Analyze and propose only.
Let me start by reading the GitHub issue and adding the inprogress label.
>cd /Users/badlogic/workspaces/pi-mono && gh issue view 2791 --json title,body,comments,labels,state>cd /Users/badlogic/workspaces/pi-mono && gh issue edit 2791 --add-label "inprogress" 2>&1Let me read the theme.ts file to verify the issue independently.
Label added. Now let me read the actual source file to verify the issue independently.
Now I've read the full file. Let me verify the issue independently.
Looking at startThemeWatcher() (around line 420-470):
try { themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => { if (currentThemeName !== watchedThemeName) { return; } if (!filename) { scheduleReload(); return; } const changedFile = String(filename); if (changedFile !== watchedFileName) { return; } scheduleReload(); });} catch (_error) { // Ignore errors starting watcher}The problem is clear: fs.watch() returns an FSWatcher that's an EventEmitter, but the try/catch only handles synchronous errors during the initial call. If the watcher later encounters an error—like the directory being deleted or an OS handle becoming invalid—it emits an error event. Without a handler for that event, Node.js crashes the process with an unhandled exception.
The fix is straightforward: add an error event listener...
Now I should scan the rest of the codebase to see if there are other fs.watch calls that might have the same vulnerability.
Let me check if there are other fs.watch usages with the same pattern in the codebase.
>cd /Users/badlogic/workspaces/pi-mono && rg 'fs\.watch\(' --type ts -l>cd /Users/badlogic/workspaces/pi-mono && rg "fs\.watch\(" --type ts -A 10 packages/coding-agent/examples/extensions/file-trigger.tsThe example file has the same pattern (no .on('error') handler), but it's just an example, not production code.
Issue: #2791 — fs.watch error event crashes all pi sessions simultaneously
Type: Bug
Independently verified: Yes. The issue is real and the root cause analysis in the issue is correct.
In packages/coding-agent/src/modes/interactive/theme/theme.ts, the startThemeWatcher() function (lines ~420-470) creates an fs.watch() handle on the custom themes directory. The code wraps the call in try/catch, but this only catches synchronous errors from fs.watch() itself.
fs.watch() returns an FSWatcher (an EventEmitter). Runtime errors (directory renamed/deleted, OS handle invalidation, handle limit exhaustion) are emitted asynchronously as error events. Without an .on('error', ...) handler, Node.js treats these as uncaught exceptions and terminates the process.
The specific code:
try { themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => { // ... callback ... }); // No .on('error', ...) handler} catch (_error) { // Only catches synchronous errors}Add an error event handler to themeWatcher immediately after creation, inside the try block. On error, close the watcher and set it to undefined (theme hot-reload becomes unavailable, but pi stays alive):
try { themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => { // ... existing callback ... }); themeWatcher.on("error", () => { try { themeWatcher?.close(); } catch { /* ignore */ } themeWatcher = undefined; });} catch (_error) { // Ignore errors starting watcher}packages/coding-agent/src/modes/interactive/theme/theme.ts — single change in startThemeWatcher()initTheme()/setTheme() call, but all sessions remain alivecan you reproduce this in a small test?
The user wants me to reproduce the bug in a small test. Let me think about how to do this.
The core issue is that fs.watch() returns an FSWatcher EventEmitter, and if it emits an error event without a handler, Node.js crashes the process.
I can write a test that:
startThemeWatcher() (or the relevant code path)Actually, the simplest approach would be to directly test the behavior: create an fs.watch on a directory, then programmatically emit an error on it, and see what happens.
But I need to consider the test infrastructure for this codebase. The themeWatcher variable is module-private, so I can't directly access it from a test. I could either write a standalone reproduction script that demonstrates the bug, or create a test that exercises the theme initialization code path with a custom theme and then triggers a directory error. Let me explore the second approach by setting up a test scenario that exercises the relevant code paths. Looking at the test structure in AGENTS.md, I need to put regression tests under packages/coding-agent/test/suite/regressions/ with the naming convention <issue-number>-<short-slug>.test.ts. The challenge is that themeWatcher isn't exported, so I'll need to either mock getCustomThemesDir and trigger the directory deletion to test the error handling, or find another way to access the watcher instance for verification.
The simplest approach is to spy on fs.watch to capture the FSWatcher instance, then manually emit an error event on it to verify the process handles it gracefully without crashing.
Let me look at the test infrastructure to understand how to write this properly.
>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && ls test/suite/regressions/ 2>/dev/null | head -20>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && ls test/ | head -20>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && head -30 test/suite/regressions/2753-reload-stale-resource-settings.test.ts>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && ls test/theme* 2>/dev/null; rg -l "theme" test/ --max-depth=1 2>/dev/nullGood, now I understand the test patterns. Let me also check how getCustomThemesDir works to know how to set up the environment.
>cd /Users/badlogic/workspaces/pi-mono && rg "getCustomThemesDir" packages/coding-agent/src/config.ts>cd /Users/badlogic/workspaces/pi-mono && rg -A 5 "export function getCustomThemesDir" packages/coding-agent/src/config.ts>cd /Users/badlogic/workspaces/pi-mono && rg -A 5 "export function getAgentDir" packages/coding-agent/src/config.tsNow let me look at a dark.json theme to use as a template for the test:
>cd /Users/badlogic/workspaces/pi-mono && rg "ENV_AGENT_DIR" packages/coding-agent/src/config.ts | head -3Now let me write the regression test:
>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && npx tsx ../../node_modules/vitest/dist/cli.js --run test/suite/regressions/2791-fswatch-error-crash.test.ts 2>&1Can't spy on ESM modules. Let me take a different approach. Instead of spying on fs.watch, I can:
Actually, the simplest approach is to not try to capture the watcher at all. Instead:
Set up the custom theme and enable the watcher
Delete the watched directory (which should trigger an error on the FSWatcher on most platforms)
Listen for uncaughtException events on the process to detect if the error crashes it
A subprocess approach would be more reliable—spawn a child process that sets up the theme, creates the watcher, deletes the directory, and then check its exit code to determine if the bug is present.
Actually, I'm overcomplicating this. The real issue is whether the themeWatcher FSWatcher has an error handler attached, so I can verify that directly by checking the listeners on the process or by examining the watcher's error handlers after setup. I can access the active handles in the process using process._getActiveHandles() to locate the FSWatcher, then emit an error directly on it to test if the error handling works. But actually, the child process approach is more reliable for testing whether the process crashes or survives an error.
Can't spy on ESM namespace. Let me use a child process approach instead, which is actually more faithful to the bug (it tests whether the process actually crashes).
>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && npx tsx ../../node_modules/vitest/dist/cli.js --run test/suite/regressions/2791-fswatch-error-crash.test.ts 2>&1Exit code 3 means ERROR_THROWN - the error was thrown when fsWatcher.emit("error", ...) was called. This confirms the bug: there's no error handler, so emit("error") throws.
Actually wait, the emit("error") in EventEmitter: when there are no error listeners, EventEmitter.emit("error") throws the error. The try/catch in the script caught it. So exit code 3 and "ERROR_THROWN" means: confirmed, no error handler is registered.
The test is working correctly - it's demonstrating the bug.
Now I want to make the test output clearer and verify that stderr also shows the expected error message. I'll refine the test slightly so it's ready to pass once the fix is applied, and I should capture stderr to confirm the error details are being logged correctly.
The test confirms the bug: exit code 3 means emit("error") threw because there's no error handler. The test is working correctly: it fails with the current code (bug present) and will pass once the fix is applied.
Let me clean up the test slightly to make the failure output clearer:
>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && npx tsx ../../node_modules/vitest/dist/cli.js --run test/suite/regressions/2791-fswatch-error-crash.test.ts 2>&1Test works correctly. The failure message is clear:
BUG: FSWatcher has no error handler (issue #2791)error event was unhandled and threwThis confirms the bug: the FSWatcher from fs.watch() has zero error listeners, so any async error event crashes the process.
Summary:
packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.tsFSWatcher handle, and emits a synthetic error event on it. Without an error handler, EventEmitter.emit('error') throws/crashes. With a handler, the child exits cleanly.The fix remains the same one-liner addition in startThemeWatcher() in theme.ts:
themeWatcher.on("error", () => { try { themeWatcher?.close(); } catch { /* ignore */ } themeWatcher = undefined;});ok fix it
>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && npx tsx ../../node_modules/vitest/dist/cli.js --run test/suite/regressions/2791-fswatch-error-crash.test.ts 2>&1Test passes. Now run the full check:
>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1>cd /Users/badlogic/workspaces/pi-mono && npm run check 2>&1All checks pass. Now update the changelog:
Run the test one more time to confirm everything is solid:
>cd /Users/badlogic/workspaces/pi-mono/packages/coding-agent && npx tsx ../../node_modules/vitest/dist/cli.js --run test/suite/regressions/2791-fswatch-error-crash.test.ts 2>&1All done. Here's a summary of the changes:
Files modified:
packages/coding-agent/src/modes/interactive/theme/theme.ts — Added .on("error", ...) handler to the FSWatcher in startThemeWatcher(). On error, the watcher is closed and set to undefined. Theme hot-reload stops, but pi stays alive.
packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts — New regression test. Spawns a child process that sets up the theme watcher, finds the FSWatcher handle, and emits a synthetic error event. Verifies the process survives instead of crashing.
packages/coding-agent/CHANGELOG.md — Added entry under [Unreleased] > Fixed.
Wrap it.
Additional instructions:
Determine context from the conversation history first.
Rules for context detection:
/is or /pr, assume the issue or PR context is already known from the conversation and from the analysis work already done.Unless I explicitly override something in this request, do the following in order:
## [Unreleased] using the repo changelog rules.closes #<issue> in the commit message. If it is tied to multiple issues, stop and ask which one to use. If it is not tied to any issue, do not include closes # or fixes # in the commit message.main, stop and ask what to do. Do not push from another branch unless I explicitly say so.Constraints:
git add . or git add -A.Let me review what happened in this session:
inprogress labelThe changelog is already updated. I need to:
closes #2791 in the commitLet me check the branch first, then draft the comment.
>cd /Users/badlogic/workspaces/pi-mono && git branch --show-current>cd /Users/badlogic/workspaces/pi-mono && git status --shortOn main. My files are: theme.ts, CHANGELOG.md, and the new test file. The other changes are from other work.
Now draft the issue comment:
>cat > /tmp/2791-comment.md << 'EOF'
Fixed. Added `.on("error", ...)` to the `FSWatcher` in `startThemeWatcher()`. On error, the watcher closes itself and sets the handle to `undefined`. Theme hot-reload stops but pi stays alive.
Regression test in `test/suite/regressions/2791-fswatch-error-crash.test.ts` spawns a child process, finds the `FSWatcher` handle, and emits a synthetic error event to verify the process survives.
EOF
cat /tmp/2791-comment.mdLooks good. Posting and committing:
>cd /Users/badlogic/workspaces/pi-mono && gh issue comment 2791 --body-file /tmp/2791-comment.md>cd /Users/badlogic/workspaces/pi-mono && git add \
packages/coding-agent/src/modes/interactive/theme/theme.ts \
packages/coding-agent/CHANGELOG.md \
packages/coding-agent/test/suite/regressions/2791-fswatch-error-crash.test.ts>cd /Users/badlogic/workspaces/pi-mono && git diff --cached --statOnly my 3 files staged.
>cd /Users/badlogic/workspaces/pi-mono && git commit -m "fix(coding-agent): handle async fs.watch error events in theme watcher
closes #2791">cd /Users/badlogic/workspaces/pi-mono && git pushPushed. Issue #2791 will be auto-closed by the commit.
try { themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => { if (currentThemeName !== watchedThemeName) { return; } if (!filename) { scheduleReload(); return; } const changedFile = String(filename); if (changedFile !== watchedFileName) { return; } scheduleReload(); });} catch (_error) { // Ignore errors starting watcher}try { themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => { // ... callback ... }); // No .on('error', ...) handler} catch (_error) { // Only catches synchronous errors}try { themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => { // ... existing callback ... }); themeWatcher.on("error", () => { try { themeWatcher?.close(); } catch { /* ignore */ } themeWatcher = undefined; });} catch (_error) { // Ignore errors starting watcher}BUG: FSWatcher has no error handler (issue #2791)error event was unhandled and threwthemeWatcher.on("error", () => { try { themeWatcher?.close(); } catch { /* ignore */ } themeWatcher = undefined;});