files - speed up glob matching for file events in extension host (#305962)

* files - speed up glob matching for file events in extension host

* partially restore old behaviour
This commit is contained in:
Benjamin Pasero
2026-03-28 16:36:18 +01:00
committed by GitHub
parent 8ce4cb75af
commit 1c7585a791
3 changed files with 163 additions and 35 deletions

View File

@@ -355,9 +355,7 @@ function parsePattern(arg1: string | IRelativePattern, options: IGlobOptions): P
...options,
equals: ignoreCase ? equalsIgnoreCase : (a: string, b: string) => a === b,
endsWith: ignoreCase ? endsWithIgnoreCase : (str: string, candidate: string) => str.endsWith(candidate),
// TODO: the '!isLinux' part below is to keep current behavior unchanged, but it should probably be removed
// in favor of passing correct options from the caller.
isEqualOrParent: (base: string, candidate: string) => isEqualOrParent(base, candidate, !isLinux || ignoreCase)
isEqualOrParent: (base: string, candidate: string) => isEqualOrParent(base, candidate, options.ignoreCase ?? !isLinux /* preserve old behaviour for when option is not adopted */)
};
// Check cache
@@ -371,13 +369,13 @@ function parsePattern(arg1: string | IRelativePattern, options: IGlobOptions): P
let match: RegExpExecArray | null;
if (T1.test(pattern)) {
parsedPattern = trivia1(pattern.substring(4), pattern, internalOptions); // common pattern: **/*.txt just need endsWith check
} else if (match = T2.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/some.txt just need basename check
} else if (match = T2.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/some.txt just need basename check
parsedPattern = trivia2(match[1], pattern, internalOptions);
} else if ((options.trimForExclusions ? T3_2 : T3).test(pattern)) { // repetition of common patterns (see above) {**/*.txt,**/*.png}
} else if ((options.trimForExclusions ? T3_2 : T3).test(pattern)) { // repetition of common patterns (see above) {**/*.txt,**/*.png}
parsedPattern = trivia3(pattern, internalOptions);
} else if (match = T4.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/something/else just need endsWith check
} else if (match = T4.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: **/something/else just need endsWith check
parsedPattern = trivia4and5(match[1].substring(1), pattern, true, internalOptions);
} else if (match = T5.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: something/else just need equals check
} else if (match = T5.exec(trimForExclusions(pattern, internalOptions))) { // common pattern: something/else just need equals check
parsedPattern = trivia4and5(match[1], pattern, false, internalOptions);
}

View File

@@ -5,7 +5,7 @@
import { Emitter, Event, AsyncEmitter, IWaitUntil, IWaitUntilData } from '../../../base/common/event.js';
import { GLOBSTAR, GLOB_SPLIT, IRelativePattern, parse } from '../../../base/common/glob.js';
import { URI } from '../../../base/common/uri.js';
import { URI, UriComponents } from '../../../base/common/uri.js';
import { ExtHostDocumentsAndEditors } from './extHostDocumentsAndEditors.js';
import type * as vscode from 'vscode';
import { ExtHostFileSystemEventServiceShape, FileSystemEvents, IMainContext, SourceTargetPair, IWorkspaceEditDto, IWillRunFileOperationParticipation, MainContext, IRelativePatternDto } from './extHost.protocol.js';
@@ -52,7 +52,7 @@ class FileSystemWatcher implements vscode.FileSystemWatcher {
return Boolean(this._config & 0b100);
}
constructor(mainContext: IMainContext, configuration: ExtHostConfigProvider, fileSystemInfo: ExtHostFileSystemInfo, workspace: IExtHostWorkspace, extension: IExtensionDescription, dispatcher: Event<FileSystemEvents>, globPattern: string | IRelativePatternDto, options: FileSystemWatcherCreateOptions) {
constructor(mainContext: IMainContext, configuration: ExtHostConfigProvider, fileSystemInfo: ExtHostFileSystemInfo, workspace: IExtHostWorkspace, extension: IExtensionDescription, dispatcher: Event<LazyRevivedFileSystemEvents>, globPattern: string | IRelativePatternDto, options: FileSystemWatcherCreateOptions) {
this._config = 0;
if (options.ignoreCreateEvents) {
this._config += 0b001;
@@ -68,7 +68,18 @@ class FileSystemWatcher implements vscode.FileSystemWatcher {
!((fileSystemInfo.getCapabilities(Schemas.file) ?? 0) & FileSystemProviderCapabilities.PathCaseSensitive) :
fileSystemInfo.extUri.ignorePathCasing(URI.revive(globPattern.baseUri));
const parsedPattern = parse(globPattern, { ignoreCase });
// Performance: pre-lowercase pattern and paths to use fast case-sensitive
// matching instead of repeated case-insensitive comparisons in the hot loop.
// By normalizing to lowercase upfront, we enforce `ignoreCase: false` so the
// glob parser uses strict `===` / `endsWith` instead of character-by-character
// case-folding on every comparison.
let matchGlob: string | IRelativePattern = globPattern;
if (ignoreCase) {
matchGlob = typeof globPattern === 'string'
? globPattern.toLowerCase()
: { base: globPattern.base.toLowerCase(), pattern: globPattern.pattern.toLowerCase() };
}
const parsedPattern = parse(matchGlob, { ignoreCase: false /* speeds up matching, but requires us to lowercase paths and patterns */ });
// 1.64.x behavior change: given the new support to watch any folder
// we start to ignore events outside the workspace when only a string
@@ -95,25 +106,22 @@ class FileSystemWatcher implements vscode.FileSystemWatcher {
}
if (!options.ignoreCreateEvents) {
for (const created of events.created) {
const uri = URI.revive(created);
if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {
for (const { uri, lowerCaseFsPath } of events.created) {
if (parsedPattern(ignoreCase ? lowerCaseFsPath : uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {
this._onDidCreate.fire(uri);
}
}
}
if (!options.ignoreChangeEvents) {
for (const changed of events.changed) {
const uri = URI.revive(changed);
if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {
for (const { uri, lowerCaseFsPath } of events.changed) {
if (parsedPattern(ignoreCase ? lowerCaseFsPath : uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {
this._onDidChange.fire(uri);
}
}
}
if (!options.ignoreDeleteEvents) {
for (const deleted of events.deleted) {
const uri = URI.revive(deleted);
if (parsedPattern(uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {
for (const { uri, lowerCaseFsPath } of events.deleted) {
if (parsedPattern(ignoreCase ? lowerCaseFsPath : uri.fsPath) && (!excludeOutOfWorkspaceEvents || workspace.getWorkspaceFolder(uri))) {
this._onDidDelete.fire(uri);
}
}
@@ -247,18 +255,28 @@ interface IExtensionListener<E> {
(e: E): any;
}
class LazyRevivedFileSystemEvents implements FileSystemEvents {
interface RevivedFileSystemEvent {
readonly uri: URI;
readonly lowerCaseFsPath: string;
}
class LazyRevivedFileSystemEvents {
readonly session: number | undefined;
private _created = new Lazy(() => this._events.created.map(URI.revive) as URI[]);
get created(): URI[] { return this._created.value; }
private _created = new Lazy(() => this._events.created.map(LazyRevivedFileSystemEvents._revive));
get created(): RevivedFileSystemEvent[] { return this._created.value; }
private _changed = new Lazy(() => this._events.changed.map(URI.revive) as URI[]);
get changed(): URI[] { return this._changed.value; }
private _changed = new Lazy(() => this._events.changed.map(LazyRevivedFileSystemEvents._revive));
get changed(): RevivedFileSystemEvent[] { return this._changed.value; }
private _deleted = new Lazy(() => this._events.deleted.map(URI.revive) as URI[]);
get deleted(): URI[] { return this._deleted.value; }
private _deleted = new Lazy(() => this._events.deleted.map(LazyRevivedFileSystemEvents._revive));
get deleted(): RevivedFileSystemEvent[] { return this._deleted.value; }
private static _revive(uriComponents: UriComponents): RevivedFileSystemEvent {
const uri = URI.revive(uriComponents);
return { uri, lowerCaseFsPath: uri.fsPath.toLowerCase() };
}
constructor(private readonly _events: FileSystemEvents) {
this.session = this._events.session;
@@ -267,7 +285,7 @@ class LazyRevivedFileSystemEvents implements FileSystemEvents {
export class ExtHostFileSystemEventService implements ExtHostFileSystemEventServiceShape {
private readonly _onFileSystemEvent = new Emitter<FileSystemEvents>();
private readonly _onFileSystemEvent = new Emitter<LazyRevivedFileSystemEvents>();
private readonly _onDidRenameFile = new Emitter<vscode.FileRenameEvent>();
private readonly _onDidCreateFile = new Emitter<vscode.FileCreateEvent>();

View File

@@ -8,20 +8,34 @@ import { IMainContext } from '../../common/extHost.protocol.js';
import { NullLogService } from '../../../../platform/log/common/log.js';
import { ensureNoDisposablesAreLeakedInTestSuite } from '../../../../base/test/common/utils.js';
import { ExtHostFileSystemInfo } from '../../common/extHostFileSystemInfo.js';
import { URI } from '../../../../base/common/uri.js';
import { FileSystemProviderCapabilities } from '../../../../platform/files/common/files.js';
import { IExtHostWorkspace } from '../../common/extHostWorkspace.js';
import { RelativePattern } from '../../common/extHostTypes.js';
import { nullExtensionDescription } from '../../../services/extensions/common/extensions.js';
import { ExtHostConfigProvider } from '../../common/extHostConfiguration.js';
suite('ExtHostFileSystemEventService', () => {
ensureNoDisposablesAreLeakedInTestSuite();
test('FileSystemWatcher ignore events properties are reversed #26851', function () {
const protocol: IMainContext = {
getProxy: () => { return undefined!; },
set: undefined!,
dispose: undefined!,
assertRegistered: undefined!,
drain: undefined!
};
const protocol: IMainContext = {
getProxy: () => { return undefined!; },
set: undefined!,
dispose: undefined!,
assertRegistered: undefined!,
drain: undefined!
};
const protocolWithProxy: IMainContext = {
getProxy: () => ({ $watch() { }, $unwatch() { }, dispose() { } }) as never,
set: undefined!,
dispose: undefined!,
assertRegistered: undefined!,
drain: undefined!
};
test('FileSystemWatcher ignore events properties are reversed #26851', function () {
const fileSystemInfo = new ExtHostFileSystemInfo();
@@ -38,4 +52,102 @@ suite('ExtHostFileSystemEventService', () => {
watcher2.dispose();
});
test('FileSystemWatcher matches case-insensitively via pre-lowercasing', function () {
const fileSystemInfo = new ExtHostFileSystemInfo();
// Default: no PathCaseSensitive capability → ignoreCase=true for string patterns
const workspace: Pick<IExtHostWorkspace, 'getWorkspaceFolder'> = {
getWorkspaceFolder: () => ({ uri: URI.file('/workspace'), name: 'test', index: 0 })
};
const service = new ExtHostFileSystemEventService(protocol, new NullLogService(), undefined!);
const watcher = service.createFileSystemWatcher(workspace as IExtHostWorkspace, undefined!, fileSystemInfo, undefined!, '**/*.TXT', {});
const created: URI[] = [];
const sub = watcher.onDidCreate(uri => created.push(uri));
// lowercase path should match uppercase pattern on case-insensitive fs
service.$onFileEvent({
session: undefined,
created: [URI.file('/workspace/file.txt')],
changed: [],
deleted: []
});
assert.strictEqual(created.length, 1);
sub.dispose();
watcher.dispose();
});
test('FileSystemWatcher matches case-sensitively when PathCaseSensitive', function () {
const fileSystemInfo = new ExtHostFileSystemInfo();
fileSystemInfo.$acceptProviderInfos(URI.file('/'), FileSystemProviderCapabilities.PathCaseSensitive);
const workspace: Pick<IExtHostWorkspace, 'getWorkspaceFolder'> = {
getWorkspaceFolder: () => ({ uri: URI.file('/workspace'), name: 'test', index: 0 })
};
const service = new ExtHostFileSystemEventService(protocol, new NullLogService(), undefined!);
const watcher = service.createFileSystemWatcher(workspace as IExtHostWorkspace, undefined!, fileSystemInfo, undefined!, '**/*.TXT', {});
const created: URI[] = [];
const sub = watcher.onDidCreate(uri => created.push(uri));
// lowercase path should NOT match uppercase pattern on case-sensitive fs
service.$onFileEvent({
session: undefined,
created: [URI.file('/workspace/file.txt')],
changed: [],
deleted: []
});
assert.strictEqual(created.length, 0);
// uppercase path SHOULD match
service.$onFileEvent({
session: undefined,
created: [URI.file('/workspace/file.TXT')],
changed: [],
deleted: []
});
assert.strictEqual(created.length, 1);
sub.dispose();
watcher.dispose();
});
test('FileSystemWatcher matches relative pattern case-insensitively via pre-lowercasing', function () {
const fileSystemInfo = new ExtHostFileSystemInfo();
fileSystemInfo.$acceptProviderInfos(URI.file('/'), FileSystemProviderCapabilities.FileReadWrite); // no PathCaseSensitive → ignoreCase=true
const workspace: Pick<IExtHostWorkspace, 'getWorkspaceFolder'> = {
getWorkspaceFolder: () => ({ uri: URI.file('/workspace'), name: 'test', index: 0 })
};
const configProvider = {
getConfiguration: () => ({ get: () => ({}) })
} as unknown as ExtHostConfigProvider;
const service = new ExtHostFileSystemEventService(protocolWithProxy, new NullLogService(), undefined!);
const watcher = service.createFileSystemWatcher(workspace as IExtHostWorkspace, configProvider, fileSystemInfo, nullExtensionDescription, new RelativePattern('/Workspace', '**/*.TXT'), {});
const created: URI[] = [];
const sub = watcher.onDidCreate(uri => created.push(uri));
// lowercase path should match mixed-case base + uppercase extension on case-insensitive fs
service.$onFileEvent({
session: undefined,
created: [URI.file('/workspace/file.txt')],
changed: [],
deleted: []
});
assert.strictEqual(created.length, 1);
sub.dispose();
watcher.dispose();
});
});