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
5 changes: 5 additions & 0 deletions .changeset/windows-watch-eperm-polling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@open-codesign/desktop': patch
---

Fall back to polling workspace files when native recursive watching is blocked by permission errors.
91 changes: 91 additions & 0 deletions apps/desktop/src/main/workspace-watcher.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { describe, expect, it, vi } from 'vitest';
Expand Down Expand Up @@ -141,6 +142,96 @@ describe('files-watcher subscribe / unsubscribe', () => {
expect(watchMock).toHaveBeenCalledTimes(1);
});

it('falls back to polling when native watch is denied by permissions', () => {
reset();
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
watchMock.mockImplementation(() => {
throw err;
});
getDesignMock.mockReturnValue({
id: 'd1',
workspacePath: tempWorkspace('codesign-watch-eperm'),
});
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');

expect(sub(null, { schemaVersion: 1, designId: 'd1' })).toEqual({ ok: true });

const entry = __test.watchers.get('d1');
expect(watchMock).toHaveBeenCalledTimes(1);
expect(entry?.watcher).toBeNull();
expect(entry?.pollTimer).not.toBeNull();
});

it('switches an active watcher to polling on runtime permission errors', () => {
reset();
const closeSpy = vi.fn();
let errorHandler: ((err: Error) => void) | undefined;
watchMock.mockImplementation(
() =>
({
on: vi.fn((event: string, cb: (err: Error) => void) => {
if (event === 'error') errorHandler = cb;
}),
close: closeSpy,
}) as never,
);
getDesignMock.mockReturnValue({
id: 'd1',
workspacePath: tempWorkspace('codesign-watch-runtime-eperm'),
});
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');

expect(sub(null, { schemaVersion: 1, designId: 'd1' })).toEqual({ ok: true });
errorHandler?.(Object.assign(new Error('operation not permitted'), { code: 'EPERM' }));

const entry = __test.watchers.get('d1');
expect(closeSpy).toHaveBeenCalledTimes(1);
expect(entry?.watcher).toBeNull();
expect(entry?.pollTimer).not.toBeNull();
});

it('polling signatures change when workspace file metadata changes', async () => {
reset();
const root = await mkdtemp(path.join(tmpdir(), 'codesign-watch-poll-'));
await writeFile(path.join(root, 'index.html'), '<main>one</main>');
const first = await __test.pollWorkspaceSignature(root);

await writeFile(path.join(root, 'index.html'), '<main>two plus more bytes</main>');
const second = await __test.pollWorkspaceSignature(root);

expect(second).not.toBe(first);
});

it('tears down polling watchers after the idle unsubscribe window', () => {
reset();
vi.useFakeTimers();
try {
const err = Object.assign(new Error('operation not permitted'), { code: 'EPERM' });
watchMock.mockImplementation(() => {
throw err;
});
getDesignMock.mockReturnValue({
id: 'd1',
workspacePath: tempWorkspace('codesign-watch-poll-teardown'),
});
registerFilesWatcherIpc({} as never, () => null);
const sub = getHandler('codesign:files:v1:subscribe');
const unsub = getHandler('codesign:files:v1:unsubscribe');

expect(sub(null, { schemaVersion: 1, designId: 'd1' })).toEqual({ ok: true });
expect(__test.watchers.get('d1')?.pollTimer).not.toBeNull();

expect(unsub(null, { schemaVersion: 1, designId: 'd1' })).toEqual({ ok: true });
vi.advanceTimersByTime(__test.IDLE_TEARDOWN_MS + 10);

expect(__test.watchers.has('d1')).toBe(false);
} finally {
vi.useRealTimers();
}
});

it('ref-counts subscribers and tears down only after idle window', async () => {
reset();
vi.useFakeTimers();
Expand Down
131 changes: 120 additions & 11 deletions apps/desktop/src/main/workspace-watcher.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { type FSWatcher, watch as nodeWatch } from 'node:fs';
import { type Dirent, type FSWatcher, watch as nodeWatch } from 'node:fs';
import { readdir, stat } from 'node:fs/promises';
import { join, relative, sep } from 'node:path';
import { CodesignError } from '@open-codesign/shared';
import type { BrowserWindow } from 'electron';
import { ipcMain } from './electron-runtime';
Expand Down Expand Up @@ -34,13 +36,18 @@ const log = getLogger('files-watcher');
const COALESCE_MS = 250;
/** Keep an idle watcher alive briefly so quick tab-switches don't churn. */
const IDLE_TEARDOWN_MS = 5 * 60_000;
/** Permission-constrained Windows folders can reject recursive fs.watch. */
const POLL_INTERVAL_MS = 2_000;

interface ActiveWatcher {
watcher: FSWatcher;
watcher: FSWatcher | null;
workspacePath: string;
refCount: number;
pendingEmit: ReturnType<typeof setTimeout> | null;
idleTimer: ReturnType<typeof setTimeout> | null;
pollTimer: ReturnType<typeof setInterval> | null;
pollSnapshot: string | null;
pollBusy: boolean;
}

const watchers = new Map<string, ActiveWatcher>();
Expand All @@ -54,6 +61,49 @@ function isIgnored(rel: string): boolean {
return false;
}

function toForwardSlashes(path: string): string {
return sep === '/' ? path : path.split(sep).join('/');
}

function isPermissionWatchError(err: unknown): boolean {
const code = (err as NodeJS.ErrnoException).code;
return code === 'EPERM' || code === 'EACCES';
}

async function pollWorkspaceSignature(root: string): Promise<string> {
const rows: string[] = [];

async function walk(dir: string): Promise<void> {
let entries: Dirent[] = [];
try {
entries = await readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const abs = join(dir, entry.name);
const rel = toForwardSlashes(relative(root, abs));
if (entry.isDirectory()) {
if (isIgnored(rel)) continue;
await walk(abs);
continue;
}
if (!entry.isFile() || isIgnored(rel)) continue;
try {
const s = await stat(abs);
rows.push(`${rel}\0${s.size}\0${s.mtimeMs}`);
} catch {
// Locked files are common during editor saves. Skip this polling pass;
// the next interval will pick up the settled metadata.
}
}
}

await walk(root);
rows.sort();
return rows.join('\n');
}

function scheduleEmit(designId: string, getWin: () => BrowserWindow | null): void {
const entry = watchers.get(designId);
if (!entry) return;
Expand All @@ -66,11 +116,56 @@ function scheduleEmit(designId: string, getWin: () => BrowserWindow | null): voi
}, COALESCE_MS);
}

function startPolling(
designId: string,
entry: ActiveWatcher,
getWin: () => BrowserWindow | null,
): void {
if (entry.pollTimer) return;
const poll = async (): Promise<void> => {
if (entry.pollBusy) return;
entry.pollBusy = true;
try {
const nextSnapshot = await pollWorkspaceSignature(entry.workspacePath);
if (entry.pollSnapshot === null) {
entry.pollSnapshot = nextSnapshot;
} else if (entry.pollSnapshot !== nextSnapshot) {
entry.pollSnapshot = nextSnapshot;
scheduleEmit(designId, getWin);
}
} catch (err) {
log.warn('files.watch.poll.fail', {
designId,
workspacePath: entry.workspacePath,
error: err instanceof Error ? err.message : String(err),
});
} finally {
entry.pollBusy = false;
}
};

void poll();
entry.pollTimer = setInterval(() => {
void poll();
}, POLL_INTERVAL_MS);
log.info('files.watch.polling.start', { designId, workspacePath: entry.workspacePath });
}

function startWatcher(
designId: string,
workspacePath: string,
getWin: () => BrowserWindow | null,
): ActiveWatcher | null {
const entry: ActiveWatcher = {
watcher: null,
workspacePath,
refCount: 0,
pendingEmit: null,
idleTimer: null,
pollTimer: null,
pollSnapshot: null,
pollBusy: false,
};
let watcher: FSWatcher;
try {
watcher = nodeWatch(workspacePath, { recursive: true }, (_eventType, filename) => {
Expand All @@ -83,16 +178,27 @@ function startWatcher(
workspacePath,
error: err instanceof Error ? err.message : String(err),
});
if (isPermissionWatchError(err)) {
watchers.set(designId, entry);
startPolling(designId, entry, getWin);
return entry;
}
return null;
}
watcher.on('error', (err) => log.warn('files.watch.error', { designId, error: String(err) }));
const entry: ActiveWatcher = {
watcher,
workspacePath,
refCount: 0,
pendingEmit: null,
idleTimer: null,
};
entry.watcher = watcher;
watcher.on('error', (err) => {
log.warn('files.watch.error', { designId, error: String(err) });
if (!isPermissionWatchError(err)) return;
const active = watchers.get(designId);
if (!active || active.watcher !== watcher) return;
try {
watcher.close();
} catch (closeErr) {
log.warn('files.watch.stop.fail', { designId, error: String(closeErr) });
}
active.watcher = null;
startPolling(designId, active, getWin);
});
watchers.set(designId, entry);
return entry;
}
Expand All @@ -102,9 +208,10 @@ function stopWatcher(designId: string): void {
if (!entry) return;
if (entry.pendingEmit) clearTimeout(entry.pendingEmit);
if (entry.idleTimer) clearTimeout(entry.idleTimer);
if (entry.pollTimer) clearInterval(entry.pollTimer);
watchers.delete(designId);
try {
entry.watcher.close();
entry.watcher?.close();
} catch (err) {
log.warn('files.watch.stop.fail', { designId, error: String(err) });
}
Expand Down Expand Up @@ -190,5 +297,7 @@ export const __test = {
watchers,
COALESCE_MS,
IDLE_TEARDOWN_MS,
POLL_INTERVAL_MS,
pollWorkspaceSignature,
stopWatcher,
};
Loading