From 5e6fd2e73e8cb2183a53384b4e840eda4da3143e Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 31 May 2026 07:35:54 -0700 Subject: [PATCH] perf: bound WSL watcher polling (#4191) --- src/main/ipc/filesystem-watcher-wsl.test.ts | 34 +++++++++ src/main/ipc/filesystem-watcher-wsl.ts | 78 ++++++++++++++++----- 2 files changed, 95 insertions(+), 17 deletions(-) diff --git a/src/main/ipc/filesystem-watcher-wsl.test.ts b/src/main/ipc/filesystem-watcher-wsl.test.ts index c6536338bf6..8b310436608 100644 --- a/src/main/ipc/filesystem-watcher-wsl.test.ts +++ b/src/main/ipc/filesystem-watcher-wsl.test.ts @@ -143,6 +143,40 @@ describe('createWslWatcher', () => { await root.subscription.unsubscribe() }) + it('limits concurrent child directory reads during WSL snapshots', async () => { + const scheduleBatchFlush = vi.fn() + const childDirs = Array.from({ length: 40 }, (_, index) => dirent(`dir-${index}`, 'dir')) + let activeChildReads = 0 + let maxActiveChildReads = 0 + + readdirMock.mockImplementation((dirPath: string) => { + if (dirPath === rootPath) { + return Promise.resolve(childDirs) + } + activeChildReads += 1 + maxActiveChildReads = Math.max(maxActiveChildReads, activeChildReads) + return new Promise[]>((resolve) => { + setTimeout(() => { + activeChildReads -= 1 + resolve([]) + }, 1) + }) + }) + + const rootPromise = createWslWatcher(rootKey, rootPath, deps(scheduleBatchFlush)) + await Promise.resolve() + await Promise.resolve() + + expect(maxActiveChildReads).toBeLessThanOrEqual(8) + for (let i = 0; i < childDirs.length; i += 1) { + await vi.advanceTimersByTimeAsync(1) + } + + const root = await rootPromise + expect(maxActiveChildReads).toBeLessThanOrEqual(8) + await root.subscription.unsubscribe() + }) + it('marks a large WSL poll event batch for overflow without retaining every event', async () => { const scheduleBatchFlush = vi.fn() const initialEntries = Array.from({ length: 200_000 }, (_, index) => dirent(`file-${index}.ts`)) diff --git a/src/main/ipc/filesystem-watcher-wsl.ts b/src/main/ipc/filesystem-watcher-wsl.ts index 0955207d090..e7d045445d8 100644 --- a/src/main/ipc/filesystem-watcher-wsl.ts +++ b/src/main/ipc/filesystem-watcher-wsl.ts @@ -14,7 +14,7 @@ import { readdir } from 'fs/promises' import * as path from 'path' import type { WebContents } from 'electron' import type { Event as WatcherEvent } from '@parcel/watcher' -import { queueWatcherEvents } from './filesystem-watcher-event-batch' +import { MAX_BATCHED_WATCHER_EVENTS, queueWatcherEvents } from './filesystem-watcher-event-batch' export type WatcherSubscription = { unsubscribe(): Promise @@ -40,6 +40,8 @@ export type WslWatcherDeps = { } const POLL_INTERVAL_MS = 2000 +const SNAPSHOT_CHILD_READ_CONCURRENCY = 8 +const DIFF_EVENT_OVERFLOW_LIMIT = MAX_BATCHED_WATCHER_EVENTS + 1 type DirSnapshot = Map> @@ -56,6 +58,23 @@ function shouldIgnore(name: string, ignoreDirs: string[]): boolean { return ignoreDirs.includes(name) } +async function forEachWithConcurrency( + items: readonly T[], + limit: number, + worker: (item: T) => Promise +): Promise { + let nextIndex = 0 + const workerCount = Math.min(limit, items.length) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const item = items[nextIndex++] + await worker(item) + } + }) + ) +} + /** * Take a snapshot of the root directory and one level of subdirectories. * Returns a map of dirPath → set of entry names. @@ -70,22 +89,24 @@ async function takeSnapshot(rootPath: string, ignoreDirs: string[]): Promise entry.isDirectory() || entry.isSymbolicLink()) - .map(async (entry) => { - const childPath = path.join(rootPath, entry.name) - const childEntries = await readDirEntriesSafe(childPath) - const childFiltered = childEntries - .filter((childEntry) => !shouldIgnore(childEntry.name, ignoreDirs)) - .map((childEntry) => childEntry.name) - snapshot.set(childPath, new Set(childFiltered)) - }) - ) + const childDirs = filtered.filter((entry) => entry.isDirectory() || entry.isSymbolicLink()) + await forEachWithConcurrency(childDirs, SNAPSHOT_CHILD_READ_CONCURRENCY, async (entry) => { + const childPath = path.join(rootPath, entry.name) + const childEntries = await readDirEntriesSafe(childPath) + const childFiltered = childEntries + .filter((childEntry) => !shouldIgnore(childEntry.name, ignoreDirs)) + .map((childEntry) => childEntry.name) + snapshot.set(childPath, new Set(childFiltered)) + }) return snapshot } +function appendDiffEvent(events: WatcherEvent[], event: WatcherEvent): boolean { + events.push(event) + return events.length >= DIFF_EVENT_OVERFLOW_LIMIT +} + /** * Diff two snapshots and return synthetic watcher events. */ @@ -97,7 +118,14 @@ function diffSnapshots(prev: DirSnapshot, next: DirSnapshot): WatcherEvent[] { if (!prevEntries) { // New directory appeared — emit create for all entries for (const name of nextEntries) { - events.push({ type: 'create', path: path.join(dirPath, name) } as WatcherEvent) + if ( + appendDiffEvent(events, { + type: 'create', + path: path.join(dirPath, name) + } as WatcherEvent) + ) { + return events + } } continue } @@ -105,14 +133,28 @@ function diffSnapshots(prev: DirSnapshot, next: DirSnapshot): WatcherEvent[] { // Check for new entries (create) for (const name of nextEntries) { if (!prevEntries.has(name)) { - events.push({ type: 'create', path: path.join(dirPath, name) } as WatcherEvent) + if ( + appendDiffEvent(events, { + type: 'create', + path: path.join(dirPath, name) + } as WatcherEvent) + ) { + return events + } } } // Check for removed entries (delete) for (const name of prevEntries) { if (!nextEntries.has(name)) { - events.push({ type: 'delete', path: path.join(dirPath, name) } as WatcherEvent) + if ( + appendDiffEvent(events, { + type: 'delete', + path: path.join(dirPath, name) + } as WatcherEvent) + ) { + return events + } } } } @@ -120,7 +162,9 @@ function diffSnapshots(prev: DirSnapshot, next: DirSnapshot): WatcherEvent[] { // Check for directories that disappeared entirely for (const [dirPath] of prev) { if (!next.has(dirPath)) { - events.push({ type: 'delete', path: dirPath } as WatcherEvent) + if (appendDiffEvent(events, { type: 'delete', path: dirPath } as WatcherEvent)) { + return events + } } }