perf: bound WSL watcher polling (#4191)

This commit is contained in:
Neil
2026-05-31 07:35:54 -07:00
committed by GitHub
parent 9972cdd9bb
commit 5e6fd2e73e
2 changed files with 95 additions and 17 deletions
@@ -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<ReturnType<typeof dirent>[]>((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`))
+61 -17
View File
@@ -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<void>
@@ -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<string, Set<string>>
@@ -56,6 +58,23 @@ function shouldIgnore(name: string, ignoreDirs: string[]): boolean {
return ignoreDirs.includes(name)
}
async function forEachWithConcurrency<T>(
items: readonly T[],
limit: number,
worker: (item: T) => Promise<void>
): Promise<void> {
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<Dir
// Why: poll one level of subdirectories so changes inside immediate
// children are detected, but use Dirent metadata to avoid probing every
// root-level file with a failing readdir on each WSL poll.
await Promise.all(
filtered
.filter((entry) => 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
}
}
}