From 85c5eb7281c69c4a83ba923163f0f6f1f6d904a7 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Mon, 11 Oct 2021 11:16:56 +0200 Subject: [PATCH] Enable `parcel-watcher` as new default watcher (#132483) --- .eslintrc.json | 48 +- build/.moduleignore | 6 + .../linux/product-build-linux.yml | 2 +- package.json | 1 + remote/package.json | 1 + remote/yarn.lock | 18 + .../test/node/nativeModules.test.ts | 5 + .../files/node/diskFileSystemProvider.ts | 44 +- .../node/watcher/nsfw/nsfwWatcherService.ts | 4 +- .../watcher/parcel/parcelWatcherService.ts | 567 ++++++++++++++++++ .../files/node/watcher/parcel/watcher.ts | 39 ++ .../files/node/watcher/parcel/watcherApp.ts | 12 + .../node/watcher/parcel/watcherService.ts | 96 +++ src/vs/platform/files/node/watcher/watcher.ts | 10 +- .../files/test/node/recursiveWatcher.test.ts | 363 +++++++++-- .../files/test/node/watcherNormalizer.test.ts | 129 ++-- src/vs/platform/windows/common/windows.ts | 2 +- .../electron-main/windowsMainService.ts | 2 +- src/vs/workbench/buildfile.desktop.js | 1 + .../files/browser/files.contribution.ts | 21 +- .../contrib/files/browser/workspaceWatcher.ts | 4 +- .../browser/relauncher.contribution.ts | 6 +- .../electron-browser/desktop.main.ts | 2 +- yarn.lock | 18 + 24 files changed, 1215 insertions(+), 186 deletions(-) create mode 100644 src/vs/platform/files/node/watcher/parcel/parcelWatcherService.ts create mode 100644 src/vs/platform/files/node/watcher/parcel/watcher.ts create mode 100644 src/vs/platform/files/node/watcher/parcel/watcherApp.ts create mode 100644 src/vs/platform/files/node/watcher/parcel/watcherService.ts diff --git a/.eslintrc.json b/.eslintrc.json index a3287e03b67..99af298016e 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -132,7 +132,7 @@ "restrictions": [ "vs/nls", "**/vs/base/{common,node}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -170,7 +170,7 @@ "vs/nls", "**/vs/base/{common,node}/**", "**/vs/base/parts/*/{common,node}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -189,7 +189,7 @@ "vs/css!./**/*", "**/vs/base/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/base/parts/*/{common,browser,node,electron-sandbox,electron-browser}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -198,7 +198,7 @@ "vs/nls", "**/vs/base/{common,node,electron-main}/**", "**/vs/base/parts/*/{common,node,electron-main}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -255,7 +255,7 @@ "**/vs/base/{common,node}/**", "**/vs/base/parts/*/{common,node}/**", "**/vs/platform/*/{common,node}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -276,7 +276,7 @@ "**/vs/base/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/base/parts/*/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/platform/*/{common,browser,node,electron-sandbox,electron-browser}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -286,7 +286,7 @@ "**/vs/base/{common,node,electron-main}/**", "**/vs/base/parts/*/{common,node,electron-main}/**", "**/vs/platform/*/{common,node,electron-main}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -513,7 +513,7 @@ "**/vs/workbench/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/workbench/api/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/workbench/services/*/{common,browser,node,electron-sandbox,electron-browser}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -528,7 +528,7 @@ "vs/workbench/contrib/files/browser/editors/fileEditorInput", "**/vs/workbench/services/**", "**/vs/workbench/test/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -592,7 +592,7 @@ "**/vs/workbench/{common,node}/**", "**/vs/workbench/api/{common,node}/**", "**/vs/workbench/services/**/{common,node}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -623,7 +623,7 @@ "**/vs/workbench/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/workbench/api/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/workbench/services/**/{common,browser,node,electron-sandbox,electron-browser}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -742,7 +742,7 @@ "**/vs/workbench/api/{common,node}/**", "**/vs/workbench/services/**/{common,node}/**", "**/vs/workbench/contrib/**/{common,node}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -775,7 +775,7 @@ "**/vs/workbench/api/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/workbench/services/**/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/workbench/contrib/**/{common,browser,node,electron-sandbox,electron-browser}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -798,7 +798,7 @@ "**/vs/base/parts/**/{common,node}/**", "**/vs/platform/**/{common,node}/**", "**/vs/code/**/{common,node}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -810,7 +810,7 @@ "**/vs/base/parts/**/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/platform/**/{common,browser,node,electron-sandbox,electron-browser}/**", "**/vs/code/**/{common,browser,node,electron-sandbox,electron-browser}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -821,7 +821,7 @@ "**/vs/base/parts/**/{common,node,electron-main}/**", "**/vs/platform/**/{common,node,electron-main}/**", "**/vs/code/**/{common,node,electron-main}/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -833,7 +833,7 @@ "**/vs/platform/**/{common,node}/**", "**/vs/workbench/**/{common,node}/**", "**/vs/server/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -904,28 +904,28 @@ "target": "**/test/smoke/**", "restrictions": [ "**/test/smoke/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { "target": "**/test/automation/**", "restrictions": [ "**/test/automation/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { "target": "**/test/integration/**", "restrictions": [ "**/test/integration/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { "target": "**/test/monaco/**", "restrictions": [ "**/test/monaco/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { @@ -943,21 +943,21 @@ "target": "**/{node,electron-browser,electron-main}/**/*.test.ts", "restrictions": [ "**/vs/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { "target": "**/{node,electron-browser,electron-main}/**/test/**", "restrictions": [ "**/vs/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { "target": "**/test/{node,electron-browser,electron-main}/**", "restrictions": [ "**/vs/**", - "@vscode/*", "*" // node modules + "@vscode/*", "@parcel/*", "*" // node modules ] }, { diff --git a/build/.moduleignore b/build/.moduleignore index 2994170c257..5bbca4640b8 100644 --- a/build/.moduleignore +++ b/build/.moduleignore @@ -86,6 +86,12 @@ vscode-nsfw/src/** vscode-nsfw/includes/** !vscode-nsfw/build/Release/*.node +@parcel/watcher/binding.gyp +@parcel/watcher/build/** +@parcel/watcher/prebuilds/** +@parcel/watcher/src/** +!@parcel/watcher/build/Release/*.node + vsda/build/** vsda/ci/** vsda/src/** diff --git a/build/azure-pipelines/linux/product-build-linux.yml b/build/azure-pipelines/linux/product-build-linux.yml index cc5ceefd5ca..8c377f1993f 100644 --- a/build/azure-pipelines/linux/product-build-linux.yml +++ b/build/azure-pipelines/linux/product-build-linux.yml @@ -91,7 +91,7 @@ steps: # Set compiler toolchain export CC=$PWD/.build/CR_Clang/bin/clang export CXX=$PWD/.build/CR_Clang/bin/clang++ - export CXXFLAGS="-nostdinc++ -D_LIBCPP_HAS_NO_VENDOR_AVAILABILITY_ANNOTATIONS -isystem$PWD/.build/libcxx_headers/include -isystem$PWD/.build/libcxxabi_headers/include -fPIC -flto=thin -fsplit-lto-unit" + export CXXFLAGS="-nostdinc++ -D_LIBCPP_HAS_NO_VENDOR_AVAILABILITY_ANNOTATIONS -D__NO_INLINE__ -isystem$PWD/.build/libcxx_headers/include -isystem$PWD/.build/libcxxabi_headers/include -fPIC -flto=thin -fsplit-lto-unit" export LDFLAGS="-stdlib=libc++ -fuse-ld=lld -flto=thin -fsplit-lto-unit -L$PWD/.build/libcxx-objects -lc++abi" fi diff --git a/package.json b/package.json index 4e9fd4e009a..1516ba009b6 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ }, "dependencies": { "@microsoft/applicationinsights-web": "^2.6.4", + "@parcel/watcher": "2.0.0", "@vscode/sqlite3": "4.0.12", "@vscode/vscode-languagedetection": "1.0.21", "applicationinsights": "1.0.8", diff --git a/remote/package.json b/remote/package.json index b0bff622a7c..cec6f8326ab 100644 --- a/remote/package.json +++ b/remote/package.json @@ -4,6 +4,7 @@ "private": true, "dependencies": { "@microsoft/applicationinsights-web": "^2.6.4", + "@parcel/watcher": "2.0.0", "@vscode/vscode-languagedetection": "1.0.21", "applicationinsights": "1.0.8", "chokidar": "3.5.1", diff --git a/remote/yarn.lock b/remote/yarn.lock index 3ce157e5f9f..7b4c277a88d 100644 --- a/remote/yarn.lock +++ b/remote/yarn.lock @@ -83,6 +83,14 @@ resolved "https://registry.yarnpkg.com/@microsoft/dynamicproto-js/-/dynamicproto-js-1.1.4.tgz#40e1c0ad20743fcee1604a7df2c57faf0aa1af87" integrity sha512-Ot53G927ykMF8cQ3/zq4foZtdk+Tt1YpX7aUTHxBU7UHNdkEiBvBfZSq+rnlUmKCJ19VatwPG4mNzvcGpBj4og== +"@parcel/watcher@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.0.tgz#ebe992a4838b35c3da9a568eb95a71cb26ddf551" + integrity sha512-ByalKmRRXNNAhwZ0X1r0XeIhh1jG8zgdlvjgHk9ZV3YxiersEGNQkwew+RfqJbIL4gOJfvC2ey6lg5kaeRainw== + dependencies: + node-addon-api "^3.2.1" + node-gyp-build "^4.3.0" + "@tootallnate/once@1", "@tootallnate/once@^1.1.2": version "1.1.2" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" @@ -444,11 +452,21 @@ node-addon-api@^3.0.2: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.1.0.tgz#98b21931557466c6729e51cb77cd39c965f42239" integrity sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw== +node-addon-api@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== + node-addon-api@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.2.0.tgz#117cbb5a959dff0992e1c586ae0393573e4d2a87" integrity sha512-eazsqzwG2lskuzBqCGPi7Ac2UgOoMz8JVOXVhTvvPDYhthvNpefx8jWD8Np7Gv+2Sz0FlPWZk0nJV0z598Wn8Q== +node-gyp-build@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" + integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== + node-pty@0.11.0-beta7: version "0.11.0-beta7" resolved "https://registry.yarnpkg.com/node-pty/-/node-pty-0.11.0-beta7.tgz#aed0888b5032d96c54d8473455e6adfae3bbebbe" diff --git a/src/vs/platform/environment/test/node/nativeModules.test.ts b/src/vs/platform/environment/test/node/nativeModules.test.ts index 74565abdac9..3cb1ee1a509 100644 --- a/src/vs/platform/environment/test/node/nativeModules.test.ts +++ b/src/vs/platform/environment/test/node/nativeModules.test.ts @@ -42,6 +42,11 @@ suite('Native Modules (all platforms)', () => { assert.ok(typeof nsfWatcher === 'function', testErrorMessage('nsfw')); }); + test('parcel', async () => { + const parcelWatcher = await import('@parcel/watcher'); + assert.ok(typeof parcelWatcher.subscribe === 'function', testErrorMessage('parcel')); + }); + test('sqlite3', async () => { const sqlite3 = await import('@vscode/sqlite3'); assert.ok(typeof sqlite3.Database === 'function', testErrorMessage('@vscode/sqlite3')); diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index ce3bd9ace21..f81f69bf4b9 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -22,6 +22,7 @@ import { createFileSystemProviderError, FileDeleteOptions, FileOpenOptions, File import { readFileIntoStream } from 'vs/platform/files/common/io'; import { FileWatcher as NodeJSWatcherService } from 'vs/platform/files/node/watcher/nodejs/watcherService'; import { FileWatcher as NsfwWatcherService } from 'vs/platform/files/node/watcher/nsfw/watcherService'; +import { FileWatcher as ParcelWatcherService } from 'vs/platform/files/node/watcher/parcel/watcherService'; import { FileWatcher as UnixWatcherService } from 'vs/platform/files/node/watcher/unix/watcherService'; import { IDiskFileChange, ILogMessage, IWatchRequest, toFileChanges } from 'vs/platform/files/node/watcher/watcher'; import { FileWatcher as WindowsWatcherService } from 'vs/platform/files/node/watcher/win32/watcherService'; @@ -36,7 +37,7 @@ export interface IWatcherOptions { export interface IDiskFileSystemProviderOptions { bufferSize?: number; watcher?: IWatcherOptions; - enableLegacyRecursiveWatcher?: boolean; + legacyWatcher?: string; } export class DiskFileSystemProvider extends Disposable implements @@ -532,7 +533,7 @@ export class DiskFileSystemProvider extends Disposable implements private readonly _onDidChangeFile = this._register(new Emitter()); readonly onDidChangeFile = this._onDidChangeFile.event; - private recursiveWatcher: WindowsWatcherService | UnixWatcherService | NsfwWatcherService | undefined; + private recursiveWatcher: WindowsWatcherService | UnixWatcherService | NsfwWatcherService | ParcelWatcherService | undefined; private readonly recursiveFoldersToWatch: IWatchRequest[] = []; private recursiveWatchRequestDelayer = this._register(new ThrottledDelayer(0)); @@ -577,7 +578,7 @@ export class DiskFileSystemProvider extends Disposable implements private doRefreshRecursiveWatchers(): void { // Reuse existing - if (this.recursiveWatcher instanceof NsfwWatcherService) { + if (this.recursiveWatcher instanceof NsfwWatcherService || this.recursiveWatcher instanceof ParcelWatcherService) { this.recursiveWatcher.watch(this.recursiveFoldersToWatch); } @@ -597,7 +598,7 @@ export class DiskFileSystemProvider extends Disposable implements onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean, watcherOptions?: IWatcherOptions - ): WindowsWatcherService | UnixWatcherService | NsfwWatcherService + ): WindowsWatcherService | UnixWatcherService | NsfwWatcherService | ParcelWatcherService }; let watcherOptions: IWatcherOptions | undefined = undefined; @@ -608,30 +609,27 @@ export class DiskFileSystemProvider extends Disposable implements watcherOptions = this.options?.watcher; } + // can use efficient watcher else { - - // Conditionally fallback to our legacy file watcher: - // - If provided as option from the outside (i.e. via settings) - // - Linux: until we support ignore patterns (unless insiders) - let enableLegacyWatcher: boolean; - if (this.options?.enableLegacyRecursiveWatcher) { - enableLegacyWatcher = true; + let enableLegacyWatcher = false; + if (this.options?.legacyWatcher === 'on' || this.options?.legacyWatcher === 'off') { + enableLegacyWatcher = this.options.legacyWatcher === 'on'; // setting always wins } else { - enableLegacyWatcher = product.quality === 'stable' && isLinux; - } - - // Legacy Watcher - if (enableLegacyWatcher && this.recursiveFoldersToWatch.length === 1) { - if (isWindows) { - watcherImpl = WindowsWatcherService; - } else { - watcherImpl = UnixWatcherService; + if (product.quality === 'stable') { + // in stable use legacy for single folder workspaces + // TODO@bpasero remove me eventually + enableLegacyWatcher = this.recursiveFoldersToWatch.length === 1; } } - // Standard Watcher - else { - watcherImpl = NsfwWatcherService; + if (enableLegacyWatcher) { + if (isLinux) { + watcherImpl = UnixWatcherService; + } else { + watcherImpl = NsfwWatcherService; + } + } else { + watcherImpl = ParcelWatcherService; } } diff --git a/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts b/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts index 3463ea59fac..1c90fd4b640 100644 --- a/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts +++ b/src/vs/platform/files/node/watcher/nsfw/nsfwWatcherService.ts @@ -172,7 +172,7 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { // Log the raw event before normalization or checking for ignore patterns if (this.verboseLogging) { const logPath = event.action === nsfw.actions.RENAMED ? `${join(event.directory, event.oldFile || '')} -> ${event.newFile}` : join(event.directory, event.file || ''); - this.log(`${event.action === nsfw.actions.CREATED ? '[CREATED]' : event.action === nsfw.actions.DELETED ? '[DELETED]' : event.action === nsfw.actions.MODIFIED ? '[CHANGED]' : '[RENAMED]'} ${logPath}`); + this.log(`${event.action === nsfw.actions.CREATED ? '[ADDED]' : event.action === nsfw.actions.DELETED ? '[DELETED]' : event.action === nsfw.actions.MODIFIED ? '[CHANGED]' : '[RENAMED]'} ${logPath}`); } // Rename: convert into DELETE & ADD @@ -311,7 +311,7 @@ export class NsfwWatcherService extends Disposable implements IWatcherService { else { const handled = this.onUnexpectedError(msg, watcher); if (!handled) { - this.error(`Unexpected error: ${msg} (ESHUTDOWN)`, watcher); + this.error(`Unexpected error: ${msg} (EUNKNOWN)`, watcher); } } } diff --git a/src/vs/platform/files/node/watcher/parcel/parcelWatcherService.ts b/src/vs/platform/files/node/watcher/parcel/parcelWatcherService.ts new file mode 100644 index 00000000000..07d4ecdf88b --- /dev/null +++ b/src/vs/platform/files/node/watcher/parcel/parcelWatcherService.ts @@ -0,0 +1,567 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as parcelWatcher from '@parcel/watcher'; +import { existsSync } from 'fs'; +import { RunOnceScheduler } from 'vs/base/common/async'; +import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; +import { toErrorMessage } from 'vs/base/common/errorMessage'; +import { Emitter } from 'vs/base/common/event'; +import { isEqualOrParent } from 'vs/base/common/extpath'; +import { parse, ParsedPattern } from 'vs/base/common/glob'; +import { Disposable, IDisposable } from 'vs/base/common/lifecycle'; +import { TernarySearchTree } from 'vs/base/common/map'; +import { normalizeNFC } from 'vs/base/common/normalization'; +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 { watchFolder } from 'vs/base/node/watcher'; +import { FileChangeType } from 'vs/platform/files/common/files'; +import { IWatcherService } from 'vs/platform/files/node/watcher/parcel/watcher'; +import { IDiskFileChange, ILogMessage, normalizeFileChanges, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; + +export interface IWatcher extends IDisposable { + + /** + * The Parcel watcher instance is resolved when the watching has started. + */ + readonly instance: Promise; + + /** + * The watch request associated to the watcher. + */ + request: IWatchRequest; + + /** + * Associated ignored patterns for the watcher that can be updated. + */ + ignored: ParsedPattern[]; + + /** + * How often this watcher has been restarted in case of an unexpected + * shutdown. + */ + restarts: number; + + /** + * The cancellation token associated with the lifecycle of the watcher. + */ + token: CancellationToken; + + /** + * Stops and disposes the watcher. Same as `dispose` but allows to await + * the watcher getting unsubscribed. + */ + stop(): Promise; +} + +export class ParcelWatcherService extends Disposable implements IWatcherService { + + private static readonly MAX_RESTARTS = 5; // number of restarts we allow before giving up in case of unexpected errors + + private static readonly MAP_PARCEL_WATCHER_ACTION_TO_FILE_CHANGE = new Map( + [ + ['create', FileChangeType.ADDED], + ['update', FileChangeType.UPDATED], + ['delete', FileChangeType.DELETED] + ] + ); + + private static readonly GLOB_MARKERS = { + Star: '*', + GlobStar: '**', + GlobStarPathStartPosix: '**/', + GlobStarPathEndPosix: '/**', + StarPathEndPosix: '/*', + GlobStarPathStartWindows: '**\\', + GlobStarPathEndWindows: '\\**' + }; + + private static readonly PARCEL_WATCHER_BACKEND = isWindows ? 'windows' : isLinux ? 'inotify' : 'fs-events'; + + private readonly _onDidChangeFile = this._register(new Emitter()); + readonly onDidChangeFile = this._onDidChangeFile.event; + + private readonly _onDidLogMessage = this._register(new Emitter()); + readonly onDidLogMessage = this._onDidLogMessage.event; + + protected readonly watchers = new Map(); + + private verboseLogging = false; + private enospcErrorLogged = false; + + constructor() { + super(); + + this.registerListeners(); + } + + private registerListeners(): void { + + // Error handling on process + process.on('uncaughtException', error => this.onUnexpectedError(error)); + process.on('unhandledRejection', error => this.onUnexpectedError(error)); + } + + async watch(requests: IWatchRequest[]): Promise { + + // 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 + } + + // Re-watch path if excludes have changed + return watcher.request.excludes !== request.excludes; + }); + + // 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); + }).map(({ request }) => request.path); + + // Logging + this.debug(`Request to start watching: ${requestsToStartWatching.map(request => `${request.path} (excludes: ${request.excludes})`).join(',')}`); + this.debug(`Request to stop watching: ${pathsToStopWatching.join(',')}`); + + // Stop watching as instructed + for (const pathToStopWatching of pathsToStopWatching) { + await this.stopWatching(pathToStopWatching); + } + + // Start watching as instructed + for (const request of requestsToStartWatching) { + this.startWatching(request); + } + } + + private toExcludePatterns(excludes: string[] | undefined): ParsedPattern[] { + return Array.isArray(excludes) ? excludes.map(exclude => parse(exclude)) : []; + } + + protected toExcludePaths(path: string, excludes: string[] | undefined): string[] | undefined { + if (!Array.isArray(excludes)) { + return undefined; + } + + const excludePaths = new Set(); + + // Parcel watcher currently does not support glob patterns + // for native exclusions. As long as that is the case, try + // to convert exclude patterns into absolute paths that the + // watcher supports natively to reduce the overhead at the + // level of the file watcher as much as possible. + // Refs: https://github.com/parcel-bundler/watcher/issues/64 + for (const exclude of excludes) { + const isGlob = exclude.includes(ParcelWatcherService.GLOB_MARKERS.Star); + + // Glob pattern: check for typical patterns and convert + let normalizedExclude: string | undefined = undefined; + if (isGlob) { + + // Examples: ** + if (exclude === ParcelWatcherService.GLOB_MARKERS.GlobStar) { + normalizedExclude = path; + } + + // Examples: + // - **/node_modules/** + // - **/.git/objects/** + // - **/build-folder + // - output/** + else { + const startsWithGlobStar = exclude.startsWith(ParcelWatcherService.GLOB_MARKERS.GlobStarPathStartPosix) || exclude.startsWith(ParcelWatcherService.GLOB_MARKERS.GlobStarPathStartWindows); + const endsWithGlobStar = exclude.endsWith(ParcelWatcherService.GLOB_MARKERS.GlobStarPathEndPosix) || exclude.endsWith(ParcelWatcherService.GLOB_MARKERS.GlobStarPathEndWindows); + if (startsWithGlobStar || endsWithGlobStar) { + if (startsWithGlobStar && endsWithGlobStar) { + normalizedExclude = exclude.substring(ParcelWatcherService.GLOB_MARKERS.GlobStarPathStartPosix.length, exclude.length - ParcelWatcherService.GLOB_MARKERS.GlobStarPathEndPosix.length); + } else if (startsWithGlobStar) { + normalizedExclude = exclude.substring(ParcelWatcherService.GLOB_MARKERS.GlobStarPathStartPosix.length); + } else { + normalizedExclude = exclude.substring(0, exclude.length - ParcelWatcherService.GLOB_MARKERS.GlobStarPathEndPosix.length); + } + } + + // Support even more glob patterns on Linux where we know + // that each folder requires a file handle to watch. + // Examples: + // - node_modules/* (full form: **/node_modules/*/**) + if (isLinux && normalizedExclude) { + const endsWithStar = normalizedExclude?.endsWith(ParcelWatcherService.GLOB_MARKERS.StarPathEndPosix); + if (endsWithStar) { + normalizedExclude = normalizedExclude.substring(0, normalizedExclude.length - ParcelWatcherService.GLOB_MARKERS.StarPathEndPosix.length); + } + } + } + } + + // Not a glob pattern, take as is + else { + normalizedExclude = exclude; + } + + if (!normalizedExclude || normalizedExclude.includes(ParcelWatcherService.GLOB_MARKERS.Star)) { + continue; // skip for parcel (will be applied later by our glob matching) + } + + // Absolute path: normalize to watched path and + // exclude if not a parent of it otherwise. + if (isAbsolute(normalizedExclude)) { + if (!isEqualOrParent(normalizedExclude, path, !isLinux)) { + continue; // exclude points to path outside of watched folder, ignore + } + + // convert to relative path to ensure we + // get the correct path casing going forward + normalizedExclude = normalizedExclude.substr(path.length); + } + + // Finally take as relative path joined to watched path + excludePaths.add(rtrim(join(path, normalizedExclude), sep)); + } + + if (excludePaths.size > 0) { + return Array.from(excludePaths); + } + + return undefined; + } + + private startWatching(request: IWatchRequest, restarts = 0): void { + const cts = new CancellationTokenSource(); + + let parcelWatcherPromiseResolve: (watcher: parcelWatcher.AsyncSubscription | undefined) => void; + const instance = new Promise(resolve => parcelWatcherPromiseResolve = resolve); + + // Remember as watcher instance + const watcher: IWatcher = { + request, + instance, + ignored: this.toExcludePatterns(request.excludes), + restarts, + token: cts.token, + stop: async () => { + cts.dispose(true); + (await instance)?.unsubscribe(); + }, + dispose: () => { + watcher.stop(); + } + }; + this.watchers.set(request.path, watcher); + + // Path checks for symbolic links / wrong casing + const { realPath, realPathDiffers, realPathLength } = this.normalizePath(request); + + let undeliveredFileEvents: IDiskFileChange[] = []; + + const onRawFileEvent = (path: string, type: FileChangeType) => { + if (this.verboseLogging) { + this.log(`${type === FileChangeType.ADDED ? '[ADDED]' : type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${path}`); + } + + if (!this.isPathIgnored(path, watcher.ignored)) { + undeliveredFileEvents.push({ type, path }); + } else { + if (this.verboseLogging) { + this.log(` >> ignored ${path}`); + } + } + }; + + const ignore = this.toExcludePaths(realPath, watcher.request.excludes); + parcelWatcher.subscribe(realPath, (error, events) => { + if (watcher.token.isCancellationRequested) { + return; // return early when disposed + } + + if (error) { + this.error(`Unexpected error in event callback: ${toErrorMessage(error)}`, watcher); + } + + if (events.length === 0) { + return; // assume this can happen if we had an error before + } + + for (const event of events) { + onRawFileEvent(event.path, ParcelWatcherService.MAP_PARCEL_WATCHER_ACTION_TO_FILE_CHANGE.get(event.type)!); + } + + // Reset undelivered events array + const undeliveredFileEventsToEmit = undeliveredFileEvents; + undeliveredFileEvents = []; + + // Normalize and detect root path deletes + const { events: normalizedEvents, rootDeleted } = this.normalizeEvents(undeliveredFileEventsToEmit, request, realPathDiffers, realPathLength); + + // Broadcast to clients coalesced + const coalescedEvents = normalizeFileChanges(normalizedEvents); + this.emitEvents(coalescedEvents); + + // Handle root path delete if confirmed from coalseced events + if (rootDeleted && coalescedEvents.some(event => event.path === watcher.request.path && event.type === FileChangeType.DELETED)) { + this.onWatchedPathDeleted(watcher); + } + }, { + backend: ParcelWatcherService.PARCEL_WATCHER_BACKEND, + ignore + }).then(parcelWatcher => { + this.debug(`Started watching: '${realPath}' with backend '${ParcelWatcherService.PARCEL_WATCHER_BACKEND}' and native excludes '${ignore?.join(', ')}'`); + + parcelWatcherPromiseResolve(parcelWatcher); + }).catch(error => { + this.onUnexpectedError(error, watcher); + + parcelWatcherPromiseResolve(undefined); + }); + } + + private emitEvents(events: IDiskFileChange[]): void { + + // Send outside + this._onDidChangeFile.fire(events); + + // Logging + if (this.verboseLogging) { + for (const event of events) { + this.log(` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.path}`); + } + } + } + + private normalizePath(request: IWatchRequest): { realPath: string, realPathDiffers: boolean, realPathLength: number } { + let realPath = request.path; + let realPathDiffers = false; + let realPathLength = request.path.length; + + try { + + // First check for symbolic link + realPath = realpathSync(request.path); + + // Second check for casing difference + if (request.path === realPath) { + realPath = realcaseSync(request.path) ?? request.path; + } + + // Correct watch path as needed + if (request.path !== realPath) { + realPathLength = realPath.length; + realPathDiffers = true; + + this.warn(`correcting a path to watch that seems to be a symbolic link (original: ${request.path}, real: ${realPath})`); + } + } catch (error) { + // ignore + } + + return { realPath, realPathDiffers, realPathLength }; + } + + private normalizeEvents(events: IDiskFileChange[], request: IWatchRequest, realPathDiffers: boolean, realPathLength: number): { events: IDiskFileChange[], rootDeleted: boolean } { + let rootDeleted = false; + + for (const event of events) { + + // Mac uses NFD unicode form on disk, but we want NFC + if (isMacintosh) { + event.path = normalizeNFC(event.path); + } + + // TODO@bpasero workaround for https://github.com/parcel-bundler/watcher/issues/68 + // where watching root drive letter adds extra backslashes. + if (isWindows) { + if (request.path.length <= 3) { // for ex. c:, C:\ + event.path = normalize(event.path); + } + } + + // Convert paths back to original form in case it differs + if (realPathDiffers) { + event.path = request.path + event.path.substr(realPathLength); + } + + // Check for root deleted + if (event.path === request.path && event.type === FileChangeType.DELETED) { + rootDeleted = true; + } + } + + return { events, rootDeleted }; + } + + private onWatchedPathDeleted(watcher: IWatcher): void { + this.warn('Watcher shutdown because watched path got deleted', watcher); + + const parentPath = dirname(watcher.request.path); + if (existsSync(parentPath)) { + const disposable = watchFolder(parentPath, (type, path) => { + if (watcher.token.isCancellationRequested) { + return; // return early when disposed + } + + // Watcher path came back! Restart watching... + if (path === watcher.request.path && (type === 'added' || type === 'changed')) { + this.warn('Watcher restarts because watched path got created again', watcher); + + // Stop watching that parent folder + disposable.dispose(); + + // Send a manual event given we know the root got added again + this.emitEvents([{ path: watcher.request.path, type: FileChangeType.ADDED }]); + + // Restart the file watching + this.restartWatching(watcher); + } + }, error => { + // Ignore + }); + + // Make sure to stop watching when the watcher is disposed + watcher.token.onCancellationRequested(() => disposable.dispose()); + } + } + + private onUnexpectedError(error: unknown, watcher?: IWatcher): void { + const msg = toErrorMessage(error); + + // Specially handle ENOSPC errors that can happen when + // the watcher consumes so many file descriptors that + // we are running into a limit. We only want to warn + // once in this case to avoid log spam. + // See https://github.com/microsoft/vscode/issues/7950 + if (msg.indexOf('No space left on device') !== -1) { + if (!this.enospcErrorLogged) { + this.error('Inotify limit reached (ENOSPC)', watcher); + + this.enospcErrorLogged = true; + } + } + + // Any other error is unexpected and we should try to + // restart the watcher as a result to get into healthy + // state again if possible and if not attempted too much + else { + if (watcher && watcher.restarts < ParcelWatcherService.MAX_RESTARTS) { + if (existsSync(watcher.request.path)) { + this.warn(`Watcher will be restarted due to unexpected error: ${error}`, watcher); + + this.restartWatching(watcher); + } else { + this.error(`Unexpected error: ${msg} (EUNKNOWN: path ${watcher.request.path} no longer exists)`, watcher); + } + } else { + this.error(`Unexpected error: ${msg} (EUNKNOWN)`, watcher); + } + } + } + + async stop(): Promise { + for (const [path] of this.watchers) { + await this.stopWatching(path); + } + + this.watchers.clear(); + } + + protected restartWatching(watcher: IWatcher, delay = 800): void { + + // Restart watcher delayed to accomodate for + // changes on disk that have triggered the + // need for a restart in the first place. + const scheduler = new RunOnceScheduler(async () => { + if (watcher.token.isCancellationRequested) { + return; // return early when disposed + } + + // Await the watcher having stopped, as this is + // needed to properly re-watch the same path + await this.stopWatching(watcher.request.path); + + // Start watcher again counting the restarts + this.startWatching(watcher.request, watcher.restarts + 1); + }, delay); + + scheduler.schedule(); + watcher.token.onCancellationRequested(() => scheduler.dispose()); + } + + private async stopWatching(path: string): Promise { + const watcher = this.watchers.get(path); + if (watcher) { + this.watchers.delete(path); + + await watcher.stop(); + } + } + + protected normalizeRequests(requests: IWatchRequest[]): IWatchRequest[] { + const requestTrie = TernarySearchTree.forPaths(); + + // Sort requests by path length to have shortest first + // to have a way to prevent children to be watched if + // parents exist. + requests.sort((requestA, requestB) => requestA.path.length - requestB.path.length); + + // Only consider requests for watching that are not + // a child of an existing request path to prevent + // duplication. + // + // However, allow explicit requests to watch folders + // that are symbolic links because the Parcel watcher + // does not allow to recursively watch symbolic links. + for (const request of requests) { + if (requestTrie.findSubstr(request.path)) { + try { + const realpath = realpathSync(request.path); + if (realpath === request.path) { + this.warn(`ignoring a path for watching who's parent is already watched: ${request.path}`); + + continue; // path is not a symbolic link or similar + } + } catch (error) { + continue; // invalid path - ignore from watching + } + } + + requestTrie.set(request.path, request); + } + + return Array.from(requestTrie).map(([, request]) => request); + } + + private isPathIgnored(absolutePath: string, ignored: ParsedPattern[]): boolean { + return ignored.some(ignore => ignore(absolutePath)); + } + + async setVerboseLogging(enabled: boolean): Promise { + this.verboseLogging = enabled; + } + + private log(message: string) { + this._onDidLogMessage.fire({ type: 'trace', message: this.toMessage(message) }); + } + + private warn(message: string, watcher?: IWatcher) { + this._onDidLogMessage.fire({ type: 'warn', message: this.toMessage(message, watcher) }); + } + + private error(message: string, watcher: IWatcher | undefined) { + this._onDidLogMessage.fire({ type: 'error', message: this.toMessage(message, watcher) }); + } + + private debug(message: string): void { + this._onDidLogMessage.fire({ type: 'debug', message: this.toMessage(message) }); + } + + private toMessage(message: string, watcher?: IWatcher): string { + return watcher ? `[File Watcher (parcel)] ${message} (path: ${watcher.request.path})` : `[File Watcher (parcel)] ${message}`; + } +} diff --git a/src/vs/platform/files/node/watcher/parcel/watcher.ts b/src/vs/platform/files/node/watcher/parcel/watcher.ts new file mode 100644 index 00000000000..2f91b6c97bc --- /dev/null +++ b/src/vs/platform/files/node/watcher/parcel/watcher.ts @@ -0,0 +1,39 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Event } from 'vs/base/common/event'; +import { IDiskFileChange, ILogMessage, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; + +export interface IWatcherService { + + /** + * A normalized file change event from the raw events + * the watcher emits. + */ + readonly onDidChangeFile: Event; + + /** + * An event to indicate a message that should get logged. + */ + readonly onDidLogMessage: Event; + + /** + * Configures the watcher service to watch according + * to the requests. Any existing watched path that + * is not in the array, will be removed from watching + * and any new path will be added to watching. + */ + watch(requests: IWatchRequest[]): Promise; + + /** + * Enable verbose logging in the watcher. + */ + setVerboseLogging(enabled: boolean): Promise; + + /** + * Stop all watchers. + */ + stop(): Promise; +} diff --git a/src/vs/platform/files/node/watcher/parcel/watcherApp.ts b/src/vs/platform/files/node/watcher/parcel/watcherApp.ts new file mode 100644 index 00000000000..65a3d1bfe7e --- /dev/null +++ b/src/vs/platform/files/node/watcher/parcel/watcherApp.ts @@ -0,0 +1,12 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ProxyChannel } from 'vs/base/parts/ipc/common/ipc'; +import { Server } from 'vs/base/parts/ipc/node/ipc.cp'; +import { ParcelWatcherService } from 'vs/platform/files/node/watcher/parcel/parcelWatcherService'; + +const server = new Server('watcher'); +const service = new ParcelWatcherService(); +server.registerChannel('watcher', ProxyChannel.fromService(service)); diff --git a/src/vs/platform/files/node/watcher/parcel/watcherService.ts b/src/vs/platform/files/node/watcher/parcel/watcherService.ts new file mode 100644 index 00000000000..e2fa4f7d78a --- /dev/null +++ b/src/vs/platform/files/node/watcher/parcel/watcherService.ts @@ -0,0 +1,96 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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 { IWatcherService } from 'vs/platform/files/node/watcher/parcel/watcher'; +import { IDiskFileChange, ILogMessage, IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; + +export class FileWatcher extends Disposable { + + private static readonly MAX_RESTARTS = 5; + + private service: IWatcherService | undefined; + + private isDisposed = false; + private restartCounter = 0; + + constructor( + private requests: IWatchRequest[], + private readonly onDidFilesChange: (changes: IDiskFileChange[]) => void, + private readonly onLogMessage: (msg: ILogMessage) => void, + private verboseLogging: boolean, + ) { + super(); + + this.startWatching(); + } + + private startWatching(): void { + const client = this._register(new Client( + FileAccess.asFileUri('bootstrap-fork', require).fsPath, + { + serverName: 'File Watcher (parcel)', + args: ['--type=watcherService'], + env: { + VSCODE_AMD_ENTRYPOINT: 'vs/platform/files/node/watcher/parcel/watcherApp', + VSCODE_PIPE_LOGGING: 'true', + VSCODE_VERBOSE_LOGGING: 'true' // transmit console logs from server to client + } + } + )); + + this._register(client.onDidProcessExit(() => { + // our watcher app should never be completed because it keeps on watching. being in here indicates + // that the watcher process died and we want to restart it here. we only do it a max number of times + if (!this.isDisposed) { + if (this.restartCounter <= FileWatcher.MAX_RESTARTS) { + this.error('terminated unexpectedly and is restarted again...'); + this.restartCounter++; + this.startWatching(); + } else { + this.error('failed to start after retrying for some time, giving up. Please report this as a bug report!'); + } + } + })); + + // Initialize watcher + this.service = ProxyChannel.toService(getNextTickChannel(client.getChannel('watcher'))); + this.service.setVerboseLogging(this.verboseLogging); + + // Wire in event handlers + this._register(this.service.onDidChangeFile(e => !this.isDisposed && this.onDidFilesChange(e))); + this._register(this.service.onDidLogMessage(e => this.onLogMessage(e))); + + // Start watching + this.watch(this.requests); + } + + setVerboseLogging(verboseLogging: boolean): void { + this.verboseLogging = verboseLogging; + + if (!this.isDisposed) { + this.service?.setVerboseLogging(verboseLogging); + } + } + + error(message: string) { + this.onLogMessage({ type: 'error', message: `[File Watcher (parcel)] ${message}` }); + } + + watch(requests: IWatchRequest[]): void { + this.requests = requests; + + this.service?.watch(requests); + } + + override dispose(): void { + this.isDisposed = true; + + super.dispose(); + } +} diff --git a/src/vs/platform/files/node/watcher/watcher.ts b/src/vs/platform/files/node/watcher/watcher.ts index f7903459f98..c184fda10b8 100644 --- a/src/vs/platform/files/node/watcher/watcher.ts +++ b/src/vs/platform/files/node/watcher/watcher.ts @@ -72,10 +72,12 @@ class EventNormalizer { const newChangeType = event.type; // macOS/Windows: track renames to different case but - // same name by changing previous event to DELETED - // whenever new event with different case occurs - if (existingEvent.path !== event.path && (newChangeType === FileChangeType.ADDED || newChangeType === FileChangeType.UPDATED)) { - existingEvent.type = FileChangeType.DELETED; + // same name by changing current event to DELETED + // this encodes some underlying knowledge about the + // file watcher being used by assuming we first get + // an event for the CREATE and then an event that we + // consider as DELETE if same name / different case. + if (existingEvent.path !== event.path && event.type === FileChangeType.DELETED) { keepEvent = true; } diff --git a/src/vs/platform/files/test/node/recursiveWatcher.test.ts b/src/vs/platform/files/test/node/recursiveWatcher.test.ts index ff52ad8cafe..3bfafe8a329 100644 --- a/src/vs/platform/files/test/node/recursiveWatcher.test.ts +++ b/src/vs/platform/files/test/node/recursiveWatcher.test.ts @@ -4,19 +4,20 @@ *--------------------------------------------------------------------------------------------*/ import * as assert from 'assert'; +import { realpathSync } from 'fs'; import { tmpdir } from 'os'; import { timeout } from 'vs/base/common/async'; -import { join } from 'vs/base/common/path'; +import { dirname, join, sep } from 'vs/base/common/path'; import { isLinux, isWindows } from 'vs/base/common/platform'; -import { Promises } from 'vs/base/node/pfs'; +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 { NsfwWatcherService } from 'vs/platform/files/node/watcher/nsfw/nsfwWatcherService'; +import { IWatcher, ParcelWatcherService } from 'vs/platform/files/node/watcher/parcel/parcelWatcherService'; import { IWatchRequest } from 'vs/platform/files/node/watcher/watcher'; -flakySuite('Recursive Watcher', () => { +flakySuite('Recursive Watcher (parcel)', () => { - class TestNsfwWatcherService extends NsfwWatcherService { + class TestParcelWatcherService extends ParcelWatcherService { testNormalizePaths(paths: string[]): string[] { @@ -30,22 +31,26 @@ flakySuite('Recursive Watcher', () => { override async watch(requests: IWatchRequest[]): Promise { await super.watch(requests); + await this.whenReady(); + } + async whenReady(): Promise { for (const [, watcher] of this.watchers) { await watcher.instance; } } - protected override getOptions(watcher: any) { - return { - ...super.getOptions(watcher), - debounceMS: 1 - }; + override toExcludePaths(path: string, excludes: string[] | undefined): string[] | undefined { + return super.toExcludePaths(path, excludes); + } + + override restartWatching(watcher: IWatcher, delay = 10): void { + return super.restartWatching(watcher, delay); } } let testDir: string; - let service: TestNsfwWatcherService; + let service: TestParcelWatcherService; let loggingEnabled = false; @@ -57,7 +62,7 @@ flakySuite('Recursive Watcher', () => { enableLogging(false); setup(async () => { - service = new TestNsfwWatcherService(); + service = new TestParcelWatcherService(); service.onDidLogMessage(e => { if (loggingEnabled) { @@ -78,23 +83,43 @@ flakySuite('Recursive Watcher', () => { return Promises.rm(testDir); }); - function awaitEvent(service: TestNsfwWatcherService, path: string, type: FileChangeType): Promise { - return new Promise(resolve => { + function toMsg(type: FileChangeType): string { + switch (type) { + case FileChangeType.ADDED: return 'added'; + case FileChangeType.DELETED: return 'deleted'; + default: return 'changed'; + } + } + + function awaitEvent(service: TestParcelWatcherService, path: string, type: FileChangeType, failOnEvent?: boolean): Promise { + if (loggingEnabled) { + console.log(`Awaiting change type '${toMsg(type)}' on file '${path}'`); + } + + return new Promise((resolve, reject) => { const disposable = service.onDidChangeFile(events => { for (const event of events) { if (event.path === path && event.type === type) { disposable.dispose(); - resolve(); + if (failOnEvent) { + reject(new Error('Unexpected file event')); + } else { + resolve(); + } break; } } }); + }).then(() => { + // Unwind a bit to avoid calling watcher methods directly + // after a file event was send. At least one test was seen + // to crash when immediately re-watching the same folder + // from within the event callback due to a mutex lock issue. + return timeout(5); }); } - const runWatchTests = process.env['BUILD_SOURCEVERSION'] || process.env['CI'] || !!process.env['VSCODE_RUN_RECURSIVE_WATCH_TESTS']; - - (runWatchTests ? test : test.skip)('basics', async function () { + test('basics', async function () { await service.watch([{ path: testDir, excludes: [] }]); // New file @@ -127,29 +152,25 @@ flakySuite('Recursive Watcher', () => { await Promises.rename(newFolderPath, renamedFolderPath); await changeFuture; - // Case rename is currently broken on macOS (https://github.com/Axosoft/nsfw/issues/146) - if (isWindows) { + // Rename file (same name, different case) + const caseRenamedFilePath = join(testDir, 'deep', 'RenamedFile.txt'); + changeFuture = Promise.all([ + awaitEvent(service, renamedFilePath, FileChangeType.DELETED), + awaitEvent(service, caseRenamedFilePath, FileChangeType.ADDED) + ]); + await Promises.rename(renamedFilePath, caseRenamedFilePath); + await changeFuture; + renamedFilePath = caseRenamedFilePath; - // Rename file (same name, different case) - const caseRenamedFilePath = join(testDir, 'deep', 'RenamedFile.txt'); - changeFuture = Promise.all([ - awaitEvent(service, renamedFilePath, FileChangeType.DELETED), - awaitEvent(service, caseRenamedFilePath, FileChangeType.ADDED) - ]); - await Promises.rename(renamedFilePath, caseRenamedFilePath); - await changeFuture; - renamedFilePath = caseRenamedFilePath; - - // Rename folder (same name, different case) - const caseRenamedFolderPath = join(testDir, 'deep', 'REnamed Folder'); - changeFuture = Promise.all([ - awaitEvent(service, renamedFolderPath, FileChangeType.DELETED), - awaitEvent(service, caseRenamedFolderPath, FileChangeType.ADDED) - ]); - await Promises.rename(renamedFolderPath, caseRenamedFolderPath); - await changeFuture; - renamedFolderPath = caseRenamedFolderPath; - } + // Rename folder (same name, different case) + const caseRenamedFolderPath = join(testDir, 'deep', 'REnamed Folder'); + changeFuture = Promise.all([ + awaitEvent(service, renamedFolderPath, FileChangeType.DELETED), + awaitEvent(service, caseRenamedFolderPath, FileChangeType.ADDED) + ]); + await Promises.rename(renamedFolderPath, caseRenamedFolderPath); + await changeFuture; + renamedFolderPath = caseRenamedFolderPath; // Move file const movedFilepath = join(testDir, 'movedFile.txt'); @@ -182,11 +203,20 @@ flakySuite('Recursive Watcher', () => { await changeFuture; // Change file - await timeout(1100); // nsfw cannot distinguish a create from a change when time period from create to change is <1s changeFuture = awaitEvent(service, copiedFilepath, FileChangeType.UPDATED); await Promises.writeFile(copiedFilepath, 'Hello Change'); await changeFuture; + // Read file does not emit event + changeFuture = awaitEvent(service, copiedFilepath, FileChangeType.UPDATED, true /* unexpected */); + await Promises.readFile(copiedFilepath); + await Promise.race([timeout(100), changeFuture]); + + // Stat file does not emit event + changeFuture = awaitEvent(service, copiedFilepath, FileChangeType.UPDATED, true /* unexpected */); + await Promises.stat(copiedFilepath); + await Promise.race([timeout(100), changeFuture]); + // Delete file changeFuture = awaitEvent(service, copiedFilepath, FileChangeType.DELETED); await Promises.unlink(copiedFilepath); @@ -198,8 +228,100 @@ flakySuite('Recursive Watcher', () => { await changeFuture; }); - (runWatchTests ? test : test.skip)('subsequent watch updates watchers', async function () { - await service.watch([{ path: testDir, excludes: ['**/*.js'] }]); + test('multiple events', async function () { + await service.watch([{ path: testDir, excludes: [] }]); + await Promises.mkdir(join(testDir, 'deep-multiple')); + + // multiple add + + const newFilePath1 = join(testDir, 'newFile-1.txt'); + const newFilePath2 = join(testDir, 'newFile-2.txt'); + const newFilePath3 = join(testDir, 'newFile-3.txt'); + const newFilePath4 = join(testDir, 'deep-multiple', 'newFile-1.txt'); + const newFilePath5 = join(testDir, 'deep-multiple', 'newFile-2.txt'); + const newFilePath6 = join(testDir, 'deep-multiple', 'newFile-3.txt'); + + const addedFuture1: Promise = awaitEvent(service, newFilePath1, FileChangeType.ADDED); + const addedFuture2: Promise = awaitEvent(service, newFilePath2, FileChangeType.ADDED); + const addedFuture3: Promise = awaitEvent(service, newFilePath3, FileChangeType.ADDED); + const addedFuture4: Promise = awaitEvent(service, newFilePath4, FileChangeType.ADDED); + const addedFuture5: Promise = awaitEvent(service, newFilePath5, FileChangeType.ADDED); + const addedFuture6: Promise = awaitEvent(service, newFilePath6, FileChangeType.ADDED); + + await Promise.all([ + await Promises.writeFile(newFilePath1, 'Hello World 1'), + await Promises.writeFile(newFilePath2, 'Hello World 2'), + await Promises.writeFile(newFilePath3, 'Hello World 3'), + await Promises.writeFile(newFilePath4, 'Hello World 4'), + await Promises.writeFile(newFilePath5, 'Hello World 5'), + await Promises.writeFile(newFilePath6, 'Hello World 6') + ]); + + await Promise.all([addedFuture1, addedFuture2, addedFuture3, addedFuture4, addedFuture5, addedFuture6]); + + // multiple change + + const changeFuture1: Promise = awaitEvent(service, newFilePath1, FileChangeType.UPDATED); + const changeFuture2: Promise = awaitEvent(service, newFilePath2, FileChangeType.UPDATED); + const changeFuture3: Promise = awaitEvent(service, newFilePath3, FileChangeType.UPDATED); + const changeFuture4: Promise = awaitEvent(service, newFilePath4, FileChangeType.UPDATED); + const changeFuture5: Promise = awaitEvent(service, newFilePath5, FileChangeType.UPDATED); + const changeFuture6: Promise = awaitEvent(service, newFilePath6, FileChangeType.UPDATED); + + await Promise.all([ + await Promises.writeFile(newFilePath1, 'Hello Update 1'), + await Promises.writeFile(newFilePath2, 'Hello Update 2'), + await Promises.writeFile(newFilePath3, 'Hello Update 3'), + await Promises.writeFile(newFilePath4, 'Hello Update 4'), + await Promises.writeFile(newFilePath5, 'Hello Update 5'), + await Promises.writeFile(newFilePath6, 'Hello Update 6') + ]); + + await Promise.all([changeFuture1, changeFuture2, changeFuture3, changeFuture4, changeFuture5, changeFuture6]); + + // copy with multiple files + + const copyFuture1: Promise = awaitEvent(service, join(testDir, 'deep-multiple-copy', 'newFile-1.txt'), FileChangeType.ADDED); + const copyFuture2: Promise = awaitEvent(service, join(testDir, 'deep-multiple-copy', 'newFile-2.txt'), FileChangeType.ADDED); + const copyFuture3: Promise = awaitEvent(service, join(testDir, 'deep-multiple-copy', 'newFile-3.txt'), FileChangeType.ADDED); + const copyFuture4: Promise = awaitEvent(service, join(testDir, 'deep-multiple-copy'), FileChangeType.ADDED); + + await Promises.copy(join(testDir, 'deep-multiple'), join(testDir, 'deep-multiple-copy'), { preserveSymlinks: false }); + + await Promise.all([copyFuture1, copyFuture2, copyFuture3, copyFuture4]); + + // multiple delete (single files) + + const deleteFuture1: Promise = awaitEvent(service, newFilePath1, FileChangeType.DELETED); + const deleteFuture2: Promise = awaitEvent(service, newFilePath2, FileChangeType.DELETED); + const deleteFuture3: Promise = awaitEvent(service, newFilePath3, FileChangeType.DELETED); + const deleteFuture4: Promise = awaitEvent(service, newFilePath4, FileChangeType.DELETED); + const deleteFuture5: Promise = awaitEvent(service, newFilePath5, FileChangeType.DELETED); + const deleteFuture6: Promise = awaitEvent(service, newFilePath6, FileChangeType.DELETED); + + await Promise.all([ + await Promises.unlink(newFilePath1), + await Promises.unlink(newFilePath2), + await Promises.unlink(newFilePath3), + await Promises.unlink(newFilePath4), + await Promises.unlink(newFilePath5), + await Promises.unlink(newFilePath6) + ]); + + await Promise.all([deleteFuture1, deleteFuture2, deleteFuture3, deleteFuture4, deleteFuture5, deleteFuture6]); + + // multiple delete (folder) + + const deleteFolderFuture1: Promise = awaitEvent(service, join(testDir, 'deep-multiple'), FileChangeType.DELETED); + const deleteFolderFuture2: Promise = awaitEvent(service, join(testDir, 'deep-multiple-copy'), FileChangeType.DELETED); + + await Promise.all([Promises.rm(join(testDir, 'deep-multiple'), RimRafMode.UNLINK), Promises.rm(join(testDir, 'deep-multiple-copy'), RimRafMode.UNLINK)]); + + await Promise.all([deleteFolderFuture1, deleteFolderFuture2]); + }); + + test('subsequent watch updates watchers (path)', async function () { + await service.watch([{ path: testDir, excludes: [join(realpathSync(testDir), 'unrelated')] }]); // New file (*.txt) let newTextFilePath = join(testDir, 'deep', 'newFile.txt'); @@ -207,13 +329,13 @@ flakySuite('Recursive Watcher', () => { await Promises.writeFile(newTextFilePath, 'Hello World'); await changeFuture; - await service.watch([{ path: join(testDir, 'deep'), excludes: ['**/*.js'] }]); + await service.watch([{ path: join(testDir, 'deep'), excludes: [join(realpathSync(testDir), 'unrelated')] }]); newTextFilePath = join(testDir, 'deep', 'newFile2.txt'); changeFuture = awaitEvent(service, newTextFilePath, FileChangeType.ADDED); await Promises.writeFile(newTextFilePath, 'Hello World'); await changeFuture; - await service.watch([{ path: join(testDir, 'deep'), excludes: ['**/*.txt'] }]); + await service.watch([{ path: join(testDir, 'deep'), excludes: [realpathSync(testDir)] }]); await service.watch([{ path: join(testDir, 'deep'), excludes: [] }]); newTextFilePath = join(testDir, 'deep', 'newFile3.txt'); changeFuture = awaitEvent(service, newTextFilePath, FileChangeType.ADDED); @@ -223,7 +345,20 @@ flakySuite('Recursive Watcher', () => { return service.stop(); }); - ((isWindows /* windows: cannot create file symbolic link without elevated context */ || !runWatchTests) ? test.skip : test)('symlink support (root)', async function () { + test('subsequent watch updates watchers (excludes)', async function () { + await service.watch([{ path: testDir, excludes: [realpathSync(testDir)] }]); + await service.watch([{ path: testDir, excludes: [] }]); + + // New file (*.txt) + let newTextFilePath = join(testDir, 'deep', 'newFile.txt'); + let changeFuture: Promise = awaitEvent(service, newTextFilePath, FileChangeType.ADDED); + await Promises.writeFile(newTextFilePath, 'Hello World'); + await changeFuture; + + return service.stop(); + }); + + (isWindows /* windows: cannot create file symbolic link without elevated context */ ? test.skip : test)('symlink support (root)', async function () { const link = join(testDir, 'deep-linked'); const linkTarget = join(testDir, 'deep'); await Promises.symlink(linkTarget, link); @@ -237,7 +372,7 @@ flakySuite('Recursive Watcher', () => { await changeFuture; }); - ((isWindows /* windows: cannot create file symbolic link without elevated context */ || !runWatchTests) ? test.skip : test)('symlink support (via extra watch)', async function () { + (isWindows /* windows: cannot create file symbolic link without elevated context */ ? test.skip : test)('symlink support (via extra watch)', async function () { const link = join(testDir, 'deep-linked'); const linkTarget = join(testDir, 'deep'); await Promises.symlink(linkTarget, link); @@ -251,7 +386,7 @@ flakySuite('Recursive Watcher', () => { await changeFuture; }); - ((isLinux /* linux: is case sensitive */ || !runWatchTests) ? test.skip : test)('wrong casing', async function () { + (isLinux /* linux: is case sensitive */ ? test.skip : test)('wrong casing', async function () { const deepWrongCasedPath = join(testDir, 'DEEP'); await service.watch([{ path: deepWrongCasedPath, excludes: [] }]); @@ -263,6 +398,37 @@ flakySuite('Recursive Watcher', () => { await changeFuture; }); + test('invalid folder does not explode', async function () { + const invalidPath = join(testDir, 'invalid'); + + await service.watch([{ path: invalidPath, excludes: [] }]); + }); + + test('deleting watched path is handled properly', async function () { + const watchedPath = join(testDir, 'deep'); + + await service.watch([{ path: watchedPath, excludes: [] }]); + + // Delete watched path + let changeFuture: Promise = awaitEvent(service, watchedPath, FileChangeType.DELETED); + await Promises.rm(watchedPath, RimRafMode.UNLINK); + await changeFuture; + + // Restore watched path + changeFuture = awaitEvent(service, watchedPath, FileChangeType.ADDED); + await Promises.mkdir(watchedPath); + await changeFuture; + + await timeout(20); // restart is delayed + await service.whenReady(); + + // Verify events come in again + const newFilePath = join(watchedPath, 'newFile.txt'); + changeFuture = awaitEvent(service, newFilePath, FileChangeType.ADDED); + await Promises.writeFile(newFilePath, 'Hello World'); + await changeFuture; + }); + test('should not exclude roots that do not overlap', () => { if (isWindows) { assert.deepStrictEqual(service.testNormalizePaths(['C:\\a']), ['C:\\a']); @@ -288,4 +454,105 @@ flakySuite('Recursive Watcher', () => { assert.deepStrictEqual(service.testNormalizePaths(['/a', '/a/b', '/a/c/d']), ['/a']); } }); + + test('excludes are converted to absolute paths', () => { + + // undefined / empty + + assert.strictEqual(service.toExcludePaths(testDir, undefined), undefined); + assert.strictEqual(service.toExcludePaths(testDir, []), undefined); + + // absolute paths + + let excludes = service.toExcludePaths(testDir, [testDir]); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], testDir); + + excludes = service.toExcludePaths(testDir, [`${testDir}${sep}`, join(testDir, 'foo', 'bar'), `${join(testDir, 'other', 'deep')}${sep}`]); + assert.strictEqual(excludes?.length, 3); + assert.strictEqual(excludes[0], testDir); + assert.strictEqual(excludes[1], join(testDir, 'foo', 'bar')); + assert.strictEqual(excludes[2], join(testDir, 'other', 'deep')); + + // wrong casing is normalized for root + excludes = service.toExcludePaths(testDir, [join(testDir.toUpperCase(), 'node_modules', '**')]); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, 'node_modules')); + + // exclude ignored if not parent of watched dir + excludes = service.toExcludePaths(testDir, [join(dirname(testDir), 'node_modules', '**')]); + assert.strictEqual(excludes, undefined); + + // relative paths + + excludes = service.toExcludePaths(testDir, ['.']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], testDir); + + excludes = service.toExcludePaths(testDir, ['foo', `bar${sep}`, join('foo', 'bar'), `${join('other', 'deep')}${sep}`]); + assert.strictEqual(excludes?.length, 4); + assert.strictEqual(excludes[0], join(testDir, 'foo')); + assert.strictEqual(excludes[1], join(testDir, 'bar')); + assert.strictEqual(excludes[2], join(testDir, 'foo', 'bar')); + assert.strictEqual(excludes[3], join(testDir, 'other', 'deep')); + + // simple globs (relative) + + excludes = service.toExcludePaths(testDir, ['**']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], testDir); + + excludes = service.toExcludePaths(testDir, ['**/node_modules/**']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, 'node_modules')); + + excludes = service.toExcludePaths(testDir, ['**/.git/objects/**']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, '.git', 'objects')); + + excludes = service.toExcludePaths(testDir, ['**/node_modules']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, 'node_modules')); + + excludes = service.toExcludePaths(testDir, ['**/.git/objects']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, '.git', 'objects')); + + excludes = service.toExcludePaths(testDir, ['node_modules/**']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, 'node_modules')); + + excludes = service.toExcludePaths(testDir, ['.git/objects/**']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, '.git', 'objects')); + + // simple globs (absolute) + + excludes = service.toExcludePaths(testDir, [join(testDir, 'node_modules', '**')]); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, 'node_modules')); + + // Linux: more restrictive glob treatment + if (isLinux) { + excludes = service.toExcludePaths(testDir, ['**/node_modules/*/**']); + assert.strictEqual(excludes?.length, 1); + assert.strictEqual(excludes[0], join(testDir, 'node_modules')); + } + + // unsupported globs + + else { + excludes = service.toExcludePaths(testDir, ['**/node_modules/*/**']); + assert.strictEqual(excludes, undefined); + } + + excludes = service.toExcludePaths(testDir, ['**/*.js']); + assert.strictEqual(excludes, undefined); + + excludes = service.toExcludePaths(testDir, ['*.js']); + assert.strictEqual(excludes, undefined); + + excludes = service.toExcludePaths(testDir, ['*']); + assert.strictEqual(excludes, undefined); + }); }); diff --git a/src/vs/platform/files/test/node/watcherNormalizer.test.ts b/src/vs/platform/files/test/node/watcherNormalizer.test.ts index 1ab305b0790..73c4642420f 100644 --- a/src/vs/platform/files/test/node/watcherNormalizer.test.ts +++ b/src/vs/platform/files/test/node/watcherNormalizer.test.ts @@ -11,10 +11,6 @@ import { URI as uri } from 'vs/base/common/uri'; import { FileChangesEvent, FileChangeType, IFileChange } from 'vs/platform/files/common/files'; import { IDiskFileChange, normalizeFileChanges, toFileChanges } from 'vs/platform/files/node/watcher/watcher'; -function toFileChangesEvent(changes: IDiskFileChange[]): FileChangesEvent { - return new FileChangesEvent(toFileChanges(changes), !isLinux); -} - class TestFileWatcher { private readonly _onDidFilesChange: Emitter<{ raw: IFileChange[], event: FileChangesEvent }>; @@ -37,9 +33,13 @@ class TestFileWatcher { // Emit through event emitter if (normalizedEvents.length > 0) { - this._onDidFilesChange.fire({ raw: toFileChanges(normalizedEvents), event: toFileChangesEvent(normalizedEvents) }); + this._onDidFilesChange.fire({ raw: toFileChanges(normalizedEvents), event: this.toFileChangesEvent(normalizedEvents) }); } } + + private toFileChangesEvent(changes: IDiskFileChange[]): FileChangesEvent { + return new FileChangesEvent(toFileChanges(changes), !isLinux); + } } enum Path { @@ -50,7 +50,7 @@ enum Path { suite('Watcher Events Normalizer', () => { - test('simple add/update/delete', function (done: () => void) { + test('simple add/update/delete', done => { const watch = new TestFileWatcher(); const added = uri.file('/users/data/src/added.txt'); @@ -63,12 +63,12 @@ suite('Watcher Events Normalizer', () => { { path: deleted.fsPath, type: FileChangeType.DELETED }, ]; - watch.onDidFilesChange(({ event: e, raw }) => { - assert.ok(e); + watch.onDidFilesChange(({ event, raw }) => { + assert.ok(event); assert.strictEqual(raw.length, 3); - assert.ok(e.contains(added, FileChangeType.ADDED)); - assert.ok(e.contains(updated, FileChangeType.UPDATED)); - assert.ok(e.contains(deleted, FileChangeType.DELETED)); + assert.ok(event.contains(added, FileChangeType.ADDED)); + assert.ok(event.contains(updated, FileChangeType.UPDATED)); + assert.ok(event.contains(deleted, FileChangeType.DELETED)); done(); }); @@ -76,20 +76,19 @@ suite('Watcher Events Normalizer', () => { watch.report(raw); }); - let pathSpecs = isWindows ? [Path.WINDOWS, Path.UNC] : [Path.UNIX]; - pathSpecs.forEach((p) => { - test('delete only reported for top level folder (' + p + ')', function (done: () => void) { + (isWindows ? [Path.WINDOWS, Path.UNC] : [Path.UNIX]).forEach(path => { + test(`delete only reported for top level folder (${path})`, done => { const watch = new TestFileWatcher(); - const deletedFolderA = uri.file(p === Path.UNIX ? '/users/data/src/todelete1' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete1' : '\\\\localhost\\users\\data\\src\\todelete1'); - const deletedFolderB = uri.file(p === Path.UNIX ? '/users/data/src/todelete2' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2' : '\\\\localhost\\users\\data\\src\\todelete2'); - const deletedFolderBF1 = uri.file(p === Path.UNIX ? '/users/data/src/todelete2/file.txt' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2\\file.txt' : '\\\\localhost\\users\\data\\src\\todelete2\\file.txt'); - const deletedFolderBF2 = uri.file(p === Path.UNIX ? '/users/data/src/todelete2/more/test.txt' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2\\more\\test.txt' : '\\\\localhost\\users\\data\\src\\todelete2\\more\\test.txt'); - const deletedFolderBF3 = uri.file(p === Path.UNIX ? '/users/data/src/todelete2/super/bar/foo.txt' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2\\super\\bar\\foo.txt' : '\\\\localhost\\users\\data\\src\\todelete2\\super\\bar\\foo.txt'); - const deletedFileA = uri.file(p === Path.UNIX ? '/users/data/src/deleteme.txt' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\deleteme.txt' : '\\\\localhost\\users\\data\\src\\deleteme.txt'); + const deletedFolderA = uri.file(path === Path.UNIX ? '/users/data/src/todelete1' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete1' : '\\\\localhost\\users\\data\\src\\todelete1'); + const deletedFolderB = uri.file(path === Path.UNIX ? '/users/data/src/todelete2' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2' : '\\\\localhost\\users\\data\\src\\todelete2'); + const deletedFolderBF1 = uri.file(path === Path.UNIX ? '/users/data/src/todelete2/file.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2\\file.txt' : '\\\\localhost\\users\\data\\src\\todelete2\\file.txt'); + const deletedFolderBF2 = uri.file(path === Path.UNIX ? '/users/data/src/todelete2/more/test.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2\\more\\test.txt' : '\\\\localhost\\users\\data\\src\\todelete2\\more\\test.txt'); + const deletedFolderBF3 = uri.file(path === Path.UNIX ? '/users/data/src/todelete2/super/bar/foo.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\todelete2\\super\\bar\\foo.txt' : '\\\\localhost\\users\\data\\src\\todelete2\\super\\bar\\foo.txt'); + const deletedFileA = uri.file(path === Path.UNIX ? '/users/data/src/deleteme.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\deleteme.txt' : '\\\\localhost\\users\\data\\src\\deleteme.txt'); - const addedFile = uri.file(p === Path.UNIX ? '/users/data/src/added.txt' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\added.txt' : '\\\\localhost\\users\\data\\src\\added.txt'); - const updatedFile = uri.file(p === Path.UNIX ? '/users/data/src/updated.txt' : p === Path.WINDOWS ? 'C:\\users\\data\\src\\updated.txt' : '\\\\localhost\\users\\data\\src\\updated.txt'); + const addedFile = uri.file(path === Path.UNIX ? '/users/data/src/added.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\added.txt' : '\\\\localhost\\users\\data\\src\\added.txt'); + const updatedFile = uri.file(path === Path.UNIX ? '/users/data/src/updated.txt' : path === Path.WINDOWS ? 'C:\\users\\data\\src\\updated.txt' : '\\\\localhost\\users\\data\\src\\updated.txt'); const raw: IDiskFileChange[] = [ { path: deletedFolderA.fsPath, type: FileChangeType.DELETED }, @@ -102,15 +101,15 @@ suite('Watcher Events Normalizer', () => { { path: updatedFile.fsPath, type: FileChangeType.UPDATED } ]; - watch.onDidFilesChange(({ event: e, raw }) => { - assert.ok(e); + watch.onDidFilesChange(({ event, raw }) => { + assert.ok(event); assert.strictEqual(raw.length, 5); - assert.ok(e.contains(deletedFolderA, FileChangeType.DELETED)); - assert.ok(e.contains(deletedFolderB, FileChangeType.DELETED)); - assert.ok(e.contains(deletedFileA, FileChangeType.DELETED)); - assert.ok(e.contains(addedFile, FileChangeType.ADDED)); - assert.ok(e.contains(updatedFile, FileChangeType.UPDATED)); + assert.ok(event.contains(deletedFolderA, FileChangeType.DELETED)); + assert.ok(event.contains(deletedFolderB, FileChangeType.DELETED)); + assert.ok(event.contains(deletedFileA, FileChangeType.DELETED)); + assert.ok(event.contains(addedFile, FileChangeType.ADDED)); + assert.ok(event.contains(updatedFile, FileChangeType.UPDATED)); done(); }); @@ -119,7 +118,7 @@ suite('Watcher Events Normalizer', () => { }); }); - test('event normalization: ignore CREATE followed by DELETE', function (done: () => void) { + test('event normalization: ignore CREATE followed by DELETE', done => { const watch = new TestFileWatcher(); const created = uri.file('/users/data/src/related'); @@ -132,11 +131,11 @@ suite('Watcher Events Normalizer', () => { { path: unrelated.fsPath, type: FileChangeType.UPDATED }, ]; - watch.onDidFilesChange(({ event: e, raw }) => { - assert.ok(e); + watch.onDidFilesChange(({ event, raw }) => { + assert.ok(event); assert.strictEqual(raw.length, 1); - assert.ok(e.contains(unrelated, FileChangeType.UPDATED)); + assert.ok(event.contains(unrelated, FileChangeType.UPDATED)); done(); }); @@ -144,7 +143,7 @@ suite('Watcher Events Normalizer', () => { watch.report(raw); }); - test('event normalization: flatten DELETE followed by CREATE into CHANGE', function (done: () => void) { + test('event normalization: flatten DELETE followed by CREATE into CHANGE', done => { const watch = new TestFileWatcher(); const deleted = uri.file('/users/data/src/related'); @@ -157,12 +156,12 @@ suite('Watcher Events Normalizer', () => { { path: unrelated.fsPath, type: FileChangeType.UPDATED }, ]; - watch.onDidFilesChange(({ event: e, raw }) => { - assert.ok(e); + watch.onDidFilesChange(({ event, raw }) => { + assert.ok(event); assert.strictEqual(raw.length, 2); - assert.ok(e.contains(deleted, FileChangeType.UPDATED)); - assert.ok(e.contains(unrelated, FileChangeType.UPDATED)); + assert.ok(event.contains(deleted, FileChangeType.UPDATED)); + assert.ok(event.contains(unrelated, FileChangeType.UPDATED)); done(); }); @@ -170,7 +169,7 @@ suite('Watcher Events Normalizer', () => { watch.report(raw); }); - test('event normalization: ignore UPDATE when CREATE received', function (done: () => void) { + test('event normalization: ignore UPDATE when CREATE received', done => { const watch = new TestFileWatcher(); const created = uri.file('/users/data/src/related'); @@ -183,13 +182,13 @@ suite('Watcher Events Normalizer', () => { { path: unrelated.fsPath, type: FileChangeType.UPDATED }, ]; - watch.onDidFilesChange(({ event: e, raw }) => { - assert.ok(e); + watch.onDidFilesChange(({ event, raw }) => { + assert.ok(event); assert.strictEqual(raw.length, 2); - assert.ok(e.contains(created, FileChangeType.ADDED)); - assert.ok(!e.contains(created, FileChangeType.UPDATED)); - assert.ok(e.contains(unrelated, FileChangeType.UPDATED)); + assert.ok(event.contains(created, FileChangeType.ADDED)); + assert.ok(!event.contains(created, FileChangeType.UPDATED)); + assert.ok(event.contains(unrelated, FileChangeType.UPDATED)); done(); }); @@ -197,7 +196,7 @@ suite('Watcher Events Normalizer', () => { watch.report(raw); }); - test('event normalization: apply DELETE', function (done: () => void) { + test('event normalization: apply DELETE', done => { const watch = new TestFileWatcher(); const updated = uri.file('/users/data/src/related'); @@ -212,13 +211,13 @@ suite('Watcher Events Normalizer', () => { { path: updated.fsPath, type: FileChangeType.DELETED } ]; - watch.onDidFilesChange(({ event: e, raw }) => { - assert.ok(e); + watch.onDidFilesChange(({ event, raw }) => { + assert.ok(event); assert.strictEqual(raw.length, 2); - assert.ok(e.contains(deleted, FileChangeType.DELETED)); - assert.ok(!e.contains(updated, FileChangeType.UPDATED)); - assert.ok(e.contains(unrelated, FileChangeType.UPDATED)); + assert.ok(event.contains(deleted, FileChangeType.DELETED)); + assert.ok(!event.contains(updated, FileChangeType.UPDATED)); + assert.ok(event.contains(unrelated, FileChangeType.UPDATED)); done(); }); @@ -226,35 +225,25 @@ suite('Watcher Events Normalizer', () => { watch.report(raw); }); - test('event normalization: track case renames', function (done: () => void) { + test('event normalization: track case renames', done => { const watch = new TestFileWatcher(); - const updated = uri.file('/users/data/src/related'); - const updated2 = uri.file('/users/data/src/Related'); - - const added = uri.file('/users/data/src/added'); - const added2 = uri.file('/users/data/src/ADDED'); + const oldPath = uri.file('/users/data/src/added'); + const newPath = uri.file('/users/data/src/ADDED'); const raw: IDiskFileChange[] = [ - { path: updated.fsPath, type: FileChangeType.UPDATED }, - { path: updated2.fsPath, type: FileChangeType.UPDATED }, - { path: added.fsPath, type: FileChangeType.ADDED }, - { path: added2.fsPath, type: FileChangeType.ADDED }, + { path: newPath.fsPath, type: FileChangeType.ADDED }, + { path: oldPath.fsPath, type: FileChangeType.DELETED } ]; - watch.onDidFilesChange(({ event: e, raw }) => { - assert.ok(e); - assert.strictEqual(raw.length, 4); - + watch.onDidFilesChange(({ event, raw }) => { + assert.ok(event); + assert.strictEqual(raw.length, 2); for (const r of raw) { - if (isEqual(r.resource, updated)) { - assert.strictEqual(r.type, isLinux ? FileChangeType.UPDATED : FileChangeType.DELETED); - } else if (isEqual(r.resource, updated2)) { - assert.strictEqual(r.type, FileChangeType.UPDATED); - } else if (isEqual(r.resource, added)) { + if (isEqual(r.resource, oldPath)) { assert.strictEqual(r.type, isLinux ? FileChangeType.ADDED : FileChangeType.DELETED); - } else if (isEqual(r.resource, added2)) { + } else if (isEqual(r.resource, newPath)) { assert.strictEqual(r.type, FileChangeType.ADDED); } else { assert.fail(); diff --git a/src/vs/platform/windows/common/windows.ts b/src/vs/platform/windows/common/windows.ts index 0ce49dc17a3..b53f89e6294 100644 --- a/src/vs/platform/windows/common/windows.ts +++ b/src/vs/platform/windows/common/windows.ts @@ -289,7 +289,7 @@ export interface INativeWindowConfiguration extends IWindowConfiguration, Native maximized?: boolean; accessibilitySupport?: boolean; - enableLegacyRecursiveWatcher?: boolean; // TODO@bpasero remove me once watcher is settled + legacyWatcher?: string; // TODO@bpasero remove me once watcher is settled perfMarks: PerformanceMark[]; diff --git a/src/vs/platform/windows/electron-main/windowsMainService.ts b/src/vs/platform/windows/electron-main/windowsMainService.ts index 86ed2b06957..b7d5f0fcf67 100644 --- a/src/vs/platform/windows/electron-main/windowsMainService.ts +++ b/src/vs/platform/windows/electron-main/windowsMainService.ts @@ -1270,7 +1270,7 @@ export class WindowsMainService extends Disposable implements IWindowsMainServic os: { release: release(), hostname: hostname() }, zoomLevel: typeof windowConfig?.zoomLevel === 'number' ? windowConfig.zoomLevel : undefined, - enableLegacyRecursiveWatcher: this.configurationService.getValue('files.legacyWatcher'), + legacyWatcher: this.configurationService.getValue('files.legacyWatcher'), autoDetectHighContrast: windowConfig?.autoDetectHighContrast ?? true, accessibilitySupport: app.accessibilitySupportEnabled, colorScheme: { diff --git a/src/vs/workbench/buildfile.desktop.js b/src/vs/workbench/buildfile.desktop.js index 46bcaa57bab..463074b2cfe 100644 --- a/src/vs/workbench/buildfile.desktop.js +++ b/src/vs/workbench/buildfile.desktop.js @@ -16,6 +16,7 @@ exports.collectModules = function () { createModuleDescription('vs/platform/files/node/watcher/unix/watcherApp'), createModuleDescription('vs/platform/files/node/watcher/nsfw/watcherApp'), + createModuleDescription('vs/platform/files/node/watcher/parcel/watcherApp'), createModuleDescription('vs/platform/terminal/node/ptyHostMain'), diff --git a/src/vs/workbench/contrib/files/browser/files.contribution.ts b/src/vs/workbench/contrib/files/browser/files.contribution.ts index 114f1357403..81ef14904da 100644 --- a/src/vs/workbench/contrib/files/browser/files.contribution.ts +++ b/src/vs/workbench/contrib/files/browser/files.contribution.ts @@ -17,7 +17,7 @@ import { FileEditorInput } from 'vs/workbench/contrib/files/browser/editors/file import { BinaryFileEditor } from 'vs/workbench/contrib/files/browser/editors/binaryFileEditor'; import { ServicesAccessor } from 'vs/platform/instantiation/common/instantiation'; import { SyncDescriptor } from 'vs/platform/instantiation/common/descriptors'; -import { isLinux, isNative, isWeb, isWindows } from 'vs/base/common/platform'; +import { isNative, isWeb, isWindows } from 'vs/base/common/platform'; import { ExplorerViewletViewsContribution } from 'vs/workbench/contrib/files/browser/explorerViewlet'; import { IEditorPaneRegistry, EditorPaneDescriptor } from 'vs/workbench/browser/editor'; import { LifecyclePhase } from 'vs/workbench/services/lifecycle/common/lifecycle'; @@ -34,7 +34,6 @@ import { IUndoRedoService } from 'vs/platform/undoRedo/common/undoRedo'; import { IExplorerService } from 'vs/workbench/contrib/files/browser/files'; import { FileEditorInputSerializer, FileEditorWorkingCopyEditorHandler } from 'vs/workbench/contrib/files/browser/editors/fileEditorHandler'; import { ModesRegistry } from 'vs/editor/common/modes/modesRegistry'; -import product from 'vs/platform/product/common/product'; class FileUriLabelContribution implements IWorkbenchContribution { @@ -245,7 +244,7 @@ configurationRegistry.registerConfiguration({ 'files.watcherExclude': { 'type': 'object', 'default': { '**/.git/objects/**': true, '**/.git/subtree-cache/**': true, '**/node_modules/*/**': true, '**/.hg/store/**': true }, - 'markdownDescription': nls.localize('watcherExclude', "Configure glob patterns of file paths to exclude from file watching. Patterns must match on absolute paths, i.e. prefix with `**/` or the full path to match properly and suffix with `/**` to match files within a path (for example `**/build/output/**` or `/Users/name/workspaces/project/build/output/**`). When you experience Code consuming lots of CPU time on startup, you can exclude large folders to reduce the initial load."), + 'markdownDescription': nls.localize('watcherExclude', "Configure paths or glob patterns to exclude from file watching. Paths that are relative (for example `build/output`) will be resolved to an absolute path using the currently opened workspace. Glob patterns must match on absolute paths (i.e. prefix with `**/` or the full path and suffix with `/**` to match files within a path) to match properly (for example `**/build/output/**` or `/Users/name/workspaces/project/build/output/**`). When you experience the file watcher process consuming a lot of CPU, make sure to exclude large folders that are of less interest (such as build output folders)."), 'scope': ConfigurationScope.RESOURCE }, 'files.watcherInclude': { @@ -254,12 +253,22 @@ configurationRegistry.registerConfiguration({ 'type': 'string' }, 'default': [], - 'description': nls.localize('watcherInclude', "Configure extra paths to watch for changes inside the workspace. By default, all workspace folders will be watched recursively, except for folders that are symbolic links. You can explicitly add absolute or relative paths to support watching folders that are symbolic links. Relative paths will be resolved against the workspace folder to form an absolute path."), + 'description': nls.localize('watcherInclude', "Configure extra paths to watch for changes inside the workspace. By default, all workspace folders will be watched recursively, except for folders that are symbolic links. You can explicitly add absolute or relative paths to support watching folders that are symbolic links. Relative paths will be resolved to an absolute path using the currently opened workspace."), 'scope': ConfigurationScope.RESOURCE }, 'files.legacyWatcher': { - 'type': 'boolean', - 'default': product.quality === 'stable' && isLinux, + 'type': 'string', + 'enum': [ + 'on', + 'off', + 'default', + ], + 'markdownEnumDescriptions': [ + nls.localize('files.legacyWatcher.on', "Enable the legacy file watcher in case you see issues with the new file watcher."), + nls.localize('files.legacyWatcher.off', "Disable the legacy file watcher and enable the new file watcher to benefit from its capabilities."), + nls.localize('files.legacyWatcher.default', "The new file watcher will be enabled if you are using insiders version or whenever you open multi-root workspaces."), + ], + 'default': 'default', 'description': nls.localize('legacyWatcher', "Controls the mechanism used for file watching. Only change this when you see issues related to file watching."), }, 'files.hotExit': hotExitConfiguration, diff --git a/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts b/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts index d13e31382c5..434547704a5 100644 --- a/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts +++ b/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts @@ -86,8 +86,8 @@ export class WorkspaceWatcher extends Disposable { ); } - // Detect when the watcher shutsdown unexpectedly - else if (msg.indexOf('ESHUTDOWN') >= 0) { + // Detect when the watcher throws an error unexpectedly + else if (msg.indexOf('EUNKNOWN') >= 0) { this.notificationService.prompt( Severity.Warning, localize('eshutdownError', "File changes watcher stopped unexpectedly. Please reload the window to enable the watcher again."), diff --git a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts index e53b4d89b3f..9e310185283 100644 --- a/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts +++ b/src/vs/workbench/contrib/relauncher/browser/relauncher.contribution.ts @@ -26,7 +26,7 @@ interface IConfiguration extends IWindowsConfiguration { debug?: { console?: { wordWrap?: boolean } }; editor?: { accessibilitySupport?: 'on' | 'off' | 'auto' }; security?: { workspace?: { trust?: { enabled?: boolean } } }; - files?: { legacyWatcher?: boolean }; + files?: { legacyWatcher?: string }; } export class SettingsChangeRelauncher extends Disposable implements IWorkbenchContribution { @@ -38,7 +38,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo private updateMode: string | undefined; private accessibilitySupport: 'on' | 'off' | 'auto' | undefined; private workspaceTrustEnabled: boolean | undefined; - private legacyFileWatcher: boolean | undefined = undefined; + private legacyFileWatcher: string | undefined = undefined; constructor( @IHostService private readonly hostService: IHostService, @@ -102,7 +102,7 @@ export class SettingsChangeRelauncher extends Disposable implements IWorkbenchCo } // Legacy File Watcher - if (typeof config.files?.legacyWatcher === 'boolean' && config.files.legacyWatcher !== this.legacyFileWatcher) { + if (typeof config.files?.legacyWatcher === 'string' && config.files.legacyWatcher !== this.legacyFileWatcher) { this.legacyFileWatcher = config.files.legacyWatcher; changed = true; } diff --git a/src/vs/workbench/electron-browser/desktop.main.ts b/src/vs/workbench/electron-browser/desktop.main.ts index e19b825cd56..5dacbbb381f 100644 --- a/src/vs/workbench/electron-browser/desktop.main.ts +++ b/src/vs/workbench/electron-browser/desktop.main.ts @@ -26,7 +26,7 @@ class DesktopMain extends SharedDesktopMain { protected registerFileSystemProviders(environmentService: INativeWorkbenchEnvironmentService, fileService: IFileService, logService: ILogService, nativeHostService: INativeHostService): void { // Local Files - const diskFileSystemProvider = this._register(new DiskFileSystemProvider(logService, nativeHostService, { enableLegacyRecursiveWatcher: this.configuration.enableLegacyRecursiveWatcher })); + const diskFileSystemProvider = this._register(new DiskFileSystemProvider(logService, nativeHostService, { legacyWatcher: this.configuration.legacyWatcher })); fileService.registerProvider(Schemas.file, diskFileSystemProvider); // User Data Provider diff --git a/yarn.lock b/yarn.lock index 5f57d021822..de2298dd054 100644 --- a/yarn.lock +++ b/yarn.lock @@ -417,6 +417,14 @@ dependencies: "@octokit/openapi-types" "^10.2.2" +"@parcel/watcher@2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.0.0.tgz#ebe992a4838b35c3da9a568eb95a71cb26ddf551" + integrity sha512-ByalKmRRXNNAhwZ0X1r0XeIhh1jG8zgdlvjgHk9ZV3YxiersEGNQkwew+RfqJbIL4gOJfvC2ey6lg5kaeRainw== + dependencies: + node-addon-api "^3.2.1" + node-gyp-build "^4.3.0" + "@sindresorhus/is@^0.14.0": version "0.14.0" resolved "https://registry.yarnpkg.com/@sindresorhus/is/-/is-0.14.0.tgz#9fb3a3cf3132328151f353de4632e01e52102bea" @@ -6935,6 +6943,11 @@ node-addon-api@^3.0.0, node-addon-api@^3.0.2: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.1.0.tgz#98b21931557466c6729e51cb77cd39c965f42239" integrity sha512-flmrDNB06LIl5lywUz7YlNGZH/5p0M7W28k8hzd9Lshtdh1wshD2Y+U4h9LD6KObOy1f+fEVdgprPrEymjM5uw== +node-addon-api@^3.2.1: + version "3.2.1" + resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" + integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== + node-addon-api@^4.2.0: version "4.2.0" resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-4.2.0.tgz#117cbb5a959dff0992e1c586ae0393573e4d2a87" @@ -6945,6 +6958,11 @@ node-fetch@^2.6.1: resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.6.2.tgz#986996818b73785e47b1965cc34eb093a1d464d0" integrity sha512-aLoxToI6RfZ+0NOjmWAgn9+LEd30YCkJKFSyWacNZdEKTit/ZMcKjGkTRo8uWEsnIb/hfKecNPEbln02PdWbcA== +node-gyp-build@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/node-gyp-build/-/node-gyp-build-4.3.0.tgz#9f256b03e5826150be39c764bf51e993946d71a3" + integrity sha512-iWjXZvmboq0ja1pUGULQBexmxq8CV4xBhX7VDOTbL7ZR4FOowwY/VOtRxBN/yKxmdGoIp4j5ysNT4u3S2pDQ3Q== + node-libs-browser@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/node-libs-browser/-/node-libs-browser-2.2.1.tgz#b64f513d18338625f90346d27b0d235e631f6425"