From 9281fcab1e2a9eff6f24f254a4b8fecfd7b4d9e8 Mon Sep 17 00:00:00 2001 From: Jean-Baptiste Renard Date: Fri, 18 Sep 2026 17:36:51 +0200 Subject: [PATCH] fix(main): release the viewer-panel file watchers when the window closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The window-`closed` handler drains the PTYs, the Changes watches and the subagent watches, but `fileWatchers` was released only by an explicit `unwatch-file`, which a closing window never sends. Every file a ViewerPanel had open kept its fs.watch descriptor for the life of the process. Drain the registry alongside the other two, swallowing a close() that throws so a handle whose file is already gone cannot strand the rest of the teardown. Refs #301 — whether one registry could serve all three callers is left open. --- .ai/contexts/ipc-bridge.md | 2 +- main.js | 8 +++ test/window-close-releases-watchers.test.js | 71 +++++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) create mode 100644 test/window-close-releases-watchers.test.js diff --git a/.ai/contexts/ipc-bridge.md b/.ai/contexts/ipc-bridge.md index aa6e164d..cd7d6d39 100644 --- a/.ai/contexts/ipc-bridge.md +++ b/.ai/contexts/ipc-bridge.md @@ -78,7 +78,7 @@ This file is the **canonical inventory** of the IPC surface. When you add a new | IPC | Args | |---|---| | `read-file-for-panel` / `save-file-for-panel` | Arbitrary file IO inside the user's projects | -| `watch-file` / `unwatch-file` | fs.watch wrapper, emits `file-changed` event | +| `watch-file` / `unwatch-file` | fs.watch wrapper, emits `file-changed` event. The registry is keyed by resolved path and is released either one path at a time by `unwatch-file`, or wholesale by `closeAllFileWatchers()` in the window-`closed` handler, alongside `changesWatchers.closeAll()` and the subagent watches. A closing window sends no `unwatch-file`, so that teardown is what bounds these descriptors to the window's lifetime; it swallows a `close()` that throws, since a handle whose file is already gone must not strand the rest of the teardown. | ### Changes panel (issue #251) diff --git a/main.js b/main.js index 772da514..049b8fe1 100644 --- a/main.js +++ b/main.js @@ -390,6 +390,7 @@ function createWindow() { activeSessions.delete(id); } changesWatchers.closeAll(); + closeAllFileWatchers(); // Release all subagent file watchers (closes fs.watch handles + clears any // debounce timers / polling fallbacks via the stored teardown closure) for (const [, entry] of subagentWatchers) { @@ -994,6 +995,13 @@ ipcMain.handle('save-file-for-panel', async (_event, filePath, content) => { // ── File Watching (for viewer panels) ──────────────────────────────── const fileWatchers = new Map(); // filePath → FSWatcher +function closeAllFileWatchers() { + for (const watcher of fileWatchers.values()) { + try { watcher.close(); } catch {} + } + fileWatchers.clear(); +} + ipcMain.handle('watch-file', (_event, filePath) => { const resolved = path.resolve(filePath); if (isSensitivePath(resolved)) return { ok: false, error: 'access to sensitive path denied' }; diff --git a/test/window-close-releases-watchers.test.js b/test/window-close-releases-watchers.test.js new file mode 100644 index 00000000..e9494fc9 --- /dev/null +++ b/test/window-close-releases-watchers.test.js @@ -0,0 +1,71 @@ +'use strict'; + +// The window-`closed` handler is the only place the three watcher registries +// are released together. Source-text assertions are the house pattern for +// main.js handlers (see read-file-for-panel-bounds.test.js): they prove the +// teardown is written, not that Electron runs it. The helper itself is lifted +// out of the source and exercised, because a close() that throws inside the +// handler would strand everything after it. + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const path = require('path'); + +const MAIN = fs.readFileSync(path.join(__dirname, '..', 'main.js'), 'utf8'); + +function closedHandler() { + const start = MAIN.indexOf("mainWindow.on('closed'"); + assert.notEqual(start, -1, "the 'closed' handler must exist"); + const end = MAIN.indexOf('\n });', start); + assert.notEqual(end, -1, "end of the 'closed' handler not found"); + return MAIN.slice(start, end); +} + +function helperSource() { + const start = MAIN.indexOf('function closeAllFileWatchers()'); + assert.notEqual(start, -1, 'closeAllFileWatchers must be declared'); + const end = MAIN.indexOf('\n}', start); + assert.notEqual(end, -1, 'end of closeAllFileWatchers not found'); + return MAIN.slice(start, end + 2); +} + +test('the closed handler releases all three watcher registries', () => { + const body = closedHandler(); + assert.match(body, /changesWatchers\.closeAll\(\)/, 'the Changes panel watches'); + assert.match(body, /closeAllFileWatchers\(\)/, + 'the viewer-panel file watches — a closing window never sends unwatch-file'); + assert.match(body, /subagentWatchers\.clear\(\)/, 'the subagent transcript watches'); +}); + +test('closeAllFileWatchers is declared beside the registry it drains', () => { + const decl = MAIN.indexOf('const fileWatchers = new Map()'); + assert.ok(decl > 0, 'fileWatchers must be declared'); + const helper = MAIN.indexOf('function closeAllFileWatchers()'); + assert.ok(helper > decl, 'the helper belongs next to the map, not at a distance'); +}); + +test('closeAllFileWatchers closes every watcher and empties the registry', () => { + const closed = []; + const fileWatchers = new Map([ + ['/a.js', { close: () => closed.push('/a.js') }], + ['/b.js', { close: () => closed.push('/b.js') }], + ]); + new Function('fileWatchers', `${helperSource()}\nreturn closeAllFileWatchers();`)(fileWatchers); + + assert.deepEqual(closed, ['/a.js', '/b.js']); + assert.equal(fileWatchers.size, 0, 'nothing may survive the window'); +}); + +test('a watcher whose close() throws does not strand the rest of the teardown', () => { + const closed = []; + const fileWatchers = new Map([ + ['/gone.js', { close: () => { throw new Error('ENOENT'); } }], + ['/b.js', { close: () => closed.push('/b.js') }], + ]); + const run = new Function('fileWatchers', `${helperSource()}\nreturn closeAllFileWatchers();`); + + assert.doesNotThrow(() => run(fileWatchers)); + assert.deepEqual(closed, ['/b.js']); + assert.equal(fileWatchers.size, 0); +});