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 a64367c423c56..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); @@ -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 { @@ -160,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) {