From b26abaf934c6e1011ed5f3f6ed6aff6c8515d3ed Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 21 Jun 2021 07:04:26 +0200 Subject: [PATCH 1/4] file watcher - some code :lipstick: --- .../node/watcher/nodejs/watcherService.ts | 6 +- .../node/watcher/nsfw/nsfwWatcherService.ts | 97 +++++++++++-------- .../nsfw/test/nsfwWatcherService.test.ts | 32 +++--- .../files/node/watcher/nsfw/watcherService.ts | 6 +- .../watcher/unix/chokidarWatcherService.ts | 16 +-- .../files/node/watcher/unix/watcherService.ts | 6 +- .../watcher/win32/csharpWatcherService.ts | 28 +++--- .../node/watcher/win32/watcherService.ts | 4 +- .../services/editor/browser/editorService.ts | 2 +- 9 files changed, 106 insertions(+), 91 deletions(-) diff --git a/src/vs/platform/files/node/watcher/nodejs/watcherService.ts b/src/vs/platform/files/node/watcher/nodejs/watcherService.ts index 0d03d317fc073..d81d57cb3df47 100644 --- a/src/vs/platform/files/node/watcher/nodejs/watcherService.ts +++ b/src/vs/platform/files/node/watcher/nodejs/watcherService.ts @@ -100,9 +100,9 @@ export class FileWatcher extends Disposable { // Logging if (this.verboseLogging) { - normalizedFileChanges.forEach(event => { - this.onVerbose(`>> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`); - }); + for (const e of normalizedFileChanges) { + this.onVerbose(`>> normalized ${e.type === FileChangeType.ADDED ? '[ADDED]' : e.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${e.path}`); + } } // Fire diff --git a/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts b/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts index 2f0d7a8a6ee14..3d50d8e73599d 100644 --- a/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts +++ b/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import * as nsfw from 'nsfw'; -import * as glob from 'vs/base/common/glob'; +import { ParsedPattern, parse } from 'vs/base/common/glob'; import { join } from 'vs/base/common/path'; import { isMacintosh } from 'vs/base/common/platform'; import { isEqualOrParent } from 'vs/base/common/extpath'; @@ -22,15 +22,15 @@ nsfwActionToRawChangeType[nsfw.actions.CREATED] = FileChangeType.ADDED; nsfwActionToRawChangeType[nsfw.actions.MODIFIED] = FileChangeType.UPDATED; nsfwActionToRawChangeType[nsfw.actions.DELETED] = FileChangeType.DELETED; -interface IWatcherObjet { +interface IWatcher { start(): void; stop(): void; } interface IPathWatcher { - ready: Promise; - watcher?: IWatcherObjet; - ignored: glob.ParsedPattern[]; + readonly ready: Promise; + watcher?: IWatcher; + ignored: ParsedPattern[]; } export class NsfwWatcherService extends Disposable implements IWatcherService { @@ -47,8 +47,24 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { private verboseLogging: boolean | undefined; private enospcErrorLogged: boolean | undefined; + constructor() { + super(); + + process.on('uncaughtException', (e: Error | string) => { + // Specially handle ENOSPC errors that can happen when + // the watcher consumes so many file descriptors that + // we are running into a limit. We only want to warn + // once in this case to avoid log spam. + // See https://github.com/microsoft/vscode/issues/7950 + if (e === 'Inotify limit reached' && !this.enospcErrorLogged) { + this.enospcErrorLogged = true; + this.error('Inotify limit reached (ENOSPC)'); + } + }); + } + async setRoots(roots: IWatcherRequest[]): Promise { - const normalizedRoots = this._normalizeRoots(roots); + const normalizedRoots = this.normalizeRoots(roots); // Gather roots that are not currently being watched const rootsToStartWatching = normalizedRoots.filter(root => { @@ -61,48 +77,35 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { }); // Logging - this.debug(`Start watching: [${rootsToStartWatching.map(root => root.path).join(',')}]\nStop watching: [${rootsToStopWatching.join(',')}]`); + this.debug(`Start watching: ${rootsToStartWatching.map(root => `${root.path} (excludes: ${root.excludes})`).join(',')}`); + this.debug(`Stop watching: ${rootsToStopWatching.join(',')}`); // Stop watching some roots - rootsToStopWatching.forEach(root => { + for (const root of rootsToStopWatching) { this.pathWatchers[root].ready.then(watcher => watcher.stop()); delete this.pathWatchers[root]; - }); + } // Start watching some roots - rootsToStartWatching.forEach(root => this.doWatch(root)); + for (const root of rootsToStartWatching) { + this.doWatch(root); + } // Refresh ignored arrays in case they changed - roots.forEach(root => { + for (const root of roots) { if (root.path in this.pathWatchers) { - this.pathWatchers[root.path].ignored = Array.isArray(root.excludes) ? root.excludes.map(ignored => glob.parse(ignored)) : []; + this.pathWatchers[root.path].ignored = Array.isArray(root.excludes) ? root.excludes.map(ignored => parse(ignored)) : []; } - }); + } } private doWatch(request: IWatcherRequest): void { - let undeliveredFileEvents: IDiskFileChange[] = []; - const fileEventDelayer = new ThrottledDelayer(NsfwWatcherService.FS_EVENT_DELAY); - - let readyPromiseResolve: (watcher: IWatcherObjet) => void; + let readyPromiseResolve: (watcher: IWatcher) => void; this.pathWatchers[request.path] = { - ready: new Promise(resolve => readyPromiseResolve = resolve), - ignored: Array.isArray(request.excludes) ? request.excludes.map(ignored => glob.parse(ignored)) : [] + ready: new Promise(resolve => readyPromiseResolve = resolve), + ignored: Array.isArray(request.excludes) ? request.excludes.map(ignored => parse(ignored)) : [] }; - process.on('uncaughtException', (e: Error | string) => { - - // Specially handle ENOSPC errors that can happen when - // the watcher consumes so many file descriptors that - // we are running into a limit. We only want to warn - // once in this case to avoid log spam. - // See https://github.com/microsoft/vscode/issues/7950 - if (e === 'Inotify limit reached' && !this.enospcErrorLogged) { - this.enospcErrorLogged = true; - this.error('Inotify limit reached (ENOSPC)'); - } - }); - // NSFW does not report file changes in the path provided on macOS if // - the path uses wrong casing // - the path is a symbolic link @@ -133,25 +136,31 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { this.debug(`Start watching with nsfw: ${request.path}`); + let undeliveredFileEvents: IDiskFileChange[] = []; + const fileEventDelayer = new ThrottledDelayer(NsfwWatcherService.FS_EVENT_DELAY); + nsfw(request.path, events => { for (const e of events) { + // Logging if (this.verboseLogging) { const logPath = e.action === nsfw.actions.RENAMED ? join(e.directory, e.oldFile || '') + ' -> ' + e.newFile : join(e.directory, e.file || ''); this.log(`${e.action === nsfw.actions.CREATED ? '[CREATED]' : e.action === nsfw.actions.DELETED ? '[DELETED]' : e.action === nsfw.actions.MODIFIED ? '[CHANGED]' : '[RENAMED]'} ${logPath}`); } - // Convert nsfw event to IRawFileChange and add to queue + // Convert nsfw event to `IRawFileChange` and add to queue let absolutePath: string; if (e.action === nsfw.actions.RENAMED) { - // Rename fires when a file's name changes within a single directory - absolutePath = join(e.directory, e.oldFile || ''); + absolutePath = join(e.directory, e.oldFile || ''); // Rename fires when a file's name changes within a single directory + if (!this.isPathIgnored(absolutePath, this.pathWatchers[request.path].ignored)) { undeliveredFileEvents.push({ type: FileChangeType.DELETED, path: absolutePath }); } else if (this.verboseLogging) { this.log(` >> ignored ${absolutePath}`); } + absolutePath = join(e.newDirectory || e.directory, e.newFile || ''); + if (!this.isPathIgnored(absolutePath, this.pathWatchers[request.path].ignored)) { undeliveredFileEvents.push({ type: FileChangeType.ADDED, path: absolutePath }); } else if (this.verboseLogging) { @@ -159,6 +168,7 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { } } else { absolutePath = join(e.directory, e.file || ''); + if (!this.isPathIgnored(absolutePath, this.pathWatchers[request.path].ignored)) { undeliveredFileEvents.push({ type: nsfwActionToRawChangeType[e.action], @@ -176,7 +186,7 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { undeliveredFileEvents = []; if (isMacintosh) { - events.forEach(e => { + for (const e of events) { // Mac uses NFD unicode form on disk, but we want NFC e.path = normalizeNFC(e.path); @@ -185,7 +195,7 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { if (realBasePathDiffers) { e.path = request.path + e.path.substr(realBasePathLength); } - }); + } } // Broadcast to clients normalized @@ -194,9 +204,9 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { // Logging if (this.verboseLogging) { - normalizedEvents.forEach(e => { + for (const e of normalizedEvents) { this.log(` >> normalized ${e.type === FileChangeType.ADDED ? '[ADDED]' : e.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${e.path}`); - }); + } } }); }).then(watcher => { @@ -216,21 +226,22 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { for (let path in this.pathWatchers) { let watcher = this.pathWatchers[path]; watcher.ready.then(watcher => watcher.stop()); + delete this.pathWatchers[path]; } this.pathWatchers = Object.create(null); } - protected _normalizeRoots(roots: IWatcherRequest[]): IWatcherRequest[] { + protected normalizeRoots(roots: IWatcherRequest[]): IWatcherRequest[] { // Normalizes a set of root paths by removing any root paths that are // sub-paths of other roots. - return roots.filter(r => roots.every(other => { - return !(r.path.length > other.path.length && isEqualOrParent(r.path, other.path)); + return roots.filter(root => roots.every(otherRoot => { + return !(root.path.length > otherRoot.path.length && isEqualOrParent(root.path, otherRoot.path)); })); } - private isPathIgnored(absolutePath: string, ignored: glob.ParsedPattern[]): boolean { + private isPathIgnored(absolutePath: string, ignored: ParsedPattern[]): boolean { return ignored && ignored.some(ignore => ignore(absolutePath)); } diff --git a/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts b/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts index b2f9e4103b079..99792bfa19d96 100644 --- a/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts +++ b/src/vs/platform/files/node/watcher/nsfw/test/nsfwWatcherService.test.ts @@ -15,14 +15,14 @@ suite('NSFW Watcher Service', async () => { class TestNsfwWatcherService extends NsfwWatcherService { - normalizeRoots(roots: string[]): string[] { + testNormalizeRoots(roots: string[]): string[] { // Work with strings as paths to simplify testing const requests: IWatcherRequest[] = roots.map(r => { return { path: r, excludes: [] }; }); - return this._normalizeRoots(requests).map(r => r.path); + return this.normalizeRoots(requests).map(r => r.path); } } @@ -30,28 +30,28 @@ suite('NSFW Watcher Service', async () => { test('should not impacts roots that don\'t overlap', () => { const service = new TestNsfwWatcherService(); if (platform.isWindows) { - assert.deepStrictEqual(service.normalizeRoots(['C:\\a']), ['C:\\a']); - assert.deepStrictEqual(service.normalizeRoots(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(service.normalizeRoots(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); + assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a']), ['C:\\a']); + assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\b', 'C:\\c\\d\\e']), ['C:\\a', 'C:\\b', 'C:\\c\\d\\e']); } else { - assert.deepStrictEqual(service.normalizeRoots(['/a']), ['/a']); - assert.deepStrictEqual(service.normalizeRoots(['/a', '/b']), ['/a', '/b']); - assert.deepStrictEqual(service.normalizeRoots(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); + assert.deepStrictEqual(service.testNormalizeRoots(['/a']), ['/a']); + assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/b']), ['/a', '/b']); + assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/b', '/c/d/e']), ['/a', '/b', '/c/d/e']); } }); test('should remove sub-folders of other roots', () => { const service = new TestNsfwWatcherService(); if (platform.isWindows) { - assert.deepStrictEqual(service.normalizeRoots(['C:\\a', 'C:\\a\\b']), ['C:\\a']); - assert.deepStrictEqual(service.normalizeRoots(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(service.normalizeRoots(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); - assert.deepStrictEqual(service.normalizeRoots(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); + assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\a\\b']), ['C:\\a']); + assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(service.testNormalizeRoots(['C:\\b\\a', 'C:\\a', 'C:\\b', 'C:\\a\\b']), ['C:\\a', 'C:\\b']); + assert.deepStrictEqual(service.testNormalizeRoots(['C:\\a', 'C:\\a\\b', 'C:\\a\\c\\d']), ['C:\\a']); } else { - assert.deepStrictEqual(service.normalizeRoots(['/a', '/a/b']), ['/a']); - assert.deepStrictEqual(service.normalizeRoots(['/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(service.normalizeRoots(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); - assert.deepStrictEqual(service.normalizeRoots(['/a', '/a/b', '/a/c/d']), ['/a']); + assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/a/b']), ['/a']); + assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(service.testNormalizeRoots(['/b/a', '/a', '/b', '/a/b']), ['/a', '/b']); + assert.deepStrictEqual(service.testNormalizeRoots(['/a', '/a/b', '/a/c/d']), ['/a']); } }); }); diff --git a/src/vs/platform/files/node/watcher/nsfw/watcherService.ts b/src/vs/platform/files/node/watcher/nsfw/watcherService.ts index ab758c0247154..36d43de1c2bfd 100644 --- a/src/vs/platform/files/node/watcher/nsfw/watcherService.ts +++ b/src/vs/platform/files/node/watcher/nsfw/watcherService.ts @@ -20,8 +20,8 @@ export class FileWatcher extends Disposable { constructor( private folders: IWatcherRequest[], - private onDidFilesChange: (changes: IDiskFileChange[]) => void, - private onLogMessage: (msg: ILogMessage) => void, + private readonly onDidFilesChange: (changes: IDiskFileChange[]) => void, + private readonly onLogMessage: (msg: ILogMessage) => void, private verboseLogging: boolean, ) { super(); @@ -62,9 +62,9 @@ export class FileWatcher extends Disposable { // Initialize watcher this.service = ProxyChannel.toService(getNextTickChannel(client.getChannel('watcher'))); - this.service.setVerboseLogging(this.verboseLogging); + // Wire in event handlers this._register(this.service.onDidChangeFile(e => !this.isDisposed && this.onDidFilesChange(e))); this._register(this.service.onDidLogMessage(e => this.onLogMessage(e))); diff --git a/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts b/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts index a8e0693396180..c1973c2147acf 100644 --- a/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts +++ b/src/vs/platform/files/node/watcher/unix/chokidarWatcherService.ts @@ -6,7 +6,7 @@ import * as chokidar from 'chokidar'; import * as fs from 'fs'; import * as gracefulFs from 'graceful-fs'; -import * as glob from 'vs/base/common/glob'; +import { match, ParsedPattern, parse } from 'vs/base/common/glob'; import { isEqualOrParent } from 'vs/base/common/extpath'; import { FileChangeType } from 'vs/platform/files/common/files'; import { ThrottledDelayer } from 'vs/base/common/async'; @@ -29,7 +29,7 @@ interface IWatcher { } interface ExtendedWatcherRequest extends IWatcherRequest { - parsedPattern?: glob.ParsedPattern; + parsedPattern?: ParsedPattern; } export class ChokidarWatcherService extends Disposable implements IWatcherService { @@ -104,7 +104,7 @@ export class ChokidarWatcherService extends Disposable implements IWatcherServic let usePolling = this.usePolling; // boolean or a list of path patterns if (Array.isArray(usePolling)) { // switch to polling if one of the paths matches with a watched path - usePolling = usePolling.some(pattern => requests.some(request => glob.match(pattern, request.path))); + usePolling = usePolling.some(pattern => requests.some(request => match(pattern, request.path))); } const watcherOpts: chokidar.WatchOptions = { @@ -146,7 +146,7 @@ export class ChokidarWatcherService extends Disposable implements IWatcherServic this.warn(`Watcher basePath does not match version on disk and was corrected (original: ${basePath}, real: ${realBasePath})`); } - this.debug(`Start watching with chokidar: ${realBasePath}, excludes: ${excludes.join(',')}, usePolling: ${usePolling ? 'true, interval ' + pollingInterval : 'false'}`); + this.debug(`Start watching: ${realBasePath}, excludes: ${excludes.join(',')}, usePolling: ${usePolling ? 'true, interval ' + pollingInterval : 'false'}`); let chokidarWatcher: chokidar.FSWatcher | null = chokidar.watch(realBasePath, watcherOpts); this._watcherCount++; @@ -166,11 +166,13 @@ export class ChokidarWatcherService extends Disposable implements IWatcherServic if (this.verboseLogging) { this.log(`Stop watching: ${basePath}]`); } + if (chokidarWatcher) { await chokidarWatcher.close(); this._watcherCount--; chokidarWatcher = null; } + if (fileEventDelayer) { fileEventDelayer.cancel(); fileEventDelayer = null; @@ -255,9 +257,9 @@ export class ChokidarWatcherService extends Disposable implements IWatcherServic // Logging if (this.verboseLogging) { - normalizedEvents.forEach(e => { + for (const e of normalizedEvents) { this.log(` >> normalized ${e.type === FileChangeType.ADDED ? '[ADDED]' : e.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${e.path}`); - }); + } } return undefined; @@ -322,7 +324,7 @@ function isIgnored(path: string, requests: ExtendedWatcherRequest[]): boolean { if (!request.parsedPattern) { if (request.excludes && request.excludes.length > 0) { const pattern = `{${request.excludes.join(',')}}`; - request.parsedPattern = glob.parse(pattern); + request.parsedPattern = parse(pattern); } else { request.parsedPattern = () => false; } diff --git a/src/vs/platform/files/node/watcher/unix/watcherService.ts b/src/vs/platform/files/node/watcher/unix/watcherService.ts index e159d48f40eda..52c0cf9c43df3 100644 --- a/src/vs/platform/files/node/watcher/unix/watcherService.ts +++ b/src/vs/platform/files/node/watcher/unix/watcherService.ts @@ -20,10 +20,10 @@ export class FileWatcher extends Disposable { constructor( private folders: IWatcherRequest[], - private onDidFilesChange: (changes: IDiskFileChange[]) => void, - private onLogMessage: (msg: ILogMessage) => void, + private readonly onDidFilesChange: (changes: IDiskFileChange[]) => void, + private readonly onLogMessage: (msg: ILogMessage) => void, private verboseLogging: boolean, - private watcherOptions: IWatcherOptions = {} + private readonly watcherOptions: IWatcherOptions = {} ) { super(); diff --git a/src/vs/platform/files/node/watcher/win32/csharpWatcherService.ts b/src/vs/platform/files/node/watcher/win32/csharpWatcherService.ts index 949923b2dfaee..af70668627746 100644 --- a/src/vs/platform/files/node/watcher/win32/csharpWatcherService.ts +++ b/src/vs/platform/files/node/watcher/win32/csharpWatcherService.ts @@ -3,10 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as cp from 'child_process'; +import { ChildProcess, spawn } from 'child_process'; import { FileChangeType } from 'vs/platform/files/common/files'; -import * as decoder from 'vs/base/node/decoder'; -import * as glob from 'vs/base/common/glob'; +import { LineDecoder } from 'vs/base/node/decoder'; +import { parse, ParsedPattern } from 'vs/base/common/glob'; import { IDiskFileChange, ILogMessage } from 'vs/platform/files/node/watcher/watcher'; import { FileAccess } from 'vs/base/common/network'; @@ -14,11 +14,11 @@ export class OutOfProcessWin32FolderWatcher { private static readonly MAX_RESTARTS = 5; - private static changeTypeMap: FileChangeType[] = [FileChangeType.UPDATED, FileChangeType.ADDED, FileChangeType.DELETED]; + private static readonly changeTypeMap = [FileChangeType.UPDATED, FileChangeType.ADDED, FileChangeType.DELETED]; - private ignored: glob.ParsedPattern[]; + private readonly ignored: ParsedPattern[]; - private handle: cp.ChildProcess | undefined; + private handle: ChildProcess | undefined; private restartCounter: number; constructor( @@ -31,14 +31,14 @@ export class OutOfProcessWin32FolderWatcher { this.restartCounter = 0; if (Array.isArray(ignored)) { - this.ignored = ignored.map(ignore => glob.parse(ignore)); + this.ignored = ignored.map(ignore => parse(ignore)); } else { this.ignored = []; } // Logging if (this.verboseLogging) { - this.log(`Start watching: ${watchedFolder}`); + this.log(`Start watching: ${watchedFolder}, excludes: ${ignored.join(',')}`); } this.startWatcher(); @@ -50,16 +50,16 @@ export class OutOfProcessWin32FolderWatcher { args.push('-verbose'); } - this.handle = cp.spawn(FileAccess.asFileUri('vs/platform/files/node/watcher/win32/CodeHelper.exe', require).fsPath, args); + this.handle = spawn(FileAccess.asFileUri('vs/platform/files/node/watcher/win32/CodeHelper.exe', require).fsPath, args); - const stdoutLineDecoder = new decoder.LineDecoder(); + const stdoutLineDecoder = new LineDecoder(); // Events over stdout this.handle.stdout!.on('data', (data: Buffer) => { // Collect raw events from output const rawEvents: IDiskFileChange[] = []; - stdoutLineDecoder.write(data).forEach((line) => { + for (const line of stdoutLineDecoder.write(data)) { const eventParts = line.split('|'); if (eventParts.length === 2) { const changeType = Number(eventParts[0]); @@ -89,7 +89,7 @@ export class OutOfProcessWin32FolderWatcher { this.log(eventParts[1]); } } - }); + } // Trigger processing of events through the delayer to batch them up properly if (rawEvents.length > 0) { @@ -110,7 +110,9 @@ export class OutOfProcessWin32FolderWatcher { } private onExit(code: number, signal: string): void { - if (this.handle) { // exit while not yet being disposed is unexpected! + if (this.handle) { + + // exit while not yet being disposed is unexpected! this.error(`terminated unexpectedly (code: ${code}, signal: ${signal})`); if (this.restartCounter <= OutOfProcessWin32FolderWatcher.MAX_RESTARTS) { diff --git a/src/vs/platform/files/node/watcher/win32/watcherService.ts b/src/vs/platform/files/node/watcher/win32/watcherService.ts index d062e34ae9fa5..aac2584d95e8f 100644 --- a/src/vs/platform/files/node/watcher/win32/watcherService.ts +++ b/src/vs/platform/files/node/watcher/win32/watcherService.ts @@ -16,8 +16,8 @@ export class FileWatcher implements IDisposable { constructor( folders: { path: string, excludes: string[] }[], - private onDidFilesChange: (changes: IDiskFileChange[]) => void, - private onLogMessage: (msg: ILogMessage) => void, + private readonly onDidFilesChange: (changes: IDiskFileChange[]) => void, + private readonly onLogMessage: (msg: ILogMessage) => void, private verboseLogging: boolean ) { this.folder = folders[0]; diff --git a/src/vs/workbench/services/editor/browser/editorService.ts b/src/vs/workbench/services/editor/browser/editorService.ts index 1e05a0d5c6984..ec8b6ecb72da7 100644 --- a/src/vs/workbench/services/editor/browser/editorService.ts +++ b/src/vs/workbench/services/editor/browser/editorService.ts @@ -538,7 +538,7 @@ export class EditorService extends Disposable implements EditorServiceImpl { return undefined; } - doResolveEditorOpenRequest(editor: IEditorInput | IResourceEditorInputType, optionsOrGroup?: IEditorOptions | OpenInEditorGroup, group?: OpenInEditorGroup): [IEditorGroup, EditorInput, IEditorOptions | undefined] | undefined { + private doResolveEditorOpenRequest(editor: IEditorInput | IResourceEditorInputType, optionsOrGroup?: IEditorOptions | OpenInEditorGroup, group?: OpenInEditorGroup): [IEditorGroup, EditorInput, IEditorOptions | undefined] | undefined { let resolvedGroup: IEditorGroup | undefined; let candidateGroup: OpenInEditorGroup | undefined; From eeed7b3fafc471eeeba0e07b327b6b823c757dbf Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 21 Jun 2021 07:12:17 +0200 Subject: [PATCH 2/4] file watcher - implement some render side throttling --- src/vs/base/common/async.ts | 99 ++++++++++- src/vs/base/test/common/async.test.ts | 185 ++++++++++++++++++++ src/vs/platform/files/common/fileService.ts | 51 +++++- 3 files changed, 331 insertions(+), 4 deletions(-) diff --git a/src/vs/base/common/async.ts b/src/vs/base/common/async.ts index b5a4bbbf02939..08066a336a3d7 100644 --- a/src/vs/base/common/async.ts +++ b/src/vs/base/common/async.ts @@ -6,7 +6,7 @@ import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; import { canceled, onUnexpectedError } from 'vs/base/common/errors'; import { Emitter, Event, Listener } from 'vs/base/common/event'; -import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { IDisposable, toDisposable, Disposable, MutableDisposable } from 'vs/base/common/lifecycle'; import { LinkedList } from 'vs/base/common/linkedList'; import { extUri as defaultExtUri, IExtUri } from 'vs/base/common/resources'; import { URI } from 'vs/base/common/uri'; @@ -787,6 +787,103 @@ export class RunOnceWorker extends RunOnceScheduler { } } +/** + * The `ThrottledWorker` will accept units of work `T` + * to handle. The contract is: + * * there is a maximum of units the worker can handle at once (via `chunkSize`) + * * after having handled units, the worker needs to rest (via `throttleDelay`) + */ +export class ThrottledWorker extends Disposable { + + private readonly pendingWork: T[] = []; + + private readonly throttler = this._register(new MutableDisposable()); + private disposed = false; + + constructor( + private readonly maxWorkChunkSize: number, + private readonly maxPendingWork: number | undefined, + private readonly throttleDelay: number, + private readonly handler: (units: readonly T[]) => void + ) { + super(); + } + + /** + * The number of work units that are pending to be processed. + */ + get pending(): number { return this.pendingWork.length; } + + /** + * Add units to be worked on. Use `pending` to figure out + * how many units are not yet processed after this method + * was called. + * + * @returns whether the work was accepted or not. If the + * worker is disposed, it will not accept any more work. + * If the number of pending units would become larger + * than `maxPendingWork`, more work will also not be accepted. + */ + work(units: readonly T[]): boolean { + if (this.disposed) { + return false; // work not accepted: disposed + } + + // Check for reaching maximum of pending work + if (typeof this.maxPendingWork === 'number') { + + // Throttled: simple check if pending + units exceeds max pending + if (this.throttler.value) { + if (this.pending + units.length > this.maxPendingWork) { + return false; // work not accepted: too much pending work + } + } + + // Unthrottled: same as throttled, but account for max chunk getting + // worked on directly without being pending + else { + if (this.pending + units.length - this.maxWorkChunkSize > this.maxPendingWork) { + return false; // work not accepted: too much pending work + } + } + } + + // Add to pending units first + this.pendingWork.push(...units); + + // If not throttled, start working directly + // Otherwise, when the throttle delay has + // past, pending work will be worked again. + if (!this.throttler.value) { + this.doWork(); + } + + return true; // work accepted + } + + private doWork(): void { + + // Extract chunk to handle and handle it + this.handler(this.pendingWork.splice(0, this.maxWorkChunkSize)); + + // If we have remaining work, schedule it after a delay + if (this.pendingWork.length > 0) { + this.throttler.value = new RunOnceScheduler(() => { + this.throttler.clear(); + + this.doWork(); + }, this.throttleDelay); + this.throttler.value.schedule(); + } + } + + override dispose(): void { + super.dispose(); + + this.disposed = true; + } +} + //#region -- run on idle tricks ------------ export interface IdleDeadline { diff --git a/src/vs/base/test/common/async.test.ts b/src/vs/base/test/common/async.test.ts index 1bbd7b813e070..ad41b5be81d2a 100644 --- a/src/vs/base/test/common/async.test.ts +++ b/src/vs/base/test/common/async.test.ts @@ -1026,4 +1026,189 @@ suite('Async', () => { assert.ok(p3Handled); }); }); + + suite('ThrottledWorker', () => { + + function assertArrayEquals(actual: unknown[], expected: unknown[]) { + assert.strictEqual(actual.length, expected.length); + + for (let i = 0; i < actual.length; i++) { + assert.strictEqual(actual[i], expected[i]); + } + } + + test('basics', async () => { + let handled: number[] = []; + + let handledCallback: Function; + let handledPromise = new Promise(resolve => handledCallback = resolve); + let handledCounterToResolve = 1; + let currentHandledCounter = 0; + + const handler = (units: readonly number[]) => { + handled.push(...units); + + currentHandledCounter++; + if (currentHandledCounter === handledCounterToResolve) { + handledCallback(); + + handledPromise = new Promise(resolve => handledCallback = resolve); + currentHandledCounter = 0; + } + }; + + const worker = new async.ThrottledWorker(5, undefined, 1, handler); + + // Work less than chunk size + + let worked = worker.work([1, 2, 3]); + + assertArrayEquals(handled, [1, 2, 3]); + assert.strictEqual(worker.pending, 0); + assert.strictEqual(worked, true); + + worker.work([4, 5]); + worked = worker.work([6]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5, 6]); + assert.strictEqual(worker.pending, 0); + assert.strictEqual(worked, true); + + // Work more than chunk size (variant 1) + + handled = []; + handledCounterToResolve = 2; + + worked = worker.work([1, 2, 3, 4, 5, 6, 7]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5]); + assert.strictEqual(worker.pending, 2); + assert.strictEqual(worked, true); + + await handledPromise; + + assertArrayEquals(handled, [1, 2, 3, 4, 5, 6, 7]); + + handled = []; + handledCounterToResolve = 4; + + worked = worker.work([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5]); + assert.strictEqual(worker.pending, 14); + assert.strictEqual(worked, true); + + await handledPromise; + + assertArrayEquals(handled, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]); + + // Work more than chunk size (variant 2) + + handled = []; + handledCounterToResolve = 2; + + worked = worker.work([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5]); + assert.strictEqual(worker.pending, 5); + assert.strictEqual(worked, true); + + await handledPromise; + + assertArrayEquals(handled, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + + // Work more while throttled (variant 1) + + handled = []; + handledCounterToResolve = 3; + + worked = worker.work([1, 2, 3, 4, 5, 6, 7]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5]); + assert.strictEqual(worker.pending, 2); + assert.strictEqual(worked, true); + + worker.work([8]); + worked = worker.work([9, 10, 11]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5]); + assert.strictEqual(worker.pending, 6); + assert.strictEqual(worked, true); + + await handledPromise; + + assertArrayEquals(handled, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + assert.strictEqual(worker.pending, 0); + + // Work more while throttled (variant 2) + + handled = []; + handledCounterToResolve = 2; + + worked = worker.work([1, 2, 3, 4, 5, 6, 7]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5]); + assert.strictEqual(worked, true); + + worker.work([8]); + worked = worker.work([9, 10]); + + assertArrayEquals(handled, [1, 2, 3, 4, 5]); + assert.strictEqual(worked, true); + + await handledPromise; + + assertArrayEquals(handled, [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + }); + + test('do not accept too much work', async () => { + let handled: number[] = []; + const handler = (units: readonly number[]) => handled.push(...units); + + const worker = new async.ThrottledWorker(5, 5, 1, handler); + + let worked = worker.work([1, 2, 3]); + assert.strictEqual(worked, true); + + worked = worker.work([1, 2, 3, 4, 5, 6]); + assert.strictEqual(worked, true); + assert.strictEqual(worker.pending, 1); + + worked = worker.work([7]); + assert.strictEqual(worked, true); + assert.strictEqual(worker.pending, 2); + + worked = worker.work([8, 9, 10, 11]); + assert.strictEqual(worked, false); + assert.strictEqual(worker.pending, 2); + }); + + test('do not accept too much work (account for max chunk size', async () => { + let handled: number[] = []; + const handler = (units: readonly number[]) => handled.push(...units); + + const worker = new async.ThrottledWorker(5, 5, 1, handler); + + let worked = worker.work([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11]); + assert.strictEqual(worked, false); + assert.strictEqual(worker.pending, 0); + + worked = worker.work([1, 2, 3, 4, 5, 6, 7, 8, 9, 10]); + assert.strictEqual(worked, true); + assert.strictEqual(worker.pending, 5); + }); + + test('disposed', async () => { + let handled: number[] = []; + const handler = (units: readonly number[]) => handled.push(...units); + + const worker = new async.ThrottledWorker(5, undefined, 1, handler); + worker.dispose(); + const worked = worker.work([1, 2, 3]); + + assertArrayEquals(handled, []); + assert.strictEqual(worker.pending, 0); + assert.strictEqual(worked, false); + }); + }); }); diff --git a/src/vs/platform/files/common/fileService.ts b/src/vs/platform/files/common/fileService.ts index 5cfc79d94ef53..ed720fe757da8 100644 --- a/src/vs/platform/files/common/fileService.ts +++ b/src/vs/platform/files/common/fileService.ts @@ -6,7 +6,7 @@ import { localize } from 'vs/nls'; import { mark } from 'vs/base/common/performance'; import { Disposable, IDisposable, toDisposable, dispose, DisposableStore } from 'vs/base/common/lifecycle'; -import { IFileService, IResolveFileOptions, FileChangesEvent, FileOperationEvent, IFileSystemProviderRegistrationEvent, IFileSystemProvider, IFileStat, IResolveFileResult, ICreateFileOptions, IFileSystemProviderActivationEvent, FileOperationError, FileOperationResult, FileOperation, FileSystemProviderCapabilities, FileType, toFileSystemProviderErrorCode, FileSystemProviderErrorCode, IStat, IFileStatWithMetadata, IResolveMetadataFileOptions, etag, hasReadWriteCapability, hasFileFolderCopyCapability, hasOpenReadWriteCloseCapability, toFileOperationResult, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadWriteCapability, IResolveFileResultWithMetadata, IWatchOptions, IWriteFileOptions, IReadFileOptions, IFileStreamContent, IFileContent, ETAG_DISABLED, hasFileReadStreamCapability, IFileSystemProviderWithFileReadStreamCapability, ensureFileSystemProviderError, IFileSystemProviderCapabilitiesChangeEvent, IReadFileStreamOptions, FileDeleteOptions, FilePermission, NotModifiedSinceFileOperationError } from 'vs/platform/files/common/files'; +import { IFileService, IResolveFileOptions, FileChangesEvent, FileOperationEvent, IFileSystemProviderRegistrationEvent, IFileSystemProvider, IFileStat, IResolveFileResult, ICreateFileOptions, IFileSystemProviderActivationEvent, FileOperationError, FileOperationResult, FileOperation, FileSystemProviderCapabilities, FileType, toFileSystemProviderErrorCode, FileSystemProviderErrorCode, IStat, IFileStatWithMetadata, IResolveMetadataFileOptions, etag, hasReadWriteCapability, hasFileFolderCopyCapability, hasOpenReadWriteCloseCapability, toFileOperationResult, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadWriteCapability, IResolveFileResultWithMetadata, IWatchOptions, IWriteFileOptions, IReadFileOptions, IFileStreamContent, IFileContent, ETAG_DISABLED, hasFileReadStreamCapability, IFileSystemProviderWithFileReadStreamCapability, ensureFileSystemProviderError, IFileSystemProviderCapabilitiesChangeEvent, IReadFileStreamOptions, FileDeleteOptions, FilePermission, NotModifiedSinceFileOperationError, IFileChange } from 'vs/platform/files/common/files'; import { URI } from 'vs/base/common/uri'; import { Emitter } from 'vs/base/common/event'; import { IExtUri, extUri, extUriIgnorePathCase, isAbsolutePath } from 'vs/base/common/resources'; @@ -15,7 +15,7 @@ import { isNonEmptyArray, coalesce } from 'vs/base/common/arrays'; import { ILogService } from 'vs/platform/log/common/log'; import { VSBuffer, VSBufferReadable, readableToBuffer, bufferToReadable, streamToBuffer, VSBufferReadableStream, VSBufferReadableBufferedStream, bufferedStreamToBuffer, newWriteableBufferStream } from 'vs/base/common/buffer'; import { isReadableStream, transform, peekReadable, peekStream, isReadableBufferedStream, newWriteableStream, listenStream, consumeStream } from 'vs/base/common/stream'; -import { Promises, ResourceQueue } from 'vs/base/common/async'; +import { Promises, ResourceQueue, ThrottledWorker } from 'vs/base/common/async'; import { CancellationTokenSource, CancellationToken } from 'vs/base/common/cancellation'; import { Schemas } from 'vs/base/common/network'; import { readFileIntoStream } from 'vs/platform/files/common/io'; @@ -57,7 +57,7 @@ export class FileService extends Disposable implements IFileService { // Forward events from provider const providerDisposables = new DisposableStore(); - providerDisposables.add(provider.onDidChangeFile(changes => this._onDidFilesChange.fire(new FileChangesEvent(changes, !this.isPathCaseSensitive(provider))))); + providerDisposables.add(provider.onDidChangeFile(changes => this.onDidChangeFile(changes, this.isPathCaseSensitive(provider)))); providerDisposables.add(provider.onDidChangeCapabilities(() => this._onDidChangeFileSystemProviderCapabilities.fire({ provider, scheme }))); if (typeof provider.onDidErrorOccur === 'function') { providerDisposables.add(provider.onDidErrorOccur(error => this._onError.fire(new Error(error)))); @@ -965,11 +965,56 @@ export class FileService extends Disposable implements IFileService { //#region File Watching + /** + * Providers can send unlimited amount of `IFileChange` events + * and we want to protect against this to reduce CPU pressure. + * The following settings limit the amount of file changes we + * process at once. + * (https://github.com/microsoft/vscode/issues/124723) + */ + private static readonly FILE_EVENTS_THROTTLING = { + maxChangesChunkSize: 500 as const, // number of changes we process per interval + maxChangesBufferSize: 30000 as const, // total number of changes we are willing to buffer in memory + coolDownDelay: 200 as const, // rest for 100ms before processing next events + warningscounter: 0 // keep track how many warnings we showed to reduce log spam + }; + private readonly _onDidFilesChange = this._register(new Emitter()); readonly onDidFilesChange = this._onDidFilesChange.event; private readonly activeWatchers = new Map(); + private readonly caseSensitiveFileEventsWorker = this._register( + new ThrottledWorker( + FileService.FILE_EVENTS_THROTTLING.maxChangesChunkSize, + FileService.FILE_EVENTS_THROTTLING.maxChangesBufferSize, + FileService.FILE_EVENTS_THROTTLING.coolDownDelay, + chunks => this._onDidFilesChange.fire(new FileChangesEvent(chunks, false)) + ) + ); + + private readonly caseInsensitiveFileEventsWorker = this._register( + new ThrottledWorker( + FileService.FILE_EVENTS_THROTTLING.maxChangesChunkSize, + FileService.FILE_EVENTS_THROTTLING.maxChangesBufferSize, + FileService.FILE_EVENTS_THROTTLING.coolDownDelay, + chunks => this._onDidFilesChange.fire(new FileChangesEvent(chunks, true)) + ) + ); + + private onDidChangeFile(changes: readonly IFileChange[], caseSensitive: boolean): void { + const worker = caseSensitive ? this.caseSensitiveFileEventsWorker : this.caseInsensitiveFileEventsWorker; + const worked = worker.work(changes); + + if (!worked && FileService.FILE_EVENTS_THROTTLING.warningscounter++ < 10) { + this.logService.warn(`[File watcher]: started ignoring events due to too many file change events at once (incoming: ${changes.length}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + } + + if (worker.pending > 0) { + this.logService.trace(`[File watcher]: started throttling events due to large amount of file change events at once (pending: ${worker.pending}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + } + } + watch(resource: URI, options: IWatchOptions = { recursive: false, excludes: [] }): IDisposable { let watchDisposed = false; let disposeWatch = () => { watchDisposed = true; }; From a102b4478e7a2c289b1e8aadefef8158d6e3acb5 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 21 Jun 2021 07:38:43 +0200 Subject: [PATCH 3/4] file watcher - provide an event for raw changes access --- .../node/extensionsWatcher.ts | 2 +- src/vs/platform/files/common/fileService.ts | 24 ++++++++--- src/vs/platform/files/common/files.ts | 19 ++++----- .../platform/files/test/common/files.test.ts | 1 - .../electron-browser/diskFileService.test.ts | 16 ++++---- .../test/electron-browser/normalizer.test.ts | 40 +++++++++---------- .../mainThreadFileSystemEventService.ts | 4 +- .../test/browser/workbenchTestServices.ts | 3 ++ 8 files changed, 62 insertions(+), 47 deletions(-) diff --git a/src/vs/platform/extensionManagement/node/extensionsWatcher.ts b/src/vs/platform/extensionManagement/node/extensionsWatcher.ts index 06d6b23b04a75..9f2ca6fd922b1 100644 --- a/src/vs/platform/extensionManagement/node/extensionsWatcher.ts +++ b/src/vs/platform/extensionManagement/node/extensionsWatcher.ts @@ -41,7 +41,7 @@ export class ExtensionsWatcher extends Disposable { const extensionsResource = URI.file(environmentService.extensionsPath); const extUri = new ExtUri(resource => !fileService.hasCapability(resource, FileSystemProviderCapabilities.PathCaseSensitive)); this._register(fileService.watch(extensionsResource)); - this._register(Event.filter(fileService.onDidFilesChange, e => e.raw.some(change => this.doesChangeAffects(change, extensionsResource, extUri)))(() => this.onDidChange())); + this._register(Event.filter(fileService.onDidChangeFilesRaw, raw => raw.some(change => this.doesChangeAffects(change, extensionsResource, extUri)))(() => this.onDidChange())); } private doesChangeAffects(change: IFileChange, extensionsResource: URI, extUri: ExtUri): boolean { diff --git a/src/vs/platform/files/common/fileService.ts b/src/vs/platform/files/common/fileService.ts index ed720fe757da8..8ff554a40139c 100644 --- a/src/vs/platform/files/common/fileService.ts +++ b/src/vs/platform/files/common/fileService.ts @@ -982,6 +982,9 @@ export class FileService extends Disposable implements IFileService { private readonly _onDidFilesChange = this._register(new Emitter()); readonly onDidFilesChange = this._onDidFilesChange.event; + private readonly _onDidChangeFilesRaw = this._register(new Emitter()); + readonly onDidChangeFilesRaw = this._onDidChangeFilesRaw.event; + private readonly activeWatchers = new Map(); private readonly caseSensitiveFileEventsWorker = this._register( @@ -1003,15 +1006,24 @@ export class FileService extends Disposable implements IFileService { ); private onDidChangeFile(changes: readonly IFileChange[], caseSensitive: boolean): void { - const worker = caseSensitive ? this.caseSensitiveFileEventsWorker : this.caseInsensitiveFileEventsWorker; - const worked = worker.work(changes); - if (!worked && FileService.FILE_EVENTS_THROTTLING.warningscounter++ < 10) { - this.logService.warn(`[File watcher]: started ignoring events due to too many file change events at once (incoming: ${changes.length}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + // Event #1: access to raw events + { + this._onDidChangeFilesRaw.fire(changes); } - if (worker.pending > 0) { - this.logService.trace(`[File watcher]: started throttling events due to large amount of file change events at once (pending: ${worker.pending}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + // Event #2: throttled due to performance reasons + { + const worker = caseSensitive ? this.caseSensitiveFileEventsWorker : this.caseInsensitiveFileEventsWorker; + const worked = worker.work(changes); + + if (!worked && FileService.FILE_EVENTS_THROTTLING.warningscounter++ < 10) { + this.logService.warn(`[File watcher]: started ignoring events due to too many file change events at once (incoming: ${changes.length}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + } + + if (worker.pending > 0) { + this.logService.trace(`[File watcher]: started throttling events due to large amount of file change events at once (pending: ${worker.pending}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + } } } diff --git a/src/vs/platform/files/common/files.ts b/src/vs/platform/files/common/files.ts index d0161b454b646..bad6dd986c01f 100644 --- a/src/vs/platform/files/common/files.ts +++ b/src/vs/platform/files/common/files.ts @@ -77,6 +77,15 @@ export interface IFileService { */ readonly onDidFilesChange: Event; + /** + * + * Raw access to all file events emitted from file system providers. + * + * @deprecated use this method only if you know what you are doing. use the other watch related events + * and APIs for more efficient file watching. + */ + readonly onDidChangeFilesRaw: Event; + /** * An event that is fired upon successful completion of a certain file operation. */ @@ -645,7 +654,7 @@ export class FileChangesEvent { private readonly updated: TernarySearchTree | undefined = undefined; private readonly deleted: TernarySearchTree | undefined = undefined; - constructor(private readonly changes: readonly IFileChange[], ignorePathCasing: boolean) { + constructor(changes: readonly IFileChange[], ignorePathCasing: boolean) { for (const change of changes) { switch (change.type) { case FileChangeType.ADDED: @@ -752,14 +761,6 @@ export class FileChangesEvent { return !!this.updated; } - /** - * @deprecated use the `contains` or `affects` method to efficiently find - * out if the event relates to a given resource. these methods ensure: - * - that there is no expensive lookup needed (by using a `TernarySearchTree`) - * - correctly handles `FileChangeType.DELETED` events - */ - get raw(): readonly IFileChange[] { return this.changes; } - /** * @deprecated use the `contains` or `affects` method to efficiently find * out if the event relates to a given resource. these methods ensure: diff --git a/src/vs/platform/files/test/common/files.test.ts b/src/vs/platform/files/test/common/files.test.ts index 41f95573eca2e..aa537add58785 100644 --- a/src/vs/platform/files/test/common/files.test.ts +++ b/src/vs/platform/files/test/common/files.test.ts @@ -70,7 +70,6 @@ suite('Files', () => { } assert(!event.contains(toResource.call(this, '/bar/folder2/somefile'), FileChangeType.DELETED)); - assert.strictEqual(6, event.raw.length); assert.strictEqual(1, count(event.rawAdded)); assert.strictEqual(true, event.gotAdded()); assert.strictEqual(true, event.gotUpdated()); diff --git a/src/vs/platform/files/test/electron-browser/diskFileService.test.ts b/src/vs/platform/files/test/electron-browser/diskFileService.test.ts index 03920cb53e4d2..54ba93f78db5a 100644 --- a/src/vs/platform/files/test/electron-browser/diskFileService.test.ts +++ b/src/vs/platform/files/test/electron-browser/diskFileService.test.ts @@ -13,7 +13,7 @@ import { join, basename, dirname, posix } from 'vs/base/common/path'; import { Promises, rimrafSync } from 'vs/base/node/pfs'; import { URI } from 'vs/base/common/uri'; import { existsSync, statSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, createReadStream } from 'fs'; -import { FileOperation, FileOperationEvent, IFileStat, FileOperationResult, FileSystemProviderCapabilities, FileChangeType, IFileChange, FileChangesEvent, FileOperationError, etag, IStat, IFileStatWithMetadata, IReadFileOptions, FilePermission, NotModifiedSinceFileOperationError } from 'vs/platform/files/common/files'; +import { FileOperation, FileOperationEvent, IFileStat, FileOperationResult, FileSystemProviderCapabilities, FileChangeType, IFileChange, FileOperationError, etag, IStat, IFileStatWithMetadata, IReadFileOptions, FilePermission, NotModifiedSinceFileOperationError } from 'vs/platform/files/common/files'; import { NullLogService } from 'vs/platform/log/common/log'; import { isLinux, isWindows } from 'vs/base/common/platform'; import { DisposableStore } from 'vs/base/common/lifecycle'; @@ -2279,23 +2279,23 @@ flakySuite('Disk File Service', function () { } } - function printEvents(event: FileChangesEvent): string { - return event.raw.map(change => `Change: type ${toString(change.type)} path ${change.resource.toString()}`).join('\n'); + function printEvents(raw: readonly IFileChange[]): string { + return raw.map(change => `Change: type ${toString(change.type)} path ${change.resource.toString()}`).join('\n'); } - const listenerDisposable = service.onDidFilesChange(event => { + const listenerDisposable = service.onDidChangeFilesRaw(raw => { watcherDisposable.dispose(); listenerDisposable.dispose(); try { - assert.strictEqual(event.raw.length, expected.length, `Expected ${expected.length} events, but got ${event.raw.length}. Details (${printEvents(event)})`); + assert.strictEqual(raw.length, expected.length, `Expected ${expected.length} events, but got ${raw.length}. Details (${printEvents(raw)})`); if (expected.length === 1) { - assert.strictEqual(event.raw[0].type, expected[0][0], `Expected ${toString(expected[0][0])} but got ${toString(event.raw[0].type)}. Details (${printEvents(event)})`); - assert.strictEqual(event.raw[0].resource.fsPath, expected[0][1].fsPath); + assert.strictEqual(raw[0].type, expected[0][0], `Expected ${toString(expected[0][0])} but got ${toString(raw[0].type)}. Details (${printEvents(raw)})`); + assert.strictEqual(raw[0].resource.fsPath, expected[0][1].fsPath); } else { for (const expect of expected) { - assert.strictEqual(hasChange(event.raw, expect[0], expect[1]), true, `Unable to find ${toString(expect[0])} for ${expect[1].fsPath}. Details (${printEvents(event)})`); + assert.strictEqual(hasChange(raw, expect[0], expect[1]), true, `Unable to find ${toString(expect[0])} for ${expect[1].fsPath}. Details (${printEvents(raw)})`); } } diff --git a/src/vs/platform/files/test/electron-browser/normalizer.test.ts b/src/vs/platform/files/test/electron-browser/normalizer.test.ts index 745f3cf199998..bbaecf3788810 100644 --- a/src/vs/platform/files/test/electron-browser/normalizer.test.ts +++ b/src/vs/platform/files/test/electron-browser/normalizer.test.ts @@ -4,24 +4,24 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; -import * as platform from 'vs/base/common/platform'; -import { FileChangeType, FileChangesEvent } from 'vs/platform/files/common/files'; +import { isWindows, isLinux } from 'vs/base/common/platform'; +import { FileChangesEvent, FileChangeType, IFileChange } from 'vs/platform/files/common/files'; import { URI as uri } from 'vs/base/common/uri'; import { IDiskFileChange, normalizeFileChanges, toFileChanges } from 'vs/platform/files/node/watcher/watcher'; import { Event, Emitter } from 'vs/base/common/event'; function toFileChangesEvent(changes: IDiskFileChange[]): FileChangesEvent { - return new FileChangesEvent(toFileChanges(changes), !platform.isLinux); + return new FileChangesEvent(toFileChanges(changes), !isLinux); } class TestFileWatcher { - private readonly _onDidFilesChange: Emitter; + private readonly _onDidFilesChange: Emitter<{ raw: IFileChange[], event: FileChangesEvent }>; constructor() { - this._onDidFilesChange = new Emitter(); + this._onDidFilesChange = new Emitter<{ raw: IFileChange[], event: FileChangesEvent }>(); } - get onDidFilesChange(): Event { + get onDidFilesChange(): Event<{ raw: IFileChange[], event: FileChangesEvent }> { return this._onDidFilesChange.event; } @@ -36,7 +36,7 @@ class TestFileWatcher { // Emit through event emitter if (normalizedEvents.length > 0) { - this._onDidFilesChange.fire(toFileChangesEvent(normalizedEvents)); + this._onDidFilesChange.fire({ raw: toFileChanges(normalizedEvents), event: toFileChangesEvent(normalizedEvents) }); } } } @@ -62,9 +62,9 @@ suite('Normalizer', () => { { path: deleted.fsPath, type: FileChangeType.DELETED }, ]; - watch.onDidFilesChange(e => { + watch.onDidFilesChange(({ event: e, raw }) => { assert.ok(e); - assert.strictEqual(e.raw.length, 3); + assert.strictEqual(raw.length, 3); assert.ok(e.contains(added, FileChangeType.ADDED)); assert.ok(e.contains(updated, FileChangeType.UPDATED)); assert.ok(e.contains(deleted, FileChangeType.DELETED)); @@ -75,7 +75,7 @@ suite('Normalizer', () => { watch.report(raw); }); - let pathSpecs = platform.isWindows ? [Path.WINDOWS, Path.UNC] : [Path.UNIX]; + let pathSpecs = isWindows ? [Path.WINDOWS, Path.UNC] : [Path.UNIX]; pathSpecs.forEach((p) => { test('delete only reported for top level folder (' + p + ')', function (done: () => void) { const watch = new TestFileWatcher(); @@ -101,9 +101,9 @@ suite('Normalizer', () => { { path: updatedFile.fsPath, type: FileChangeType.UPDATED } ]; - watch.onDidFilesChange(e => { + watch.onDidFilesChange(({ event: e, raw }) => { assert.ok(e); - assert.strictEqual(e.raw.length, 5); + assert.strictEqual(raw.length, 5); assert.ok(e.contains(deletedFolderA, FileChangeType.DELETED)); assert.ok(e.contains(deletedFolderB, FileChangeType.DELETED)); @@ -131,9 +131,9 @@ suite('Normalizer', () => { { path: unrelated.fsPath, type: FileChangeType.UPDATED }, ]; - watch.onDidFilesChange(e => { + watch.onDidFilesChange(({ event: e, raw }) => { assert.ok(e); - assert.strictEqual(e.raw.length, 1); + assert.strictEqual(raw.length, 1); assert.ok(e.contains(unrelated, FileChangeType.UPDATED)); @@ -156,9 +156,9 @@ suite('Normalizer', () => { { path: unrelated.fsPath, type: FileChangeType.UPDATED }, ]; - watch.onDidFilesChange(e => { + watch.onDidFilesChange(({ event: e, raw }) => { assert.ok(e); - assert.strictEqual(e.raw.length, 2); + assert.strictEqual(raw.length, 2); assert.ok(e.contains(deleted, FileChangeType.UPDATED)); assert.ok(e.contains(unrelated, FileChangeType.UPDATED)); @@ -182,9 +182,9 @@ suite('Normalizer', () => { { path: unrelated.fsPath, type: FileChangeType.UPDATED }, ]; - watch.onDidFilesChange(e => { + watch.onDidFilesChange(({ event: e, raw }) => { assert.ok(e); - assert.strictEqual(e.raw.length, 2); + assert.strictEqual(raw.length, 2); assert.ok(e.contains(created, FileChangeType.ADDED)); assert.ok(!e.contains(created, FileChangeType.UPDATED)); @@ -211,9 +211,9 @@ suite('Normalizer', () => { { path: updated.fsPath, type: FileChangeType.DELETED } ]; - watch.onDidFilesChange(e => { + watch.onDidFilesChange(({ event: e, raw }) => { assert.ok(e); - assert.strictEqual(e.raw.length, 2); + assert.strictEqual(raw.length, 2); assert.ok(e.contains(deleted, FileChangeType.DELETED)); assert.ok(!e.contains(updated, FileChangeType.UPDATED)); diff --git a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts index e7b99badc0e7a..f9c8758c1bece 100644 --- a/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts +++ b/src/vs/workbench/api/browser/mainThreadFileSystemEventService.ts @@ -51,8 +51,8 @@ export class MainThreadFileSystemEventService { changed: [], deleted: [] }; - this._listener.add(fileService.onDidFilesChange(event => { - for (let change of event.raw) { + this._listener.add(fileService.onDidChangeFilesRaw(changes => { + for (let change of changes) { switch (change.type) { case FileChangeType.ADDED: events.created.push(change.resource); diff --git a/src/vs/workbench/test/browser/workbenchTestServices.ts b/src/vs/workbench/test/browser/workbenchTestServices.ts index f37d34b28b2c4..64288574238ff 100644 --- a/src/vs/workbench/test/browser/workbenchTestServices.ts +++ b/src/vs/workbench/test/browser/workbenchTestServices.ts @@ -836,6 +836,9 @@ export class TestFileService implements IFileService { get onDidFilesChange(): Event { return this._onDidFilesChange.event; } fireFileChanges(event: FileChangesEvent): void { this._onDidFilesChange.fire(event); } + private readonly _onDidChangeFilesRaw = new Emitter(); + get onDidChangeFilesRaw(): Event { return this._onDidChangeFilesRaw.event; } + private readonly _onDidRunOperation = new Emitter(); get onDidRunOperation(): Event { return this._onDidRunOperation.event; } fireAfterOperation(event: FileOperationEvent): void { this._onDidRunOperation.fire(event); } From 242d33b092f28ba4699aeb7c09f3cb0bb4b83f39 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 21 Jun 2021 11:57:55 +0200 Subject: [PATCH 4/4] file watcher - give explicitly watched resources higher prio --- src/vs/platform/files/common/fileService.ts | 106 ++++++++++++++---- .../files/test/browser/fileService.test.ts | 92 ++++++++++++++- .../test/common/nullFileSystemProvider.ts | 13 ++- 3 files changed, 187 insertions(+), 24 deletions(-) diff --git a/src/vs/platform/files/common/fileService.ts b/src/vs/platform/files/common/fileService.ts index 8ff554a40139c..cebcb7ec74abd 100644 --- a/src/vs/platform/files/common/fileService.ts +++ b/src/vs/platform/files/common/fileService.ts @@ -1007,44 +1007,112 @@ export class FileService extends Disposable implements IFileService { private onDidChangeFile(changes: readonly IFileChange[], caseSensitive: boolean): void { - // Event #1: access to raw events + // Event #1: access to raw events goes out instantly { this._onDidChangeFilesRaw.fire(changes); } - // Event #2: throttled due to performance reasons + // Event #2: immediately send out events for + // explicitly watched resources by splitting + // changes up into 2 buckets + let explicitlyWatchedFileChanges: IFileChange[] | undefined = undefined; + let implicitlyWatchedFileChanges: IFileChange[] | undefined = undefined; { + for (const change of changes) { + if (this.watchedResources.has(change.resource)) { + if (!explicitlyWatchedFileChanges) { + explicitlyWatchedFileChanges = []; + } + explicitlyWatchedFileChanges.push(change); + } else { + if (!implicitlyWatchedFileChanges) { + implicitlyWatchedFileChanges = []; + } + implicitlyWatchedFileChanges.push(change); + } + } + + if (explicitlyWatchedFileChanges) { + this._onDidFilesChange.fire(new FileChangesEvent(explicitlyWatchedFileChanges, !caseSensitive)); + } + } + + // Event #3: implicitly watched resources get + // throttled due to performance reasons + if (implicitlyWatchedFileChanges) { const worker = caseSensitive ? this.caseSensitiveFileEventsWorker : this.caseInsensitiveFileEventsWorker; - const worked = worker.work(changes); + const worked = worker.work(implicitlyWatchedFileChanges); if (!worked && FileService.FILE_EVENTS_THROTTLING.warningscounter++ < 10) { - this.logService.warn(`[File watcher]: started ignoring events due to too many file change events at once (incoming: ${changes.length}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + this.logService.warn(`[File watcher]: started ignoring events due to too many file change events at once (incoming: ${implicitlyWatchedFileChanges.length}, most recent change: ${implicitlyWatchedFileChanges[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); } if (worker.pending > 0) { - this.logService.trace(`[File watcher]: started throttling events due to large amount of file change events at once (pending: ${worker.pending}, most recent change: ${changes[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + this.logService.trace(`[File watcher]: started throttling events due to large amount of file change events at once (pending: ${worker.pending}, most recent change: ${implicitlyWatchedFileChanges[0].resource.toString()}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); } } } + private readonly watchedResources = TernarySearchTree.forUris(uri => { + const provider = this.getProvider(uri.scheme); + if (provider) { + return !this.isPathCaseSensitive(provider); + } + + return false; + }); + watch(resource: URI, options: IWatchOptions = { recursive: false, excludes: [] }): IDisposable { - let watchDisposed = false; - let disposeWatch = () => { watchDisposed = true; }; - - // Watch and wire in disposable which is async but - // check if we got disposed meanwhile and forward - this.doWatch(resource, options).then(disposable => { - if (watchDisposed) { - dispose(disposable); - } else { - disposeWatch = () => dispose(disposable); - } - }, error => this.logService.error(error)); + const disposables = new DisposableStore(); + + // Forward watch request to provider and + // wire in disposables. + { + let watchDisposed = false; + let disposeWatch = () => { watchDisposed = true; }; + disposables.add(toDisposable(() => disposeWatch())); + + // Watch and wire in disposable which is async but + // check if we got disposed meanwhile and forward + this.doWatch(resource, options).then(disposable => { + if (watchDisposed) { + dispose(disposable); + } else { + disposeWatch = () => dispose(disposable); + } + }, error => this.logService.error(error)); + } + + // Remember as watched resource and unregister + // properly on disposal. + // + // Note: we only do this for non-recursive watchers + // until we have a better `createWatcher` based API + // (https://github.com/microsoft/vscode/issues/126809) + // + if (!options.recursive) { + + // Increment counter for resource + this.watchedResources.set(resource, (this.watchedResources.get(resource) ?? 0) + 1); + + // Decrement counter for resource on dispose + // and remove from map when last one is gone + disposables.add(toDisposable(() => { + const watchedResourceCounter = this.watchedResources.get(resource); + if (typeof watchedResourceCounter === 'number') { + if (watchedResourceCounter <= 1) { + this.watchedResources.delete(resource); + } else { + this.watchedResources.set(resource, watchedResourceCounter - 1); + } + } + })); + } - return toDisposable(() => disposeWatch()); + return disposables; } - async doWatch(resource: URI, options: IWatchOptions): Promise { + private async doWatch(resource: URI, options: IWatchOptions): Promise { const provider = await this.withProvider(resource); const key = this.toWatchKey(provider, resource, options); diff --git a/src/vs/platform/files/test/browser/fileService.test.ts b/src/vs/platform/files/test/browser/fileService.test.ts index 093b5e0aff37c..e55f606a8a32f 100644 --- a/src/vs/platform/files/test/browser/fileService.test.ts +++ b/src/vs/platform/files/test/browser/fileService.test.ts @@ -6,7 +6,7 @@ import * as assert from 'assert'; import { FileService } from 'vs/platform/files/common/fileService'; import { URI } from 'vs/base/common/uri'; -import { IFileSystemProviderRegistrationEvent, FileSystemProviderCapabilities, IFileSystemProviderCapabilitiesChangeEvent, FileOpenOptions, FileReadStreamOptions, IStat, FileType } from 'vs/platform/files/common/files'; +import { IFileSystemProviderRegistrationEvent, FileSystemProviderCapabilities, IFileSystemProviderCapabilitiesChangeEvent, FileOpenOptions, FileReadStreamOptions, IStat, FileType, IFileChange, FileChangeType } from 'vs/platform/files/common/files'; import { IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { NullLogService } from 'vs/platform/log/common/log'; import { timeout } from 'vs/base/common/async'; @@ -82,6 +82,45 @@ suite('File Service', () => { service.dispose(); }); + test('provider change events are throttled', async () => { + const service = new FileService(new NullLogService()); + + const provider = new NullFileSystemProvider(); + service.registerProvider('test', provider); + + await service.activateProvider('test'); + + let onDidFilesChangeFired = false; + service.onDidFilesChange(e => { + if (e.contains(URI.file('marker'))) { + onDidFilesChangeFired = true; + } + }); + + const throttledEvents: IFileChange[] = []; + for (let i = 0; i < 1000; i++) { + throttledEvents.push({ resource: URI.file(String(i)), type: FileChangeType.ADDED }); + } + throttledEvents.push({ resource: URI.file('marker'), type: FileChangeType.ADDED }); + + const nonThrottledEvents: IFileChange[] = []; + for (let i = 0; i < 100; i++) { + nonThrottledEvents.push({ resource: URI.file(String(i)), type: FileChangeType.ADDED }); + } + nonThrottledEvents.push({ resource: URI.file('marker'), type: FileChangeType.ADDED }); + + // 100 events are not throttled + provider.emitFileChangeEvents(nonThrottledEvents); + assert.strictEqual(onDidFilesChangeFired, true); + onDidFilesChangeFired = false; + + // 1000 events are throttled + provider.emitFileChangeEvents(throttledEvents); + assert.strictEqual(onDidFilesChangeFired, false); + + service.dispose(); + }); + test('watch', async () => { const service = new FileService(new NullLogService()); @@ -131,6 +170,57 @@ suite('File Service', () => { service.dispose(); }); + test('watch: explicit watched resources have preference over implicit and do not get throttled', async () => { + const service = new FileService(new NullLogService()); + + const provider = new NullFileSystemProvider(); + service.registerProvider('test', provider); + + await service.activateProvider('test'); + + let onDidFilesChangeFired = false; + service.onDidFilesChange(e => { + if (e.contains(URI.file('marker'))) { + onDidFilesChangeFired = true; + } + }); + + const throttledEvents: IFileChange[] = []; + for (let i = 0; i < 1000; i++) { + throttledEvents.push({ resource: URI.file(String(i)), type: FileChangeType.ADDED }); + } + throttledEvents.push({ resource: URI.file('marker'), type: FileChangeType.ADDED }); + + // not throttled when explicitly watching + let disposable1 = service.watch(URI.file('marker')); + provider.emitFileChangeEvents(throttledEvents); + assert.strictEqual(onDidFilesChangeFired, true); + onDidFilesChangeFired = false; + + let disposable2 = service.watch(URI.file('marker')); + provider.emitFileChangeEvents(throttledEvents); + assert.strictEqual(onDidFilesChangeFired, true); + onDidFilesChangeFired = false; + + disposable1.dispose(); + provider.emitFileChangeEvents(throttledEvents); + assert.strictEqual(onDidFilesChangeFired, true); + onDidFilesChangeFired = false; + + // throttled again after dispose + disposable2.dispose(); + provider.emitFileChangeEvents(throttledEvents); + assert.strictEqual(onDidFilesChangeFired, false); + + // not throttled when watched again + service.watch(URI.file('marker')); + provider.emitFileChangeEvents(throttledEvents); + assert.strictEqual(onDidFilesChangeFired, true); + onDidFilesChangeFired = false; + + service.dispose(); + }); + test('error from readFile bubbles through (https://github.com/microsoft/vscode/issues/118060) - async', async () => { testReadErrorBubbles(true); }); diff --git a/src/vs/platform/files/test/common/nullFileSystemProvider.ts b/src/vs/platform/files/test/common/nullFileSystemProvider.ts index 7f47f2006d01c..413cbf6ba396a 100644 --- a/src/vs/platform/files/test/common/nullFileSystemProvider.ts +++ b/src/vs/platform/files/test/common/nullFileSystemProvider.ts @@ -15,16 +15,21 @@ export class NullFileSystemProvider implements IFileSystemProvider { private readonly _onDidChangeCapabilities = new Emitter(); readonly onDidChangeCapabilities: Event = this._onDidChangeCapabilities.event; + private readonly _onDidChangeFile = new Emitter(); + readonly onDidChangeFile: Event = this._onDidChangeFile.event; + + constructor(private disposableFactory: () => IDisposable = () => Disposable.None) { } + + emitFileChangeEvents(changes: IFileChange[]): void { + this._onDidChangeFile.fire(changes); + } + setCapabilities(capabilities: FileSystemProviderCapabilities): void { this.capabilities = capabilities; this._onDidChangeCapabilities.fire(); } - readonly onDidChangeFile: Event = Event.None; - - constructor(private disposableFactory: () => IDisposable = () => Disposable.None) { } - watch(resource: URI, opts: IWatchOptions): IDisposable { return this.disposableFactory(); } async stat(resource: URI): Promise { return undefined!; } async mkdir(resource: URI): Promise { return undefined; }