Skip to content

Commit 152b244

Browse files
mussonkingclaude
andauthored
fix(desktop): harden workspace watcher fallback (#352)
## Summary Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
1 parent bf86fea commit 152b244

2 files changed

Lines changed: 33 additions & 17 deletions

File tree

apps/desktop/src/main/workspace-watcher.test.ts

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -124,10 +124,10 @@ describe('files-watcher subscribe / unsubscribe', () => {
124124
expect(watchMock).not.toHaveBeenCalled();
125125
});
126126

127-
it('rejects when the watcher cannot start', () => {
127+
it('rejects when the watcher throws a non-system error', () => {
128128
reset();
129129
watchMock.mockImplementation(() => {
130-
throw new Error('watch denied');
130+
throw new Error('watcher implementation bug');
131131
});
132132
getDesignMock.mockReturnValue({
133133
id: 'd1',
@@ -136,21 +136,24 @@ describe('files-watcher subscribe / unsubscribe', () => {
136136
registerFilesWatcherIpc({} as never, () => null);
137137
const sub = getHandler('codesign:files:v1:subscribe');
138138

139-
const err = captureError(() => sub(null, { schemaVersion: 1, designId: 'd1' }));
139+
const caught = captureError(() => sub(null, { schemaVersion: 1, designId: 'd1' }));
140140

141-
expect(err).toMatchObject({ name: 'CodesignError', code: 'IPC_DB_ERROR' });
141+
expect(caught).toMatchObject({ name: 'CodesignError', code: 'IPC_DB_ERROR' });
142142
expect(watchMock).toHaveBeenCalledTimes(1);
143143
});
144144

145-
it('does not reject when the bound workspace folder is missing', () => {
145+
it.each([
146+
'ENOENT',
147+
'ENOTDIR',
148+
])('does not reject when the bound workspace is unavailable with %s', (code) => {
146149
reset();
147-
const err = Object.assign(new Error('no such file or directory'), { code: 'ENOENT' });
150+
const err = Object.assign(new Error('workspace unavailable'), { code });
148151
watchMock.mockImplementation(() => {
149152
throw err;
150153
});
151154
getDesignMock.mockReturnValue({
152155
id: 'd1',
153-
workspacePath: tempWorkspace('codesign-watch-missing'),
156+
workspacePath: tempWorkspace(`codesign-watch-${code.toLowerCase()}`),
154157
});
155158
registerFilesWatcherIpc({} as never, () => null);
156159
const sub = getHandler('codesign:files:v1:subscribe');
@@ -162,11 +165,15 @@ describe('files-watcher subscribe / unsubscribe', () => {
162165
});
163166

164167
it.each([
165-
['permission denial', 'EPERM'],
166-
['unsupported directory watch', 'EISDIR'],
167-
])('falls back to polling when native watch fails from %s', (_reason, code) => {
168+
'EPERM',
169+
'EACCES',
170+
'EISDIR',
171+
'EINVAL',
172+
'ENOSPC',
173+
'ERR_FEATURE_UNAVAILABLE_ON_PLATFORM',
174+
])('falls back to polling when native watch fails with %s', (code) => {
168175
reset();
169-
const err = Object.assign(new Error('watch unavailable'), { code });
176+
const err = Object.assign(new Error('native watch unavailable'), { code });
170177
watchMock.mockImplementation(() => {
171178
throw err;
172179
});

apps/desktop/src/main/workspace-watcher.ts

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { isIgnoredWorkspacePath } from './workspace-reader';
2525
* are coalesced into a single emit per 250ms so a `pnpm install` in the
2626
* workspace doesn't spam IPC.
2727
*
28-
* Uses `node:fs.watch({recursive: true})` works on macOS (FSEvents) and
28+
* Uses `node:fs.watch({recursive: true})` -- works on macOS (FSEvents) and
2929
* Linux (recent kernel). No chokidar dep; Windows recursive coverage is
3030
* weaker but we're macOS-first.
3131
*/
@@ -36,7 +36,7 @@ const log = getLogger('files-watcher');
3636
const COALESCE_MS = 250;
3737
/** Keep an idle watcher alive briefly so quick tab-switches don't churn. */
3838
const IDLE_TEARDOWN_MS = 5 * 60_000;
39-
/** Permission-constrained Windows folders can reject recursive fs.watch. */
39+
/** Some workspace roots reject recursive fs.watch, especially Windows UNC paths. */
4040
const POLL_INTERVAL_MS = 2_000;
4141

4242
interface ActiveWatcher {
@@ -65,16 +65,25 @@ function toForwardSlashes(path: string): string {
6565
return sep === '/' ? path : path.split(sep).join('/');
6666
}
6767

68-
function shouldFallbackToPolling(err: unknown): boolean {
68+
function watchErrorCode(err: unknown): string | null {
6969
const code = (err as NodeJS.ErrnoException).code;
70-
return code === 'EPERM' || code === 'EACCES' || code === 'EISDIR';
70+
return typeof code === 'string' && code.length > 0 ? code : null;
7171
}
7272

73-
function isWorkspaceUnavailableWatchError(err: unknown): boolean {
74-
const code = (err as NodeJS.ErrnoException).code;
73+
function shouldFallbackToPolling(err: unknown): boolean {
74+
const code = watchErrorCode(err);
75+
return code !== null && !isWorkspaceUnavailableWatchErrorCode(code);
76+
}
77+
78+
function isWorkspaceUnavailableWatchErrorCode(code: string): boolean {
7579
return code === 'ENOENT' || code === 'ENOTDIR';
7680
}
7781

82+
function isWorkspaceUnavailableWatchError(err: unknown): boolean {
83+
const code = watchErrorCode(err);
84+
return code !== null && isWorkspaceUnavailableWatchErrorCode(code);
85+
}
86+
7887
async function pollWorkspaceSignature(root: string): Promise<string> {
7988
const rows: string[] = [];
8089

0 commit comments

Comments
 (0)