Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
- [ai-chat-ui] replaced the back/forward navigation stack with a single Home button in the chat view header (hidden on the overview); added `Ctrl/Cmd+Shift+L` to trigger it
- [ai-ide] redesigned the chat session overview as a list with Active/Restored sections, agent icon per row, contextual toolbar (Home, Browse all chats..., lock/summarize hidden on the overview), and keybindings `Ctrl+Shift+L` (Home) and `Ctrl+Alt+L` (Browse all chats...)
- [ai-mcp] added OAuth 2.1 authorization for remote MCP servers, including interactive sign-in/sign-out, automatic token refresh and storage, and a command to retrieve the OAuth redirect URL [#17638](https://github.com/eclipse-theia/theia/pull/17638)
- [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)
- [task] fixed `onDidStartTaskProcess` never firing for process tasks because `TaskServer.runTask` omitted the `task` argument to `fireTaskCreatedEvent` [#17663](https://github.com/eclipse-theia/theia/pull/17663)
- [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)
- [core] added support for React 19 and declared React peer dependencies as `^18.3.1 || ^19.0.0`. [#17567](https://github.com/eclipse-theia/theia/pull/17567)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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: [] });
Expand All @@ -76,3 +81,98 @@ 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 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);
});

});
Original file line number Diff line number Diff line change
Expand Up @@ -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<number, Disposable>();
/** Ancestor-of-workspace roots already skipped, to avoid logging on every re-registration. */
private readonly skippedWatchRoots = new Set<string>();

constructor(
rpc: RPCProtocol,
container: interfaces.Container,
private readonly fileService = container.get(FileService),
private readonly preferences = container.get<FileSystemPreferences>(FileSystemPreferences)
private readonly preferences = container.get<FileSystemPreferences>(FileSystemPreferences),
private readonly workspaceService = container.get(WorkspaceService)
) {
const proxy = rpc.getProxy(MAIN_RPC_CONTEXT.ExtHostFileSystemEventService);

Expand Down Expand Up @@ -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
Expand All @@ -93,6 +102,43 @@ 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 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;
}

protected getExcludes(uri: URI, requested: string[] = []): string[] {
const configured = this.preferences.get('files.watcherExclude', undefined, uri.toString());
const excludes = new Set(requested);
Expand Down
Loading