diff --git a/common/changes/@rushstack/rush-daemon/linux-tree-watcher_2026-09-24.json b/common/changes/@rushstack/rush-daemon/linux-tree-watcher_2026-09-24.json new file mode 100644 index 0000000000..c658db784c --- /dev/null +++ b/common/changes/@rushstack/rush-daemon/linux-tree-watcher_2026-09-24.json @@ -0,0 +1,10 @@ +{ + "changes": [ + { + "packageName": "@rushstack/rush-daemon", + "comment": "On Linux, observe watched projects with one non-recursive watch per directory created by an asynchronous walk instead of Node's recursive fs.watch emulation (one inotify watch per file, synchronous tree walk). Skip node_modules, .git, .rush/temp and declared output folders, follow directories created or removed later, tolerate directories that vanish during registration, and report inotify watch-limit exhaustion (ENOSPC) explicitly.", + "type": "patch" + } + ], + "packageName": "@rushstack/rush-daemon" +} diff --git a/libraries/rush-daemon/src/LinuxTreeWatcher.ts b/libraries/rush-daemon/src/LinuxTreeWatcher.ts new file mode 100644 index 0000000000..062a330daa --- /dev/null +++ b/libraries/rush-daemon/src/LinuxTreeWatcher.ts @@ -0,0 +1,269 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { EventEmitter } from 'node:events'; + +/** Watches one directory without recursion. */ +export type DirectoryWatchFunction = ( + folderPath: string, + listener: fs.WatchListener +) => fs.FSWatcher; + +/** Options for {@link LinuxTreeWatcher}. */ +export interface ILinuxTreeWatcherOptions { + /** Absolute directory paths that are never observed, such as declared build output folders. */ + readonly getExcludedFolderPathsAsync?: () => Promise>; + /** + * Reports the root once the initial walk finishes, covering changes made to directories before they + * were registered. Callers that await {@link LinuxTreeWatcher.initialWalk} instead can leave this off. + */ + readonly reportInitialWalkCompletion?: boolean; + /** Test hook; defaults to a non-recursive `fs.watch`. */ + readonly watchDirectory?: DirectoryWatchFunction; +} + +/** Directory names that are never observed at any depth. */ +export const PRUNED_DIRECTORY_NAMES: ReadonlySet = new Set(['node_modules', '.git']); + +const TRANSIENT_ERROR_CODES: ReadonlySet = new Set(['ENOENT', 'ENOTDIR', 'EACCES', 'EPERM']); +const EMPTY_SET: ReadonlySet = new Set(); + +const defaultWatchDirectory: DirectoryWatchFunction = (folderPath, listener) => + fs.watch(folderPath, { encoding: 'utf8', persistent: true }, listener); + +/** + * A recursive watcher for Linux built from one non-recursive inotify watch per directory. + * + * @remarks + * Node's Linux emulation of `fs.watch(..., { recursive: true })` walks the tree synchronously on the event + * loop and registers one inotify watch per file, including `node_modules` and build outputs. A directory-level + * inotify watch already reports changes to the files it contains, so this watcher registers directories only, + * walks asynchronously, prunes `node_modules`, `.git` and caller-provided folders, follows directories that are + * created or removed later, and tolerates directories that disappear while they are being registered. + * + * The root is registered synchronously so a missing root fails like `fs.watch`. Each directory watch is created + * before the directory is listed, so a child created during the walk is reported by its parent and then walked. + * The object is `fs.FSWatcher`-compatible: it emits `error` and `close` and supports `ref`/`unref`. + */ +export class LinuxTreeWatcher extends EventEmitter { + readonly #root: string; + readonly #listener: fs.WatchListener; + readonly #watchDirectory: DirectoryWatchFunction; + readonly #watchers: Map = new Map(); + readonly #excludedFolderPaths: Promise>; + #resolvedExcludedFolderPaths: ReadonlySet = EMPTY_SET; + #closed: boolean = false; + #failed: boolean = false; + #isUnref: boolean = false; + + /** Resolves when the initial asynchronous walk has finished (or stopped). */ + public readonly initialWalk: Promise; + + public constructor(root: string, listener: fs.WatchListener, options: ILinuxTreeWatcherOptions = {}) { + super(); + this.#root = path.resolve(root); + this.#listener = listener; + this.#watchDirectory = options.watchDirectory ?? defaultWatchDirectory; + this.#excludedFolderPaths = loadExcludedFolderPathsAsync(options.getExcludedFolderPathsAsync); + this.#addDirectory(this.#root, true); + this.initialWalk = this.#walkAsync(this.#root).then(() => { + if (options.reportInitialWalkCompletion) this.#reportCoverage(this.#root); + }); + } + + /** The directories that currently hold an inotify watch. */ + public get watchedFolderPaths(): ReadonlySet { + return new Set(this.#watchers.keys()); + } + + public close(): void { + if (this.#closed) return; + this.#closed = true; + for (const watcher of this.#watchers.values()) watcher.close(); + this.#watchers.clear(); + process.nextTick(() => this.emit('close')); + } + + public ref(): this { + this.#isUnref = false; + for (const watcher of this.#watchers.values()) watcher.ref(); + return this; + } + + public unref(): this { + this.#isUnref = true; + for (const watcher of this.#watchers.values()) watcher.unref(); + return this; + } + + #isPruned(folderPath: string): boolean { + return ( + PRUNED_DIRECTORY_NAMES.has(path.basename(folderPath)) || this.#resolvedExcludedFolderPaths.has(folderPath) + ); + } + + /** Returns false when the directory vanished before it could be watched. */ + #addDirectory(folderPath: string, isRoot: boolean = false): boolean { + if (this.#closed || this.#failed) return false; + if (this.#watchers.has(folderPath)) return true; + let watcher: fs.FSWatcher; + try { + watcher = this.#watchDirectory(folderPath, (eventType, filename) => + this.#onDirectoryEvent(folderPath, eventType, filename) + ); + } catch (error) { + if (isRoot) throw toWatchError(error, folderPath); + if (TRANSIENT_ERROR_CODES.has(getErrorCode(error))) return false; + this.#fail(error, folderPath); + return false; + } + watcher.on('error', (error: Error) => this.#onDirectoryError(folderPath, error)); + if (this.#isUnref) watcher.unref(); + this.#watchers.set(folderPath, watcher); + return true; + } + + #onDirectoryEvent(folderPath: string, eventType: fs.WatchEventType, filename: string | null): void { + if (this.#closed) return; + if (!filename) { + // An unknown entry must stay unknown so the consumer performs a full invalidation. + this.#listener(eventType, null); + return; + } + const changedPath: string = path.join(folderPath, filename); + if (this.#isPruned(changedPath)) return; + this.#listener(eventType, path.relative(this.#root, changedPath)); + if (eventType === 'rename') { + void this.#reconcileEntryAsync(changedPath); + } + } + + #onDirectoryError(folderPath: string, error: Error): void { + if (TRANSIENT_ERROR_CODES.has(getErrorCode(error)) && folderPath !== this.#root) { + // The directory was removed; its parent's `rename` event already reported the change. + this.#removeDirectory(folderPath); + return; + } + this.#fail(error, folderPath); + } + + async #reconcileEntryAsync(changedPath: string): Promise { + // Exclusions may still be loading when the first events arrive; never register an excluded folder. + this.#resolvedExcludedFolderPaths = await this.#excludedFolderPaths; + if (this.#closed || this.#failed || this.#isPruned(changedPath)) return; + let isDirectory: boolean; + try { + isDirectory = (await fs.promises.lstat(changedPath)).isDirectory(); + } catch { + isDirectory = false; + } + // A `rename` for a directory that still exists may be a delete-and-recreate or an atomic replacement. + // inotify stays attached to the old inode, so always drop existing watches and register the current one. + this.#removeDirectory(changedPath); + if (isDirectory && this.#addDirectory(changedPath)) { + await this.#walkAsync(changedPath); + // Files written into the new directory before its watch existed were not reported. + this.#reportCoverage(changedPath); + } + } + + #reportCoverage(folderPath: string): void { + if (!this.#closed && !this.#failed) this.#listener('rename', path.relative(this.#root, folderPath)); + } + + #removeDirectory(folderPath: string): void { + const prefix: string = folderPath + path.sep; + for (const [key, watcher] of this.#watchers) { + if (key === folderPath || key.startsWith(prefix)) { + watcher.close(); + this.#watchers.delete(key); + } + } + } + + async #walkAsync(folderPath: string): Promise { + this.#resolvedExcludedFolderPaths = await this.#excludedFolderPaths; + let directory: fs.Dir; + try { + directory = await fs.promises.opendir(folderPath); + } catch (error) { + this.#onWalkError(folderPath, error); + return; + } + const children: string[] = []; + try { + for await (const entry of directory) { + if (this.#closed || this.#failed) break; + const childPath: string = path.join(folderPath, entry.name); + if (entry.isDirectory() && !this.#isPruned(childPath) && this.#addDirectory(childPath)) { + children.push(childPath); + } + } + } catch (error) { + this.#onWalkError(folderPath, error); + } + for (const childPath of children) { + if (this.#closed || this.#failed) return; + await this.#walkAsync(childPath); + } + } + + #onWalkError(folderPath: string, error: unknown): void { + if (folderPath !== this.#root && TRANSIENT_ERROR_CODES.has(getErrorCode(error))) { + // Removed or replaced while walking; the parent's `rename` event already reported the change. + this.#removeDirectory(folderPath); + return; + } + // Any other failure (for example EMFILE or EIO) leaves part of the tree unobserved. + this.#fail(error, folderPath); + } + + #fail(error: unknown, folderPath: string): void { + if (this.#failed || this.#closed) return; + this.#failed = true; + this.emit('error', toWatchError(error, folderPath)); + } +} + +/** Creates a {@link LinuxTreeWatcher} typed as an `fs.FSWatcher`. */ +export function createLinuxTreeWatcher( + root: string, + listener: fs.WatchListener, + options?: ILinuxTreeWatcherOptions +): fs.FSWatcher { + return new LinuxTreeWatcher(root, listener, options) as unknown as fs.FSWatcher; +} + +async function loadExcludedFolderPathsAsync( + getExcludedFolderPathsAsync: (() => Promise>) | undefined +): Promise> { + if (!getExcludedFolderPathsAsync) return EMPTY_SET; + try { + const folders: ReadonlySet = await getExcludedFolderPathsAsync(); + return new Set(Array.from(folders, (folder: string) => path.resolve(folder))); + } catch { + // Pruning is an optimization; observing a folder that could have been skipped is always safe. + return EMPTY_SET; + } +} + +function getErrorCode(error: unknown): string | undefined { + return (error as NodeJS.ErrnoException | undefined)?.code; +} + +/** Makes inotify exhaustion (`ENOSPC`) explicit instead of surfacing a bare libuv error. */ +export function toWatchError(error: unknown, folderPath: string): Error { + const cause: Error = error instanceof Error ? error : new Error(String(error)); + if (getErrorCode(error) !== 'ENOSPC') return cause; + const limitError: NodeJS.ErrnoException = new Error( + `The Linux inotify watch limit was reached while watching "${folderPath}" ` + + `(fs.inotify.max_user_watches). Rush daemon change detection is incomplete. ` + + `Increase the limit (for example "sudo sysctl fs.inotify.max_user_watches=524288") ` + + `or unset RUSH_DAEMON_WATCH.`, + { cause } + ); + limitError.code = 'ENOSPC'; + return limitError; +} diff --git a/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts b/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts index 05cb910190..095021b7d2 100644 --- a/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts +++ b/libraries/rush-daemon/src/WorkspaceSessionFileWatcher.ts @@ -4,9 +4,15 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import type { RushConfiguration } from '@microsoft/rush-lib'; +import { + RushProjectConfiguration, + type RushConfiguration, + type RushConfigurationProject +} from '@microsoft/rush-lib'; +import { NoOpTerminalProvider, Terminal } from '@rushstack/terminal'; import type { IWorkspaceInvalidationWatcher } from './WorkspaceSession'; +import { createLinuxTreeWatcher, type LinuxTreeWatcher } from './LinuxTreeWatcher'; /** Options for the generation-owned workspace watcher. @beta */ export interface IWorkspaceSessionFileWatcherOptions { @@ -20,6 +26,7 @@ export interface IWorkspaceSessionFileWatcherOptions { interface IWatchPath { readonly folderPath: string; readonly recursive: boolean; + readonly project?: RushConfigurationProject; } /** Creates an individual filesystem watcher. @beta */ @@ -38,9 +45,10 @@ const PATH_SEGMENT_SEPARATOR_REGEXP: RegExp = /[\\/]/; */ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatcher { readonly #onError: ((error: Error) => void) | undefined; - readonly #watchFactory: WorkspaceWatchFactory; + readonly #watchFactory: WorkspaceWatchFactory | undefined; readonly #permanentPaths: ReadonlyArray; readonly #projectFolders: ReadonlyMap; + readonly #projects: ReadonlyMap; readonly #initialProjectNames: ReadonlyArray; readonly #watchers: Map = new Map(); readonly #closing: Map> = new Map(); @@ -49,8 +57,11 @@ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatche public constructor(options: IWorkspaceSessionFileWatcherOptions) { this.#onError = options.onError; - this.#watchFactory = options.watchFactory ?? fs.watch; + this.#watchFactory = options.watchFactory; this.#permanentPaths = getPermanentWatchPaths(options.rushConfiguration); + this.#projects = new Map( + options.rushConfiguration.projects.map((project) => [project.packageName, project]) + ); this.#projectFolders = new Map( options.rushConfiguration.projects.map((project) => [project.packageName, project.projectFolder]) ); @@ -77,6 +88,10 @@ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatche for (const watchPath of this.#permanentPaths) { this.#watchers.set(watchPath.folderPath, this.#createWatcher(watchPath)); } + // Permanent config folders are small; finish registering them before initialization is acknowledged. + await Promise.all( + Array.from(this.#watchers.values(), (watcher) => (watcher as Partial).initialWalk) + ); this.watchProjects(this.#initialProjectNames); } @@ -94,7 +109,10 @@ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatche throw new Error(`Project watcher is still closing: ${name}`); } if (!existing) { - this.#watchers.set(folderPath, this.#createWatcher({ folderPath, recursive: true })); + this.#watchers.set( + folderPath, + this.#createWatcher({ folderPath, recursive: true, project: this.#projects.get(name) }) + ); // Cover the observation gap without claiming an unknown graph/configuration mutation. this.#onInvalidation(folderPath); } @@ -167,19 +185,22 @@ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatche } #createWatcher(watchPath: IWatchPath): fs.FSWatcher { - const watcher: fs.FSWatcher = this.#watchFactory( - watchPath.folderPath, - { encoding: 'utf8', recursive: watchPath.recursive }, - (eventType: string, filename: string | null) => { - void eventType; - const changedFilename: string | undefined = filename ?? undefined; - if (!isIgnoredPath(changedFilename)) { - this.#onInvalidation?.( - changedFilename === undefined ? undefined : path.resolve(watchPath.folderPath, changedFilename) - ); - } + const listener: fs.WatchListener = (eventType: string, filename: string | null) => { + void eventType; + const changedFilename: string | undefined = filename ?? undefined; + if (!isIgnoredPath(changedFilename)) { + this.#onInvalidation?.( + changedFilename === undefined ? undefined : path.resolve(watchPath.folderPath, changedFilename) + ); } - ); + }; + const watchOptions: { encoding: 'utf8'; recursive: boolean } = { + encoding: 'utf8', + recursive: watchPath.recursive + }; + const watcher: fs.FSWatcher = this.#watchFactory + ? this.#watchFactory(watchPath.folderPath, watchOptions, listener) + : createDefaultWatcher(watchPath, watchOptions, listener); watcher.on('error', (error: Error) => { this.#onInvalidation?.(); if (this.#onError) this.#onError(error); @@ -195,6 +216,49 @@ export class WorkspaceSessionFileWatcher implements IWorkspaceInvalidationWatche } } +/** + * Node's recursive `fs.watch` on Linux walks the tree synchronously and adds one inotify watch per file, + * including build outputs. On Linux, recursive observation uses per-directory watches from an async walk instead. + */ +function createDefaultWatcher( + watchPath: IWatchPath, + watchOptions: { encoding: 'utf8'; recursive: boolean }, + listener: fs.WatchListener +): fs.FSWatcher { + if (process.platform !== 'linux' || !watchPath.recursive) { + return fs.watch(watchPath.folderPath, watchOptions, listener); + } + const project: RushConfigurationProject | undefined = watchPath.project; + return createLinuxTreeWatcher(watchPath.folderPath, listener, { + getExcludedFolderPathsAsync: project ? () => getProjectExcludedFolderPathsAsync(project) : undefined, + reportInitialWalkCompletion: project !== undefined + }); +} + +/** The project's `.rush/temp` folder plus every declared operation output folder. @internal */ +export async function getProjectExcludedFolderPathsAsync( + project: RushConfigurationProject +): Promise> { + const excluded: Set = new Set([path.resolve(project.projectRushTempFolder)]); + const terminal: Terminal = new Terminal(new NoOpTerminalProvider()); + let configuration: RushProjectConfiguration | undefined; + try { + configuration = await RushProjectConfiguration.tryLoadForProjectAsync(project, terminal); + } catch { + // Output-folder pruning is an optimization; an unreadable configuration only loses that part. + return excluded; + } + const projectFolder: string = path.resolve(project.projectFolder); + for (const settings of configuration?.operationSettingsByOperationName.values() ?? []) { + for (const outputFolderName of settings.outputFolderNames ?? []) { + const outputFolder: string = path.resolve(projectFolder, outputFolderName); + // Never prune the project folder itself or anything outside it. + if (outputFolder.startsWith(projectFolder + path.sep)) excluded.add(outputFolder); + } + } + return excluded; +} + function getPermanentWatchPaths(rushConfiguration: RushConfiguration): ReadonlyArray { const recursiveFolders: Set = new Set([rushConfiguration.commonRushConfigFolder]); for (const subspace of rushConfiguration.subspaces) { diff --git a/libraries/rush-daemon/src/test/LinuxTreeWatcher.test.ts b/libraries/rush-daemon/src/test/LinuxTreeWatcher.test.ts new file mode 100644 index 0000000000..3d4c18874a --- /dev/null +++ b/libraries/rush-daemon/src/test/LinuxTreeWatcher.test.ts @@ -0,0 +1,295 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license. +// See LICENSE in the project root for license information. + +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; +import { EventEmitter } from 'node:events'; + +import { + LinuxTreeWatcher, + toWatchError, + type DirectoryWatchFunction, + type ILinuxTreeWatcherOptions +} from '../LinuxTreeWatcher'; + +class FakeDirectoryWatcher extends EventEmitter { + public closed: boolean = false; + public readonly listener: fs.WatchListener; + public constructor(listener: fs.WatchListener) { + super(); + this.listener = listener; + } + public close(): void { + this.closed = true; + } + public ref(): this { + return this; + } + public unref(): this { + return this; + } +} + +interface IHarness { + readonly root: string; + readonly events: string[]; + readonly errors: Error[]; + readonly fakes: Map; + readonly watcher: LinuxTreeWatcher; + fire(folder: string, eventType: fs.WatchEventType, filename: string): void; +} + +const roots: string[] = []; + +function makeTree(files: string[]): string { + const root: string = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'rushd-tree-'))); + roots.push(root); + for (const file of files) { + fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true }); + fs.writeFileSync(path.join(root, file), ''); + } + return root; +} + +function createHarness( + root: string, + options: Omit = {}, + watchOverride?: (folder: string) => void +): IHarness { + const events: string[] = []; + const errors: Error[] = []; + const fakes: Map = new Map(); + const watchDirectory: DirectoryWatchFunction = (folder, listener) => { + watchOverride?.(folder); + const fake: FakeDirectoryWatcher = new FakeDirectoryWatcher(listener); + fakes.set(folder, fake); + return fake as unknown as fs.FSWatcher; + }; + const watcher: LinuxTreeWatcher = new LinuxTreeWatcher( + root, + (eventType, filename) => events.push(`${eventType}:${filename}`), + { ...options, watchDirectory } + ); + watcher.on('error', (error: Error) => errors.push(error)); + return { + root, + events, + errors, + fakes, + watcher, + fire: (folder, eventType, filename) => fakes.get(path.join(root, folder))!.listener(eventType, filename) + }; +} + +function relativeWatched(harness: IHarness): string[] { + return [...harness.watcher.watchedFolderPaths].map((folder) => path.relative(harness.root, folder)).sort(); +} + +async function waitForAsync(condition: () => boolean): Promise { + for (let attempt: number = 0; attempt < 200 && !condition(); attempt++) { + await new Promise((resolve) => setTimeout(resolve, 5)); + } + expect(condition()).toBe(true); +} + +afterAll(() => { + for (const root of roots) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe(LinuxTreeWatcher.name, () => { + it('watches directories only and prunes node_modules, .git, .rush/temp and output folders', async () => { + const root: string = makeTree([ + 'package.json', + 'src/a.ts', + 'src/nested/b.ts', + 'lib/a.js', + 'lib/nested/b.js', + 'node_modules/x/index.js', + '.git/HEAD', + '.rush/temp/shrinkwrap.yaml', + '.rush/other.json', + 'config/rush-project.json' + ]); + const harness: IHarness = createHarness(root, { + getExcludedFolderPathsAsync: async () => + new Set([path.join(root, 'lib'), path.join(root, '.rush', 'temp')]) + }); + await harness.watcher.initialWalk; + expect(relativeWatched(harness)).toEqual(['', '.rush', 'config', 'src', path.join('src', 'nested')]); + expect(harness.errors).toEqual([]); + harness.watcher.close(); + }); + + it('suppresses events for pruned paths and reports others relative to the root', async () => { + const root: string = makeTree(['src/a.ts']); + const harness: IHarness = createHarness(root, { + getExcludedFolderPathsAsync: async () => new Set([path.join(root, 'lib')]) + }); + await harness.watcher.initialWalk; + harness.fire('', 'rename', 'lib'); + harness.fire('', 'rename', 'node_modules'); + harness.fire('src', 'change', 'a.ts'); + expect(harness.events).toEqual([`change:${path.join('src', 'a.ts')}`]); + harness.watcher.close(); + }); + + it('follows directories created and removed after the initial walk', async () => { + const root: string = makeTree(['src/a.ts']); + const harness: IHarness = createHarness(root); + await harness.watcher.initialWalk; + fs.mkdirSync(path.join(root, 'src', 'added', 'deep'), { recursive: true }); + harness.fire('src', 'rename', 'added'); + const added: string = path.join('src', 'added'); + await waitForAsync(() => harness.events.includes(`rename:${added}`) && harness.events.length === 2); + expect(relativeWatched(harness)).toEqual(['', 'src', added, path.join(added, 'deep')]); + + const deepWatcher: FakeDirectoryWatcher = harness.fakes.get(path.join(root, added, 'deep'))!; + fs.rmSync(path.join(root, added), { recursive: true }); + harness.fire('src', 'rename', 'added'); + await waitForAsync(() => relativeWatched(harness).length === 2); + expect(deepWatcher.closed).toBe(true); + expect(harness.errors).toEqual([]); + harness.watcher.close(); + }); + + it('tolerates directories that disappear during registration without reporting an error', async () => { + const root: string = makeTree(['a/x.ts', 'b/y.ts']); + const harness: IHarness = createHarness(root, {}, (folder) => { + if (path.basename(folder) === 'a') { + throw Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' }); + } + }); + await harness.watcher.initialWalk; + expect(relativeWatched(harness)).toEqual(['', 'b']); + harness.fakes.get(path.join(root, 'b'))!.emit('error', Object.assign(new Error('gone'), { code: 'ENOENT' })); + expect(relativeWatched(harness)).toEqual(['']); + expect(harness.errors).toEqual([]); + harness.watcher.close(); + }); + + it('reports ENOSPC once with an explicit inotify limit error and stops registering', async () => { + const root: string = makeTree(['a/x.ts', 'b/y.ts', 'c/z.ts']); + const harness: IHarness = createHarness(root, {}, (folder) => { + if (folder !== root) { + throw Object.assign(new Error('ENOSPC: System limit for number of file watchers reached'), { + code: 'ENOSPC' + }); + } + }); + await harness.watcher.initialWalk; + expect(harness.errors).toHaveLength(1); + expect((harness.errors[0] as NodeJS.ErrnoException).code).toBe('ENOSPC'); + expect(harness.errors[0].message).toContain('fs.inotify.max_user_watches'); + harness.watcher.close(); + }); + + it('throws synchronously when the root cannot be watched, like fs.watch', () => { + const missing: string = path.join(makeTree([]), 'missing'); + expect( + () => + new LinuxTreeWatcher(missing, () => {}, { + watchDirectory: (folder) => fs.watch(folder) + }) + ).toThrow(/ENOENT/); + }); + + it('reports the root after the initial walk when requested and emits close asynchronously', async () => { + const root: string = makeTree(['src/a.ts']); + const harness: IHarness = createHarness(root, { reportInitialWalkCompletion: true }); + await harness.watcher.initialWalk; + expect(harness.events).toEqual(['rename:']); + const closed: Promise = new Promise((resolve) => harness.watcher.once('close', resolve)); + harness.watcher.close(); + await closed; + expect([...harness.fakes.values()].every((fake) => fake.closed)).toBe(true); + expect(harness.watcher.watchedFolderPaths.size).toBe(0); + }); + + it('forwards an unknown (null) filename unchanged so the consumer invalidates fully', async () => { + const root: string = makeTree(['src/a.ts']); + const harness: IHarness = createHarness(root); + await harness.watcher.initialWalk; + harness.fakes.get(path.join(root, 'src'))!.listener('change', null); + expect(harness.events).toEqual(['change:null']); + harness.watcher.close(); + }); + + it('does not register a folder that becomes excluded after exclusions finish loading', async () => { + const root: string = makeTree(['src/a.ts', 'lib/a.js']); + let resolveExclusions!: (folders: ReadonlySet) => void; + const exclusions: Promise> = new Promise((resolve) => (resolveExclusions = resolve)); + const harness: IHarness = createHarness(root, { getExcludedFolderPathsAsync: () => exclusions }); + harness.fire('', 'rename', 'lib'); + await new Promise((resolve) => setTimeout(resolve, 10)); + resolveExclusions(new Set([path.join(root, 'lib')])); + await harness.watcher.initialWalk; + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(relativeWatched(harness)).toEqual(['', 'src']); + harness.watcher.close(); + }); + + it('re-registers a directory that was replaced in place', async () => { + const root: string = makeTree(['src/nested/a.ts']); + const harness: IHarness = createHarness(root); + await harness.watcher.initialWalk; + const oldSrc: FakeDirectoryWatcher = harness.fakes.get(path.join(root, 'src'))!; + const oldNested: FakeDirectoryWatcher = harness.fakes.get(path.join(root, 'src', 'nested'))!; + fs.rmSync(path.join(root, 'src'), { recursive: true }); + fs.mkdirSync(path.join(root, 'src', 'nested'), { recursive: true }); + harness.fire('', 'rename', 'src'); + await waitForAsync(() => harness.fakes.get(path.join(root, 'src', 'nested')) !== oldNested); + expect(oldSrc.closed).toBe(true); + expect(oldNested.closed).toBe(true); + expect(harness.fakes.get(path.join(root, 'src'))).not.toBe(oldSrc); + expect(relativeWatched(harness)).toEqual(['', 'src', path.join('src', 'nested')]); + expect(harness.errors).toEqual([]); + harness.watcher.close(); + }); + + it('reports non-transient walk failures instead of treating them as a vanished directory', async () => { + const root: string = makeTree(['a/x.ts']); + const opendir: typeof fs.promises.opendir = fs.promises.opendir; + const spy: jest.SpyInstance = jest + .spyOn(fs.promises, 'opendir') + .mockImplementation(async (folder: fs.PathLike, options?: fs.OpenDirOptions) => { + if (path.basename(String(folder)) === 'a') { + throw Object.assign(new Error('EMFILE: too many open files'), { code: 'EMFILE' }); + } + return await opendir(folder, options); + }); + try { + const harness: IHarness = createHarness(root); + await harness.watcher.initialWalk; + expect(harness.errors.map((error) => (error as NodeJS.ErrnoException).code)).toEqual(['EMFILE']); + harness.watcher.close(); + } finally { + spy.mockRestore(); + } + }); + + it('leaves non-ENOSPC errors unchanged', () => { + const error: Error = Object.assign(new Error('EMFILE'), { code: 'EMFILE' }); + expect(toWatchError(error, '/x')).toBe(error); + }); + + (process.platform === 'linux' ? it : it.skip)('observes real inotify events in new directories', async () => { + const root: string = makeTree(['src/a.ts', 'lib/a.js']); + const events: string[] = []; + const watcher: LinuxTreeWatcher = new LinuxTreeWatcher(root, (...args) => events.push(args[1]!), { + getExcludedFolderPathsAsync: async () => new Set([path.join(root, 'lib')]) + }); + try { + await watcher.initialWalk; + expect(watcher.watchedFolderPaths.size).toBe(2); + fs.mkdirSync(path.join(root, 'src', 'new')); + await waitForAsync(() => watcher.watchedFolderPaths.size === 3); + fs.writeFileSync(path.join(root, 'src', 'new', 'b.ts'), 'x'); + fs.writeFileSync(path.join(root, 'lib', 'b.js'), 'x'); + await waitForAsync(() => events.includes(path.join('src', 'new', 'b.ts'))); + expect(events.some((event) => event.startsWith('lib'))).toBe(false); + } finally { + watcher.close(); + } + }); +}); diff --git a/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts b/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts index 7596e49d0d..59cf271ed5 100644 --- a/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts +++ b/libraries/rush-daemon/src/test/WorkspaceSessionFileWatcher.test.ts @@ -2,9 +2,12 @@ // See LICENSE in the project root for license information. import type * as fs from 'node:fs'; +import * as path from 'node:path'; import { EventEmitter } from 'node:events'; -import { WorkspaceSessionFileWatcher } from '../WorkspaceSessionFileWatcher'; +import { RushProjectConfiguration } from '@microsoft/rush-lib'; + +import { getProjectExcludedFolderPathsAsync, WorkspaceSessionFileWatcher } from '../WorkspaceSessionFileWatcher'; import { TEST_RUSH_CONFIGURATION } from './TestWorkspaceSession'; class TestFsWatcher extends EventEmitter { @@ -22,6 +25,26 @@ class TestFsWatcher extends EventEmitter { } describe(WorkspaceSessionFileWatcher.name, () => { + it('excludes the project .rush/temp folder from Linux tree observation', async () => { + const project = TEST_RUSH_CONFIGURATION.projects[0]; + const excluded: ReadonlySet = await getProjectExcludedFolderPathsAsync(project); + expect(excluded.has(path.resolve(project.projectRushTempFolder))).toBe(true); + expect(excluded.has(path.resolve(project.projectFolder))).toBe(false); + }); + + it('keeps the .rush/temp exclusion when rush-project.json cannot be loaded', async () => { + const project = TEST_RUSH_CONFIGURATION.projects[0]; + const spy: jest.SpyInstance = jest + .spyOn(RushProjectConfiguration, 'tryLoadForProjectAsync') + .mockRejectedValue(new Error('invalid rush-project.json')); + try { + const excluded: ReadonlySet = await getProjectExcludedFolderPathsAsync(project); + expect([...excluded]).toEqual([path.resolve(project.projectRushTempFolder)]); + } finally { + spy.mockRestore(); + } + }); + it('watches every configured subspace config folder', async () => { const watchedPaths: string[] = []; const watcher: WorkspaceSessionFileWatcher = new WorkspaceSessionFileWatcher({