diff --git a/build/gulpfile.reh.js b/build/gulpfile.reh.js index 8cacc9687c1..8629fef8edc 100644 --- a/build/gulpfile.reh.js +++ b/build/gulpfile.reh.js @@ -103,7 +103,7 @@ const serverEntryPoints = [ exclude: ['vs/css', 'vs/nls'] }, { - name: 'vs/platform/files/node/watcher/parcel/parcelWatcherMain', + name: 'vs/platform/files/node/watcher/watcherMain', exclude: ['vs/css', 'vs/nls'] }, { diff --git a/src/vs/code/electron-main/app.ts b/src/vs/code/electron-main/app.ts index 73231eb026e..e729948f403 100644 --- a/src/vs/code/electron-main/app.ts +++ b/src/vs/code/electron-main/app.ts @@ -573,7 +573,7 @@ export class CodeApplication extends Disposable { // Local Files const diskFileSystemProvider = this.fileService.getProvider(Schemas.file); assertType(diskFileSystemProvider instanceof DiskFileSystemProvider); - const fileSystemProviderChannel = new DiskFileSystemProviderChannel(diskFileSystemProvider, this.logService); + const fileSystemProviderChannel = new DiskFileSystemProviderChannel(diskFileSystemProvider, this.logService, this.environmentMainService); mainProcessElectronServer.registerChannel(LOCAL_FILE_SYSTEM_CHANNEL_NAME, fileSystemProviderChannel); sharedProcessClient.then(client => client.registerChannel(LOCAL_FILE_SYSTEM_CHANNEL_NAME, fileSystemProviderChannel)); diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index f564b6af707..500b11f00ba 100644 --- a/src/vs/code/node/cli.ts +++ b/src/vs/code/node/cli.ts @@ -14,7 +14,7 @@ import { randomPort } from 'vs/base/common/ports'; import { isString } from 'vs/base/common/types'; import { whenDeleted, writeFileSync } from 'vs/base/node/pfs'; import { findFreePort } from 'vs/base/node/ports'; -import { watchFileContents } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; +import { watchFileContents } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; import { NativeParsedArgs } from 'vs/platform/environment/common/argv'; import { buildHelpMessage, buildVersionMessage, OPTIONS } from 'vs/platform/environment/node/argv'; import { addArg, parseCLIProcessArgv } from 'vs/platform/environment/node/argvHelper'; diff --git a/src/vs/platform/files/common/diskFileSystemProvider.ts b/src/vs/platform/files/common/diskFileSystemProvider.ts index c463904d3de..d65e3a20028 100644 --- a/src/vs/platform/files/common/diskFileSystemProvider.ts +++ b/src/vs/platform/files/common/diskFileSystemProvider.ts @@ -7,17 +7,40 @@ import { insert } from 'vs/base/common/arrays'; import { ThrottledDelayer } from 'vs/base/common/async'; import { onUnexpectedError } from 'vs/base/common/errors'; import { Emitter } from 'vs/base/common/event'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { normalize } from 'vs/base/common/path'; import { URI } from 'vs/base/common/uri'; import { IFileChange, IWatchOptions } from 'vs/platform/files/common/files'; -import { AbstractRecursiveWatcherClient, IDiskFileChange, ILogMessage, INonRecursiveWatcher, INonRecursiveWatchRequest, IRecursiveWatchRequest, toFileChanges } from 'vs/platform/files/common/watcher'; +import { AbstractNonRecursiveWatcherClient, AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage, INonRecursiveWatchRequest, IRecursiveWatcherOptions, isRecursiveWatchRequest, IUniversalWatchRequest, toFileChanges } from 'vs/platform/files/common/watcher'; import { ILogService, LogLevel } from 'vs/platform/log/common/log'; +export interface IDiskFileSystemProviderOptions { + watcher?: { + + /** + * Extra options for the recursive file watching. + */ + recursive?: IRecursiveWatcherOptions; + + /** + * Forces all file watch requests to run through a + * single universal file watcher, both recursive + * and non-recursively. + * + * Enabling this option might cause some overhead, + * specifically the universal file watcher will run + * in a separate process given its complexity. Only + * enable it when you understand the consequences. + */ + forceUniversal?: boolean; + }; +} + export abstract class AbstractDiskFileSystemProvider extends Disposable { constructor( - protected readonly logService: ILogService + protected readonly logService: ILogService, + private readonly options?: IDiskFileSystemProviderOptions ) { super(); } @@ -29,53 +52,53 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable { readonly onDidWatchError = this._onDidWatchError.event; watch(resource: URI, opts: IWatchOptions): IDisposable { - if (opts.recursive) { - return this.watchRecursive(resource, opts); + if (opts.recursive || this.options?.watcher?.forceUniversal) { + return this.watchUniversal(resource, opts); } return this.watchNonRecursive(resource, opts); } - //#region File Watching (recursive) + //#region File Watching (universal) - private recursiveWatcher: AbstractRecursiveWatcherClient | undefined; + private universalWatcher: AbstractUniversalWatcherClient | undefined; - private readonly recursiveFoldersToWatch: IRecursiveWatchRequest[] = []; - private readonly recursiveWatchRequestDelayer = this._register(new ThrottledDelayer(0)); + private readonly universalPathsToWatch: IUniversalWatchRequest[] = []; + private readonly universalWatchRequestDelayer = this._register(new ThrottledDelayer(0)); - private watchRecursive(resource: URI, opts: IWatchOptions): IDisposable { + private watchUniversal(resource: URI, opts: IWatchOptions): IDisposable { - // Add to list of folders to watch recursively - const folderToWatch: IRecursiveWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes }; - const remove = insert(this.recursiveFoldersToWatch, folderToWatch); + // Add to list of paths to watch universally + const pathToWatch: IUniversalWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes, recursive: opts.recursive }; + const remove = insert(this.universalPathsToWatch, pathToWatch); // Trigger update - this.refreshRecursiveWatchers(); + this.refreshUniversalWatchers(); return toDisposable(() => { - // Remove from list of folders to watch recursively + // Remove from list of paths to watch universally remove(); // Trigger update - this.refreshRecursiveWatchers(); + this.refreshUniversalWatchers(); }); } - private refreshRecursiveWatchers(): void { + private refreshUniversalWatchers(): void { - // Buffer requests for recursive watching to decide on right watcher - // that supports potentially watching more than one folder at once - this.recursiveWatchRequestDelayer.trigger(() => { - return this.doRefreshRecursiveWatchers(); + // Buffer requests for universal watching to decide on right watcher + // that supports potentially watching more than one path at once + this.universalWatchRequestDelayer.trigger(() => { + return this.doRefreshUniversalWatchers(); }).catch(error => onUnexpectedError(error)); } - private doRefreshRecursiveWatchers(): Promise { + private doRefreshUniversalWatchers(): Promise { // Create watcher if this is the first time - if (!this.recursiveWatcher) { - this.recursiveWatcher = this._register(this.createRecursiveWatcher( + if (!this.universalWatcher) { + this.universalWatcher = this._register(this.createUniversalWatcher( changes => this._onDidChangeFile.fire(toFileChanges(changes)), msg => this.onWatcherLogMessage(msg), this.logService.getLevel() === LogLevel.Trace @@ -83,57 +106,102 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable { // Apply log levels dynamically this._register(this.logService.onDidChangeLogLevel(() => { - this.recursiveWatcher?.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace); + this.universalWatcher?.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace); })); } - // Allow subclasses to override watch requests - this.massageRecursiveWatchRequests(this.recursiveFoldersToWatch); + // Adjust for polling + const usePolling = this.options?.watcher?.recursive?.usePolling; + if (usePolling === true) { + for (const request of this.universalPathsToWatch) { + if (isRecursiveWatchRequest(request)) { + request.pollingInterval = this.options?.watcher?.recursive?.pollingInterval ?? 5000; + } + } + } else if (Array.isArray(usePolling)) { + for (const request of this.universalPathsToWatch) { + if (isRecursiveWatchRequest(request)) { + if (usePolling.includes(request.path)) { + request.pollingInterval = this.options?.watcher?.recursive?.pollingInterval ?? 5000; + } + } + } + } - // Ask to watch the provided folders - return this.recursiveWatcher.watch(this.recursiveFoldersToWatch); + // Ask to watch the provided paths + return this.universalWatcher.watch(this.universalPathsToWatch); } - protected massageRecursiveWatchRequests(requests: IRecursiveWatchRequest[]): void { - // subclasses can override to alter behaviour - } - - protected abstract createRecursiveWatcher( + protected abstract createUniversalWatcher( onChange: (changes: IDiskFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean - ): AbstractRecursiveWatcherClient; + ): AbstractUniversalWatcherClient; //#endregion //#region File Watching (non-recursive) + private nonRecursiveWatcher: AbstractNonRecursiveWatcherClient | undefined; + + private readonly nonRecursivePathsToWatch: INonRecursiveWatchRequest[] = []; + private readonly nonRecursiveWatchRequestDelayer = this._register(new ThrottledDelayer(0)); + private watchNonRecursive(resource: URI, opts: IWatchOptions): IDisposable { - const disposables = new DisposableStore(); - const watcher = disposables.add(this.createNonRecursiveWatcher( - { - path: this.toFilePath(resource), - excludes: opts.excludes - }, - changes => this._onDidChangeFile.fire(toFileChanges(changes)), - msg => this.onWatcherLogMessage(msg), - this.logService.getLevel() === LogLevel.Trace - )); + // Add to list of paths to watch non-recursively + const pathToWatch: INonRecursiveWatchRequest = { path: this.toFilePath(resource), excludes: opts.excludes, recursive: false }; + const remove = insert(this.nonRecursivePathsToWatch, pathToWatch); - disposables.add(this.logService.onDidChangeLogLevel(() => { - watcher.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace); - })); + // Trigger update + this.refreshNonRecursiveWatchers(); - return disposables; + return toDisposable(() => { + + // Remove from list of paths to watch non-recursively + remove(); + + // Trigger update + this.refreshNonRecursiveWatchers(); + }); + } + + private refreshNonRecursiveWatchers(): void { + + // Buffer requests for nonrecursive watching to decide on right watcher + // that supports potentially watching more than one path at once + this.nonRecursiveWatchRequestDelayer.trigger(() => { + return this.doRefreshNonRecursiveWatchers(); + }).catch(error => onUnexpectedError(error)); + } + + private doRefreshNonRecursiveWatchers(): Promise { + + // Create watcher if this is the first time + if (!this.nonRecursiveWatcher) { + this.nonRecursiveWatcher = this._register(this.createNonRecursiveWatcher( + changes => this._onDidChangeFile.fire(toFileChanges(changes)), + msg => this.onWatcherLogMessage(msg), + this.logService.getLevel() === LogLevel.Trace + )); + + // Apply log levels dynamically + this._register(this.logService.onDidChangeLogLevel(() => { + this.nonRecursiveWatcher?.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace); + })); + } + + // Ask to watch the provided paths + return this.nonRecursiveWatcher.watch(this.nonRecursivePathsToWatch); } protected abstract createNonRecursiveWatcher( - request: INonRecursiveWatchRequest, onChange: (changes: IDiskFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean - ): INonRecursiveWatcher; + ): AbstractNonRecursiveWatcherClient; + + //#endregion private onWatcherLogMessage(msg: ILogMessage): void { if (msg.type === 'error') { @@ -146,6 +214,4 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable { protected toFilePath(resource: URI): string { return normalize(resource.fsPath); } - - //#endregion } diff --git a/src/vs/platform/files/common/watcher.ts b/src/vs/platform/files/common/watcher.ts index 9bee90f45ab..ae04e5dae49 100644 --- a/src/vs/platform/files/common/watcher.ts +++ b/src/vs/platform/files/common/watcher.ts @@ -4,28 +4,44 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; import { isLinux } from 'vs/base/common/platform'; import { URI as uri } from 'vs/base/common/uri'; import { FileChangeType, IFileChange, isParent } from 'vs/platform/files/common/files'; -export interface IWatchRequest { +interface IWatchRequest { /** * The path to watch. */ path: string; + /** + * Whether to watch recursively or not. + */ + recursive: boolean; + /** * A set of glob patterns or paths to exclude from watching. */ excludes: string[]; } -export interface INonRecursiveWatchRequest extends IWatchRequest { } +export interface INonRecursiveWatchRequest extends IWatchRequest { + + /** + * The watcher will be non-recursive. + */ + recursive: false; +} export interface IRecursiveWatchRequest extends IWatchRequest { + /** + * The watcher will be recursive. + */ + recursive: true; + /** * @deprecated this only exists for WSL1 support and should never * be used in any other case. @@ -33,7 +49,13 @@ export interface IRecursiveWatchRequest extends IWatchRequest { pollingInterval?: number; } -export interface IRecursiveWatcher extends IDisposable { +export function isRecursiveWatchRequest(request: IWatchRequest): request is IRecursiveWatchRequest { + return request.recursive === true; +} + +export type IUniversalWatchRequest = IRecursiveWatchRequest | INonRecursiveWatchRequest; + +interface IWatcher { /** * A normalized file change event from the raw events @@ -59,7 +81,7 @@ export interface IRecursiveWatcher extends IDisposable { * in the array, will be removed from watching and * any new path will be added to watching. */ - watch(requests: IRecursiveWatchRequest[]): Promise; + watch(requests: IWatchRequest[]): Promise; /** * Enable verbose logging in the watcher. @@ -72,39 +94,63 @@ export interface IRecursiveWatcher extends IDisposable { stop(): Promise; } -export interface INonRecursiveWatcher extends IDisposable { - - /** - * A promise that indicates when the watcher is ready. - */ - readonly ready: Promise; - - /** - * Enable verbose logging in the watcher. - */ - setVerboseLogging(enabled: boolean): void; +export interface IRecursiveWatcher extends IWatcher { + watch(requests: IRecursiveWatchRequest[]): Promise; } -export abstract class AbstractRecursiveWatcherClient extends Disposable { +export interface IRecursiveWatcherOptions { + + /** + * If `true`, will enable polling for all watchers, otherwise + * will enable it for paths included in the string array. + * + * @deprecated this only exists for WSL1 support and should never + * be used in any other case. + */ + usePolling: boolean | string[]; + + /** + * If polling is enabled (via `usePolling`), defines the duration + * in which the watcher will poll for changes. + * + * @deprecated this only exists for WSL1 support and should never + * be used in any other case. + */ + pollingInterval?: number; +} + +export interface INonRecursiveWatcher extends IWatcher { + watch(requests: INonRecursiveWatchRequest[]): Promise; +} + +export interface IUniversalWatcher extends IWatcher { + watch(requests: IUniversalWatchRequest[]): Promise; +} + +export abstract class AbstractWatcherClient extends Disposable { private static readonly MAX_RESTARTS = 5; - private watcher: IRecursiveWatcher | undefined; + private watcher: IWatcher | undefined; private readonly watcherDisposables = this._register(new MutableDisposable()); - private requests: IRecursiveWatchRequest[] | undefined = undefined; + private requests: IWatchRequest[] | undefined = undefined; private restartCounter = 0; constructor( private readonly onFileChanges: (changes: IDiskFileChange[]) => void, private readonly onLogMessage: (msg: ILogMessage) => void, - private verboseLogging: boolean + private verboseLogging: boolean, + private options: { + type: string, + restartOnError: boolean + } ) { super(); } - protected abstract createWatcher(disposables: DisposableStore): IRecursiveWatcher; + protected abstract createWatcher(disposables: DisposableStore): IWatcher; protected init(): void { @@ -117,33 +163,37 @@ export abstract class AbstractRecursiveWatcherClient extends Disposable { this.watcher.setVerboseLogging(this.verboseLogging); // Wire in event handlers - disposables.add(this.watcher.onDidChangeFile(e => this.onFileChanges(e))); - disposables.add(this.watcher.onDidLogMessage(e => this.onLogMessage(e))); - disposables.add(this.watcher.onDidError(e => this.onError(e))); + disposables.add(this.watcher.onDidChangeFile(changes => this.onFileChanges(changes))); + disposables.add(this.watcher.onDidLogMessage(msg => this.onLogMessage(msg))); + disposables.add(this.watcher.onDidError(error => this.onError(error))); } protected onError(error: string): void { - // Restart up to N times - if (this.restartCounter < AbstractRecursiveWatcherClient.MAX_RESTARTS && this.requests) { - this.error(`restarting watcher after error: ${error}`); - this.restart(this.requests); + // Restart on error (up to N times, if enabled) + if (this.options.restartOnError) { + if (this.restartCounter < AbstractWatcherClient.MAX_RESTARTS && this.requests) { + this.error(`restarting watcher after error: ${error}`); + this.restart(this.requests); + } else { + this.error(`gave up attempting to restart watcher after error: ${error}`); + } } - // Otherwise log that we have given up to restart + // Do not attempt to restart if not enabled else { - this.error(`gave up attempting to restart watcher after error: ${error}`); + this.error(error); } } - private restart(requests: IRecursiveWatchRequest[]): void { + private restart(requests: IUniversalWatchRequest[]): void { this.restartCounter++; this.init(); this.watch(requests); } - async watch(requests: IRecursiveWatchRequest[]): Promise { + async watch(requests: IUniversalWatchRequest[]): Promise { this.requests = requests; await this.watcher?.watch(requests); @@ -156,7 +206,7 @@ export abstract class AbstractRecursiveWatcherClient extends Disposable { } private error(message: string) { - this.onLogMessage({ type: 'error', message: `[File Watcher (parcel)] ${message}` }); + this.onLogMessage({ type: 'error', message: `[File Watcher (${this.options.type})] ${message}` }); } override dispose(): void { @@ -168,6 +218,32 @@ export abstract class AbstractRecursiveWatcherClient extends Disposable { } } +export abstract class AbstractNonRecursiveWatcherClient extends AbstractWatcherClient { + + constructor( + onFileChanges: (changes: IDiskFileChange[]) => void, + onLogMessage: (msg: ILogMessage) => void, + verboseLogging: boolean + ) { + super(onFileChanges, onLogMessage, verboseLogging, { type: 'node.js', restartOnError: false }); + } + + protected abstract override createWatcher(disposables: DisposableStore): INonRecursiveWatcher; +} + +export abstract class AbstractUniversalWatcherClient extends AbstractWatcherClient { + + constructor( + onFileChanges: (changes: IDiskFileChange[]) => void, + onLogMessage: (msg: ILogMessage) => void, + verboseLogging: boolean + ) { + super(onFileChanges, onLogMessage, verboseLogging, { type: 'universal', restartOnError: true }); + } + + protected abstract override createWatcher(disposables: DisposableStore): IUniversalWatcher; +} + export interface IDiskFileChange { type: FileChangeType; path: string; diff --git a/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts b/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts index f44da63f2f8..7f2fcf40402 100644 --- a/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts +++ b/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts @@ -9,23 +9,20 @@ import { isWindows } from 'vs/base/common/platform'; import { Emitter } from 'vs/base/common/event'; import { URI, UriComponents } from 'vs/base/common/uri'; import { FileDeleteOptions, IFileChange, IWatchOptions, createFileSystemProviderError, FileSystemProviderErrorCode } from 'vs/platform/files/common/files'; -import { NodeJSFileWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; import { DiskFileSystemProvider } from 'vs/platform/files/node/diskFileSystemProvider'; import { basename, normalize } from 'vs/base/common/path'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { ILogMessage, toFileChanges } from 'vs/platform/files/common/watcher'; -import { ILogService, LogLevel } from 'vs/platform/log/common/log'; -import { AbstractDiskFileSystemProviderChannel, ISessionFileWatcher } from 'vs/platform/files/node/diskFileSystemProviderServer'; +import { IDisposable } from 'vs/base/common/lifecycle'; +import { ILogService } from 'vs/platform/log/common/log'; +import { AbstractDiskFileSystemProviderChannel, AbstractSessionFileWatcher, ISessionFileWatcher } from 'vs/platform/files/node/diskFileSystemProviderServer'; import { DefaultURITransformer, IURITransformer } from 'vs/base/common/uriIpc'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; -/** - * A server implementation for a IPC based file system provider client. - */ export class DiskFileSystemProviderChannel extends AbstractDiskFileSystemProviderChannel { constructor( provider: DiskFileSystemProvider, - logService: ILogService + logService: ILogService, + private readonly environmentService: IEnvironmentService ) { super(provider, logService); } @@ -59,65 +56,20 @@ export class DiskFileSystemProviderChannel extends AbstractDiskFileSystemProvide //#region File Watching protected createSessionFileWatcher(uriTransformer: IURITransformer, emitter: Emitter): ISessionFileWatcher { - return new SessionFileWatcher(emitter, this.logService); + return new SessionFileWatcher(uriTransformer, emitter, this.logService, this.environmentService); } //#endregion } -class SessionFileWatcher extends Disposable implements ISessionFileWatcher { +class SessionFileWatcher extends AbstractSessionFileWatcher { - private readonly watcherRequests = new Map(); - - constructor( - private readonly sessionEmitter: Emitter, - private readonly logService: ILogService - ) { - super(); - } - - watch(req: number, resource: URI, opts: IWatchOptions): IDisposable { + override watch(req: number, resource: URI, opts: IWatchOptions): IDisposable { if (opts.recursive) { - throw createFileSystemProviderError('Recursive watcher is not supported from main process', FileSystemProviderErrorCode.Unavailable); + throw createFileSystemProviderError('Recursive file watching is not supported from main process for performance reasons.', FileSystemProviderErrorCode.Unavailable); } - const disposable = new DisposableStore(); - - this.watcherRequests.set(req, disposable); - disposable.add(toDisposable(() => this.watcherRequests.delete(req))); - - const watcher = disposable.add(new NodeJSFileWatcher( - { - path: normalize(resource.fsPath), - excludes: opts.excludes - }, - changes => this.sessionEmitter.fire(toFileChanges(changes)), - msg => this.onWatcherLogMessage(msg), - this.logService.getLevel() === LogLevel.Trace - )); - - disposable.add(this.logService.onDidChangeLogLevel(() => { - watcher.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace); - })); - - return disposable; - } - - private onWatcherLogMessage(msg: ILogMessage): void { - if (msg.type === 'error') { - this.sessionEmitter.fire(msg.message); - } - - this.logService[msg.type](msg.message); - } - - override dispose(): void { - super.dispose(); - - for (const [, disposable] of this.watcherRequests) { - disposable.dispose(); - } - this.watcherRequests.clear(); + return super.watch(req, resource, opts); } } diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index 14d6a6a8559..1dc0cdaa287 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -21,12 +21,12 @@ import { IDirent, Promises, RimRafMode, SymlinkSupport } from 'vs/base/node/pfs' import { localize } from 'vs/nls'; import { createFileSystemProviderError, FileAtomicReadOptions, FileDeleteOptions, FileOpenOptions, FileOverwriteOptions, FileReadStreamOptions, FileSystemProviderCapabilities, FileSystemProviderError, FileSystemProviderErrorCode, FileType, FileWriteOptions, IFileSystemProviderWithFileAtomicReadCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, isFileOpenForWriteOptions, IStat } from 'vs/platform/files/common/files'; import { readFileIntoStream } from 'vs/platform/files/common/io'; -import { NodeJSFileWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; -import { ParcelWatcherClient } from 'vs/platform/files/node/watcher/parcel/parcelWatcherClient'; -import { AbstractRecursiveWatcherClient, IDiskFileChange, ILogMessage, INonRecursiveWatcher, INonRecursiveWatchRequest, IRecursiveWatchRequest } from 'vs/platform/files/common/watcher'; +import { AbstractNonRecursiveWatcherClient, AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage } from 'vs/platform/files/common/watcher'; import { ILogService } from 'vs/platform/log/common/log'; -import { AbstractDiskFileSystemProvider } from 'vs/platform/files/common/diskFileSystemProvider'; +import { AbstractDiskFileSystemProvider, IDiskFileSystemProviderOptions } from 'vs/platform/files/common/diskFileSystemProvider'; import { toErrorMessage } from 'vs/base/common/errorMessage'; +import { UniversalWatcherClient } from 'vs/platform/files/node/watcher/watcherClient'; +import { NodeJSWatcherClient } from 'vs/platform/files/node/watcher/nodejs/nodejsClient'; /** * Enable graceful-fs very early from here to have it enabled @@ -40,31 +40,6 @@ import { toErrorMessage } from 'vs/base/common/errorMessage'; } })(); -export interface IWatcherOptions { - - /** - * If `true`, will enable polling for all watchers, otherwise - * will enable it for paths included in the string array. - * - * @deprecated this only exists for WSL1 support and should never - * be used in any other case. - */ - usePolling: boolean | string[]; - - /** - * If polling is enabled (via `usePolling`), defines the duration - * in which the watcher will poll for changes. - * - * @deprecated this only exists for WSL1 support and should never - * be used in any other case. - */ - pollingInterval?: number; -} - -export interface IDiskFileSystemProviderOptions { - watcher?: IWatcherOptions; -} - export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider implements IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, @@ -74,14 +49,14 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple constructor( logService: ILogService, - private readonly options?: IDiskFileSystemProviderOptions + options?: IDiskFileSystemProviderOptions ) { - super(logService); + super(logService, options); } //#region File Capabilities - readonly onDidChangeCapabilities: Event = Event.None; + readonly onDidChangeCapabilities = Event.None; private _capabilities: FileSystemProviderCapabilities | undefined; get capabilities(): FileSystemProviderCapabilities { @@ -609,45 +584,20 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple //#region File Watching - protected createRecursiveWatcher( + protected createUniversalWatcher( onChange: (changes: IDiskFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean - ): AbstractRecursiveWatcherClient { - return new ParcelWatcherClient( - changes => onChange(changes), - msg => onLogMessage(msg), - verboseLogging - ); - } - - protected override massageRecursiveWatchRequests(requests: IRecursiveWatchRequest[]): void { - const usePolling = this.options?.watcher?.usePolling; - if (usePolling === true) { - for (const request of requests) { - request.pollingInterval = this.options?.watcher?.pollingInterval ?? 5000; - } - } else if (Array.isArray(usePolling)) { - for (const request of requests) { - if (usePolling.includes(request.path)) { - request.pollingInterval = this.options?.watcher?.pollingInterval ?? 5000; - } - } - } + ): AbstractUniversalWatcherClient { + return new UniversalWatcherClient(changes => onChange(changes), msg => onLogMessage(msg), verboseLogging); } protected createNonRecursiveWatcher( - request: INonRecursiveWatchRequest, onChange: (changes: IDiskFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean - ): INonRecursiveWatcher { - return new NodeJSFileWatcher( - request, - changes => onChange(changes), - msg => onLogMessage(msg), - verboseLogging - ); + ): AbstractNonRecursiveWatcherClient { + return new NodeJSWatcherClient(changes => onChange(changes), msg => onLogMessage(msg), verboseLogging); } //#endregion diff --git a/src/vs/platform/files/node/diskFileSystemProviderServer.ts b/src/vs/platform/files/node/diskFileSystemProviderServer.ts index 5670de0fa59..1b350c9e72a 100644 --- a/src/vs/platform/files/node/diskFileSystemProviderServer.ts +++ b/src/vs/platform/files/node/diskFileSystemProviderServer.ts @@ -6,7 +6,7 @@ import { Emitter, Event } from 'vs/base/common/event'; import { IServerChannel } from 'vs/base/parts/ipc/common/ipc'; import { DiskFileSystemProvider } from 'vs/platform/files/node/diskFileSystemProvider'; -import { Disposable, dispose, IDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; import { ILogService } from 'vs/platform/log/common/log'; import { IURITransformer } from 'vs/base/common/uriIpc'; import { URI, UriComponents } from 'vs/base/common/uri'; @@ -14,6 +14,8 @@ import { VSBuffer } from 'vs/base/common/buffer'; import { ReadableStreamEventPayload, listenStream } from 'vs/base/common/stream'; import { IStat, FileReadStreamOptions, FileWriteOptions, FileOpenOptions, FileDeleteOptions, FileOverwriteOptions, IFileChange, IWatchOptions, FileType, FileAtomicReadOptions } from 'vs/platform/files/common/files'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { IRecursiveWatcherOptions } from 'vs/platform/files/common/watcher'; +import { IEnvironmentService } from 'vs/platform/environment/common/environment'; export interface ISessionFileWatcher extends IDisposable { watch(req: number, resource: URI, opts: IWatchOptions): IDisposable; @@ -245,3 +247,76 @@ export abstract class AbstractDiskFileSystemProviderChannel extends Disposabl this.sessionToWatcher.clear(); } } + +export abstract class AbstractSessionFileWatcher extends Disposable implements ISessionFileWatcher { + + private readonly watcherRequests = new Map(); + + // To ensure we use one file watcher per session, we keep a + // disk file system provider instantiated for this session. + // The provider is cheap and only stateful when file watching + // starts. + // + // This is important because we want to ensure that we only + // forward events from the watched paths for this session and + // not other clients that asked to watch other paths. + private readonly fileWatcher = this._register(new DiskFileSystemProvider(this.logService, { watcher: { recursive: this.getRecursiveWatcherOptions(this.environmentService) } })); + + constructor( + private readonly uriTransformer: IURITransformer, + sessionEmitter: Emitter, + private readonly logService: ILogService, + private readonly environmentService: IEnvironmentService + ) { + super(); + + this.registerListeners(sessionEmitter); + } + + private registerListeners(sessionEmitter: Emitter): void { + const localChangeEmitter = this._register(new Emitter()); + + this._register(localChangeEmitter.event((events) => { + sessionEmitter.fire( + events.map(e => ({ + resource: this.uriTransformer.transformOutgoingURI(e.resource), + type: e.type + })) + ); + })); + + this._register(this.fileWatcher.onDidChangeFile(events => localChangeEmitter.fire(events))); + this._register(this.fileWatcher.onDidWatchError(error => sessionEmitter.fire(error))); + } + + protected getRecursiveWatcherOptions(environmentService: IEnvironmentService): IRecursiveWatcherOptions | undefined { + return undefined; // subclasses can override + } + + protected getExtraExcludes(environmentService: IEnvironmentService): string[] | undefined { + return undefined; // subclasses can override + } + + watch(req: number, resource: URI, opts: IWatchOptions): IDisposable { + const extraExcludes = this.getExtraExcludes(this.environmentService); + if (Array.isArray(extraExcludes)) { + opts.excludes = [...opts.excludes, ...extraExcludes]; + } + + this.watcherRequests.set(req, this.fileWatcher.watch(resource, opts)); + + return toDisposable(() => { + dispose(this.watcherRequests.get(req)); + this.watcherRequests.delete(req); + }); + } + + override dispose(): void { + super.dispose(); + + for (const [, disposable] of this.watcherRequests) { + disposable.dispose(); + } + this.watcherRequests.clear(); + } +} diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts new file mode 100644 index 00000000000..49a38f7c4dc --- /dev/null +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts @@ -0,0 +1,24 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { IDiskFileChange, ILogMessage, AbstractNonRecursiveWatcherClient, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; + +export class NodeJSWatcherClient extends AbstractNonRecursiveWatcherClient { + + constructor( + onFileChanges: (changes: IDiskFileChange[]) => void, + onLogMessage: (msg: ILogMessage) => void, + verboseLogging: boolean + ) { + super(onFileChanges, onLogMessage, verboseLogging); + + this.init(); + } + + protected override createWatcher(): INonRecursiveWatcher { + return new NodeJSWatcher(); + } +} diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts index c51ce5e04cb..15ae4d7b224 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts @@ -3,529 +3,136 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { watch } from 'fs'; -import { ThrottledDelayer, ThrottledWorker } from 'vs/base/common/async'; -import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { isEqualOrParent } from 'vs/base/common/extpath'; -import { parse } from 'vs/base/common/glob'; -import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { normalizeNFC } from 'vs/base/common/normalization'; -import { basename, dirname, join } from 'vs/base/common/path'; -import { isLinux, isMacintosh } from 'vs/base/common/platform'; -import { realcase } from 'vs/base/node/extpath'; -import { Promises } from 'vs/base/node/pfs'; -import { FileChangeType } from 'vs/platform/files/common/files'; -import { IDiskFileChange, ILogMessage, coalesceEvents, INonRecursiveWatcher, INonRecursiveWatchRequest } from 'vs/platform/files/common/watcher'; +import { equals } from 'vs/base/common/arrays'; +import { Event, Emitter } from 'vs/base/common/event'; +import { Disposable } from 'vs/base/common/lifecycle'; +import { isLinux } from 'vs/base/common/platform'; +import { IDiskFileChange, ILogMessage, INonRecursiveWatchRequest, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { NodeJSFileWatcherLibrary } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; -export class NodeJSFileWatcher extends Disposable implements INonRecursiveWatcher { +export interface INodeJSWatcherInstance { - // A delay in reacting to file deletes to support - // atomic save operations where a tool may chose - // to delete a file before creating it again for - // an update. - private static readonly FILE_DELETE_HANDLER_DELAY = 100; + /** + * The watcher instance. + */ + readonly instance: NodeJSFileWatcherLibrary; - // A delay for collecting file changes from node.js - // before collecting them for coalescing and emitting - // (same delay as Parcel is using) - private static readonly FILE_CHANGES_HANDLER_DELAY = 50; + /** + * The watch request associated to the watcher. + */ + readonly request: INonRecursiveWatchRequest; +} - // Reduce likelyhood of spam from file events via throttling. - // These numbers are a bit more aggressive compared to the - // recursive watcher because we can have many individual - // node.js watchers per request. - // (https://github.com/microsoft/vscode/issues/124723) - private readonly throttledFileChangesWorker = new ThrottledWorker( - { - maxWorkChunkSize: 100, // only process up to 100 changes at once before... - throttleDelay: 200, // ...resting for 200ms until we process events again... - maxBufferedWork: 10000 // ...but never buffering more than 10000 events in memory - }, - events => this.onDidFilesChange(events) - ); +export class NodeJSWatcher extends Disposable implements INonRecursiveWatcher { - private readonly fileChangesDelayer = this._register(new ThrottledDelayer(NodeJSFileWatcher.FILE_CHANGES_HANDLER_DELAY)); - private fileChangesBuffer: IDiskFileChange[] = []; + private readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; - private readonly excludes = this.request.excludes.map(exclude => parse(exclude)); + private readonly _onDidLogMessage = this._register(new Emitter()); + readonly onDidLogMessage = this._onDidLogMessage.event; - private readonly cts = new CancellationTokenSource(); + readonly onDidError = Event.None; - readonly ready = this.watch(); + protected readonly watchers = new Map(); - constructor( - private request: INonRecursiveWatchRequest, - private onDidFilesChange: (changes: IDiskFileChange[]) => void, - private onLogMessage?: (msg: ILogMessage) => void, - private verboseLogging?: boolean - ) { - super(); - } + private verboseLogging = false; - private async watch(): Promise { - try { - const realPath = await this.normalizePath(this.request); + async watch(requests: INonRecursiveWatchRequest[]): Promise { - if (this.cts.token.isCancellationRequested) { - return; + // Figure out duplicates to remove from the requests + const normalizedRequests = this.normalizeRequests(requests); + + // Gather paths that we should start watching + const requestsToStartWatching = normalizedRequests.filter(request => { + const watcher = this.watchers.get(request.path); + if (!watcher) { + return true; // not yet watching that path } - this.trace(`Request to start watching: ${realPath} (excludes: ${this.request.excludes}))}`); - - // Watch via node.js - const stat = await Promises.stat(realPath); - this._register(await this.doWatch(realPath, stat.isDirectory())); - - } catch (error) { - if (error.code !== 'ENOENT') { - this.error(error); - } else { - this.trace(error); - } - } - } - - private async normalizePath(request: INonRecursiveWatchRequest): Promise { - let realPath = request.path; - - try { - - // First check for symbolic link - realPath = await Promises.realpath(request.path); - - // Second check for casing difference - // Note: this will be a no-op on Linux platforms - if (request.path === realPath) { - realPath = await realcase(request.path) ?? request.path; - } - - // Correct watch path as needed - if (request.path !== realPath) { - this.warn(`correcting a path to watch that seems to be a symbolic link or wrong casing (original: ${request.path}, real: ${realPath})`); - } - } catch (error) { - // ignore - } - - return realPath; - } - - private async doWatch(path: string, isDirectory: boolean): Promise { - - // macOS: watching samba shares can crash VSCode so we do - // a simple check for the file path pointing to /Volumes - // (https://github.com/microsoft/vscode/issues/106879) - // TODO@electron this needs a revisit when the crash is - // fixed or mitigated upstream. - if (isMacintosh && isEqualOrParent(path, '/Volumes/')) { - this.error(`Refusing to watch ${path} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`); - - return Disposable.None; - } - - const cts = new CancellationTokenSource(this.cts.token); - - let disposables = new DisposableStore(); - - try { - const pathBasename = basename(path); - - // Creating watcher can fail with an exception - const watcher = watch(path); - disposables.add(toDisposable(() => { - watcher.removeAllListeners(); - watcher.close(); - })); - - // Folder: resolve children to emit proper events - const folderChildren = new Set(); - if (isDirectory) { - try { - for (const child of await Promises.readdir(path)) { - folderChildren.add(child); - } - } catch (error) { - this.error(error); - } - } - - const mapPathToStatDisposable = new Map(); - disposables.add(toDisposable(() => { - for (const [, disposable] of mapPathToStatDisposable) { - disposable.dispose(); - } - mapPathToStatDisposable.clear(); - })); - - watcher.on('error', (code: number, signal: string) => { - this.error(`Failed to watch ${path} for changes using fs.watch() (${code}, ${signal})`); - - // The watcher is no longer functional reliably - // so we go ahead and dispose it - this.dispose(); - }); - - watcher.on('change', (type, raw) => { - if (cts.token.isCancellationRequested) { - return; // ignore if already disposed - } - - this.trace(`[raw] ["${type}"] ${raw}`); - - // Normalize file name - let changedFileName = ''; - if (raw) { // https://github.com/microsoft/vscode/issues/38191 - changedFileName = raw.toString(); - if (isMacintosh) { - // Mac: uses NFD unicode form on disk, but we want NFC - // See also https://github.com/nodejs/node/issues/2165 - changedFileName = normalizeNFC(changedFileName); - } - } - - if (!changedFileName || (type !== 'change' && type !== 'rename')) { - return; // ignore unexpected events - } - - // Folder - if (isDirectory) { - - // Folder child added/deleted - if (type === 'rename') { - - // Cancel any previous stats for this file if existing - mapPathToStatDisposable.get(changedFileName)?.dispose(); - - // Wait a bit and try see if the file still exists on disk - // to decide on the resulting event - const timeoutHandle = setTimeout(async () => { - mapPathToStatDisposable.delete(changedFileName); - - // Depending on the OS the watcher runs on, there - // is different behaviour for when the watched - // folder path is being deleted: - // - // - macOS: not reported but events continue to - // work even when the folder is brought - // back, though it seems every change - // to a file is reported as "rename" - // - Linux: "rename" event is reported with the - // name of the folder and events stop - // working - // - Windows: an EPERM error is thrown that we - // handle from the `on('error')` event - // - // We do not re-attach the watcher after timeout - // though as we do for file watches because for - // file watching specifically we want to handle - // the atomic-write cases where the file is being - // deleted and recreated with different contents. - // - // Same as with recursive watching, we do not - // emit a delete event in this case. - if (changedFileName === pathBasename && !await Promises.exists(path)) { - this.warn('Watcher shutdown because watched path got deleted'); - - // The watcher is no longer functional reliably - // so we go ahead and dispose it - this.dispose(); - - return; - } - - // In order to properly detect renames on a case-insensitive - // file system, we need to use `existsChildStrictCase` helper - // because otherwise we would wrongly assume a file exists - // when it was renamed to same name but different case. - const fileExists = await this.existsChildStrictCase(join(path, changedFileName)); - - if (cts.token.isCancellationRequested) { - return; // ignore if disposed by now - } - - // Figure out the correct event type: - // File Exists: either 'added' or 'updated' if known before - // File Does not Exist: always 'deleted' - let type: FileChangeType; - if (fileExists) { - if (folderChildren.has(changedFileName)) { - type = FileChangeType.UPDATED; - } else { - type = FileChangeType.ADDED; - folderChildren.add(changedFileName); - } - } else { - folderChildren.delete(changedFileName); - type = FileChangeType.DELETED; - } - - this.onFileChange({ path: join(this.request.path, changedFileName), type }); - }, NodeJSFileWatcher.FILE_DELETE_HANDLER_DELAY); - - mapPathToStatDisposable.set(changedFileName, toDisposable(() => clearTimeout(timeoutHandle))); - } - - // Folder child changed - else { - - // Figure out the correct event type: if this is the - // first time we see this child, it can only be added - let type: FileChangeType; - if (folderChildren.has(changedFileName)) { - type = FileChangeType.UPDATED; - } else { - type = FileChangeType.ADDED; - folderChildren.add(changedFileName); - } - - this.onFileChange({ path: join(this.request.path, changedFileName), type }); - } - } - - // File - else { - - // File added/deleted - if (type === 'rename' || changedFileName !== pathBasename) { - - // Depending on the OS the watcher runs on, there - // is different behaviour for when the watched - // file path is being deleted: - // - // - macOS: "rename" event is reported and events - // stop working - // - Linux: "rename" event is reported and events - // stop working - // - Windows: "rename" event is reported and events - // continue to work when file is restored - // - // As opposed to folder watching, we re-attach the - // watcher after brief timeout to support "atomic save" - // operations where a tool may decide to delete a file - // and then create it with the updated contents. - // - // Different to folder watching, we emit a delete event - // though we never detect when the file is brought back - // because the watcher is disposed then. - - const timeoutHandle = setTimeout(async () => { - const fileExists = await Promises.exists(path); - - if (cts.token.isCancellationRequested) { - return; // ignore if disposed by now - } - - // File still exists, so emit as change event and reapply the watcher - if (fileExists) { - this.onFileChange({ path: this.request.path, type: FileChangeType.UPDATED }, true /* skip excludes (file is explicitly watched) */); - - disposables.add(await this.doWatch(path, false)); - } - - // File seems to be really gone, so emit a deleted event and dispose - else { - const eventPromise = this.onFileChange({ path: this.request.path, type: FileChangeType.DELETED }, true /* skip excludes (file is explicitly watched) */); - - // Important to await the event delivery - // before disposing the watcher, otherwise - // we will loose this event. - await eventPromise; - this.dispose(); - } - }, NodeJSFileWatcher.FILE_DELETE_HANDLER_DELAY); - - // Very important to dispose the watcher which now points to a stale inode - // and wire in a new disposable that tracks our timeout that is installed - disposables.clear(); - disposables.add(toDisposable(() => clearTimeout(timeoutHandle))); - } - - // File changed - else { - this.onFileChange({ path: this.request.path, type: FileChangeType.UPDATED }, true /* skip excludes (file is explicitly watched) */); - } - } - }); - } catch (error) { - if (await Promises.exists(path) && !cts.token.isCancellationRequested) { - this.error(`Failed to watch ${path} for changes using fs.watch() (${error.toString()})`); - } - } - - return toDisposable(() => { - cts.dispose(true); - disposables.dispose(); + // Re-watch path if excludes have changed + return !equals(watcher.request.excludes, request.excludes); }); - } - private async onFileChange(event: IDiskFileChange, skipExcludes = false): Promise { - if (this.cts.token.isCancellationRequested) { - return; - } + // Gather paths that we should stop watching + const pathsToStopWatching = Array.from(this.watchers.values()).filter(({ request }) => { + return !normalizedRequests.find(normalizedRequest => normalizedRequest.path === request.path && equals(normalizedRequest.excludes, request.excludes)); + }).map(({ request }) => request.path); // Logging - if (this.verboseLogging) { - this.trace(`${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`); + + if (requestsToStartWatching.length) { + this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''})`).join(',')}`); } - // Add to buffer unless ignored (not if explicitly disabled) - if (!skipExcludes && this.excludes.some(exclude => exclude(event.path))) { - if (this.verboseLogging) { - this.trace(` >> ignored ${event.path}`); - } - } else { - this.fileChangesBuffer.push(event); + if (pathsToStopWatching.length) { + this.trace(`Request to stop watching: ${pathsToStopWatching.join(',')}`); } - // Handle emit through delayer to accommodate for bulk changes and thus reduce spam - try { - await this.fileChangesDelayer.trigger(async () => { - const fileChanges = this.fileChangesBuffer; - this.fileChangesBuffer = []; + // Stop watching as instructed + for (const pathToStopWatching of pathsToStopWatching) { + this.stopWatching(pathToStopWatching); + } - // Coalesce events: merge events of same kind - const coalescedFileChanges = coalesceEvents(fileChanges); - - if (coalescedFileChanges.length > 0) { - - // Logging - if (this.verboseLogging) { - for (const event of coalescedFileChanges) { - this.trace(`>> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`); - } - } - - // Broadcast to clients via throttler - const worked = this.throttledFileChangesWorker.work(coalescedFileChanges); - - // Logging - if (!worked) { - this.warn(`started ignoring events due to too many file change events at once (incoming: ${coalescedFileChanges.length}, most recent change: ${coalescedFileChanges[0].path}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); - } else { - if (this.throttledFileChangesWorker.pending > 0) { - this.trace(`started throttling events due to large amount of file change events at once (pending: ${this.throttledFileChangesWorker.pending}, most recent change: ${coalescedFileChanges[0].path}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); - } - } - } - }); - } catch (error) { - // ignore (we are likely disposed and cancelled) + // Start watching as instructed + for (const request of requestsToStartWatching) { + this.startWatching(request); } } - private async existsChildStrictCase(path: string): Promise { - if (isLinux) { - return Promises.exists(path); + private startWatching(request: INonRecursiveWatchRequest): void { + + // Start via node.js lib + const instance = new NodeJSFileWatcherLibrary(request, changes => this._onDidChangeFile.fire(changes), msg => this._onDidLogMessage.fire(msg), this.verboseLogging); + + // Remember as watcher instance + const watcher: INodeJSWatcherInstance = { request, instance }; + this.watchers.set(request.path, watcher); + } + + async stop(): Promise { + for (const [path] of this.watchers) { + this.stopWatching(path); } - try { - const pathBasename = basename(path); - const children = await Promises.readdir(dirname(path)); + this.watchers.clear(); + } - return children.some(child => child === pathBasename); - } catch (error) { - this.trace(error); + private stopWatching(path: string): void { + const watcher = this.watchers.get(path); + if (watcher) { + this.watchers.delete(path); - return false; + watcher.instance.dispose(); } } - setVerboseLogging(verboseLogging: boolean): void { - this.verboseLogging = verboseLogging; - } + private normalizeRequests(requests: INonRecursiveWatchRequest[]): INonRecursiveWatchRequest[] { + const requestsMap = new Map(); - private error(error: string): void { - if (!this.cts.token.isCancellationRequested) { - this.onLogMessage?.({ type: 'error', message: `[File Watcher (node.js)] ${error}` }); + // Ignore requests for the same paths + for (const request of requests) { + const path = isLinux ? request.path : request.path.toLowerCase(); // adjust for case sensitivity + requestsMap.set(path, request); } + + return Array.from(requestsMap.values()); } - private warn(message: string): void { - if (!this.cts.token.isCancellationRequested) { - this.onLogMessage?.({ type: 'warn', message: `[File Watcher (node.js)] ${message}` }); + async setVerboseLogging(enabled: boolean): Promise { + this.verboseLogging = enabled; + + for (const [, watcher] of this.watchers) { + watcher.instance.setVerboseLogging(enabled); } } private trace(message: string): void { - if (!this.cts.token.isCancellationRequested && this.verboseLogging) { - this.onLogMessage?.({ type: 'trace', message: `[File Watcher (node.js)] ${message}` }); + if (this.verboseLogging) { + this._onDidLogMessage.fire({ type: 'trace', message: this.toMessage(message) }); } } - override dispose(): void { - this.trace('stopping file watcher'); - - this.cts.dispose(true); - - super.dispose(); + private toMessage(message: string, watcher?: INodeJSWatcherInstance): string { + return watcher ? `[File Watcher (node.js)] ${message} (path: ${watcher.request.path})` : `[File Watcher (node.js)] ${message}`; } } - -/** - * Watch the provided `path` for changes and return - * the data in chunks of `Uint8Array` for further use. - */ -export async function watchFileContents(path: string, onData: (chunk: Uint8Array) => void, onReady: () => void, token: CancellationToken, bufferSize = 512): Promise { - const handle = await Promises.open(path, 'r'); - const buffer = Buffer.allocUnsafe(bufferSize); - - const cts = new CancellationTokenSource(token); - - let error: Error | undefined = undefined; - let isReading = false; - - const request: INonRecursiveWatchRequest = { path, excludes: [] }; - const watcher = new NodeJSFileWatcher(request, changes => { - (async () => { - for (const { type } of changes) { - if (type === FileChangeType.UPDATED) { - - if (isReading) { - return; // return early if we are already reading the output - } - - isReading = true; - - try { - // Consume the new contents of the file until finished - // everytime there is a change event signalling a change - while (!cts.token.isCancellationRequested) { - const { bytesRead } = await Promises.read(handle, buffer, 0, bufferSize, null); - if (!bytesRead || cts.token.isCancellationRequested) { - break; - } - - onData(buffer.slice(0, bytesRead)); - } - } catch (err) { - error = new Error(err); - cts.dispose(true); - } finally { - isReading = false; - } - } - } - })(); - }); - - await watcher.ready; - onReady(); - - return new Promise((resolve, reject) => { - cts.token.onCancellationRequested(async () => { - watcher.dispose(); - - try { - await Promises.close(handle); - } catch (err) { - error = new Error(err); - } - - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); -} diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts new file mode 100644 index 00000000000..905ee57e7b5 --- /dev/null +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts @@ -0,0 +1,531 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { watch } from 'fs'; +import { ThrottledDelayer, ThrottledWorker } from 'vs/base/common/async'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { isEqualOrParent } from 'vs/base/common/extpath'; +import { parse } from 'vs/base/common/glob'; +import { Disposable, DisposableStore, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { normalizeNFC } from 'vs/base/common/normalization'; +import { basename, dirname, join } from 'vs/base/common/path'; +import { isLinux, isMacintosh } from 'vs/base/common/platform'; +import { realcase } from 'vs/base/node/extpath'; +import { Promises } from 'vs/base/node/pfs'; +import { FileChangeType } from 'vs/platform/files/common/files'; +import { IDiskFileChange, ILogMessage, coalesceEvents, INonRecursiveWatchRequest } from 'vs/platform/files/common/watcher'; + +export class NodeJSFileWatcherLibrary extends Disposable { + + // A delay in reacting to file deletes to support + // atomic save operations where a tool may chose + // to delete a file before creating it again for + // an update. + private static readonly FILE_DELETE_HANDLER_DELAY = 100; + + // A delay for collecting file changes from node.js + // before collecting them for coalescing and emitting + // (same delay as Parcel is using) + private static readonly FILE_CHANGES_HANDLER_DELAY = 50; + + // Reduce likelyhood of spam from file events via throttling. + // These numbers are a bit more aggressive compared to the + // recursive watcher because we can have many individual + // node.js watchers per request. + // (https://github.com/microsoft/vscode/issues/124723) + private readonly throttledFileChangesWorker = new ThrottledWorker( + { + maxWorkChunkSize: 100, // only process up to 100 changes at once before... + throttleDelay: 200, // ...resting for 200ms until we process events again... + maxBufferedWork: 10000 // ...but never buffering more than 10000 events in memory + }, + events => this.onDidFilesChange(events) + ); + + private readonly fileChangesDelayer = this._register(new ThrottledDelayer(NodeJSFileWatcherLibrary.FILE_CHANGES_HANDLER_DELAY)); + private fileChangesBuffer: IDiskFileChange[] = []; + + private readonly excludes = this.request.excludes.map(exclude => parse(exclude)); + + private readonly cts = new CancellationTokenSource(); + + readonly ready = this.watch(); + + constructor( + private request: INonRecursiveWatchRequest, + private onDidFilesChange: (changes: IDiskFileChange[]) => void, + private onLogMessage?: (msg: ILogMessage) => void, + private verboseLogging?: boolean + ) { + super(); + } + + private async watch(): Promise { + try { + const realPath = await this.normalizePath(this.request); + + if (this.cts.token.isCancellationRequested) { + return; + } + + // Watch via node.js + const stat = await Promises.stat(realPath); + this._register(await this.doWatch(realPath, stat.isDirectory())); + + } catch (error) { + if (error.code !== 'ENOENT') { + this.error(error); + } else { + this.trace(error); + } + } + } + + private async normalizePath(request: INonRecursiveWatchRequest): Promise { + let realPath = request.path; + + try { + + // First check for symbolic link + realPath = await Promises.realpath(request.path); + + // Second check for casing difference + // Note: this will be a no-op on Linux platforms + if (request.path === realPath) { + realPath = await realcase(request.path) ?? request.path; + } + + // Correct watch path as needed + if (request.path !== realPath) { + this.warn(`correcting a path to watch that seems to be a symbolic link or wrong casing (original: ${request.path}, real: ${realPath})`); + } + } catch (error) { + // ignore + } + + return realPath; + } + + private async doWatch(path: string, isDirectory: boolean): Promise { + + // macOS: watching samba shares can crash VSCode so we do + // a simple check for the file path pointing to /Volumes + // (https://github.com/microsoft/vscode/issues/106879) + // TODO@electron this needs a revisit when the crash is + // fixed or mitigated upstream. + if (isMacintosh && isEqualOrParent(path, '/Volumes/')) { + this.error(`Refusing to watch ${path} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`); + + return Disposable.None; + } + + const cts = new CancellationTokenSource(this.cts.token); + + let disposables = new DisposableStore(); + + try { + const pathBasename = basename(path); + + // Creating watcher can fail with an exception + const watcher = watch(path); + disposables.add(toDisposable(() => { + watcher.removeAllListeners(); + watcher.close(); + })); + + this.trace(`Started watching: '${path}'`); + + // Folder: resolve children to emit proper events + const folderChildren = new Set(); + if (isDirectory) { + try { + for (const child of await Promises.readdir(path)) { + folderChildren.add(child); + } + } catch (error) { + this.error(error); + } + } + + const mapPathToStatDisposable = new Map(); + disposables.add(toDisposable(() => { + for (const [, disposable] of mapPathToStatDisposable) { + disposable.dispose(); + } + mapPathToStatDisposable.clear(); + })); + + watcher.on('error', (code: number, signal: string) => { + this.error(`Failed to watch ${path} for changes using fs.watch() (${code}, ${signal})`); + + // The watcher is no longer functional reliably + // so we go ahead and dispose it + this.dispose(); + }); + + watcher.on('change', (type, raw) => { + if (cts.token.isCancellationRequested) { + return; // ignore if already disposed + } + + this.trace(`[raw] ["${type}"] ${raw}`); + + // Normalize file name + let changedFileName = ''; + if (raw) { // https://github.com/microsoft/vscode/issues/38191 + changedFileName = raw.toString(); + if (isMacintosh) { + // Mac: uses NFD unicode form on disk, but we want NFC + // See also https://github.com/nodejs/node/issues/2165 + changedFileName = normalizeNFC(changedFileName); + } + } + + if (!changedFileName || (type !== 'change' && type !== 'rename')) { + return; // ignore unexpected events + } + + // Folder + if (isDirectory) { + + // Folder child added/deleted + if (type === 'rename') { + + // Cancel any previous stats for this file if existing + mapPathToStatDisposable.get(changedFileName)?.dispose(); + + // Wait a bit and try see if the file still exists on disk + // to decide on the resulting event + const timeoutHandle = setTimeout(async () => { + mapPathToStatDisposable.delete(changedFileName); + + // Depending on the OS the watcher runs on, there + // is different behaviour for when the watched + // folder path is being deleted: + // + // - macOS: not reported but events continue to + // work even when the folder is brought + // back, though it seems every change + // to a file is reported as "rename" + // - Linux: "rename" event is reported with the + // name of the folder and events stop + // working + // - Windows: an EPERM error is thrown that we + // handle from the `on('error')` event + // + // We do not re-attach the watcher after timeout + // though as we do for file watches because for + // file watching specifically we want to handle + // the atomic-write cases where the file is being + // deleted and recreated with different contents. + // + // Same as with recursive watching, we do not + // emit a delete event in this case. + if (changedFileName === pathBasename && !await Promises.exists(path)) { + this.warn('Watcher shutdown because watched path got deleted'); + + // The watcher is no longer functional reliably + // so we go ahead and dispose it + this.dispose(); + + return; + } + + // In order to properly detect renames on a case-insensitive + // file system, we need to use `existsChildStrictCase` helper + // because otherwise we would wrongly assume a file exists + // when it was renamed to same name but different case. + const fileExists = await this.existsChildStrictCase(join(path, changedFileName)); + + if (cts.token.isCancellationRequested) { + return; // ignore if disposed by now + } + + // Figure out the correct event type: + // File Exists: either 'added' or 'updated' if known before + // File Does not Exist: always 'deleted' + let type: FileChangeType; + if (fileExists) { + if (folderChildren.has(changedFileName)) { + type = FileChangeType.UPDATED; + } else { + type = FileChangeType.ADDED; + folderChildren.add(changedFileName); + } + } else { + folderChildren.delete(changedFileName); + type = FileChangeType.DELETED; + } + + this.onFileChange({ path: join(this.request.path, changedFileName), type }); + }, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY); + + mapPathToStatDisposable.set(changedFileName, toDisposable(() => clearTimeout(timeoutHandle))); + } + + // Folder child changed + else { + + // Figure out the correct event type: if this is the + // first time we see this child, it can only be added + let type: FileChangeType; + if (folderChildren.has(changedFileName)) { + type = FileChangeType.UPDATED; + } else { + type = FileChangeType.ADDED; + folderChildren.add(changedFileName); + } + + this.onFileChange({ path: join(this.request.path, changedFileName), type }); + } + } + + // File + else { + + // File added/deleted + if (type === 'rename' || changedFileName !== pathBasename) { + + // Depending on the OS the watcher runs on, there + // is different behaviour for when the watched + // file path is being deleted: + // + // - macOS: "rename" event is reported and events + // stop working + // - Linux: "rename" event is reported and events + // stop working + // - Windows: "rename" event is reported and events + // continue to work when file is restored + // + // As opposed to folder watching, we re-attach the + // watcher after brief timeout to support "atomic save" + // operations where a tool may decide to delete a file + // and then create it with the updated contents. + // + // Different to folder watching, we emit a delete event + // though we never detect when the file is brought back + // because the watcher is disposed then. + + const timeoutHandle = setTimeout(async () => { + const fileExists = await Promises.exists(path); + + if (cts.token.isCancellationRequested) { + return; // ignore if disposed by now + } + + // File still exists, so emit as change event and reapply the watcher + if (fileExists) { + this.onFileChange({ path: this.request.path, type: FileChangeType.UPDATED }, true /* skip excludes (file is explicitly watched) */); + + disposables.add(await this.doWatch(path, false)); + } + + // File seems to be really gone, so emit a deleted event and dispose + else { + const eventPromise = this.onFileChange({ path: this.request.path, type: FileChangeType.DELETED }, true /* skip excludes (file is explicitly watched) */); + + // Important to await the event delivery + // before disposing the watcher, otherwise + // we will loose this event. + await eventPromise; + this.dispose(); + } + }, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY); + + // Very important to dispose the watcher which now points to a stale inode + // and wire in a new disposable that tracks our timeout that is installed + disposables.clear(); + disposables.add(toDisposable(() => clearTimeout(timeoutHandle))); + } + + // File changed + else { + this.onFileChange({ path: this.request.path, type: FileChangeType.UPDATED }, true /* skip excludes (file is explicitly watched) */); + } + } + }); + } catch (error) { + if (await Promises.exists(path) && !cts.token.isCancellationRequested) { + this.error(`Failed to watch ${path} for changes using fs.watch() (${error.toString()})`); + } + } + + return toDisposable(() => { + cts.dispose(true); + disposables.dispose(); + }); + } + + private async onFileChange(event: IDiskFileChange, skipExcludes = false): Promise { + if (this.cts.token.isCancellationRequested) { + return; + } + + // Logging + if (this.verboseLogging) { + this.trace(`${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`); + } + + // Add to buffer unless ignored (not if explicitly disabled) + if (!skipExcludes && this.excludes.some(exclude => exclude(event.path))) { + if (this.verboseLogging) { + this.trace(` >> ignored ${event.path}`); + } + } else { + this.fileChangesBuffer.push(event); + } + + // Handle emit through delayer to accommodate for bulk changes and thus reduce spam + try { + await this.fileChangesDelayer.trigger(async () => { + const fileChanges = this.fileChangesBuffer; + this.fileChangesBuffer = []; + + // Coalesce events: merge events of same kind + const coalescedFileChanges = coalesceEvents(fileChanges); + + if (coalescedFileChanges.length > 0) { + + // Logging + if (this.verboseLogging) { + for (const event of coalescedFileChanges) { + this.trace(`>> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`); + } + } + + // Broadcast to clients via throttler + const worked = this.throttledFileChangesWorker.work(coalescedFileChanges); + + // Logging + if (!worked) { + this.warn(`started ignoring events due to too many file change events at once (incoming: ${coalescedFileChanges.length}, most recent change: ${coalescedFileChanges[0].path}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + } else { + if (this.throttledFileChangesWorker.pending > 0) { + this.trace(`started throttling events due to large amount of file change events at once (pending: ${this.throttledFileChangesWorker.pending}, most recent change: ${coalescedFileChanges[0].path}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`); + } + } + } + }); + } catch (error) { + // ignore (we are likely disposed and cancelled) + } + } + + private async existsChildStrictCase(path: string): Promise { + if (isLinux) { + return Promises.exists(path); + } + + try { + const pathBasename = basename(path); + const children = await Promises.readdir(dirname(path)); + + return children.some(child => child === pathBasename); + } catch (error) { + this.trace(error); + + return false; + } + } + + setVerboseLogging(verboseLogging: boolean): void { + this.verboseLogging = verboseLogging; + } + + private error(error: string): void { + if (!this.cts.token.isCancellationRequested) { + this.onLogMessage?.({ type: 'error', message: `[File Watcher (node.js)] ${error}` }); + } + } + + private warn(message: string): void { + if (!this.cts.token.isCancellationRequested) { + this.onLogMessage?.({ type: 'warn', message: `[File Watcher (node.js)] ${message}` }); + } + } + + private trace(message: string): void { + if (!this.cts.token.isCancellationRequested && this.verboseLogging) { + this.onLogMessage?.({ type: 'trace', message: `[File Watcher (node.js)] ${message}` }); + } + } + + override dispose(): void { + this.trace(`stopping file watcher on ${this.request.path}`); + + this.cts.dispose(true); + + super.dispose(); + } +} + +/** + * Watch the provided `path` for changes and return + * the data in chunks of `Uint8Array` for further use. + */ +export async function watchFileContents(path: string, onData: (chunk: Uint8Array) => void, onReady: () => void, token: CancellationToken, bufferSize = 512): Promise { + const handle = await Promises.open(path, 'r'); + const buffer = Buffer.allocUnsafe(bufferSize); + + const cts = new CancellationTokenSource(token); + + let error: Error | undefined = undefined; + let isReading = false; + + const request: INonRecursiveWatchRequest = { path, excludes: [], recursive: false }; + const watcher = new NodeJSFileWatcherLibrary(request, changes => { + (async () => { + for (const { type } of changes) { + if (type === FileChangeType.UPDATED) { + + if (isReading) { + return; // return early if we are already reading the output + } + + isReading = true; + + try { + // Consume the new contents of the file until finished + // everytime there is a change event signalling a change + while (!cts.token.isCancellationRequested) { + const { bytesRead } = await Promises.read(handle, buffer, 0, bufferSize, null); + if (!bytesRead || cts.token.isCancellationRequested) { + break; + } + + onData(buffer.slice(0, bytesRead)); + } + } catch (err) { + error = new Error(err); + cts.dispose(true); + } finally { + isReading = false; + } + } + } + })(); + }); + + await watcher.ready; + onReady(); + + return new Promise((resolve, reject) => { + cts.token.onCancellationRequested(async () => { + watcher.dispose(); + + try { + await Promises.close(handle); + } catch (err) { + error = new Error(err); + } + + if (error) { + reject(error); + } else { + resolve(); + } + }); + }); +} diff --git a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts index d355481dcd0..692cd03586b 100644 --- a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts +++ b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts @@ -19,9 +19,10 @@ import { dirname, isAbsolute, join, normalize, sep } from 'vs/base/common/path'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { rtrim } from 'vs/base/common/strings'; import { realcaseSync, realpathSync } from 'vs/base/node/extpath'; -import { NodeJSFileWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; +import { NodeJSFileWatcherLibrary } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; import { FileChangeType } from 'vs/platform/files/common/files'; import { IDiskFileChange, ILogMessage, coalesceEvents, IRecursiveWatchRequest, IRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { equals } from 'vs/base/common/arrays'; export interface IParcelWatcherInstance { @@ -128,17 +129,28 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { } // Re-watch path if excludes have changed or polling interval - return watcher.request.excludes !== request.excludes || watcher.request.pollingInterval !== request.pollingInterval; + return !equals(watcher.request.excludes, request.excludes) || watcher.request.pollingInterval !== request.pollingInterval; }); // Gather paths that we should stop watching const pathsToStopWatching = Array.from(this.watchers.values()).filter(({ request }) => { - return !normalizedRequests.find(normalizedRequest => normalizedRequest.path === request.path && normalizedRequest.excludes === request.excludes && normalizedRequest.pollingInterval === request.pollingInterval); + return !normalizedRequests.find(normalizedRequest => { + return normalizedRequest.path === request.path && + equals(normalizedRequest.excludes, request.excludes) && + normalizedRequest.pollingInterval === request.pollingInterval; + + }); }).map(({ request }) => request.path); // Logging - this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes})`).join(',')}`); - this.trace(`Request to stop watching: ${pathsToStopWatching.join(',')}`); + + if (requestsToStartWatching.length) { + this.trace(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes.length > 0 ? request.excludes : ''})`).join(',')}`); + } + + if (pathsToStopWatching.length) { + this.trace(`Request to stop watching: ${pathsToStopWatching.join(',')}`); + } // Stop watching as instructed for (const pathToStopWatching of pathsToStopWatching) { @@ -531,7 +543,7 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { const parentPath = dirname(watcher.request.path); if (existsSync(parentPath)) { - const nodeWatcher = new NodeJSFileWatcher({ path: parentPath, excludes: [] }, changes => { + const nodeWatcher = new NodeJSFileWatcherLibrary({ path: parentPath, excludes: [], recursive: false }, changes => { if (watcher.token.isCancellationRequested) { return; // return early when disposed } @@ -620,6 +632,8 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { private async stopWatching(path: string): Promise { const watcher = this.watchers.get(path); if (watcher) { + this.trace(`stopping file watcher on ${watcher.request.path}`); + this.watchers.delete(path); try { diff --git a/src/vs/platform/files/node/watcher/watcher.ts b/src/vs/platform/files/node/watcher/watcher.ts new file mode 100644 index 00000000000..e266239cb65 --- /dev/null +++ b/src/vs/platform/files/node/watcher/watcher.ts @@ -0,0 +1,53 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Disposable } from 'vs/base/common/lifecycle'; +import { INonRecursiveWatchRequest, IRecursiveWatchRequest, IUniversalWatcher, IUniversalWatchRequest } from 'vs/platform/files/common/watcher'; +import { Event } from 'vs/base/common/event'; +import { ParcelWatcher } from 'vs/platform/files/node/watcher/parcel/parcelWatcher'; +import { NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; +import { Promises } from 'vs/base/common/async'; + +export class UniversalWatcher extends Disposable implements IUniversalWatcher { + + private readonly recursiveWatcher = this._register(new ParcelWatcher()); + private readonly nonRecursiveWatcher = this._register(new NodeJSWatcher()); + + readonly onDidChangeFile = Event.any(this.recursiveWatcher.onDidChangeFile, this.nonRecursiveWatcher.onDidChangeFile); + readonly onDidLogMessage = Event.any(this.recursiveWatcher.onDidLogMessage, this.nonRecursiveWatcher.onDidLogMessage); + readonly onDidError = Event.any(this.recursiveWatcher.onDidError, this.nonRecursiveWatcher.onDidError); + + async watch(requests: IUniversalWatchRequest[]): Promise { + const recursiveWatchRequests: IRecursiveWatchRequest[] = []; + const nonRecursiveWatchRequests: INonRecursiveWatchRequest[] = []; + + for (const request of requests) { + if (request.recursive) { + recursiveWatchRequests.push(request); + } else { + nonRecursiveWatchRequests.push(request); + } + } + + await Promises.settled([ + this.recursiveWatcher.watch(recursiveWatchRequests), + this.nonRecursiveWatcher.watch(nonRecursiveWatchRequests) + ]); + } + + async setVerboseLogging(enabled: boolean): Promise { + await Promises.settled([ + this.recursiveWatcher.setVerboseLogging(enabled), + this.nonRecursiveWatcher.setVerboseLogging(enabled) + ]); + } + + async stop(): Promise { + await Promises.settled([ + this.recursiveWatcher.stop(), + this.nonRecursiveWatcher.stop() + ]); + } +} diff --git a/src/vs/platform/files/node/watcher/parcel/parcelWatcherClient.ts b/src/vs/platform/files/node/watcher/watcherClient.ts similarity index 73% rename from src/vs/platform/files/node/watcher/parcel/parcelWatcherClient.ts rename to src/vs/platform/files/node/watcher/watcherClient.ts index 23132bf886c..465f8421d06 100644 --- a/src/vs/platform/files/node/watcher/parcel/parcelWatcherClient.ts +++ b/src/vs/platform/files/node/watcher/watcherClient.ts @@ -7,9 +7,9 @@ import { DisposableStore } from 'vs/base/common/lifecycle'; import { FileAccess } from 'vs/base/common/network'; import { getNextTickChannel, ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; import { Client } from 'vs/base/parts/ipc/node/ipc.cp'; -import { AbstractRecursiveWatcherClient, IDiskFileChange, ILogMessage, IRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage, IUniversalWatcher } from 'vs/platform/files/common/watcher'; -export class ParcelWatcherClient extends AbstractRecursiveWatcherClient { +export class UniversalWatcherClient extends AbstractUniversalWatcherClient { constructor( onFileChanges: (changes: IDiskFileChange[]) => void, @@ -21,17 +21,17 @@ export class ParcelWatcherClient extends AbstractRecursiveWatcherClient { this.init(); } - protected override createWatcher(disposables: DisposableStore): IRecursiveWatcher { + protected override createWatcher(disposables: DisposableStore): IUniversalWatcher { - // Fork the parcel file watcher and build a client around + // Fork the universal file watcher and build a client around // its server for passing over requests and receiving events. const client = disposables.add(new Client( FileAccess.asFileUri('bootstrap-fork', require).fsPath, { - serverName: 'File Watcher (parcel, node.js)', - args: ['--type=parcelWatcher'], + serverName: 'File Watcher', + args: ['--type=fileWatcher'], env: { - VSCODE_AMD_ENTRYPOINT: 'vs/platform/files/node/watcher/parcel/parcelWatcherMain', + VSCODE_AMD_ENTRYPOINT: 'vs/platform/files/node/watcher/watcherMain', VSCODE_PIPE_LOGGING: 'true', VSCODE_VERBOSE_LOGGING: 'true' // transmit console logs from server to client } @@ -41,6 +41,6 @@ export class ParcelWatcherClient extends AbstractRecursiveWatcherClient { // React on unexpected termination of the watcher process disposables.add(client.onDidProcessExit(({ code, signal }) => this.onError(`terminated by itself with code ${code}, signal: ${signal}`))); - return ProxyChannel.toService(getNextTickChannel(client.getChannel('watcher'))); + return ProxyChannel.toService(getNextTickChannel(client.getChannel('watcher'))); } } diff --git a/src/vs/platform/files/node/watcher/parcel/parcelWatcherMain.ts b/src/vs/platform/files/node/watcher/watcherMain.ts similarity index 82% rename from src/vs/platform/files/node/watcher/parcel/parcelWatcherMain.ts rename to src/vs/platform/files/node/watcher/watcherMain.ts index bc9b04171c2..3091f9665f8 100644 --- a/src/vs/platform/files/node/watcher/parcel/parcelWatcherMain.ts +++ b/src/vs/platform/files/node/watcher/watcherMain.ts @@ -5,8 +5,8 @@ import { ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; import { Server } from 'vs/base/parts/ipc/node/ipc.cp'; -import { ParcelWatcher } from 'vs/platform/files/node/watcher/parcel/parcelWatcher'; +import { UniversalWatcher } from 'vs/platform/files/node/watcher/watcher'; const server = new Server('watcher'); -const service = new ParcelWatcher(); +const service = new UniversalWatcher(); server.registerChannel('watcher', ProxyChannel.fromService(service)); diff --git a/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts index c205da6a4b0..f45d208f814 100644 --- a/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts +++ b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts @@ -3,19 +3,19 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { Event, Emitter } from 'vs/base/common/event'; import { tmpdir } from 'os'; import { basename, dirname, join } from 'vs/base/common/path'; import { Promises, RimRafMode } from 'vs/base/node/pfs'; import { flakySuite, getPathFromAmdModule, getRandomTestPath } from 'vs/base/test/node/testUtils'; import { FileChangeType } from 'vs/platform/files/common/files'; -import { IDiskFileChange } from 'vs/platform/files/common/watcher'; -import { NodeJSFileWatcher, watchFileContents } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; +import { INonRecursiveWatchRequest } from 'vs/platform/files/common/watcher'; +import { NodeJSFileWatcherLibrary, watchFileContents } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib'; import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; import { getDriveLetter } from 'vs/base/common/extpath'; import { ltrim } from 'vs/base/common/strings'; import { DeferredPromise } from 'vs/base/common/async'; import { CancellationTokenSource } from 'vs/base/common/cancellation'; +import { NodeJSWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; // this suite has shown flaky runs in Azure pipelines where // tasks would just hang and timeout after a while (not in @@ -24,20 +24,21 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; ((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('File Watcher (node.js)', () => { - let testDir: string; - let watcher: TestNodeJSFileWatcher; - let event: Event; + class TestNodeJSWatcher extends NodeJSWatcher { - let loggingEnabled = false; + override async watch(requests: INonRecursiveWatchRequest[]): Promise { + await super.watch(requests); + await this.whenReady(); + } - function enableLogging(enable: boolean) { - loggingEnabled = enable; - watcher?.setVerboseLogging(enable); + async whenReady(): Promise { + for (const [, watcher] of this.watchers) { + await watcher.instance.ready; + } + } } - enableLogging(false); - - class TestNodeJSFileWatcher extends NodeJSFileWatcher { + class TestNodeJSFileWatcherLibrary extends NodeJSFileWatcherLibrary { private readonly _whenDisposed = new DeferredPromise(); readonly whenDisposed = this._whenDisposed.p; @@ -49,34 +50,42 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; } } - setup(async function () { + let testDir: string; + let watcher: TestNodeJSWatcher; + + let loggingEnabled = false; + + function enableLogging(enable: boolean) { + loggingEnabled = enable; + watcher?.setVerboseLogging(enable); + } + + enableLogging(false); + + setup(async () => { + watcher = new TestNodeJSWatcher(); + + watcher.onDidLogMessage(e => { + if (loggingEnabled) { + console.log(`[non-recursive watcher test message] ${e.message}`); + } + }); + + watcher.onDidError(e => { + if (loggingEnabled) { + console.log(`[non-recursive watcher test error] ${e}`); + } + }); + testDir = getRandomTestPath(tmpdir(), 'vsctests', 'filewatcher'); const sourceDir = getPathFromAmdModule(require, './fixtures/service'); await Promises.copy(sourceDir, testDir, { preserveSymlinks: false }); - - await createWatcher(testDir); }); - function createWatcher(path: string, excludes: string[] = []): Promise { - if (watcher) { - watcher.dispose(); - } - - const emitter = new Emitter(); - event = emitter.event; - - watcher = new TestNodeJSFileWatcher({ path, excludes }, changes => emitter.fire(changes), msg => { - if (loggingEnabled) { - console.log(`[recursive watcher test message] ${msg.type}: ${msg.message}`); - } - }, loggingEnabled); - - return watcher.ready; - } - teardown(async () => { + await watcher.stop(); watcher.dispose(); // Possible that the file watcher is still holding @@ -86,25 +95,6 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; return Promises.rm(testDir).catch(error => console.error(error)); }); - async function awaitEvent(onDidChangeFile: Event, path: string, type: FileChangeType): Promise { - if (loggingEnabled) { - console.log(`Awaiting change type '${toMsg(type)}' on file '${path}'`); - } - - // Await the event - await new Promise((resolve, reject) => { - const disposable = onDidChangeFile(events => { - for (const event of events) { - if (event.path === path && event.type === type) { - disposable.dispose(); - setImmediate(() => resolve()); // copied from parcel watcher tests, seems to drop unrelated events on macOS - break; - } - } - }); - }); - } - function toMsg(type: FileChangeType): string { switch (type) { case FileChangeType.ADDED: return 'added'; @@ -113,25 +103,45 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; } } + async function awaitEvent(service: TestNodeJSWatcher, path: string, type: FileChangeType): Promise { + if (loggingEnabled) { + console.log(`Awaiting change type '${toMsg(type)}' on file '${path}'`); + } + + // Await the event + await new Promise(resolve => { + const disposable = service.onDidChangeFile(events => { + for (const event of events) { + if (event.path === path && event.type === type) { + disposable.dispose(); + resolve(); + break; + } + } + }); + }); + } + test('basics (folder watch)', async function () { + await watcher.watch([{ path: testDir, excludes: [], recursive: false }]); // New file const newFilePath = join(testDir, 'newFile.txt'); - let changeFuture: Promise = awaitEvent(event, newFilePath, FileChangeType.ADDED); + let changeFuture: Promise = awaitEvent(watcher, newFilePath, FileChangeType.ADDED); await Promises.writeFile(newFilePath, 'Hello World'); await changeFuture; // New folder const newFolderPath = join(testDir, 'New Folder'); - changeFuture = awaitEvent(event, newFolderPath, FileChangeType.ADDED); + changeFuture = awaitEvent(watcher, newFolderPath, FileChangeType.ADDED); await Promises.mkdir(newFolderPath); await changeFuture; // Rename file let renamedFilePath = join(testDir, 'renamedFile.txt'); changeFuture = Promise.all([ - awaitEvent(event, newFilePath, FileChangeType.DELETED), - awaitEvent(event, renamedFilePath, FileChangeType.ADDED) + awaitEvent(watcher, newFilePath, FileChangeType.DELETED), + awaitEvent(watcher, renamedFilePath, FileChangeType.ADDED) ]); await Promises.rename(newFilePath, renamedFilePath); await changeFuture; @@ -139,8 +149,8 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // Rename folder let renamedFolderPath = join(testDir, 'Renamed Folder'); changeFuture = Promise.all([ - awaitEvent(event, newFolderPath, FileChangeType.DELETED), - awaitEvent(event, renamedFolderPath, FileChangeType.ADDED) + awaitEvent(watcher, newFolderPath, FileChangeType.DELETED), + awaitEvent(watcher, renamedFolderPath, FileChangeType.ADDED) ]); await Promises.rename(newFolderPath, renamedFolderPath); await changeFuture; @@ -148,8 +158,8 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // Rename file (same name, different case) const caseRenamedFilePath = join(testDir, 'RenamedFile.txt'); changeFuture = Promise.all([ - awaitEvent(event, renamedFilePath, FileChangeType.DELETED), - awaitEvent(event, caseRenamedFilePath, FileChangeType.ADDED) + awaitEvent(watcher, renamedFilePath, FileChangeType.DELETED), + awaitEvent(watcher, caseRenamedFilePath, FileChangeType.ADDED) ]); await Promises.rename(renamedFilePath, caseRenamedFilePath); await changeFuture; @@ -158,8 +168,8 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // Rename folder (same name, different case) const caseRenamedFolderPath = join(testDir, 'REnamed Folder'); changeFuture = Promise.all([ - awaitEvent(event, renamedFolderPath, FileChangeType.DELETED), - awaitEvent(event, caseRenamedFolderPath, FileChangeType.ADDED) + awaitEvent(watcher, renamedFolderPath, FileChangeType.DELETED), + awaitEvent(watcher, caseRenamedFolderPath, FileChangeType.ADDED) ]); await Promises.rename(renamedFolderPath, caseRenamedFolderPath); await changeFuture; @@ -168,8 +178,8 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // Move file const movedFilepath = join(testDir, 'movedFile.txt'); changeFuture = Promise.all([ - awaitEvent(event, renamedFilePath, FileChangeType.DELETED), - awaitEvent(event, movedFilepath, FileChangeType.ADDED) + awaitEvent(watcher, renamedFilePath, FileChangeType.DELETED), + awaitEvent(watcher, movedFilepath, FileChangeType.ADDED) ]); await Promises.rename(renamedFilePath, movedFilepath); await changeFuture; @@ -177,42 +187,42 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // Move folder const movedFolderpath = join(testDir, 'Moved Folder'); changeFuture = Promise.all([ - awaitEvent(event, renamedFolderPath, FileChangeType.DELETED), - awaitEvent(event, movedFolderpath, FileChangeType.ADDED) + awaitEvent(watcher, renamedFolderPath, FileChangeType.DELETED), + awaitEvent(watcher, movedFolderpath, FileChangeType.ADDED) ]); await Promises.rename(renamedFolderPath, movedFolderpath); await changeFuture; // Copy file const copiedFilepath = join(testDir, 'copiedFile.txt'); - changeFuture = awaitEvent(event, copiedFilepath, FileChangeType.ADDED); + changeFuture = awaitEvent(watcher, copiedFilepath, FileChangeType.ADDED); await Promises.copyFile(movedFilepath, copiedFilepath); await changeFuture; // Copy folder const copiedFolderpath = join(testDir, 'Copied Folder'); - changeFuture = awaitEvent(event, copiedFolderpath, FileChangeType.ADDED); + changeFuture = awaitEvent(watcher, copiedFolderpath, FileChangeType.ADDED); await Promises.copy(movedFolderpath, copiedFolderpath, { preserveSymlinks: false }); await changeFuture; // Change file - changeFuture = awaitEvent(event, copiedFilepath, FileChangeType.UPDATED); + changeFuture = awaitEvent(watcher, copiedFilepath, FileChangeType.UPDATED); await Promises.writeFile(copiedFilepath, 'Hello Change'); await changeFuture; // Create new file const anotherNewFilePath = join(testDir, 'anotherNewFile.txt'); - changeFuture = awaitEvent(event, anotherNewFilePath, FileChangeType.ADDED); + changeFuture = awaitEvent(watcher, anotherNewFilePath, FileChangeType.ADDED); await Promises.writeFile(anotherNewFilePath, 'Hello Another World'); await changeFuture; // Delete file - changeFuture = awaitEvent(event, copiedFilepath, FileChangeType.DELETED); + changeFuture = awaitEvent(watcher, copiedFilepath, FileChangeType.DELETED); await Promises.unlink(copiedFilepath); await changeFuture; // Delete folder - changeFuture = awaitEvent(event, copiedFolderpath, FileChangeType.DELETED); + changeFuture = awaitEvent(watcher, copiedFolderpath, FileChangeType.DELETED); await Promises.rmdir(copiedFolderpath); await changeFuture; @@ -221,33 +231,35 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; test('basics (file watch)', async function () { const filePath = join(testDir, 'lorem.txt'); - await createWatcher(filePath); + await watcher.watch([{ path: filePath, excludes: [], recursive: false }]); // Change file - let changeFuture = awaitEvent(event, filePath, FileChangeType.UPDATED); + let changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED); await Promises.writeFile(filePath, 'Hello Change'); await changeFuture; // Delete file - changeFuture = awaitEvent(event, filePath, FileChangeType.DELETED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED); await Promises.unlink(filePath); await changeFuture; // Recreate watcher await Promises.writeFile(filePath, 'Hello Change'); - await createWatcher(filePath); + await watcher.watch([]); + await watcher.watch([{ path: filePath, excludes: [], recursive: false }]); // Move file - changeFuture = awaitEvent(event, filePath, FileChangeType.DELETED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED); await Promises.move(filePath, `${filePath}-moved`); await changeFuture; }); test('atomic writes (folder watch)', async function () { + await watcher.watch([{ path: testDir, excludes: [], recursive: false }]); // Delete + Recreate file const newFilePath = join(testDir, 'lorem.txt'); - let changeFuture: Promise = awaitEvent(event, newFilePath, FileChangeType.UPDATED); + let changeFuture: Promise = awaitEvent(watcher, newFilePath, FileChangeType.UPDATED); await Promises.unlink(newFilePath); Promises.writeFile(newFilePath, 'Hello Atomic World'); await changeFuture; @@ -255,17 +267,18 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; test('atomic writes (file watch)', async function () { const filePath = join(testDir, 'lorem.txt'); - await createWatcher(filePath); + await watcher.watch([{ path: filePath, excludes: [], recursive: false }]); // Delete + Recreate file const newFilePath = join(filePath); - let changeFuture: Promise = awaitEvent(event, newFilePath, FileChangeType.UPDATED); + let changeFuture: Promise = awaitEvent(watcher, newFilePath, FileChangeType.UPDATED); await Promises.unlink(newFilePath); Promises.writeFile(newFilePath, 'Hello Atomic World'); await changeFuture; }); test('multiple events (folder watch)', async function () { + await watcher.watch([{ path: testDir, excludes: [], recursive: false }]); // multiple add @@ -273,9 +286,9 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; const newFilePath2 = join(testDir, 'newFile-2.txt'); const newFilePath3 = join(testDir, 'newFile-3.txt'); - const addedFuture1: Promise = awaitEvent(event, newFilePath1, FileChangeType.ADDED); - const addedFuture2: Promise = awaitEvent(event, newFilePath2, FileChangeType.ADDED); - const addedFuture3: Promise = awaitEvent(event, newFilePath3, FileChangeType.ADDED); + const addedFuture1: Promise = awaitEvent(watcher, newFilePath1, FileChangeType.ADDED); + const addedFuture2: Promise = awaitEvent(watcher, newFilePath2, FileChangeType.ADDED); + const addedFuture3: Promise = awaitEvent(watcher, newFilePath3, FileChangeType.ADDED); await Promise.all([ await Promises.writeFile(newFilePath1, 'Hello World 1'), @@ -287,9 +300,9 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // multiple change - const changeFuture1: Promise = awaitEvent(event, newFilePath1, FileChangeType.UPDATED); - const changeFuture2: Promise = awaitEvent(event, newFilePath2, FileChangeType.UPDATED); - const changeFuture3: Promise = awaitEvent(event, newFilePath3, FileChangeType.UPDATED); + const changeFuture1: Promise = awaitEvent(watcher, newFilePath1, FileChangeType.UPDATED); + const changeFuture2: Promise = awaitEvent(watcher, newFilePath2, FileChangeType.UPDATED); + const changeFuture3: Promise = awaitEvent(watcher, newFilePath3, FileChangeType.UPDATED); await Promise.all([ await Promises.writeFile(newFilePath1, 'Hello Update 1'), @@ -301,9 +314,9 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // copy with multiple files - const copyFuture1: Promise = awaitEvent(event, join(testDir, 'newFile-1-copy.txt'), FileChangeType.ADDED); - const copyFuture2: Promise = awaitEvent(event, join(testDir, 'newFile-2-copy.txt'), FileChangeType.ADDED); - const copyFuture3: Promise = awaitEvent(event, join(testDir, 'newFile-3-copy.txt'), FileChangeType.ADDED); + const copyFuture1: Promise = awaitEvent(watcher, join(testDir, 'newFile-1-copy.txt'), FileChangeType.ADDED); + const copyFuture2: Promise = awaitEvent(watcher, join(testDir, 'newFile-2-copy.txt'), FileChangeType.ADDED); + const copyFuture3: Promise = awaitEvent(watcher, join(testDir, 'newFile-3-copy.txt'), FileChangeType.ADDED); await Promise.all([ Promises.copy(join(testDir, 'newFile-1.txt'), join(testDir, 'newFile-1-copy.txt'), { preserveSymlinks: false }), @@ -315,9 +328,9 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // multiple delete - const deleteFuture1: Promise = awaitEvent(event, newFilePath1, FileChangeType.DELETED); - const deleteFuture2: Promise = awaitEvent(event, newFilePath2, FileChangeType.DELETED); - const deleteFuture3: Promise = awaitEvent(event, newFilePath3, FileChangeType.DELETED); + const deleteFuture1: Promise = awaitEvent(watcher, newFilePath1, FileChangeType.DELETED); + const deleteFuture2: Promise = awaitEvent(watcher, newFilePath2, FileChangeType.DELETED); + const deleteFuture3: Promise = awaitEvent(watcher, newFilePath3, FileChangeType.DELETED); await Promise.all([ await Promises.unlink(newFilePath1), @@ -330,11 +343,11 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; test('multiple events (file watch)', async function () { const filePath = join(testDir, 'lorem.txt'); - await createWatcher(filePath); + await watcher.watch([{ path: filePath, excludes: [], recursive: false }]); // multiple change - const changeFuture1: Promise = awaitEvent(event, filePath, FileChangeType.UPDATED); + const changeFuture1: Promise = awaitEvent(watcher, filePath, FileChangeType.UPDATED); await Promise.all([ await Promises.writeFile(filePath, 'Hello Update 1'), @@ -345,9 +358,16 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; await Promise.all([changeFuture1]); }); + test('excludes can be updated (folder watch)', async function () { + await watcher.watch([{ path: testDir, excludes: ['**'], recursive: false }]); + await watcher.watch([{ path: testDir, excludes: [], recursive: false }]); + + return basicCrudTest(join(testDir, 'files-excludes.txt')); + }); + test('excludes are ignored (file watch)', async function () { const filePath = join(testDir, 'lorem.txt'); - await createWatcher(filePath, ['**']); + await watcher.watch([{ path: filePath, excludes: ['**'], recursive: false }]); return basicCrudTest(filePath, true); }); @@ -357,7 +377,7 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; const linkTarget = join(testDir, 'deep'); await Promises.symlink(linkTarget, link); - await createWatcher(link); + await watcher.watch([{ path: link, excludes: [], recursive: false }]); return basicCrudTest(join(link, 'newFile.txt')); }); @@ -367,18 +387,18 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // New file if (!skipAdd) { - changeFuture = awaitEvent(event, filePath, FileChangeType.ADDED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.ADDED); await Promises.writeFile(filePath, 'Hello World'); await changeFuture; } // Change file - changeFuture = awaitEvent(event, filePath, FileChangeType.UPDATED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.UPDATED); await Promises.writeFile(filePath, 'Hello Change'); await changeFuture; // Delete file - changeFuture = awaitEvent(event, filePath, FileChangeType.DELETED); + changeFuture = awaitEvent(watcher, filePath, FileChangeType.DELETED); await Promises.unlink(await Promises.realpath(filePath)); // support symlinks await changeFuture; } @@ -388,7 +408,7 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; const linkTarget = join(testDir, 'lorem.txt'); await Promises.symlink(linkTarget, link); - await createWatcher(link); + await watcher.watch([{ path: link, excludes: [], recursive: false }]); return basicCrudTest(link, true); }); @@ -398,7 +418,7 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // Local UNC paths are in the form of: \\localhost\c$\my_dir const uncPath = `\\\\localhost\\${getDriveLetter(testDir)?.toLowerCase()}$\\${ltrim(testDir.substr(testDir.indexOf(':') + 1), '\\')}`; - await createWatcher(uncPath); + await watcher.watch([{ path: uncPath, excludes: [], recursive: false }]); return basicCrudTest(join(uncPath, 'newFile.txt')); }); @@ -408,7 +428,7 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; // Local UNC paths are in the form of: \\localhost\c$\my_dir const uncPath = `\\\\localhost\\${getDriveLetter(testDir)?.toLowerCase()}$\\${ltrim(testDir.substr(testDir.indexOf(':') + 1), '\\')}\\lorem.txt`; - await createWatcher(uncPath); + await watcher.watch([{ path: uncPath, excludes: [], recursive: false }]); return basicCrudTest(uncPath, true); }); @@ -416,14 +436,14 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; (isLinux /* linux: is case sensitive */ ? test.skip : test)('wrong casing (folder watch)', async function () { const wrongCase = join(dirname(testDir), basename(testDir).toUpperCase()); - await createWatcher(wrongCase); + await watcher.watch([{ path: wrongCase, excludes: [], recursive: false }]); return basicCrudTest(join(wrongCase, 'newFile.txt')); }); (isLinux /* linux: is case sensitive */ ? test.skip : test)('wrong casing (file watch)', async function () { const filePath = join(testDir, 'LOREM.txt'); - await createWatcher(filePath); + await watcher.watch([{ path: filePath, excludes: [], recursive: false }]); return basicCrudTest(filePath, true); }); @@ -431,12 +451,14 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; test('invalid path does not explode', async function () { const invalidPath = join(testDir, 'invalid'); - await createWatcher(invalidPath); + await watcher.watch([{ path: invalidPath, excludes: [], recursive: false }]); }); (isMacintosh /* macOS: does not seem to report this */ ? test.skip : test)('deleting watched path is handled properly (folder watch)', async function () { const watchedPath = join(testDir, 'deep'); - await createWatcher(watchedPath); + + const watcher = new TestNodeJSFileWatcherLibrary({ path: watchedPath, excludes: [], recursive: false }, changes => { }); + await watcher.ready; // Delete watched path and ensure watcher is now disposed Promises.rm(watchedPath, RimRafMode.UNLINK); @@ -445,14 +467,11 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; test('deleting watched path is handled properly (file watch)', async function () { const watchedPath = join(testDir, 'lorem.txt'); - await createWatcher(watchedPath); + const watcher = new TestNodeJSFileWatcherLibrary({ path: watchedPath, excludes: [], recursive: false }, changes => { }); + await watcher.ready; - // Delete watched path - const changeFuture = awaitEvent(event, watchedPath, FileChangeType.DELETED); + // Delete watched path and ensure watcher is now disposed Promises.unlink(watchedPath); - await changeFuture; - - // Ensure watcher is now disposed await watcher.whenDisposed; }); @@ -476,3 +495,5 @@ import { CancellationTokenSource } from 'vs/base/common/cancellation'; return watchPromise; }); }); + +// TODO test for excludes? subsequent updates to rewatch like parcel? diff --git a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts index 4b272eca5d6..722b31aa579 100644 --- a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts +++ b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts @@ -30,7 +30,7 @@ import { ltrim } from 'vs/base/common/strings'; // Work with strings as paths to simplify testing const requests: IRecursiveWatchRequest[] = paths.map(path => { - return { path, excludes: [] }; + return { path, excludes: [], recursive: true }; }); return this.normalizeRequests(requests).map(request => request.path); @@ -92,6 +92,7 @@ import { ltrim } from 'vs/base/common/strings'; teardown(async () => { await watcher.stop(); + watcher.dispose(); // Possible that the file watcher is still holding // onto the folders on Windows specifically and the @@ -154,7 +155,7 @@ import { ltrim } from 'vs/base/common/strings'; } test('basics', async function () { - await watcher.watch([{ path: testDir, excludes: [] }]); + await watcher.watch([{ path: testDir, excludes: [], recursive: true }]); // New file const newFilePath = join(testDir, 'deep', 'newFile.txt'); @@ -280,7 +281,7 @@ import { ltrim } from 'vs/base/common/strings'; }); (isMacintosh /* this test seems not possible with fsevents backend */ ? test.skip : test)('basics (atomic writes)', async function () { - await watcher.watch([{ path: testDir, excludes: [] }]); + await watcher.watch([{ path: testDir, excludes: [], recursive: true }]); // Delete + Recreate file const newFilePath = join(testDir, 'deep', 'conway.js'); @@ -291,7 +292,7 @@ import { ltrim } from 'vs/base/common/strings'; }); (!isLinux /* polling is only used in linux environments (WSL) */ ? test.skip : test)('basics (polling)', async function () { - await watcher.watch([{ path: testDir, excludes: [], pollingInterval: 100 }]); + await watcher.watch([{ path: testDir, excludes: [], pollingInterval: 100, recursive: true }]); return basicCrudTest(join(testDir, 'deep', 'newFile.txt')); }); @@ -315,7 +316,7 @@ import { ltrim } from 'vs/base/common/strings'; } test('multiple events', async function () { - await watcher.watch([{ path: testDir, excludes: [] }]); + await watcher.watch([{ path: testDir, excludes: [], recursive: true }]); await Promises.mkdir(join(testDir, 'deep-multiple')); // multiple add @@ -407,7 +408,7 @@ import { ltrim } from 'vs/base/common/strings'; }); test('subsequent watch updates watchers (path)', async function () { - await watcher.watch([{ path: testDir, excludes: [join(realpathSync(testDir), 'unrelated')] }]); + await watcher.watch([{ path: testDir, excludes: [join(realpathSync(testDir), 'unrelated')], recursive: true }]); // New file (*.txt) let newTextFilePath = join(testDir, 'deep', 'newFile.txt'); @@ -415,14 +416,14 @@ import { ltrim } from 'vs/base/common/strings'; await Promises.writeFile(newTextFilePath, 'Hello World'); await changeFuture; - await watcher.watch([{ path: join(testDir, 'deep'), excludes: [join(realpathSync(testDir), 'unrelated')] }]); + await watcher.watch([{ path: join(testDir, 'deep'), excludes: [join(realpathSync(testDir), 'unrelated')], recursive: true }]); newTextFilePath = join(testDir, 'deep', 'newFile2.txt'); changeFuture = awaitEvent(watcher, newTextFilePath, FileChangeType.ADDED); await Promises.writeFile(newTextFilePath, 'Hello World'); await changeFuture; - await watcher.watch([{ path: join(testDir, 'deep'), excludes: [realpathSync(testDir)] }]); - await watcher.watch([{ path: join(testDir, 'deep'), excludes: [] }]); + await watcher.watch([{ path: join(testDir, 'deep'), excludes: [realpathSync(testDir)], recursive: true }]); + await watcher.watch([{ path: join(testDir, 'deep'), excludes: [], recursive: true }]); newTextFilePath = join(testDir, 'deep', 'newFile3.txt'); changeFuture = awaitEvent(watcher, newTextFilePath, FileChangeType.ADDED); await Promises.writeFile(newTextFilePath, 'Hello World'); @@ -430,8 +431,8 @@ import { ltrim } from 'vs/base/common/strings'; }); test('subsequent watch updates watchers (excludes)', async function () { - await watcher.watch([{ path: testDir, excludes: [realpathSync(testDir)] }]); - await watcher.watch([{ path: testDir, excludes: [] }]); + await watcher.watch([{ path: testDir, excludes: [realpathSync(testDir)], recursive: true }]); + await watcher.watch([{ path: testDir, excludes: [], recursive: true }]); return basicCrudTest(join(testDir, 'deep', 'newFile.txt')); }); @@ -441,7 +442,7 @@ import { ltrim } from 'vs/base/common/strings'; const linkTarget = join(testDir, 'deep'); await Promises.symlink(linkTarget, link); - await watcher.watch([{ path: link, excludes: [] }]); + await watcher.watch([{ path: link, excludes: [], recursive: true }]); return basicCrudTest(join(link, 'newFile.txt')); }); @@ -451,7 +452,7 @@ import { ltrim } from 'vs/base/common/strings'; const linkTarget = join(testDir, 'deep'); await Promises.symlink(linkTarget, link); - await watcher.watch([{ path: testDir, excludes: [] }, { path: link, excludes: [] }]); + await watcher.watch([{ path: testDir, excludes: [], recursive: true }, { path: link, excludes: [], recursive: true }]); return basicCrudTest(join(link, 'newFile.txt')); }); @@ -461,7 +462,7 @@ import { ltrim } from 'vs/base/common/strings'; // Local UNC paths are in the form of: \\localhost\c$\my_dir const uncPath = `\\\\localhost\\${getDriveLetter(testDir)?.toLowerCase()}$\\${ltrim(testDir.substr(testDir.indexOf(':') + 1), '\\')}`; - await watcher.watch([{ path: uncPath, excludes: [] }]); + await watcher.watch([{ path: uncPath, excludes: [], recursive: true }]); return basicCrudTest(join(uncPath, 'deep', 'newFile.txt')); }); @@ -469,7 +470,7 @@ import { ltrim } from 'vs/base/common/strings'; (isLinux /* linux: is case sensitive */ ? test.skip : test)('wrong casing', async function () { const deepWrongCasedPath = join(testDir, 'DEEP'); - await watcher.watch([{ path: deepWrongCasedPath, excludes: [] }]); + await watcher.watch([{ path: deepWrongCasedPath, excludes: [], recursive: true }]); return basicCrudTest(join(deepWrongCasedPath, 'newFile.txt')); }); @@ -477,13 +478,13 @@ import { ltrim } from 'vs/base/common/strings'; test('invalid folder does not explode', async function () { const invalidPath = join(testDir, 'invalid'); - await watcher.watch([{ path: invalidPath, excludes: [] }]); + await watcher.watch([{ path: invalidPath, excludes: [], recursive: true }]); }); test('deleting watched path is handled properly', async function () { const watchedPath = join(testDir, 'deep'); - await watcher.watch([{ path: watchedPath, excludes: [] }]); + await watcher.watch([{ path: watchedPath, excludes: [], recursive: true }]); // Delete watched path and await const warnFuture = awaitMessage(watcher, 'warn'); diff --git a/src/vs/server/remoteFileSystemProviderServer.ts b/src/vs/server/remoteFileSystemProviderServer.ts index b6e9892b611..33c21243523 100644 --- a/src/vs/server/remoteFileSystemProviderServer.ts +++ b/src/vs/server/remoteFileSystemProviderServer.ts @@ -4,17 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { Emitter } from 'vs/base/common/event'; -import { Disposable, IDisposable, toDisposable, dispose } from 'vs/base/common/lifecycle'; import { URI, UriComponents } from 'vs/base/common/uri'; import { IURITransformer } from 'vs/base/common/uriIpc'; -import { IFileChange, IWatchOptions } from 'vs/platform/files/common/files'; +import { IFileChange } from 'vs/platform/files/common/files'; import { ILogService } from 'vs/platform/log/common/log'; import { createRemoteURITransformer } from 'vs/server/remoteUriTransformer'; import { RemoteAgentConnectionContext } from 'vs/platform/remote/common/remoteAgentEnvironment'; -import { DiskFileSystemProvider, IWatcherOptions } from 'vs/platform/files/node/diskFileSystemProvider'; +import { DiskFileSystemProvider } from 'vs/platform/files/node/diskFileSystemProvider'; import { posix, delimiter } from 'vs/base/common/path'; import { IServerEnvironmentService } from 'vs/server/serverEnvironmentService'; -import { AbstractDiskFileSystemProviderChannel, ISessionFileWatcher } from 'vs/platform/files/node/diskFileSystemProviderServer'; +import { AbstractDiskFileSystemProviderChannel, AbstractSessionFileWatcher, ISessionFileWatcher } from 'vs/platform/files/node/diskFileSystemProviderServer'; +import { IRecursiveWatcherOptions } from 'vs/platform/files/common/watcher'; export class RemoteAgentFileSystemProviderChannel extends AbstractDiskFileSystemProviderChannel { @@ -58,40 +58,19 @@ export class RemoteAgentFileSystemProviderChannel extends AbstractDiskFileSystem //#endregion } -class SessionFileWatcher extends Disposable implements ISessionFileWatcher { - - private readonly watcherRequests = new Map(); - private readonly fileWatcher = this._register(new DiskFileSystemProvider(this.logService, { watcher: this.getWatcherOptions() })); +class SessionFileWatcher extends AbstractSessionFileWatcher { constructor( - private readonly uriTransformer: IURITransformer, + uriTransformer: IURITransformer, sessionEmitter: Emitter, - private readonly logService: ILogService, - private readonly environmentService: IServerEnvironmentService + logService: ILogService, + environmentService: IServerEnvironmentService ) { - super(); - - this.registerListeners(sessionEmitter); + super(uriTransformer, sessionEmitter, logService, environmentService); } - private registerListeners(sessionEmitter: Emitter): void { - const localChangeEmitter = this._register(new Emitter()); - - this._register(localChangeEmitter.event((events) => { - sessionEmitter.fire( - events.map(e => ({ - resource: this.uriTransformer.transformOutgoingURI(e.resource), - type: e.type - })) - ); - })); - - this._register(this.fileWatcher.onDidChangeFile(events => localChangeEmitter.fire(events))); - this._register(this.fileWatcher.onDidWatchError(error => sessionEmitter.fire(error))); - } - - private getWatcherOptions(): IWatcherOptions | undefined { - const fileWatcherPolling = this.environmentService.args['file-watcher-polling']; + protected override getRecursiveWatcherOptions(environmentService: IServerEnvironmentService): IRecursiveWatcherOptions | undefined { + const fileWatcherPolling = environmentService.args['file-watcher-polling']; if (fileWatcherPolling) { const segments = fileWatcherPolling.split(delimiter); const pollingInterval = Number(segments[0]); @@ -104,27 +83,13 @@ class SessionFileWatcher extends Disposable implements ISessionFileWatcher { return undefined; } - watch(req: number, resource: URI, opts: IWatchOptions): IDisposable { - if (this.environmentService.extensionsPath) { + protected override getExtraExcludes(environmentService: IServerEnvironmentService): string[] | undefined { + if (environmentService.extensionsPath) { // when opening the $HOME folder, we end up watching the extension folder // so simply exclude watching the extensions folder - opts.excludes = [...(opts.excludes || []), posix.join(this.environmentService.extensionsPath, '**')]; + return [posix.join(environmentService.extensionsPath, '**')]; } - this.watcherRequests.set(req, this.fileWatcher.watch(resource, opts)); - - return toDisposable(() => { - dispose(this.watcherRequests.get(req)); - this.watcherRequests.delete(req); - }); - } - - override dispose(): void { - super.dispose(); - - for (const [, disposable] of this.watcherRequests) { - disposable.dispose(); - } - this.watcherRequests.clear(); + return undefined; } } diff --git a/src/vs/workbench/buildfile.desktop.js b/src/vs/workbench/buildfile.desktop.js index 2f59f700c84..61d279dc021 100644 --- a/src/vs/workbench/buildfile.desktop.js +++ b/src/vs/workbench/buildfile.desktop.js @@ -12,7 +12,7 @@ exports.collectModules = function () { createModuleDescription('vs/workbench/contrib/debug/node/telemetryApp'), - createModuleDescription('vs/platform/files/node/watcher/parcel/parcelWatcherMain'), + createModuleDescription('vs/platform/files/node/watcher/watcherMain'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), diff --git a/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts b/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts index f3ff8b0eab0..9267985ac34 100644 --- a/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts +++ b/src/vs/workbench/services/files/electron-sandbox/diskFileSystemProvider.ts @@ -5,16 +5,15 @@ import { Event } from 'vs/base/common/event'; import { isLinux } from 'vs/base/common/platform'; -import { FileSystemProviderCapabilities, FileDeleteOptions, IStat, FileType, FileReadStreamOptions, FileWriteOptions, FileOpenOptions, FileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileFolderCopyCapability, IWatchOptions, IFileSystemProviderWithFileAtomicReadCapability, FileAtomicReadOptions } from 'vs/platform/files/common/files'; +import { FileSystemProviderCapabilities, FileDeleteOptions, IStat, FileType, FileReadStreamOptions, FileWriteOptions, FileOpenOptions, FileOverwriteOptions, IFileSystemProviderWithFileReadWriteCapability, IFileSystemProviderWithOpenReadWriteCloseCapability, IFileSystemProviderWithFileReadStreamCapability, IFileSystemProviderWithFileFolderCopyCapability, IFileSystemProviderWithFileAtomicReadCapability, FileAtomicReadOptions } from 'vs/platform/files/common/files'; import { AbstractDiskFileSystemProvider } from 'vs/platform/files/common/diskFileSystemProvider'; import { IMainProcessService } from 'vs/platform/ipc/electron-sandbox/services'; import { CancellationToken } from 'vs/base/common/cancellation'; import { ReadableStreamEvents } from 'vs/base/common/stream'; import { URI } from 'vs/base/common/uri'; import { DiskFileSystemProviderClient, LOCAL_FILE_SYSTEM_CHANNEL_NAME } from 'vs/platform/files/common/diskFileSystemProviderClient'; -import { IDisposable } from 'vs/base/common/lifecycle'; -import { IDiskFileChange, ILogMessage, AbstractRecursiveWatcherClient } from 'vs/platform/files/common/watcher'; -import { ParcelWatcherClient } from 'vs/workbench/services/files/electron-sandbox/parcelWatcherClient'; +import { IDiskFileChange, ILogMessage, AbstractUniversalWatcherClient } from 'vs/platform/files/common/watcher'; +import { UniversalWatcherClient } from 'vs/workbench/services/files/electron-sandbox/watcherClient'; import { ILogService } from 'vs/platform/log/common/log'; import { ISharedProcessWorkerWorkbenchService } from 'vs/workbench/services/sharedProcess/electron-sandbox/sharedProcessWorkerWorkbenchService'; @@ -37,7 +36,7 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple private readonly sharedProcessWorkerWorkbenchService: ISharedProcessWorkerWorkbenchService, logService: ILogService ) { - super(logService); + super(logService, { watcher: { forceUniversal: true /* send all requests to universal watcher process */ } }); this.registerListeners(); } @@ -45,8 +44,8 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple private registerListeners(): void { // Forward events from the embedded provider - this.provider.onDidChangeFile(e => this._onDidChangeFile.fire(e)); - this.provider.onDidWatchError(e => this._onDidWatchError.fire(e)); + this.provider.onDidChangeFile(changes => this._onDidChangeFile.fire(changes)); + this.provider.onDidWatchError(error => this._onDidWatchError.fire(error)); } //#region File Capabilities @@ -123,32 +122,16 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple //#region File Watching - override watch(resource: URI, opts: IWatchOptions): IDisposable { - - // Recursive: via parcel file watcher from `createRecursiveWatcher` - if (opts.recursive) { - return super.watch(resource, opts); - } - - // Non-recursive: via main process services - return this.provider.watch(resource, opts); - } - - protected createRecursiveWatcher( + protected createUniversalWatcher( onChange: (changes: IDiskFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean - ): AbstractRecursiveWatcherClient { - return new ParcelWatcherClient( - changes => onChange(changes), - msg => onLogMessage(msg), - verboseLogging, - this.sharedProcessWorkerWorkbenchService - ); + ): AbstractUniversalWatcherClient { + return new UniversalWatcherClient(changes => onChange(changes), msg => onLogMessage(msg), verboseLogging, this.sharedProcessWorkerWorkbenchService); } protected createNonRecursiveWatcher(): never { - throw new Error('Method not implemented in sandbox.'); + throw new Error('Method not implemented in sandbox.'); // we never expect this to be called given we set `forceUniversal: true` } //#endregion diff --git a/src/vs/workbench/services/files/electron-sandbox/parcelWatcherClient.ts b/src/vs/workbench/services/files/electron-sandbox/watcherClient.ts similarity index 81% rename from src/vs/workbench/services/files/electron-sandbox/parcelWatcherClient.ts rename to src/vs/workbench/services/files/electron-sandbox/watcherClient.ts index dd58e7b98cd..73b2c8cd320 100644 --- a/src/vs/workbench/services/files/electron-sandbox/parcelWatcherClient.ts +++ b/src/vs/workbench/services/files/electron-sandbox/watcherClient.ts @@ -5,10 +5,10 @@ import { DisposableStore, toDisposable } from 'vs/base/common/lifecycle'; import { getDelayedChannel, ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; -import { AbstractRecursiveWatcherClient, IDiskFileChange, ILogMessage, IRecursiveWatcher } from 'vs/platform/files/common/watcher'; +import { AbstractUniversalWatcherClient, IDiskFileChange, ILogMessage, IRecursiveWatcher } from 'vs/platform/files/common/watcher'; import { ISharedProcessWorkerWorkbenchService } from 'vs/workbench/services/sharedProcess/electron-sandbox/sharedProcessWorkerWorkbenchService'; -export class ParcelWatcherClient extends AbstractRecursiveWatcherClient { +export class UniversalWatcherClient extends AbstractUniversalWatcherClient { constructor( onFileChanges: (changes: IDiskFileChange[]) => void, @@ -24,7 +24,7 @@ export class ParcelWatcherClient extends AbstractRecursiveWatcherClient { protected override createWatcher(disposables: DisposableStore): IRecursiveWatcher { const watcher = ProxyChannel.toService(getDelayedChannel((async () => { - // Acquire parcel watcher via shared process worker + // Acquire universal watcher via shared process worker // // We explicitly do not add the worker as a disposable // because we need to call `stop` on disposal to prevent @@ -33,8 +33,8 @@ export class ParcelWatcherClient extends AbstractRecursiveWatcherClient { // The shared process worker services ensures to terminate // the process automatically when the window closes or reloads. const { client, onDidTerminate } = await this.sharedProcessWorkerWorkbenchService.createWorker({ - moduleId: 'vs/platform/files/node/watcher/parcel/parcelWatcherMain', - type: 'parcelWatcher' + moduleId: 'vs/platform/files/node/watcher/watcherMain', + type: 'fileWatcher' }); // React on unexpected termination of the watcher process @@ -49,10 +49,10 @@ export class ParcelWatcherClient extends AbstractRecursiveWatcherClient { return client.getChannel('watcher'); })())); - // Looks like parcel needs an explicit stop to prevent - // access on data structures after process exit. This - // only seem to be happening when used from Electron, - // not pure node.js. + // Looks like universal watcher needs an explicit stop + // to prevent access on data structures after process + // exit. This only seem to be happening when used from + // Electron, not pure node.js. // https://github.com/microsoft/vscode/issues/136264 disposables.add(toDisposable(() => watcher.stop()));