From 21b62772e926a98e31c83ee1e3275e511e9f7030 Mon Sep 17 00:00:00 2001 From: safi Date: Tue, 9 Jun 2026 10:56:51 +0300 Subject: [PATCH 1/3] fix(plugin-ext): skip plugin watches rooted at an ancestor of the workspace Theia's backend ignores the `recursive` flag and always watches recursively, so a non-recursive watch rooted at a strict ancestor of a workspace root - e.g. a language server (`redhat.java` / JDT-LS) watching the PARENT of the workspace folder via `RelativePattern(parentDir, folderName)` to detect deletion of the folder itself - is turned into a recursive crawl of every sibling subtree, which can exhaust the OS file-watch budget. `files.watcherExclude` cannot bound it because the watch root is outside the workspace. `MainFileSystemEventService.$watch` now skips such watches: no OS watch is registered. Explicit recursive requests and watches on or inside a workspace root are unchanged. Closes #17632 --- .../main-file-system-event-service.spec.ts | 90 ++++++++++++++++++- .../browser/main-file-system-event-service.ts | 45 +++++++++- 2 files changed, 133 insertions(+), 2 deletions(-) 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 index 28e021ba534ce..db564dfcd2eb8 100644 --- 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 @@ -17,7 +17,11 @@ 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 { WatchOptions } from '@theia/filesystem/lib/common/files'; import { UriComponents } from '../../common/uri-components'; @@ -62,8 +66,9 @@ describe('MainFileSystemEventService files.watcherExclude handling', () => { }; const rpc: any = { getProxy: () => ({}) }; + const workspaceService: any = { tryGetRoots: () => [] }; - const service = new MainFileSystemEventService(rpc, {} as any, fileService, preferences); + const service = new MainFileSystemEventService(rpc, {} as any, fileService, preferences, workspaceService); // Mirrors what `ensureWatching` sends for an absolute RelativePattern base outside the workspace. service.$watch(1, componentsFor('/outside/workspace/storage'), { recursive: true, excludes: [] }); @@ -76,3 +81,86 @@ describe('MainFileSystemEventService files.watcherExclude handling', () => { }); }); + +// 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 preferences: any = { get: () => undefined }; + const workspaceService: any = { tryGetRoots: () => rootUris.map(uri => ({ resource: new URI(uri) })) }; + const rpc: any = { getProxy: () => ({}) }; + return new MainFileSystemEventService(rpc, {} as any, fileService, preferences, 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 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 6580d7dae082e..6d436947d3b41 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 @@ -28,17 +28,21 @@ import { Disposable, DisposableCollection } from '@theia/core/lib/common/disposa import { FileService } from '@theia/filesystem/lib/browser/file-service'; import { FileChangeType, WatchOptions } from '@theia/filesystem/lib/common/files'; import { FileSystemPreferences } from '@theia/filesystem/lib/common/filesystem-preferences'; +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 preferences = container.get(FileSystemPreferences) + private readonly preferences = container.get(FileSystemPreferences), + private readonly workspaceService = container.get(WorkspaceService) ) { const proxy = rpc.getProxy(MAIN_RPC_CONTEXT.ExtHostFileSystemEventService); @@ -84,6 +88,11 @@ export class MainFileSystemEventService implements MainFileSystemEventServiceSha 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-created watchers (`vscode.workspace.createFileSystemWatcher`) arrive here with an // empty `excludes` list. Language servers frequently request recursive watches rooted at // absolute paths outside the workspace (e.g. JDT-LS's per-project globs), so apply the @@ -93,6 +102,40 @@ export class MainFileSystemEventService implements MainFileSystemEventServiceSha 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 isAncestorOfWorkspace = this.workspaceService.tryGetRoots().some( + root => uri.isEqualOrParent(root.resource) && !uri.isEqual(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; + } + protected getExcludes(uri: URI, requested: string[] = []): string[] { const configured = this.preferences.get('files.watcherExclude', undefined, uri.toString()); const excludes = new Set(requested); From 49befb9d6932fd7ebb7eb97ad2a776831e09f6d2 Mon Sep 17 00:00:00 2001 From: safi Date: Tue, 9 Jun 2026 10:58:01 +0300 Subject: [PATCH 2/3] chore: add changelog entry for #17633 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index bac83479e5543..a7b8dc853cf1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ ## 1.73.0 - tbd - [ai-core] discovered skills from `.agents/skills` directories alongside `.prompts/skills` (workspace and home directory) [#17553](https://github.com/eclipse-theia/theia/pull/17553) +- [plugin-ext] avoided exhausting OS file watches by not registering plugin watches rooted at an ancestor of the workspace folder (e.g. a language server watching the parent directory), which the backend would otherwise crawl recursively [#17633](https://github.com/eclipse-theia/theia/pull/17633) - [terminal] fixed Cmd+V / Ctrl+V paste in the integrated terminal and restored the effect of the `terminal.enablePaste` and `terminal.enableCopy` preferences [#17603](https://github.com/eclipse-theia/theia/pull/17603) [Breaking Changes:](#breaking_changes_1.73.0) From 3cfc6369e3386b5234b3586f224c60588f606c97 Mon Sep 17 00:00:00 2001 From: safi Date: Thu, 18 Jun 2026 15:55:30 +0300 Subject: [PATCH 3/3] fix(plugin-ext): keep watching a workspace root that is an ancestor of another root In a multi-root workspace where one root is nested inside another (e.g. roots `/projects` and `/projects/my-app`), `shouldSkipWatch` dropped a non-recursive watch on the outer root: for the inner root the outer folder is a strict ancestor, so the `.some(...)` check matched and the watch was skipped even though the outer folder is itself a workspace root the user opened. Treat a folder that equals any workspace root as never-skippable, and only drop watches rooted strictly above every root. Adds a regression test for the nested multi-root case. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../browser/main-file-system-event-service.spec.ts | 12 ++++++++++++ .../main/browser/main-file-system-event-service.ts | 9 ++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) 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 index db564dfcd2eb8..7ffc9840876ea 100644 --- 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 @@ -133,6 +133,18 @@ describe('MainFileSystemEventService ancestor-of-workspace watch handling', () = 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); 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 6d436947d3b41..2b1476d7f028f 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 @@ -121,9 +121,12 @@ export class MainFileSystemEventService implements MainFileSystemEventServiceSha if (options.recursive) { return false; } - const isAncestorOfWorkspace = this.workspaceService.tryGetRoots().some( - root => uri.isEqualOrParent(root.resource) && !uri.isEqual(root.resource) - ); + 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)) {