Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .ai/contexts/ipc-bridge.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
8 changes: 8 additions & 0 deletions main.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@
}

// Shell profiles → shell-profiles.js
const { discoverShellProfiles, getShellProfiles, resolveShell, isWindows, isWslShell, windowsToWslPath, shellArgs, quoteArgvForShell } = require('./shell-profiles');

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'isWindows' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 73 in main.js

View workflow job for this annotation

GitHub Actions / lint

'discoverShellProfiles' is assigned a value but never used. Allowed unused vars must match /^_/u
const { startScheduler } = require('./schedule-runner');
const { encodeProjectPath } = require('./encode-project-path');
const { scanMdFiles, acceptMdFile } = require('./scan-md-files');
Expand Down Expand Up @@ -390,6 +390,7 @@
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) {
Expand Down Expand Up @@ -461,8 +462,8 @@
isInitialScanComplete, setInitialScanComplete,
},
});
const { readSessionFile, readFolderFromFilesystem, refreshFolder, reconcileCacheFromFilesystem,

Check warning on line 465 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readFolderFromFilesystem' is assigned a value but never used. Allowed unused vars must match /^_/u

Check warning on line 465 in main.js

View workflow job for this annotation

GitHub Actions / lint

'readSessionFile' is assigned a value but never used. Allowed unused vars must match /^_/u
buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker,

Check warning on line 466 in main.js

View workflow job for this annotation

GitHub Actions / lint

'sendStatus' is assigned a value but never used. Allowed unused vars must match /^_/u
scanFoldersViaWorker, setRemoteRoots, resolveFolderDir } = sessionCache;
const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file');

Expand Down Expand Up @@ -994,6 +995,13 @@
// ── 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' };
Expand Down Expand Up @@ -2392,7 +2400,7 @@
// WSL profiles only work for plain terminals — Claude CLI sessions need the
// Windows shell because session data lives on the Windows filesystem.
const requestedProfile = resolveShell(effectiveProfileId);
const useWslProfile = isWslShell(requestedProfile.path) && isPlainTerminal;

Check warning on line 2403 in main.js

View workflow job for this annotation

GitHub Actions / lint

'useWslProfile' is assigned a value but never used. Allowed unused vars must match /^_/u
const shellProfile = (isWslShell(requestedProfile.path) && !isPlainTerminal)
? resolveShell('auto')
: requestedProfile;
Expand Down
71 changes: 71 additions & 0 deletions test/window-close-releases-watchers.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading