diff --git a/src/main/ipc/filesystem-watcher-ignore-real.test.ts b/src/main/ipc/filesystem-watcher-ignore-real.test.ts new file mode 100644 index 00000000000..8c510d39c89 --- /dev/null +++ b/src/main/ipc/filesystem-watcher-ignore-real.test.ts @@ -0,0 +1,74 @@ +import { mkdir, mkdtemp, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { subscribe, type AsyncSubscription, type Event } from '@parcel/watcher' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { WATCHER_IGNORE_DIRS, buildParcelWatcherIgnoreOptions } from './filesystem-watcher-ignore' + +describe('filesystem watcher native ignores', () => { + let root: string | null = null + let subscription: AsyncSubscription | null = null + + afterEach(async () => { + await subscription?.unsubscribe() + subscription = null + if (root) { + await rm(root, { recursive: true, force: true }) + root = null + } + }) + + it('drops root and nested generated-file storms while delivering source edits', async () => { + root = await realpath(await mkdtemp(join(tmpdir(), 'orca-watch-ignore-'))) + const rootModules = join(root, 'node_modules') + const nestedModules = join(root, 'packages', 'app', 'node_modules') + const nestedSource = join(root, 'packages', 'app', 'src') + await Promise.all( + [rootModules, nestedModules, nestedSource].map((directory) => + mkdir(directory, { recursive: true }) + ) + ) + + const events: Event[] = [] + const errors: Error[] = [] + subscription = await subscribe( + root, + (error, batch) => { + if (error) { + errors.push(error) + } + events.push(...batch) + }, + buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) + ) + + const generatedNames = Array.from({ length: 20 }, (_, index) => `generated-${index}.js`) + // Windows forbids control characters in filenames. + if (process.platform !== 'win32') { + generatedNames.push('generated\nnewline.js') + } + const generatedFiles = [rootModules, nestedModules].flatMap((directory) => + generatedNames.map((name) => join(directory, name)) + ) + await Promise.all(generatedFiles.map((file) => writeFile(file, 'generated'))) + const sourceFiles = [join(root, 'source.ts'), join(nestedSource, 'source.ts')] + if (process.platform !== 'win32') { + sourceFiles.push(join(root, 'source\nnewline.ts'), join(nestedSource, 'source\nnewline.ts')) + } + await Promise.all(sourceFiles.map((file) => writeFile(file, 'source'))) + + await vi.waitFor( + () => { + for (const sourceFile of sourceFiles) { + expect(events.some((event) => event.path === sourceFile)).toBe(true) + } + }, + { timeout: 8_000 } + ) + // Why: ignored callbacks must stay absent after the native debounce has drained. + await new Promise((resolve) => setTimeout(resolve, 300)) + expect(errors).toEqual([]) + const generatedPaths = new Set(generatedFiles) + expect(events.filter((event) => generatedPaths.has(event.path))).toEqual([]) + }) +}) diff --git a/src/main/ipc/filesystem-watcher-ignore.test.ts b/src/main/ipc/filesystem-watcher-ignore.test.ts index 28f90a53a04..755ef9489ab 100644 --- a/src/main/ipc/filesystem-watcher-ignore.test.ts +++ b/src/main/ipc/filesystem-watcher-ignore.test.ts @@ -27,12 +27,50 @@ describe('buildParcelWatcherIgnoreOptions', () => { expect(plainPaths.length).toBeLessThanOrEqual(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) expect(option).toEqual(WATCHER_IGNORE_DIRS.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) const fallbackRegex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)') - for (const dir of WATCHER_IGNORE_DIRS.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT)) { + for (const dir of WATCHER_IGNORE_DIRS) { + expect(fallbackRegex.test(dir)).toBe(true) + expect(fallbackRegex.test(`${dir}/file.ts`)).toBe(true) + expect(fallbackRegex.test(`packages/app/${dir}`)).toBe(true) expect(fallbackRegex.test(`packages/app/${dir}/file.ts`)).toBe(true) } + expect(fallbackRegex.test('packages/app/node_modules-cache/file.ts')).toBe(false) + expect(fallbackRegex.test('packages/app/.github/workflows/check.yml')).toBe(false) + expect(fallbackRegex.test('project\\node_modules/file.ts')).toBe(false) expect(options.ignoreGlobs?.[0]).not.toContain('?!') }) + it('filters nested macOS directories when all names fit in daemon exclusions', () => { + setPlatform('darwin') + const options = buildParcelWatcherIgnoreOptions(['node_modules', '.cache']) + expect(options.ignore).toEqual(['node_modules', '.cache']) + const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)') + expect(regex.test('packages/app/node_modules/file.js')).toBe(true) + expect(regex.test('packages/app/.cache/file.js')).toBe(true) + expect(regex.test('packages/app/xcache/file.js')).toBe(false) + }) + + it('keeps an empty ignore list empty', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + setPlatform(platform) + expect(buildParcelWatcherIgnoreOptions([])).toEqual({}) + } + }) + + it('matches newline-containing paths only under ignored names', () => { + for (const platform of ['darwin', 'linux', 'win32'] as const) { + setPlatform(platform) + const options = buildParcelWatcherIgnoreOptions(WATCHER_IGNORE_DIRS) + const regex = new RegExp(options.ignoreGlobs?.[0] ?? '(?!)') + expect(regex.test('packages/app/node_modules/generated\nnewline.js')).toBe(true) + expect(regex.test('packages/app/src/source\nnewline.ts')).toBe(false) + expect(regex.test('packages/app/node_modules-cache/generated\nnewline.js')).toBe(false) + expect(regex.test('packages\\app\\node_modules\\generated\nnewline.js')).toBe( + platform === 'win32' + ) + expect(regex.test('packages\\app\\src\\source\nnewline.ts')).toBe(false) + } + }) + it('uses one lookahead-free native regex for nested ignores on Linux/Windows', () => { for (const platform of ['linux', 'win32'] as const) { setPlatform(platform) diff --git a/src/main/ipc/filesystem-watcher-ignore.ts b/src/main/ipc/filesystem-watcher-ignore.ts index 7c483814c30..2f45b7ced50 100644 --- a/src/main/ipc/filesystem-watcher-ignore.ts +++ b/src/main/ipc/filesystem-watcher-ignore.ts @@ -18,9 +18,7 @@ export const WATCHER_IGNORE_DIRS: string[] = [ // closed — one entry over the cap and @parcel/watcher silently loses ALL // daemon-side exclusions, so fseventsd delivers every node_modules/.git event // to this process (measured ~29x client CPU plus daemon-side delivery load). -// Keep the 8 highest-churn dirs as plain paths (daemon-excluded) and demote -// the rest to globs (userspace-filtered). Ordering of WATCHER_IGNORE_DIRS is -// therefore meaningful: the first 8 get true daemon-side exclusion on macOS. +// The first 8 names get root-level daemon exclusions; the regex covers all names at every depth. export const MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT = 8 export type ParcelWatcherIgnoreOptions = { @@ -37,10 +35,10 @@ function escapeRegex(value: string): string { function buildNestedDirectoryRegex(ignoreDirs: readonly string[]): string { const alternatives = ignoreDirs.map(escapeRegex).join('|') if (process.platform === 'win32') { - return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/].*)?$` + return `^(?:[^\\\\/]+[\\\\/])*(?:${alternatives})(?:[\\\\/][\\s\\S]*)?$` } // Why: backslash is a legal POSIX filename character, not a path separator. - return `^(?:[^/]+/)*(?:${alternatives})(?:/.*)?$` + return `^(?:[^/]+/)*(?:${alternatives})(?:/[\\s\\S]*)?$` } export function buildParcelWatcherIgnoreOptions( @@ -56,9 +54,9 @@ export function buildParcelWatcherIgnoreOptions( return { ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] } } const daemonExcludedDirs = ignoreDirs.slice(0, MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) - const remainingDirs = ignoreDirs.slice(MACOS_FSEVENTS_EXCLUSION_PATH_LIMIT) return { ignore: [...daemonExcludedDirs], - ...(remainingDirs.length > 0 ? { ignoreGlobs: [buildNestedDirectoryRegex(remainingDirs)] } : {}) + // Why: plain exclusions resolve under the root, leaving nested generated directories unfiltered. + ignoreGlobs: [buildNestedDirectoryRegex(ignoreDirs)] } }