From b22c5c7f3d0a2497bd004e5dffd3f46ef1e0ab24 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sun, 23 Aug 2026 17:07:46 +0300 Subject: [PATCH 1/2] test(filesystem): tolerate FSEvents reporting the watched root On macOS, creating a child also modifies its parent, so FSEvents may or may not additionally report the watched root. The exact-set assertion therefore failed intermittently: on one commit, macos-15 node-24.x passed while macos-15 node-22.x failed. Assert that no unexpected path arrived instead, excluding the root. The waitForChange calls above already establish that each expected URI arrived. --- .../node/parcel-watcher/parcel-filesystem-watcher.spec.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts index a64367c423c56..19df8cb8a768b 100644 --- a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts +++ b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts @@ -88,7 +88,10 @@ describe('parcel-filesystem-watcher', function (): void { expect(fs.readFileSync(FileUri.fsPath(root.resolve('foo').resolve('bar').resolve('baz.txt')), 'utf8')).to.be.equal('baz'); await waitForChange(actualUris, changeListeners, expectedUris[2]); - assert.deepStrictEqual([...actualUris], expectedUris); + // Each expected URI already arrived above, so what is left is that nothing else did. macOS may also + // report the root, since creating a child modifies it. + const unexpectedUris = [...actualUris].filter(uri => !expectedUris.includes(uri) && uri !== root.toString()); + assert.deepStrictEqual(unexpectedUris, []); }); it('Should not receive file changes events from in the workspace by default if unwatched', async function (): Promise { From afb6b17a4c393ded00a8e1ed8785e8edbf99b685 Mon Sep 17 00:00:00 2001 From: Ehab Younes Date: Sun, 16 Aug 2026 17:25:17 +0300 Subject: [PATCH 2/2] feat(filesystem): honor the non-recursive watch option The backend dropped the recursive flag, so every watch became a recursive parcel subscription: a non-recursive watch rooted outside the workspace crawled every sibling subtree, and watching a single file crawled its parent directory. Non-recursive requests now go to a NodeDirectoryWatcher that watches one directory level with fs.watch, and requests resolving to the same directory share one handle. Sharing matters beyond saving descriptors: on macOS libuv keeps one FSEventStream per event loop and rebuilds it whenever any handle starts or stops, losing the events of every other watcher while it does. Platform handling follows VS Code where it is settled: NFC normalization and case-insensitive matching on macOS, an exact-case readdir so a rename that only changes case is not reported as an update, the deferred delete that survives atomic saves, and refusing to watch a macOS network share. It diverges where VS Code drops events: a Windows ReadDirectoryChangesW overflow arrives as a null file name and is recovered by rescanning the directory. This removes the mitigation from #17633, restoring detection of a workspace folder deleted while a plugin watches its parent. Closes #17700 --- CHANGELOG.md | 2 + .../src/common/filesystem-watcher-protocol.ts | 4 + .../src/node/disk-file-system-provider.ts | 3 +- .../src/node/filesystem-backend-module.ts | 8 +- .../node-directory-watcher.spec.ts | 630 ++++++++++++++++++ .../nodejs-watcher/node-directory-watcher.ts | 555 +++++++++++++++ .../filesystem-watcher-service.spec.ts | 205 ++++++ .../src/node/parcel-watcher/index.ts | 4 +- .../parcel-filesystem-service.ts | 122 +++- .../parcel-filesystem-watcher.spec.ts | 8 +- .../parcel-watcher-exclude.spec.ts | 6 +- .../parcel-watcher-retry.spec.ts | 6 +- .../src/node/test/watcher-test-helper.ts | 58 ++ .../main-file-system-event-service.spec.ts | 125 ---- .../browser/main-file-system-event-service.ts | 51 +- 15 files changed, 1574 insertions(+), 213 deletions(-) create mode 100644 packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.spec.ts create mode 100644 packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.ts create mode 100644 packages/filesystem/src/node/parcel-watcher/filesystem-watcher-service.spec.ts create mode 100644 packages/filesystem/src/node/test/watcher-test-helper.ts delete mode 100644 packages/plugin-ext/src/main/browser/main-file-system-event-service.spec.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 41c53f4eedd93..a4d46ab8343cc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ - [ai-ide] removed `WorkspaceFunctionScope.ensureWithinWorkspace(targetUri, workspaceRootUri)`. Every path-taking AI tool now resolves and checks its argument through `WorkspaceFunctionScope.resolveAccessiblePath(pathOrUri)`, which in addition to the workspace roots accepts locations covered by the `ai-features.workspaceFunctions.allowedExternalPaths` preference or contributed via the new `AccessibleRootContribution`. Adopters that called `ensureWithinWorkspace`, or `resolveRelativePath` followed by their own boundary check, should call `resolveAccessiblePath` instead [#17865](https://github.com/eclipse-theia/theia/pull/17865) - [core] added `onWindowLoaded` to the `SecondaryWindowService` interface; adopters implementing the interface from scratch (rather than extending `DefaultSecondaryWindowService`) must provide it [#17874](https://github.com/eclipse-theia/theia/pull/17874) - [core] many classes now inject `ILogger` in place of their raw `console` calls, so every container resolving them must provide it. This mainly affects tests, which can add `bind(ILogger).to(MockLogger)` using `MockLogger` from `@theia/core/lib/common/test/mock-logger` [#17763](https://github.com/eclipse-theia/theia/pull/17763) +- [filesystem] `WatchOptions.recursive` is now honored by the backend watcher service, where it was previously ignored and every request became a recursive `@parcel/watcher` subscription. Non-recursive requests are served by a new `NodeDirectoryWatcher` that watches a single directory level with `fs.watch`, and requests resolving to the same directory share one watcher. `ParcelFileSystemWatcherService` is renamed to `FileSystemWatcherServiceImpl` and `PacelWatcherHandle` to `WatcherHandle`, both keeping a deprecated alias; the `watchers` map now holds a `WatcherInstance` of either kind, so adopters overriding `createWatcher` or `getWatcherKey`, or reading `watchers`, should check the new signatures [#17875](https://github.com/eclipse-theia/theia/pull/17875) - [monaco] removed the `protected secondaryWindowHandler` field from `MonacoFrontendApplicationContribution`; the Monaco theme stylesheet is now injected into every secondary window via `SecondaryWindowService.onWindowLoaded` [#17874](https://github.com/eclipse-theia/theia/pull/17874) - [plugin-ext] aliased `DebuggerContribution` to `PluginPackageDebuggersContribution` [#17758](https://github.com/eclipse-theia/theia/pull/17758) - [plugin-ext] changed `Keybinding.args` from `any` to `unknown` [#17758](https://github.com/eclipse-theia/theia/pull/17758) @@ -27,6 +28,7 @@ - [plugin-ext] moved `loadManifest` and `updateActivationEvents` to `@theia/plugin-utils` [#17758](https://github.com/eclipse-theia/theia/pull/17758) - [plugin-ext] rejected grammar paths outside the plugin directory [#17758](https://github.com/eclipse-theia/theia/pull/17758) - [plugin-ext] removed `buildFrontendModuleName` from `plugin-protocol` [#17758](https://github.com/eclipse-theia/theia/pull/17758) +- [plugin-ext] removed the `workspaceService` constructor parameter and the `shouldSkipWatch` method from `MainFileSystemEventService`. They implemented a mitigation that dropped non-recursive plugin watches rooted above a workspace root, which is obsolete now that the backend honors `recursive` [#17875](https://github.com/eclipse-theia/theia/pull/17875) - [preferences] removed the `protected scopeTracker` field from `PreferencesContribution`, so the Settings widget is no longer constructed on startup. Adopters should read the scope via the new `protected currentScope` getter [#17877](https://github.com/eclipse-theia/theia/pull/17877) - [scm] widened `ScmHistoryItem.tooltip` from `string` to `string | MarkdownString | readonly MarkdownString[]`, so that hovers supplied by a history provider keep their `isTrusted` command allow-list and their multi-section form; adopters reading the field as a string must narrow it [#17880](https://github.com/eclipse-theia/theia/pull/17880) - [scm-extra] deprecated the `@theia/scm-extra` extension and stopped publishing it on npm; it has also been removed from the example applications, which drops its `SCM History` view, the `History` context menu items in the navigator and editor, and the `alt+h` keybinding. The view has been non-functional in the default application since the removal of `@theia/git`, as nothing implements `ScmHistorySupport` anymore. Please use the SCM history graph in `@theia/scm` for branch history and the Timeline view in `@theia/timeline` for per-file history instead [#17882](https://github.com/eclipse-theia/theia/pull/17882) diff --git a/packages/filesystem/src/common/filesystem-watcher-protocol.ts b/packages/filesystem/src/common/filesystem-watcher-protocol.ts index 47ec7f35a3dc8..0b678f791694c 100644 --- a/packages/filesystem/src/common/filesystem-watcher-protocol.ts +++ b/packages/filesystem/src/common/filesystem-watcher-protocol.ts @@ -89,6 +89,10 @@ export interface FileSystemWatcherClient { export interface WatchOptions { ignored: string[]; + /** + * Watch the whole subtree under the given path. Defaults to `true`. + */ + recursive?: boolean; } export interface FileChange { uri: string; diff --git a/packages/filesystem/src/node/disk-file-system-provider.ts b/packages/filesystem/src/node/disk-file-system-provider.ts index b0c22ebddca15..630b5e74c36f0 100644 --- a/packages/filesystem/src/node/disk-file-system-provider.ts +++ b/packages/filesystem/src/node/disk-file-system-provider.ts @@ -833,7 +833,8 @@ export class DiskFileSystemProvider implements Disposable, }; watcherService.watchFileChanges(resource.toString(), { // Convert from `files.WatchOptions` to internal `watcher-protocol.WatchOptions`: - ignored: opts.excludes + ignored: opts.excludes, + recursive: opts.recursive }).then(watcherId => { if (handle.disposed) { watcherService.unwatchFileChanges(watcherId); diff --git a/packages/filesystem/src/node/filesystem-backend-module.ts b/packages/filesystem/src/node/filesystem-backend-module.ts index e6686fdb1ddf9..b82366a95eaa7 100644 --- a/packages/filesystem/src/node/filesystem-backend-module.ts +++ b/packages/filesystem/src/node/filesystem-backend-module.ts @@ -19,7 +19,7 @@ import { ContainerModule, interfaces } from '@theia/core/shared/inversify'; import { ConnectionHandler, RpcConnectionHandler, ILogger } from '@theia/core/lib/common'; import { FileSystemWatcherServer, FileSystemWatcherService } from '../common/filesystem-watcher-protocol'; import { FileSystemWatcherServerClient } from './filesystem-watcher-client'; -import { ParcelFileSystemWatcherService, ParcelFileSystemWatcherServerOptions } from './parcel-watcher/parcel-filesystem-service'; +import { FileSystemWatcherServiceImpl, ParcelFileSystemWatcherServerOptions } from './parcel-watcher/parcel-filesystem-service'; import { NodeFileUploadService } from './upload/node-file-upload-service'; import { ParcelWatcherOptions } from './parcel-watcher/parcel-options'; import { DiskFileSystemProvider } from './disk-file-system-provider'; @@ -38,11 +38,11 @@ export const WATCHER_VERBOSE = process.argv.includes('--watcher-verbose'); export const FileSystemWatcherServiceProcessOptions = Symbol('FileSystemWatcherServiceProcessOptions'); /** - * Options to control the way the `ParcelFileSystemWatcherService` process is spawned. + * Options to control the way the `FileSystemWatcherServiceImpl` process is spawned. */ export interface FileSystemWatcherServiceProcessOptions { /** - * Path to the script that will run the `ParcelFileSystemWatcherService` in a new process. + * Path to the script that will run the `FileSystemWatcherServiceImpl` in a new process. */ entryPoint: string; } @@ -102,7 +102,7 @@ export function bindFileSystemWatcherServer(bind: interfaces.Bind): void { export function createParcelFileSystemWatcherService(ctx: interfaces.Context): FileSystemWatcherService { const options = ctx.container.get(ParcelFileSystemWatcherServerOptions); const dispatcher = ctx.container.get(FileSystemWatcherServiceDispatcher); - const server = new ParcelFileSystemWatcherService(options); + const server = new FileSystemWatcherServiceImpl(options); server.setClient(dispatcher); return server; } diff --git a/packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.spec.ts b/packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.spec.ts new file mode 100644 index 0000000000000..c31189d961675 --- /dev/null +++ b/packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.spec.ts @@ -0,0 +1,630 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as assert from 'assert'; +import * as path from 'path'; +import * as temp from 'temp'; +import * as fs from '@theia/core/shared/fs-extra'; +import { EventEmitter } from 'events'; +import { FSWatcher } from 'fs'; +import { Minimatch } from 'minimatch'; +import { Deferred } from '@theia/core/lib/common/promise-util'; +import { isWindows } from '@theia/core'; +import { FileUri } from '@theia/core/lib/node'; +import { DidFilesChangedParams, FileSystemWatcherServiceClient } from '../../common/filesystem-watcher-protocol'; +import { NO_LOGGING, TempDir, WATCHER_TIMINGS as TIMINGS } from '../test/watcher-test-helper'; +import { DirectoryIdentity, NodeDirectoryWatcher, NodeWatchRequest, WatchEventListener } from './node-directory-watcher'; + +const track = temp.track(); + +/** How long to keep listening after the expected changes arrived, to catch any that should not have. */ +const SETTLE_DELAY = TIMINGS.deleteDelay * 3; + +/** Indexed by `FileChangeType`. */ +const CHANGE_NAMES = ['updated', 'added', 'deleted']; + +/** Replaces the `fs.watch` handle, so that platform behavior no host can reproduce is driven directly. */ +class TestWatcher extends NodeDirectoryWatcher { + + /** Set to pretend the watched directory was replaced, which cannot be forced on a real file system. */ + fakeIdentity: DirectoryIdentity | undefined; + /** Set to exercise the macOS and Windows file name handling on any host. */ + decomposes = false; + caseInsensitive = false; + /** Set to make `fs.watch` refuse, as on EACCES or an exhausted handle budget. */ + refuseToOpen = false; + + protected listener: WatchEventListener | undefined; + protected handleEmitter: EventEmitter | undefined; + protected readonly missing = new Deferred(); + + /** Resolves once the watcher has found its target missing, so that a test can then create it. */ + readonly whenMissing = this.missing.promise; + + /** Feeds the watcher an event as the platform would. */ + fire(eventType: string, fileName: string | null): void { + assert.ok(this.listener, 'the watcher has not opened a handle'); + this.listener(eventType, fileName); + } + + /** Fails the open handle, as Windows does when the watched directory goes away. */ + fail(error: Error): void { + this.handleEmitter?.emit('error', error); + } + + protected override normalizeFileName(fileName: string): string { + return this.decomposes ? fileName.normalize('NFC') : fileName; + } + + protected override get caseInsensitiveFileNames(): boolean { + return this.caseInsensitive; + } + + /** Applies the network share check on any host, keyed on the target rather than on `/Volumes`. */ + protected override get isUnsupportedTarget(): boolean { + return this.target.includes('network-share'); + } + + protected override async readIdentity(): Promise { + return this.fakeIdentity ?? super.readIdentity(); + } + + protected override async exists(fsPath: string): Promise { + const result = await super.exists(fsPath); + if (!result && fsPath === this.target) { + this.missing.resolve(); + } + return result; + } + + protected override createWatchHandle(directory: string, listener: WatchEventListener): FSWatcher { + if (this.refuseToOpen) { + throw new Error('EACCES'); + } + this.listener = listener; + this.handleEmitter = new EventEmitter(); + return Object.assign(this.handleEmitter, { close: () => { } }) as unknown as FSWatcher; + } +} + +/** A temporary directory, the watchers on it, and the changes their clients were told about. */ +class Sandbox extends TempDir implements FileSystemWatcherServiceClient { + + /** Errors logged by the watchers of this sandbox. */ + readonly errors: unknown[] = []; + + protected readonly logging = { ...NO_LOGGING, error: (message: string) => this.errors.push(message) }; + protected readonly reports: DidFilesChangedParams[] = []; + protected readonly watchers: NodeDirectoryWatcher[] = []; + protected notify: (() => void) | undefined; + + constructor() { + super(fs.realpathSync.native(temp.mkdirSync('node-directory-watcher'))); + } + + onDidFilesChanged(report: DidFilesChangedParams): void { + this.reports.push(report); + this.notify?.(); + } + + onError(): void { } + + /** A request for every direct child of a directory. */ + directory(directoryPath = this.root, clientId = 1, ignored: string[] = []): NodeWatchRequest { + return { clientId, path: directoryPath, ignored: ignored.map(pattern => new Minimatch(pattern, { dot: true })) }; + } + + /** A request for a single file. */ + file(filePath: string, clientId = 1, ignored: string[] = []): NodeWatchRequest { + return { ...this.directory(filePath, clientId, ignored), fileName: path.basename(filePath) }; + } + + /** A watcher whose events the test feeds in, already started. */ + async watching(target = this.root, ...requests: NodeWatchRequest[]): Promise { + const watcher = this.starting(target, ...requests); + await watcher.whenStarted; + return watcher; + } + + /** A watcher whose events the test feeds in, not yet started. */ + starting(target = this.root, ...requests: NodeWatchRequest[]): TestWatcher { + return this.track(new TestWatcher(target, this.logging, this, TIMINGS), requests); + } + + /** A watcher driven by a real `fs.watch` handle, already started. */ + async watchingForReal(target = this.root, ...requests: NodeWatchRequest[]): Promise { + const watcher = this.track(new NodeDirectoryWatcher(target, this.logging, this, TIMINGS), requests); + await watcher.whenStarted; + await this.warmUp(watcher, target); + return watcher; + } + + /** + * macOS FSEvents starts asynchronously after `fs.watch` returns and drops what happens meanwhile, so keep + * touching a probe file until a change is delivered, proving the handle live before the test acts. + */ + protected async warmUp(watcher: NodeDirectoryWatcher, target: string): Promise { + const directory = fs.statSync(target).isDirectory() ? target : path.dirname(target); + watcher.addRequest(9999, this.directory(directory, 99)); + const probe = path.join(directory, '.warmup'); + const deadline = Date.now() + 5000; + while (!this.reported(99).some(entry => entry.includes('.warmup')) && Date.now() < deadline) { + fs.writeFileSync(probe, 'ping'); + await new Promise(resolve => setTimeout(resolve, 100)); + } + fs.removeSync(probe); + } + + /** What a client was told, as `' '`, the root itself being `'.'`. */ + reported(clientId = 1): string[] { + return this.reports + .filter(report => report.clients?.includes(clientId)) + .flatMap(report => report.changes) + .map(change => { + const relative = path.relative(this.root, FileUri.fsPath(change.uri)).split(path.sep).join('/'); + return `${CHANGE_NAMES[change.type]} ${relative || '.'}`; + }); + } + + /** Waits for the client to have been told exactly this, and for a moment longer to catch anything extra. */ + async expect(clientId: number, ...expected: string[]): Promise { + await this.settle(() => this.reported(clientId).length >= expected.length); + assert.deepStrictEqual(this.reported(clientId), expected); + } + + /** Waits for the client to have been told at least this, tolerating whatever else the platform reports. */ + async expectAmong(clientId: number, ...expected: string[]): Promise { + await this.settle(() => expected.every(entry => this.reported(clientId).includes(entry))); + expected.forEach(entry => assert.ok(this.reported(clientId).includes(entry), + `expected "${entry}" among ${JSON.stringify(this.reported(clientId))}`)); + } + + dispose(): void { + this.watchers.splice(0).forEach(watcher => watcher.dispose()); + } + + protected track(watcher: T, requests: NodeWatchRequest[]): T { + this.watchers.push(watcher); + requests.forEach((request, index) => watcher.addRequest(index, request)); + return watcher; + } + + protected async settle(reached: () => boolean): Promise { + const deadline = Date.now() + 5000; + while (!reached() && Date.now() < deadline) { + await new Promise(resolve => { + this.notify = resolve; + setTimeout(resolve, 5); + }); + this.notify = undefined; + } + await new Promise(resolve => setTimeout(resolve, SETTLE_DELAY)); + } +} + +describe('node-directory-watcher', function (): void { + + this.timeout(20000); + + let box: Sandbox; + + beforeEach(() => { + box = new Sandbox(); + }); + + afterEach(() => { + box.dispose(); + track.cleanupSync(); + }); + + describe('resolving changes', () => { + + it('reports a new direct child as added, and a known one as updated', async () => { + const watcher = await box.watching(box.root, box.directory()); + + box.write('a.txt'); + watcher.fire('rename', 'a.txt'); + await box.expect(1, 'added a.txt'); + + watcher.fire('rename', 'a.txt'); + await box.expect(1, 'added a.txt', 'updated a.txt'); + }); + + it('reports a modification as updated', async () => { + box.write('a.txt'); + const watcher = await box.watching(box.root, box.directory()); + + watcher.fire('change', 'a.txt'); + + await box.expect(1, 'updated a.txt'); + }); + + it('reports a deletion only once the grace period passed', async () => { + box.write('a.txt'); + const watcher = await box.watching(box.root, box.directory()); + + box.remove('a.txt'); + watcher.fire('rename', 'a.txt'); + assert.deepStrictEqual(box.reported(1), [], 'nothing is reported before the grace period'); + + await box.expect(1, 'deleted a.txt'); + }); + + it('reports an atomic save as an update rather than a deletion', async () => { + box.write('a.txt'); + const watcher = await box.watching(box.root, box.directory()); + + box.remove('a.txt'); + watcher.fire('rename', 'a.txt'); + box.write('a.txt'); + + await box.expect(1, 'updated a.txt'); + }); + + it('reports a file that appears and vanishes within the grace period as both', async () => { + const watcher = await box.watching(box.root, box.directory()); + + watcher.fire('rename', 'ghost.txt'); + + await box.expect(1, 'added ghost.txt', 'deleted ghost.txt'); + }); + + it('rescans when the platform reports a change without a file name', async () => { + box.write('gone.txt'); + const watcher = await box.watching(box.root, box.directory()); + + box.write('new.txt'); + box.remove('gone.txt'); + // eslint-disable-next-line no-null/no-null + watcher.fire('change', null); + + await box.expect(1, 'added new.txt', 'deleted gone.txt'); + }); + + it('reports a deletion once when a rescan settles it before the grace period', async () => { + box.write('a.txt'); + const watcher = await box.watching(box.root, box.directory()); + + box.remove('a.txt'); + watcher.fire('rename', 'a.txt'); + // eslint-disable-next-line no-null/no-null + watcher.fire('change', null); + + await box.expect(1, 'deleted a.txt'); + }); + + it('ignores an event naming a path below the watched directory', async () => { + box.mkdir('nested'); + box.write('nested', 'deep.txt'); + box.write('sentinel.txt'); + const watcher = await box.watching(box.root, box.directory()); + + watcher.fire('rename', path.join('nested', 'deep.txt')); + watcher.fire('rename', 'sentinel.txt'); + + await box.expect(1, 'updated sentinel.txt'); + }); + }); + + describe('routing', () => { + + it('tells a file request about its own file only', async () => { + const watcher = await box.watching(box.root, box.file(box.path('wanted.txt'))); + + box.write('other.txt'); + box.write('wanted.txt'); + watcher.fire('rename', 'other.txt'); + watcher.fire('rename', 'wanted.txt'); + + await box.expect(1, 'added wanted.txt'); + }); + + it('applies the excludes of each request separately', async () => { + const watcher = await box.watching(box.root, box.directory(box.root, 1, ['**/node_modules']), box.directory(box.root, 2)); + + box.mkdir('node_modules'); + watcher.fire('rename', 'node_modules'); + + await box.expect(2, 'added node_modules'); + await box.expect(1); + }); + + it('tells a client holding overlapping requests once, and other clients independently', async () => { + const file = box.write('a.txt'); + const watcher = await box.watching(box.root, box.directory(), box.file(file), box.directory(box.root, 2)); + + watcher.fire('change', 'a.txt'); + + await box.expect(1, 'updated a.txt'); + await box.expect(2, 'updated a.txt'); + }); + + it('reports changes under the path each request asked for', async () => { + const real = box.mkdir('real'); + fs.symlinkSync(real, box.path('link'), isWindows ? 'junction' : 'dir'); + const watcher = await box.watching(real, box.directory(real), box.directory(box.path('link'), 2)); + + box.write('real', 'a.txt'); + watcher.fire('rename', 'a.txt'); + + await box.expect(1, 'added real/a.txt'); + await box.expect(2, 'added link/a.txt'); + }); + + it('matches a decomposed file name against the composed path a request asked for', async () => { + const composed = 'café.txt'.normalize('NFC'); + const watcher = await box.watching(box.root, box.file(box.path(composed))); + watcher.decomposes = true; + + box.write(composed); + watcher.fire('rename', 'café.txt'.normalize('NFD')); + + await box.expect(1, `added ${composed}`); + }); + + it('matches a file name irrespective of case where the platform does', async () => { + const watcher = await box.watching(box.root, box.file(box.path('Wanted.txt'))); + watcher.caseInsensitive = true; + + box.write('wanted.txt'); + watcher.fire('rename', 'wanted.txt'); + + await box.expect(1, 'added Wanted.txt'); + }); + }); + + describe('the watched directory', () => { + + it('is reported and watched once it appears', async () => { + const target = box.path('later'); + const watcher = box.starting(target, box.directory(target)); + + await watcher.whenMissing; + fs.mkdirSync(target); + await watcher.whenStarted; + watcher.fire('rename', path.basename(box.write('later', 'a.txt'))); + + await box.expect(1, 'added later', 'added later/a.txt'); + }); + + it('is the parent when the target turns out to be a file', async () => { + const target = box.path('later.txt'); + const watcher = box.starting(target, box.directory(target)); + + await watcher.whenMissing; + fs.writeFileSync(target, 'content'); + await watcher.whenStarted; + box.write('sibling.txt'); + watcher.fire('rename', 'sibling.txt'); + watcher.fire('change', 'later.txt'); + + await box.expect(1, 'added later.txt', 'updated later.txt'); + }); + + it('is reported as deleted, and its recovery reports what changed meanwhile', async () => { + const target = box.mkdir('workspace'); + box.write('workspace', 'before.txt'); + const watcher = await box.watching(target, box.directory(target)); + + box.remove('workspace'); + watcher.fire('rename', 'workspace'); + await box.expect(1, 'deleted workspace'); + + box.mkdir('workspace'); + box.write('workspace', 'after.txt'); + + await box.expect(1, 'deleted workspace', 'added workspace', 'added workspace/after.txt', 'deleted workspace/before.txt'); + }); + + it('is not lost when an event names it, as macOS reports any change inside it', async () => { + box.write('a.txt'); + const watcher = await box.watching(box.root, box.directory()); + + // libuv names an event on the directory after the directory itself. + watcher.fire('rename', path.basename(box.root)); + watcher.fire('change', 'a.txt'); + + await box.expect(1, 'updated a.txt'); + }); + + it('keeps working after being replaced while its inode number was reused', async () => { + const watcher = await box.watching(box.root, box.directory()); + + watcher.fakeIdentity = { dev: 1, ino: 2, birthtimeMs: 3 }; + watcher.fire('rename', 'a.txt'); + await box.expect(1, 'deleted .', 'added .'); + + box.write('a.txt'); + watcher.fire('rename', 'a.txt'); + await box.expect(1, 'deleted .', 'added .', 'added a.txt'); + }); + + it('reports a handle that will not open once, then recovers when it does', async () => { + const watcher = box.starting(box.root); + watcher.refuseToOpen = true; + + // Several poll rounds, one report. + await new Promise(resolve => setTimeout(resolve, TIMINGS.existencePollDelay * 4)); + assert.strictEqual(box.errors.length, 1, `expected one report, got ${JSON.stringify(box.errors)}`); + + watcher.refuseToOpen = false; + watcher.addRequest(0, box.directory()); + await watcher.whenStarted; + box.write('a.txt'); + watcher.fire('rename', 'a.txt'); + + await box.expect(1, 'added a.txt'); + }); + + it('keeps working after a handle failure, without reporting a change', async () => { + const watcher = await box.watching(box.root, box.directory()); + + watcher.fail(new Error('EPERM')); + await box.expect(1); + + box.write('a.txt'); + watcher.fire('rename', 'a.txt'); + await box.expect(1, 'added a.txt'); + }); + }); + + describe('platform behavior', () => { + + it('reports a rename that only changes case as an addition and a deletion', async () => { + box.write('foo.txt'); + const watcher = await box.watching(box.root, box.directory()); + watcher.caseInsensitive = true; + + fs.renameSync(box.path('foo.txt'), box.path('Foo.txt')); + watcher.fire('rename', 'foo.txt'); + watcher.fire('rename', 'Foo.txt'); + + await box.expect(1, 'added Foo.txt', 'deleted foo.txt'); + }); + + it('refuses to watch a network share, which crashes macOS', async () => { + const target = box.mkdir('network-share'); + const watcher = box.starting(target, box.directory(target)); + + await watcher.whenStarted; + + assert.strictEqual(box.errors.length, 1, `expected a report, got ${JSON.stringify(box.errors)}`); + assert.throws(() => watcher.fire('change', 'a.txt'), /has not opened a handle/); + }); + }); + + describe('disposal', () => { + + it('happens once the last request is released', async () => { + const watcher = await box.watching(box.root, box.directory(), box.directory(box.root, 2)); + + watcher.removeRequest(0); + await new Promise(resolve => setTimeout(resolve, TIMINGS.deferredDisposalTimeout * 2)); + assert.strictEqual(watcher.isDisposed, false, 'a watcher with a request left must stay alive'); + + watcher.removeRequest(1); + await watcher.whenDisposed; + }); + + it('is called off by a request arriving before the deferred timeout', async () => { + const watcher = await box.watching(box.root, box.directory()); + + watcher.removeRequest(0); + watcher.addRequest(1, box.directory(box.root, 2)); + await new Promise(resolve => setTimeout(resolve, TIMINGS.deferredDisposalTimeout * 2)); + + assert.strictEqual(watcher.isDisposed, false); + }); + + it('silences a deletion that was still pending', async () => { + box.write('a.txt'); + const watcher = await box.watching(box.root, box.directory()); + + box.remove('a.txt'); + watcher.fire('rename', 'a.txt'); + watcher.dispose(); + + await box.expect(1); + }); + + it('keeps its state in sync while it briefly has no requests', async () => { + box.write('a.txt'); + const watcher = new TestWatcher(box.root, NO_LOGGING, box, { ...TIMINGS, deferredDisposalTimeout: 1000 }); + watcher.addRequest(0, box.directory()); + await watcher.whenStarted; + try { + // The file is deleted just as the last request leaves; the grace period keeps the watcher alive. + box.remove('a.txt'); + watcher.fire('rename', 'a.txt'); + watcher.removeRequest(0); + await new Promise(resolve => setTimeout(resolve, TIMINGS.deleteDelay * 3)); + + // The deletion was settled meanwhile, so the recreated file is an addition, not an update. + watcher.addRequest(1, box.directory(box.root, 2)); + box.write('a.txt'); + watcher.fire('rename', 'a.txt'); + await box.expect(2, 'added a.txt'); + + box.remove('a.txt'); + watcher.fire('rename', 'a.txt'); + await box.expect(2, 'added a.txt', 'deleted a.txt'); + } finally { + watcher.dispose(); + } + }); + }); + + describe('with a real fs.watch handle', () => { + + it('reports direct children and nothing below them', async () => { + box.mkdir('nested'); + await box.watchingForReal(box.root, box.directory()); + + box.write('nested', 'deep.txt'); + box.write('direct.txt'); + + await box.expectAmong(1, 'added direct.txt'); + assert.ok(!box.reported(1).includes('added nested/deep.txt'), 'a nested change must not be reported'); + }); + + it('reports an update and a deletion of a direct child', async () => { + box.write('a.txt'); + await box.watchingForReal(box.root, box.directory()); + + box.write('a.txt'); + await box.expectAmong(1, 'updated a.txt'); + + box.remove('a.txt'); + await box.expectAmong(1, 'deleted a.txt'); + }); + + it('resolves a case-only rename against the real file system', async () => { + box.write('foo.txt'); + await box.watchingForReal(box.root, box.directory()); + + // A case-insensitive host would report an update of the old name if `stat` were trusted. + fs.renameSync(box.path('foo.txt'), box.path('Foo.txt')); + + await box.expectAmong(1, 'added Foo.txt', 'deleted foo.txt'); + }); + + it('reports the watched directory being lost and coming back', async () => { + const target = box.mkdir('workspace'); + box.write('workspace', 'before.txt'); + await box.watchingForReal(target, box.directory(target)); + + // Reported as a named event, an event on the directory, or a handle error, depending on the host. + box.remove('workspace'); + await box.expectAmong(1, 'deleted workspace'); + + box.mkdir('workspace'); + box.write('workspace', 'after.txt'); + + await box.expectAmong(1, 'added workspace', 'added workspace/after.txt'); + }); + + it('reports a single file through its parent directory', async () => { + const file = box.write('a.txt'); + await box.watchingForReal(file, box.file(file)); + + box.write('other.txt'); + box.write('a.txt'); + + await box.expectAmong(1, 'updated a.txt'); + assert.ok(!box.reported(1).includes('added other.txt'), 'a sibling must not be reported'); + }); + }); +}); diff --git a/packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.ts b/packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.ts new file mode 100644 index 0000000000000..d84913bfe3efb --- /dev/null +++ b/packages/filesystem/src/node/nodejs-watcher/node-directory-watcher.ts @@ -0,0 +1,555 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as path from 'path'; +import { FSWatcher, promises as fsp, watch } from 'fs'; +import { Minimatch } from 'minimatch'; +import { isOSX, isWindows } from '@theia/core'; +import { FileUri } from '@theia/core/lib/common/file-uri'; +import { Deferred, timeout } from '@theia/core/lib/common/promise-util'; +import { FileChangeType, FileSystemWatcherServiceClient } from '../../common/filesystem-watcher-protocol'; +import { FileChangeCollection } from '../file-change-collection'; + +export interface NodeDirectoryWatcherOptions { + verbose: boolean; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + info: (message: string, ...args: any[]) => void; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + error: (message: string, ...args: any[]) => void; +} + +/** A single client request served by a {@link NodeDirectoryWatcher}. */ +export interface NodeWatchRequest { + /** Client to route the changes of this request to. */ + clientId: number; + /** Path as requested by the client. Change URIs are built from it, never from the real path. */ + path: string; + /** The single file this request wants, or `undefined` for the whole directory. */ + fileName?: string; + /** Exclude patterns of this request alone. */ + ignored: Minimatch[]; +} + +export type WatchEventListener = (eventType: string, fileName: string | null) => void; + +export interface NodeDirectoryWatcherTimings { + /** Aggregation window before raw events are resolved against the file system. */ + changeDelay: number; + /** Grace period before a deletion is confirmed, so an atomic save is not reported as one. */ + deleteDelay: number; + /** Poll interval for a path that does not exist yet. */ + existencePollDelay: number; + /** How long an unreferenced watcher is kept, so a reconnecting frontend can reuse it. */ + deferredDisposalTimeout: number; +} + +export const DEFAULT_WATCHER_TIMINGS: NodeDirectoryWatcherTimings = { + changeDelay: 75, + deleteDelay: 100, + existencePollDelay: 500, + deferredDisposalTimeout: 10_000 +}; + +/** A resolved change: a direct child of the watched directory, or the watched path itself. */ +interface ResolvedChange { + fileName?: string; + type: FileChangeType; +} + +export interface DirectoryIdentity { + dev: number; + ino: number; + birthtimeMs: number; +} + +interface PendingEvent { + eventType: string; + fileName: string | undefined; +} + +/** + * Watches one directory level with Node's `fs.watch`. + * + * One instance serves every non-recursive request resolving to the same directory, whether for the directory + * itself or for a single file inside it. Sharing saves more than handles: on macOS libuv keeps one + * `FSEventStream` per event loop and recreates it whenever any handle opens or closes, dropping the events of + * every other watcher meanwhile. + */ +export class NodeDirectoryWatcher { + + protected static debugIdSequence = 0; + + protected readonly debugId = NodeDirectoryWatcher.debugIdSequence++; + protected readonly requests = new Map(); + protected readonly pendingEvents: PendingEvent[] = []; + protected readonly pendingDeletes = new Map(); + protected readonly disposalDeferred = new Deferred(); + + /** Direct children of {@link watchedDirectory}, kept in sync to classify changes and to diff a rescan. */ + protected children = new Set(); + protected watchedDirectory: string; + protected identity: DirectoryIdentity | undefined; + protected handle: FSWatcher | undefined; + protected changeQueue: Promise = Promise.resolve(); + protected changeTimer: NodeJS.Timeout | undefined; + protected disposalTimer: NodeJS.Timeout | undefined; + protected openFailed = false; + protected restarting = false; + protected disposed = false; + + /** Resolves once this watcher disposed itself and its resources. Never rejects. */ + readonly whenDisposed = this.disposalDeferred.promise; + + /** Resolves once the watcher is up, or once it got disposed while starting. Never rejects. */ + readonly whenStarted: Promise; + + constructor( + /** + * Path the watched directory is derived from: the directory itself, or, while the path does not exist + * yet, a guess that {@link resolveTarget} corrects once it appears. + */ + readonly target: string, + protected readonly options: NodeDirectoryWatcherOptions, + protected readonly client: FileSystemWatcherServiceClient, + protected readonly timings: NodeDirectoryWatcherTimings = DEFAULT_WATCHER_TIMINGS + ) { + this.watchedDirectory = target; + this.whenStarted = this.start().catch(error => this.options.error(`Watcher failed to start at "${this.target}":`, error)); + } + + get isDisposed(): boolean { + return this.disposed; + } + + isInUse(): boolean { + return this.requests.size > 0; + } + + addRequest(watcherId: number, request: NodeWatchRequest): void { + this.requests.set(watcherId, request); + clearTimeout(this.disposalTimer); + this.debug('REQUEST++', `watcherId=${watcherId}, requests=${this.requests.size}`); + } + + removeRequest(watcherId: number): void { + if (this.requests.delete(watcherId) && this.requests.size === 0) { + this.disposalTimer = setTimeout(() => this.dispose(), this.timings.deferredDisposalTimeout); + } + this.debug('REQUEST--', `watcherId=${watcherId}, requests=${this.requests.size}`); + } + + dispose(): void { + if (this.disposed) { + return; + } + this.disposed = true; + this.closeHandle(); + this.clearPendingDeletes(); + clearTimeout(this.changeTimer); + this.changeTimer = undefined; + clearTimeout(this.disposalTimer); + this.disposalTimer = undefined; + this.disposalDeferred.resolve(); + this.debug('DISPOSED'); + } + + /** Waits for the target, then opens the handle before reading the children, so no change is missed. */ + protected async start(missing = false, previousChildren?: Set): Promise { + if (this.isUnsupportedTarget) { + this.options.error(`Refusing to watch "${this.target}": watching a macOS network share is unstable.`); + return; + } + const wasMissing = await this.openWhenAvailable() || missing; + if (this.disposed) { + return; + } + await this.takeSnapshot(); + this.restarting = false; + this.debug('STARTED', this.watchedDirectory); + if (wasMissing) { + this.report([{ type: FileChangeType.ADDED }], await this.existingRequests()); + } + if (previousChildren) { + this.report(this.diff(previousChildren, this.children)); + } + } + + /** Polls until the target exists and a handle is open. Resolves to whether it was ever missing. */ + protected async openWhenAvailable(): Promise { + let wasMissing = false; + while (!this.disposed) { + if (!await this.exists(this.target)) { + wasMissing = true; + } else { + await this.resolveTarget(); + if (this.disposed || this.openHandle()) { + break; + } + } + await timeout(this.timings.existencePollDelay); + } + return wasMissing; + } + + /** Records what changes are resolved against: the directory's children and its identity. */ + protected async takeSnapshot(): Promise { + const children = await this.readChildren(); + // Anything reported while the directory was read counts as new rather than as modified. + for (const event of this.pendingEvents) { + if (event.fileName) { + children.delete(event.fileName); + } + } + this.children = children; + this.identity = await this.readIdentity(); + } + + /** Requests whose own path exists, so a recovered directory does not announce files that are still gone. */ + protected async existingRequests(): Promise { + const requests = Array.from(this.requests.values()); + const existing = await Promise.all(requests.map(request => this.exists(request.path))); + return requests.filter((_, index) => existing[index]); + } + + /** + * Applies {@link NodeDirectoryWatcher.resolveTarget} to this watcher. A target that turns out to be a file + * only once it appears was requested as a directory, so its requests are narrowed to that file here. + */ + protected async resolveTarget(): Promise { + const { directory, fileName } = await NodeDirectoryWatcher.resolveTarget(this.target); + this.watchedDirectory = directory; + if (fileName !== undefined) { + for (const request of this.requests.values()) { + if (request.path === this.target) { + request.fileName = fileName; + } + } + } + } + + protected openHandle(): boolean { + try { + this.handle = this.createWatchHandle(this.watchedDirectory, (eventType, fileName) => this.handleEvent(eventType, fileName)); + this.handle.on('error', error => this.restart(error)); + this.openFailed = false; + return true; + } catch (error) { + // Polling recovers a missing directory, but not EACCES or an exhausted handle budget. + if (!this.openFailed) { + this.openFailed = true; + this.options.error(`Watcher failed to open a handle at "${this.watchedDirectory}", retrying every ${this.timings.existencePollDelay}ms:`, error); + } + return false; + } + } + + protected createWatchHandle(directory: string, listener: WatchEventListener): FSWatcher { + return watch(directory, { recursive: false }, listener); + } + + protected closeHandle(): void { + if (this.handle) { + this.handle.removeAllListeners(); + this.handle.close(); + this.handle = undefined; + } + } + + protected handleEvent(eventType: string, fileName: string | null): void { + if (this.disposed) { + return; + } + // Windows reports a `ReadDirectoryChangesW` buffer overflow as a change without a file name. Only a + // rescan can recover the events lost with it. + this.pendingEvents.push({ eventType, fileName: fileName ? this.normalizeFileName(fileName) : undefined }); + if (!this.changeTimer) { + this.changeTimer = setTimeout(() => this.flush(), this.timings.changeDelay); + } + } + + protected flush(): void { + this.changeTimer = undefined; + const events = this.pendingEvents.splice(0); + this.enqueue(() => this.processEvents(events)); + } + + /** Serializes the async parts of change handling so later events cannot overtake earlier ones. */ + protected enqueue(task: () => Promise): void { + this.changeQueue = this.changeQueue.then(async () => { + // Run even with no request attached: skipping would leave the children and a pending deletion + // stale for a request arriving within the disposal grace period. + if (!this.disposed) { + await task(); + } + }, error => this.options.error(`Watcher failed to process changes at "${this.watchedDirectory}":`, error)); + } + + protected async processEvents(events: PendingEvent[]): Promise { + const changes: ResolvedChange[] = []; + let renamed = false; + for (const { eventType, fileName } of events) { + if (fileName === undefined) { + const rescanned = await this.readChildren(); + const rescanChanges = this.diff(this.children, rescanned); + // Reading the directory settles what a pending deletion was waiting for. + rescanChanges.forEach(change => this.cancelDelete(change.fileName)); + changes.push(...rescanChanges); + this.children = rescanned; + } else if (fileName.includes('/') || fileName.includes('\\')) { + continue; + } else if (eventType === 'rename') { + renamed = true; + if (!this.namesWatchedDirectory(fileName)) { + await this.resolveRename(fileName, changes); + } + } else { + changes.push({ fileName, type: FileChangeType.UPDATED }); + } + } + if (renamed && await this.isWatchedDirectoryGone()) { + this.restart(); + return; + } + this.report(changes); + } + + /** + * Whether an event names the watched directory rather than a child. macOS reports it for any change + * inside, so only {@link isWatchedDirectoryGone} settles whether it is still there. + */ + protected namesWatchedDirectory(fileName: string): boolean { + return !this.children.has(fileName) && fileName === this.normalizeFileName(path.basename(this.watchedDirectory)); + } + + protected async resolveRename(fileName: string, changes: ResolvedChange[]): Promise { + if (!await this.childExists(fileName)) { + this.scheduleDelete(fileName); + return; + } + this.cancelDelete(fileName); + if (this.children.has(fileName)) { + changes.push({ fileName, type: FileChangeType.UPDATED }); + } else { + this.children.add(fileName); + changes.push({ fileName, type: FileChangeType.ADDED }); + } + } + + /** + * A deletion is confirmed rather than reported right away: tools that save atomically delete and recreate + * the file, which would otherwise surface as a deletion followed by an addition. + */ + protected scheduleDelete(fileName: string): void { + if (this.pendingDeletes.has(fileName)) { + return; + } + this.pendingDeletes.set(fileName, setTimeout(() => { + this.pendingDeletes.delete(fileName); + this.enqueue(() => this.confirmDelete(fileName)); + }, this.timings.deleteDelay)); + } + + protected cancelDelete(fileName: string): void { + clearTimeout(this.pendingDeletes.get(fileName)); + this.pendingDeletes.delete(fileName); + } + + protected clearPendingDeletes(): void { + for (const timer of this.pendingDeletes.values()) { + clearTimeout(timer); + } + this.pendingDeletes.clear(); + } + + protected async confirmDelete(fileName: string): Promise { + const known = this.children.has(fileName); + if (await this.childExists(fileName)) { + this.children.add(fileName); + this.report([{ fileName, type: known ? FileChangeType.UPDATED : FileChangeType.ADDED }]); + return; + } + this.children.delete(fileName); + this.report(known + ? [{ fileName, type: FileChangeType.DELETED }] + // It appeared and vanished within the delay, so report both rather than a deletion from nowhere. + : [{ fileName, type: FileChangeType.ADDED }, { fileName, type: FileChangeType.DELETED }]); + } + + /** + * Compares identity rather than mere existence: a directory that is deleted and recreated leaves the handle + * bound to the old inode, where it would never report anything again. + */ + protected async isWatchedDirectoryGone(): Promise { + const identity = await this.readIdentity(); + if (!identity) { + return true; + } + return this.identity !== undefined && (identity.dev !== this.identity.dev + || identity.ino !== this.identity.ino + || identity.birthtimeMs !== this.identity.birthtimeMs); + } + + /** Closes the handle and starts over, reporting the watched paths as deleted if the directory is gone. */ + protected restart(error?: unknown): void { + if (this.disposed || this.restarting) { + return; + } + this.restarting = true; + this.debug('RESTART', error ?? ''); + this.closeHandle(); + this.clearPendingDeletes(); + this.pendingEvents.length = 0; + clearTimeout(this.changeTimer); + this.changeTimer = undefined; + const previousChildren = this.children; + this.changeQueue = this.changeQueue.then(async () => { + if (this.disposed) { + return; + } + // A handle can also fail while the directory is untouched, and then nothing changed. + const gone = await this.isWatchedDirectoryGone(); + if (gone) { + // Losing the directory takes every requested path inside it along. + this.report([{ type: FileChangeType.DELETED }]); + } + // Only a comparison of the contents can recover what happened while the watcher was down. + await this.start(gone, previousChildren); + }, restartError => this.options.error(`Watcher failed to restart at "${this.target}":`, restartError)); + } + + /** + * Notifies each client once per watched path that changed, so a client holding overlapping requests does + * not hear about the same change twice. + */ + protected report(changes: ResolvedChange[], requests: Iterable = this.requests.values()): void { + if (this.disposed || changes.length === 0) { + return; + } + const perClient = new Map(); + for (const request of requests) { + for (const { fileName, type } of changes) { + const changed = this.resolveRequestPath(request, fileName); + if (changed && !request.ignored.some(pattern => pattern.match(changed))) { + let collection = perClient.get(request.clientId); + if (!collection) { + perClient.set(request.clientId, collection = new FileChangeCollection()); + } + collection.push({ uri: FileUri.create(changed).toString(), type }); + } + } + } + for (const [clientId, collection] of perClient) { + this.client.onDidFilesChanged({ clients: [clientId], changes: collection.values() }); + } + } + + /** The path a request reports a change under, or `undefined` if the change is none of its business. */ + protected resolveRequestPath(request: NodeWatchRequest, fileName?: string): string | undefined { + if (fileName === undefined) { + return request.path; + } + if (request.fileName === undefined) { + return path.resolve(request.path, fileName); + } + return this.sameFileName(request.fileName, fileName) ? request.path : undefined; + } + + protected diff(previous: Set, current: Set): Required[] { + const changes: Required[] = []; + for (const fileName of current) { + if (!previous.has(fileName)) { + changes.push({ fileName, type: FileChangeType.ADDED }); + } + } + for (const fileName of previous) { + if (!current.has(fileName)) { + changes.push({ fileName, type: FileChangeType.DELETED }); + } + } + return changes; + } + + protected async readChildren(): Promise> { + const children = await fsp.readdir(this.watchedDirectory).catch(() => []); + return new Set(children.map(fileName => this.normalizeFileName(fileName))); + } + + protected async readIdentity(): Promise { + const stat = await fsp.stat(this.watchedDirectory).catch(() => undefined); + // The inode number alone is not enough: deleting a directory frees it for its replacement. + return stat && { dev: stat.dev, ino: stat.ino, birthtimeMs: stat.birthtimeMs }; + } + + protected exists(fsPath: string): Promise { + return fsp.stat(fsPath).then(() => true, () => false); + } + + /** Exact-case lookup. `stat` accepts a differing case, making a `foo.txt` to `Foo.txt` rename an update. */ + protected async childExists(fileName: string): Promise { + return this.caseInsensitiveFileNames + ? (await this.readChildren()).has(fileName) + : this.exists(path.resolve(this.watchedDirectory, fileName)); + } + + /** Windows and macOS resolve names irrespective of case. */ + protected get caseInsensitiveFileNames(): boolean { + return isWindows || isOSX; + } + + /** macOS crashes on watching a network share, so those are refused (microsoft/vscode#106879). */ + protected get isUnsupportedTarget(): boolean { + return isOSX && (this.target === '/Volumes' || this.target.startsWith('/Volumes/')); + } + + /** macOS reports decomposed names, which would not match a composed path a client asked to watch. */ + protected normalizeFileName(fileName: string): string { + return isOSX ? fileName.normalize('NFC') : fileName; + } + + protected sameFileName(expected: string, actual: string): boolean { + return this.caseInsensitiveFileNames ? expected.toLowerCase() === actual.toLowerCase() : expected === actual; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protected debug(prefix: string, ...params: any[]): void { + if (this.options.verbose) { + this.options.info(`${prefix} NodeDirectoryWatcher(${this.debugId} at "${this.target}"):`, ...params); + } + } +} + +export namespace NodeDirectoryWatcher { + + /** The directory a path is watched through, and the single file to report, if the path is one. */ + export interface Target { + directory: string; + fileName?: string; + } + + /** + * A file is watched through its parent directory, which also keeps a file that is deleted and recreated + * observable. A path that does not exist yet is assumed to be a directory, which + * {@link NodeDirectoryWatcher} corrects once it appears. The real path is resolved because macOS FSEvents + * reports real paths and libuv drops what it cannot match against the path it registered. + */ + export async function resolveTarget(fsPath: string): Promise { + const realPath = await fsp.realpath(fsPath).catch(() => fsPath); + const stat = await fsp.stat(realPath).catch(() => undefined); + return stat?.isFile() + ? { directory: path.dirname(realPath), fileName: path.basename(realPath) } + : { directory: realPath }; + } +} diff --git a/packages/filesystem/src/node/parcel-watcher/filesystem-watcher-service.spec.ts b/packages/filesystem/src/node/parcel-watcher/filesystem-watcher-service.spec.ts new file mode 100644 index 0000000000000..70360751289c7 --- /dev/null +++ b/packages/filesystem/src/node/parcel-watcher/filesystem-watcher-service.spec.ts @@ -0,0 +1,205 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as assert from 'assert'; +import * as temp from 'temp'; +import * as fs from '@theia/core/shared/fs-extra'; +import { isWindows } from '@theia/core'; +import { FileUri } from '@theia/core/lib/node'; +import { NodeDirectoryWatcher } from '../nodejs-watcher/node-directory-watcher'; +import { NO_LOGGING, TempDir, WATCHER_TIMINGS as TIMINGS } from '../test/watcher-test-helper'; +import { FileSystemWatcherServiceImpl, ParcelFileSystemWatcherService, ParcelWatcher, ParcelWatcherOptions, WatcherInstance } from './parcel-filesystem-service'; +import { WatchOptions } from '../../common/filesystem-watcher-protocol'; + +const track = temp.track(); + +const RECURSIVE: WatchOptions = { ignored: [], recursive: true }; +const NON_RECURSIVE: WatchOptions = { ignored: [], recursive: false }; + +/** A temporary directory and a watcher service on it, one per test. */ +class Sandbox extends FileSystemWatcherServiceImpl { + + /** The directory the requests of this test point into. */ + readonly files = new TempDir(fs.realpathSync.native(temp.mkdirSync('filesystem-watcher-service'))); + + protected readonly requested: number[] = []; + + constructor() { + super(NO_LOGGING); + } + + get root(): string { + return this.files.root; + } + + watch(fsPath: string, options: WatchOptions): Promise { + return this.watchFileChanges(1, FileUri.create(fsPath).toString(), options) + .then(watcherId => (this.requested.push(watcherId), watcherId)); + } + + /** The watchers currently allocated, one per distinct key. */ + get allocated(): WatcherInstance[] { + return Array.from(this.watchers.values()); + } + + watcherOf(watcherId: number): WatcherInstance | undefined { + return this.watcherHandles.get(watcherId)?.watcher; + } + + async release(): Promise { + const outstanding = this.requested.splice(0).filter(watcherId => this.watcherOf(watcherId)); + const allocated = this.allocated; + await Promise.all(outstanding.map(watcherId => this.unwatchFileChanges(watcherId))); + await Promise.all(allocated.map(watcher => watcher.whenDisposed)); + } + + protected override createDirectoryWatcher(directory: string): NodeDirectoryWatcher { + return new NodeDirectoryWatcher(directory, this.options, this.maybeClient, TIMINGS); + } + + protected override createWatcher(clientId: number, fsPath: string, options: WatchOptions): ParcelWatcher { + const watcherOptions: ParcelWatcherOptions = { ignored: this.compileExcludes(options.ignored), ignorePatterns: options.ignored }; + return new ParcelWatcher(clientId, fsPath, watcherOptions, this.options, this.maybeClient, TIMINGS.deferredDisposalTimeout); + } +} + +describe('filesystem-watcher-service', function (): void { + + this.timeout(20000); + + let box: Sandbox; + + beforeEach(() => { + box = new Sandbox(); + }); + + afterEach(async () => { + await box.release(); + track.cleanupSync(); + }); + + describe('routing', () => { + + it('keeps the deprecated service name usable by adopters', () => { + assert.strictEqual(ParcelFileSystemWatcherService, FileSystemWatcherServiceImpl); + }); + + it('serves a recursive request, and one that does not say, with the parcel watcher', async () => { + + const recursive = await box.watch(box.root, RECURSIVE); + const unspecified = await box.watch(box.files.mkdir('other'), { ignored: [] }); + + assert.ok(box.watcherOf(recursive) instanceof ParcelWatcher); + assert.ok(box.watcherOf(unspecified) instanceof ParcelWatcher); + }); + + it('serves a non-recursive request with a directory watcher', async () => { + + const watcherId = await box.watch(box.root, NON_RECURSIVE); + + assert.ok(box.watcherOf(watcherId) instanceof NodeDirectoryWatcher); + }); + + it('keeps a recursive and a non-recursive request on the same path apart', async () => { + + const recursive = await box.watch(box.root, RECURSIVE); + const nonRecursive = await box.watch(box.root, NON_RECURSIVE); + + assert.notStrictEqual(box.watcherOf(recursive), box.watcherOf(nonRecursive)); + assert.strictEqual(box.allocated.length, 2); + }); + }); + + describe('sharing', () => { + + it('shares one watcher between a directory and the files inside it', async () => { + + const directory = await box.watch(box.root, NON_RECURSIVE); + const first = await box.watch(box.files.write('a.txt'), NON_RECURSIVE); + const second = await box.watch(box.files.write('b.txt'), NON_RECURSIVE); + + assert.strictEqual(box.allocated.length, 1); + assert.strictEqual(box.watcherOf(directory), box.watcherOf(first)); + assert.strictEqual(box.watcherOf(first), box.watcherOf(second)); + }); + + it('shares one watcher between requests with different excludes', async () => { + + await box.watch(box.root, { ignored: ['**/node_modules'], recursive: false }); + await box.watch(box.root, NON_RECURSIVE); + + assert.strictEqual(box.allocated.length, 1); + }); + + it('shares one watcher between a directory and a symbolic link to it', async () => { + const real = box.files.mkdir('real'); + fs.symlinkSync(real, box.files.path('link'), isWindows ? 'junction' : 'dir'); + + const direct = await box.watch(real, NON_RECURSIVE); + const linked = await box.watch(box.files.path('link'), NON_RECURSIVE); + + assert.strictEqual(box.allocated.length, 1); + assert.strictEqual(box.watcherOf(direct), box.watcherOf(linked)); + }); + + it('shares one watcher between concurrent requests for the same directory', async () => { + const [first, second] = [box.files.write('a.txt'), box.files.write('b.txt')]; + + const [one, two] = await Promise.all([box.watch(first, NON_RECURSIVE), box.watch(second, NON_RECURSIVE)]); + + assert.strictEqual(box.allocated.length, 1); + assert.strictEqual(box.watcherOf(one), box.watcherOf(two)); + }); + + it('does not attach a request to a watcher that is already disposed', async () => { + const first = await box.watch(box.root, NON_RECURSIVE); + const disposed = box.watcherOf(first) as NodeDirectoryWatcher; + disposed.dispose(); + + const second = await box.watch(box.root, NON_RECURSIVE); + + assert.notStrictEqual(box.watcherOf(second), disposed); + assert.strictEqual(box.allocated.length, 1); + }); + }); + + describe('releasing', () => { + + it('disposes a directory watcher once its last request is unwatched', async () => { + const first = await box.watch(box.root, NON_RECURSIVE); + const second = await box.watch(box.root, NON_RECURSIVE); + const watcher = box.watcherOf(first) as NodeDirectoryWatcher; + + await box.unwatchFileChanges(first); + await new Promise(resolve => setTimeout(resolve, TIMINGS.deferredDisposalTimeout * 2)); + assert.strictEqual(watcher.isDisposed, false, 'the remaining request must keep the watcher alive'); + + await box.unwatchFileChanges(second); + await watcher.whenDisposed; + assert.strictEqual(box.allocated.length, 0); + }); + + it('releases the client reference of a recursive watcher', async () => { + const watcherId = await box.watch(box.root, RECURSIVE); + const watcher = box.watcherOf(watcherId) as ParcelWatcher; + + await box.unwatchFileChanges(watcherId); + await watcher.whenDisposed; + + assert.strictEqual(box.allocated.length, 0); + }); + }); +}); diff --git a/packages/filesystem/src/node/parcel-watcher/index.ts b/packages/filesystem/src/node/parcel-watcher/index.ts index 0edbb8a2a0277..0ccf3ef85344c 100644 --- a/packages/filesystem/src/node/parcel-watcher/index.ts +++ b/packages/filesystem/src/node/parcel-watcher/index.ts @@ -17,7 +17,7 @@ import * as yargs from '@theia/core/shared/yargs'; import { RpcProxyFactory } from '@theia/core'; import { FileSystemWatcherServiceClient } from '../../common/filesystem-watcher-protocol'; -import { ParcelFileSystemWatcherService } from './parcel-filesystem-service'; +import { FileSystemWatcherServiceImpl } from './parcel-filesystem-service'; import { IPCEntryPoint } from '@theia/core/lib/node/messaging/ipc-protocol'; /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -38,7 +38,7 @@ const options: { .argv as any; export default (connection => { - const server = new ParcelFileSystemWatcherService(options); + const server = new FileSystemWatcherServiceImpl(options); const factory = new RpcProxyFactory(server); server.setClient(factory.createProxy()); factory.listen(connection); diff --git a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts index 41da3baef65ae..8f906e3d6ee58 100644 --- a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts +++ b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-service.ts @@ -25,6 +25,7 @@ import { FileChangeCollection } from '../file-change-collection'; import { Deferred, timeout } from '@theia/core/lib/common/promise-util'; import { subscribe, Options, AsyncSubscription, Event } from '@theia/core/shared/@parcel/watcher'; import { isOSX, isWindows } from '@theia/core'; +import { NodeDirectoryWatcher } from '../nodejs-watcher/node-directory-watcher'; export interface ParcelWatcherOptions { /** Compiled exclude patterns, used to filter events after they arrive. */ @@ -209,6 +210,10 @@ export class ParcelWatcher { return this.refsPerClient.size > 0; } + get isDisposed(): boolean { + return this.disposed; + } + /** * @throws with {@link WatcherDisposal} if this instance is disposed. */ @@ -408,23 +413,34 @@ export class ParcelWatcher { } } +export type WatcherInstance = ParcelWatcher | NodeDirectoryWatcher; + +export type ResolvedWatchOptions = Required; + /** * Each time a client makes a watchRequest, we generate a unique watcherId for it. * * This watcherId will map to this handle type which keeps track of the clientId that made the request. */ -export interface PacelWatcherHandle { +export interface WatcherHandle { clientId: number; - watcher: ParcelWatcher; + watcher: WatcherInstance; } -export class ParcelFileSystemWatcherService implements FileSystemWatcherService { +/** @deprecated since 1.75.0 - use `WatcherHandle`. */ +export type PacelWatcherHandle = WatcherHandle; + +/** + * Routes each request to a watcher honoring its `recursive` option: `@parcel/watcher` when recursive, a + * shared `fs.watch` on a single directory level when not. + */ +export class FileSystemWatcherServiceImpl implements FileSystemWatcherService { protected client: FileSystemWatcherServiceClient | undefined; protected watcherId = 0; - protected readonly watchers = new Map(); - protected readonly watcherHandles = new Map(); + protected readonly watchers = new Map(); + protected readonly watcherHandles = new Map(); protected readonly options: ParcelFileSystemWatcherServerOptions; @@ -457,38 +473,88 @@ export class ParcelFileSystemWatcherService implements FileSystemWatcherService */ async watchFileChanges(clientId: number, uri: string, options?: WatchOptions): Promise { const resolvedOptions = this.resolveWatchOptions(options); - const watcherKey = this.getWatcherKey(uri, resolvedOptions); - let watcher = this.watchers.get(watcherKey); - if (watcher === undefined) { - const fsPath = FileUri.fsPath(uri); - watcher = this.createWatcher(clientId, fsPath, resolvedOptions); - watcher.whenDisposed.then(() => this.watchers.delete(watcherKey)); - this.watchers.set(watcherKey, watcher); - } else { - watcher.addRef(clientId); - } const watcherId = this.watcherId++; + const watcher = resolvedOptions.recursive + ? this.watchRecursively(clientId, uri, resolvedOptions) + : await this.watchDirectory(clientId, watcherId, uri, resolvedOptions); this.watcherHandles.set(watcherId, { clientId, watcher }); watcher.whenDisposed.then(() => this.watcherHandles.delete(watcherId)); return watcherId; } + protected watchRecursively(clientId: number, uri: string, options: ResolvedWatchOptions): ParcelWatcher { + const watcherKey = this.getWatcherKey(uri, options); + const existing = this.getLiveWatcher(watcherKey); + if (existing) { + existing.addRef(clientId); + return existing; + } + return this.registerWatcher(watcherKey, this.createWatcher(clientId, FileUri.fsPath(uri), options)); + } + + /** Non-recursive requests resolving to the same directory share one watcher, file or directory alike. */ + protected async watchDirectory(clientId: number, watcherId: number, uri: string, options: ResolvedWatchOptions): Promise { + const fsPath = FileUri.fsPath(uri); + const { directory, fileName } = await NodeDirectoryWatcher.resolveTarget(fsPath); + const watcherKey = this.getDirectoryWatcherKey(directory); + // Nothing is awaited below, so concurrent requests for one directory cannot both create a watcher. + const watcher = this.getLiveWatcher(watcherKey) + ?? this.registerWatcher(watcherKey, this.createDirectoryWatcher(directory)); + watcher.addRequest(watcherId, { clientId, path: fsPath, fileName, ignored: this.compileExcludes(options.ignored) }); + return watcher; + } + protected createWatcher(clientId: number, fsPath: string, options: WatchOptions): ParcelWatcher { const watcherOptions: ParcelWatcherOptions = { - ignored: options.ignored - .map(pattern => new Minimatch(pattern, { dot: true })), + ignored: this.compileExcludes(options.ignored), ignorePatterns: options.ignored, }; return new ParcelWatcher(clientId, fsPath, watcherOptions, this.options, this.maybeClient); } + protected createDirectoryWatcher(directory: string): NodeDirectoryWatcher { + return new NodeDirectoryWatcher(directory, this.options, this.maybeClient); + } + + protected compileExcludes(ignored: string[]): Minimatch[] { + return ignored.map(pattern => new Minimatch(pattern, { dot: true })); + } + + protected registerWatcher(watcherKey: string, watcher: T): T { + this.watchers.set(watcherKey, watcher); + watcher.whenDisposed.then(() => { + if (this.watchers.get(watcherKey) === watcher) { + this.watchers.delete(watcherKey); + } + }); + return watcher; + } + + /** + * The watcher under `watcherKey`, if it is still usable: a watcher marks itself disposed synchronously but + * is removed from the map by a promise callback, so a request arriving in between would otherwise attach + * to one that is already tearing down. Keys are namespaced per kind, so the key implies the kind. + */ + protected getLiveWatcher(watcherKey: string): T | undefined { + const watcher = this.watchers.get(watcherKey); + if (watcher?.isDisposed) { + this.watchers.delete(watcherKey); + return undefined; + } + return watcher as T | undefined; + } + async unwatchFileChanges(watcherId: number): Promise { const handle = this.watcherHandles.get(watcherId); if (handle === undefined) { console.warn(`tried to de-allocate a disposed watcher: watcherId=${watcherId}`); } else { this.watcherHandles.delete(watcherId); - handle.watcher.removeRef(handle.clientId); + if (handle.watcher instanceof NodeDirectoryWatcher) { + handle.watcher.removeRequest(watcherId); + } else { + handle.watcher.removeRef(handle.clientId); + } } } @@ -497,18 +563,25 @@ export class ParcelFileSystemWatcherService implements FileSystemWatcherService */ protected getWatcherKey(uri: string, options: WatchOptions): string { return [ + 'recursive', uri, options.ignored.slice(0).sort().join() // use a **sorted copy** of `ignored` as part of the key ].join(); } /** - * Return fully qualified options. + * Excludes are not part of the key: a single directory level has nothing to prune, so they apply per + * request and requests with different excludes still share one handle. */ - protected resolveWatchOptions(options?: WatchOptions): WatchOptions { + protected getDirectoryWatcherKey(directory: string): string { + return `nonRecursive,${isWindows || isOSX ? directory.toLowerCase() : directory}`; + } + + /** Return fully qualified options. Watchers created before `recursive` existed were always recursive. */ + protected resolveWatchOptions(options?: WatchOptions): ResolvedWatchOptions { return { - ignored: [], - ...options, + ignored: options?.ignored ?? [], + recursive: options?.recursive ?? true }; } @@ -523,3 +596,8 @@ export class ParcelFileSystemWatcherService implements FileSystemWatcherService // Singletons shouldn't be disposed... } } + +/** @deprecated since 1.75.0 - use `FileSystemWatcherServiceImpl`, which also serves non-recursive requests. */ +export const ParcelFileSystemWatcherService = FileSystemWatcherServiceImpl; +/** @deprecated since 1.75.0 - use `FileSystemWatcherServiceImpl`, which also serves non-recursive requests. */ +export type ParcelFileSystemWatcherService = FileSystemWatcherServiceImpl; diff --git a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts index 19df8cb8a768b..ee516b7ca5aad 100644 --- a/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts +++ b/packages/filesystem/src/node/parcel-watcher/parcel-filesystem-watcher.spec.ts @@ -21,7 +21,7 @@ import * as fs from '@theia/core/shared/fs-extra'; import * as assert from 'assert'; import URI from '@theia/core/lib/common/uri'; import { FileUri } from '@theia/core/lib/node'; -import { ParcelFileSystemWatcherService } from './parcel-filesystem-service'; +import { FileSystemWatcherServiceImpl } from './parcel-filesystem-service'; import { DidFilesChangedParams, FileChange, FileChangeType } from '../../common/filesystem-watcher-protocol'; const expect = chai.expect; @@ -30,7 +30,7 @@ const track = temp.track(); describe('parcel-filesystem-watcher', function (): void { let root: URI; - let watcherService: ParcelFileSystemWatcherService; + let watcherService: FileSystemWatcherServiceImpl; let watcherId: number; this.timeout(100000); @@ -163,8 +163,8 @@ describe('parcel-filesystem-watcher', function (): void { } }); - function createParcelFileSystemWatcherService(): ParcelFileSystemWatcherService { - return new ParcelFileSystemWatcherService({ + function createParcelFileSystemWatcherService(): FileSystemWatcherServiceImpl { + return new FileSystemWatcherServiceImpl({ verbose: true }); } diff --git a/packages/filesystem/src/node/parcel-watcher/parcel-watcher-exclude.spec.ts b/packages/filesystem/src/node/parcel-watcher/parcel-watcher-exclude.spec.ts index ca174d0fffc43..a78438e8de760 100644 --- a/packages/filesystem/src/node/parcel-watcher/parcel-watcher-exclude.spec.ts +++ b/packages/filesystem/src/node/parcel-watcher/parcel-watcher-exclude.spec.ts @@ -21,7 +21,7 @@ import * as fs from '@theia/core/shared/fs-extra'; import URI from '@theia/core/lib/common/uri'; import { FileUri } from '@theia/core/lib/node'; import { Options } from '@theia/core/shared/@parcel/watcher'; -import { ParcelFileSystemWatcherService } from './parcel-filesystem-service'; +import { FileSystemWatcherServiceImpl } from './parcel-filesystem-service'; // We require the *same* module object that the production code imports from, so that // stubbing its `subscribe` export is observed by `ParcelWatcher`. The `@theia/core/shared` @@ -49,7 +49,7 @@ describe('parcel-filesystem-watcher exclude handling', function (): void { this.timeout(20000); let root: URI; - let service: ParcelFileSystemWatcherService; + let service: FileSystemWatcherServiceImpl; let subscribeStub: sinon.SinonStub; let capturedOptions: Options[]; @@ -65,7 +65,7 @@ describe('parcel-filesystem-watcher exclude handling', function (): void { capturedOptions.push(opts); return { unsubscribe: async () => undefined }; }); - service = new ParcelFileSystemWatcherService({ verbose: false }); + service = new FileSystemWatcherServiceImpl({ verbose: false }); }); afterEach(() => { diff --git a/packages/filesystem/src/node/parcel-watcher/parcel-watcher-retry.spec.ts b/packages/filesystem/src/node/parcel-watcher/parcel-watcher-retry.spec.ts index 9f4844816cf0f..dfa096da400a2 100644 --- a/packages/filesystem/src/node/parcel-watcher/parcel-watcher-retry.spec.ts +++ b/packages/filesystem/src/node/parcel-watcher/parcel-watcher-retry.spec.ts @@ -20,7 +20,7 @@ import * as temp from 'temp'; import * as fs from '@theia/core/shared/fs-extra'; import URI from '@theia/core/lib/common/uri'; import { FileUri } from '@theia/core/lib/node'; -import { ParcelFileSystemWatcherService } from './parcel-filesystem-service'; +import { FileSystemWatcherServiceImpl } from './parcel-filesystem-service'; // We require the *same* module object that the production code imports from, so that // stubbing its `subscribe` export is observed by `ParcelWatcher`. The `@theia/core/shared` @@ -46,7 +46,7 @@ describe('parcel-filesystem-watcher transient ENOENT handling', function (): voi this.timeout(20000); let root: URI; - let service: ParcelFileSystemWatcherService; + let service: FileSystemWatcherServiceImpl; let subscribeStub: sinon.SinonStub | undefined; let consoleErrorStub: sinon.SinonStub; let onError: sinon.SinonStub; @@ -57,7 +57,7 @@ describe('parcel-filesystem-watcher transient ENOENT handling', function (): voi // start() now logs the underlying error to stderr on failure; silence it // so the test output stays readable. consoleErrorStub = sinon.stub(console, 'error'); - service = new ParcelFileSystemWatcherService({ verbose: false }); + service = new FileSystemWatcherServiceImpl({ verbose: false }); onError = sinon.stub(); service.setClient({ onDidFilesChanged: () => undefined, diff --git a/packages/filesystem/src/node/test/watcher-test-helper.ts b/packages/filesystem/src/node/test/watcher-test-helper.ts new file mode 100644 index 0000000000000..d80a7c148fed3 --- /dev/null +++ b/packages/filesystem/src/node/test/watcher-test-helper.ts @@ -0,0 +1,58 @@ +// ***************************************************************************** +// Copyright (C) 2026 EclipseSource GmbH. +// +// This program and the accompanying materials are made available under the +// terms of the Eclipse Public License v. 2.0 which is available at +// http://www.eclipse.org/legal/epl-2.0. +// +// This Source Code may also be made available under the following Secondary +// Licenses when the conditions for such availability set forth in the Eclipse +// Public License v. 2.0 are satisfied: GNU General Public License, version 2 +// with the GNU Classpath Exception which is available at +// https://www.gnu.org/software/classpath/license.html. +// +// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 +// ***************************************************************************** + +import * as path from 'path'; +import * as fs from '@theia/core/shared/fs-extra'; +import { NodeDirectoryWatcherTimings } from '../nodejs-watcher/node-directory-watcher'; + +/** Scaled down so the suite stays quick; only the order of the delays matters. */ +export const WATCHER_TIMINGS: NodeDirectoryWatcherTimings = { + changeDelay: 5, + deleteDelay: 20, + existencePollDelay: 20, + deferredDisposalTimeout: 30 +}; + +export const NO_LOGGING = { verbose: false, info: () => { }, error: () => { } }; + +/** + * A directory to act on, one per test, so that no test depends on what another one left behind. The root is + * passed in because `temp` is a root-level dev dependency, and this file is published. + */ +export class TempDir { + + constructor(readonly root: string) { } + + path(...segments: string[]): string { + return path.resolve(this.root, ...segments); + } + + write(...segments: string[]): string { + const target = this.path(...segments); + fs.writeFileSync(target, 'content'); + return target; + } + + mkdir(...segments: string[]): string { + const target = this.path(...segments); + fs.mkdirSync(target, { recursive: true }); + return target; + } + + remove(...segments: string[]): void { + fs.removeSync(this.path(...segments)); + } +} diff --git a/packages/plugin-ext/src/main/browser/main-file-system-event-service.spec.ts b/packages/plugin-ext/src/main/browser/main-file-system-event-service.spec.ts deleted file mode 100644 index aa1343cb4a79b..0000000000000 --- a/packages/plugin-ext/src/main/browser/main-file-system-event-service.spec.ts +++ /dev/null @@ -1,125 +0,0 @@ -// ***************************************************************************** -// Copyright (C) 2026 Safi Seid-Ahmad, K2view and others. -// -// This program and the accompanying materials are made available under the -// terms of the Eclipse Public License v. 2.0 which is available at -// http://www.eclipse.org/legal/epl-2.0. -// -// This Source Code may also be made available under the following Secondary -// Licenses when the conditions for such availability set forth in the Eclipse -// Public License v. 2.0 are satisfied: GNU General Public License, version 2 -// with the GNU Classpath Exception which is available at -// https://www.gnu.org/software/classpath/license.html. -// -// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0 -// ***************************************************************************** - -import { enableJSDOM } from '@theia/core/lib/browser/test/jsdom'; -const disableJSDOM = enableJSDOM(); - -import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider'; -FrontendApplicationConfigProvider.set({}); - -import * as assert from 'assert'; -import { URI } from '@theia/core'; -import { Disposable } from '@theia/core/lib/common/disposable'; -import { UriComponents } from '../../common/uri-components'; -import { MainFileSystemEventService } from './main-file-system-event-service'; - -disableJSDOM(); - -/* eslint-disable @typescript-eslint/no-explicit-any */ - -// A language server (e.g. `redhat.java` / JDT-LS) registers a watcher rooted at the PARENT of the -// workspace folder - `RelativePattern(parentDir, folderName)` - purely to detect deletion of the -// workspace folder itself. The pattern has no globstar, so `ensureWatching` sends it as a -// NON-recursive `$watch` on `parentDir`. Theia's backend ignores the `recursive` flag and always -// watches recursively, so this turns into a recursive crawl of every sibling subtree under the -// parent - thousands of inodes the workspace does not own - which can exhaust the OS file-watch -// budget. `files.watcherExclude` cannot bound it because the root is outside the workspace. -// -// These tests pin the fix: a non-recursive watch rooted at a strict ancestor of a workspace root is -// not registered, while watches on/inside the workspace and explicit recursive requests are -// untouched. -describe('MainFileSystemEventService ancestor-of-workspace watch handling', () => { - - function componentsFor(path: string): UriComponents { - return { scheme: 'file', authority: '', path, query: '', fragment: '' }; - } - - function createService(rootUris: string[], watchCalls: UriComponents[]): MainFileSystemEventService { - const fileService: any = { - onDidFilesChange: () => Disposable.NULL, - onDidRunUserOperation: () => Disposable.NULL, - addFileOperationParticipant: () => Disposable.NULL, - watch: (resource: UriComponents) => { - watchCalls.push(resource); - return Disposable.NULL; - } - }; - const workspaceService: any = { tryGetRoots: () => rootUris.map(uri => ({ resource: new URI(uri) })) }; - const rpc: any = { getProxy: () => ({}) }; - return new MainFileSystemEventService(rpc, {} as any, fileService, workspaceService); - } - - it('skips a non-recursive watch rooted at a strict ancestor of a workspace root', () => { - const watchCalls: UriComponents[] = []; - const service = createService(['file:///projects/my-app'], watchCalls); - - service.$watch(1, componentsFor('/projects'), { recursive: false, excludes: [] }); - - assert.strictEqual(watchCalls.length, 0, 'ancestor-of-workspace watch must not be registered'); - }); - - it('still registers a non-recursive watch on the workspace root itself', () => { - const watchCalls: UriComponents[] = []; - const service = createService(['file:///projects/my-app'], watchCalls); - - service.$watch(1, componentsFor('/projects/my-app'), { recursive: false, excludes: [] }); - - assert.strictEqual(watchCalls.length, 1); - }); - - it('still registers a non-recursive watch on an outer root that is itself the parent of another root', () => { - const watchCalls: UriComponents[] = []; - // Multi-root workspace where `/projects` is a root AND the parent of the `/projects/my-app` root. - // The outer root is explicitly opened by the user, so its watch must not be dropped as an - // "ancestor of the workspace". - const service = createService(['file:///projects', 'file:///projects/my-app'], watchCalls); - - service.$watch(1, componentsFor('/projects'), { recursive: false, excludes: [] }); - - assert.strictEqual(watchCalls.length, 1, 'a folder that is itself a workspace root must be watched, even if it is an ancestor of another root'); - }); - - it('still registers a non-recursive watch inside the workspace', () => { - const watchCalls: UriComponents[] = []; - const service = createService(['file:///projects/my-app'], watchCalls); - - service.$watch(1, componentsFor('/projects/my-app/src'), { recursive: false, excludes: [] }); - - assert.strictEqual(watchCalls.length, 1); - }); - - it('does not skip an explicit recursive watch on an ancestor (honored as requested)', () => { - const watchCalls: UriComponents[] = []; - const service = createService(['file:///projects/my-app'], watchCalls); - - service.$watch(1, componentsFor('/projects'), { recursive: true, excludes: [] }); - - assert.strictEqual(watchCalls.length, 1); - }); - - it('frees the session for a skipped watch so $unwatch and re-watch do not throw', () => { - const watchCalls: UriComponents[] = []; - const service = createService(['file:///projects/my-app'], watchCalls); - - service.$watch(1, componentsFor('/projects'), { recursive: false, excludes: [] }); - service.$unwatch(1); - // Re-using the same session id must not throw "already a watch request". - service.$watch(1, componentsFor('/projects'), { recursive: false, excludes: [] }); - - assert.strictEqual(watchCalls.length, 0); - }); - -}); diff --git a/packages/plugin-ext/src/main/browser/main-file-system-event-service.ts b/packages/plugin-ext/src/main/browser/main-file-system-event-service.ts index 24eb68776c71b..615e2a9b6f20f 100644 --- a/packages/plugin-ext/src/main/browser/main-file-system-event-service.ts +++ b/packages/plugin-ext/src/main/browser/main-file-system-event-service.ts @@ -27,20 +27,16 @@ import { URI } from '@theia/core'; import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposable'; import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileChangeType, WatchOptions } from '@theia/filesystem/lib/common/files'; -import { WorkspaceService } from '@theia/workspace/lib/browser'; export class MainFileSystemEventService implements MainFileSystemEventServiceShape { private readonly toDispose = new DisposableCollection(); private readonly watches = new Map(); - /** Ancestor-of-workspace roots already skipped, to avoid logging on every re-registration. */ - private readonly skippedWatchRoots = new Set(); constructor( rpc: RPCProtocol, container: interfaces.Container, - private readonly fileService = container.get(FileService), - private readonly workspaceService = container.get(WorkspaceService) + private readonly fileService = container.get(FileService) ) { const proxy = rpc.getProxy(MAIN_RPC_CONTEXT.ExtHostFileSystemEventService); @@ -85,57 +81,14 @@ export class MainFileSystemEventService implements MainFileSystemEventServiceSha if (this.watches.has(session)) { throw new Error(`There is already a watch request for the key ${session}`); } - const uri = URI.fromComponents(resource); - if (this.shouldSkipWatch(uri, options)) { - // Register a no-op disposable so the session is tracked and `$unwatch` still works. - this.watches.set(session, Disposable.NULL); - return; - } // Plugin/language-server watchers (`vscode.workspace.createFileSystemWatcher`) arrive here // with an empty `excludes` list; `FileService.watch` applies `files.watcherExclude` centrally // for all watchers, so they stay bounded without merging the excludes here. - const watch = this.fileService.watch(uri, options); + const watch = this.fileService.watch(URI.fromComponents(resource), options); this.toDispose.push(watch); this.watches.set(session, watch); } - /** - * Whether a plugin-requested watch should not be registered at all. - * - * Theia's backend ignores the `recursive` flag and always watches recursively. A NON-recursive - * watch rooted at a strict ancestor of a workspace root - e.g. a language server (such as - * `redhat.java` / JDT-LS) watching the PARENT of the workspace folder via - * `RelativePattern(parentDir, folderName)` purely to detect deletion of the folder itself - - * would therefore be turned into a recursive crawl of every sibling subtree under that parent, - * i.e. thousands of inodes the workspace does not own, which can exhaust the OS file-watch - * budget. `files.watcherExclude` cannot bound it because the root is outside the workspace, so - * the only effective mitigation is to not register the watch. - * - * Explicit recursive requests are honored as-is, and watches on or inside a workspace root are - * left untouched. - */ - protected shouldSkipWatch(uri: URI, options: WatchOptions): boolean { - if (options.recursive) { - return false; - } - const roots = this.workspaceService.tryGetRoots(); - // A folder that is itself a workspace root must always be watched, even if it also happens to - // be a (strict) ancestor of another root in a multi-root workspace where one root is nested - // inside another. Only watches rooted strictly above every root are dropped. - const isWorkspaceRoot = roots.some(root => uri.isEqual(root.resource)); - const isAncestorOfWorkspace = !isWorkspaceRoot && roots.some(root => uri.isEqualOrParent(root.resource)); - if (isAncestorOfWorkspace) { - const key = uri.toString(); - if (!this.skippedWatchRoots.has(key)) { - this.skippedWatchRoots.add(key); - console.warn('[MainFileSystemEventService] skipping non-recursive watch rooted at an ancestor of the ' - + `workspace (the backend would recursively crawl sibling trees): ${key}`); - } - return true; - } - return false; - } - $unwatch(session: number): void { const watch = this.watches.get(session); if (watch) {