From b8d6d8667a0a45a4322d051ecdc51d757bdd8074 Mon Sep 17 00:00:00 2001 From: Benjamin Pasero Date: Tue, 4 Jan 2022 09:11:44 +0100 Subject: [PATCH] Reimplement node.js watcher and add tests (#140073) * files - use `fs.promises.rename` for `rimraf` to skip `graceful-fs` We do not really want the move operation to hang for a long time when the folder is locked on Windows. * watcher - rewrite non-recursive watcher and add tests * fixes * fix tests --- src/vs/base/node/extpath.ts | 41 ++ src/vs/base/node/watcher.ts | 264 ----------- src/vs/base/test/node/extpath.test.ts | 28 +- src/vs/code/node/cli.ts | 2 +- .../files/common/diskFileSystemProvider.ts | 37 +- src/vs/platform/files/common/watcher.ts | 27 +- .../diskFileSystemProviderServer.ts | 12 +- .../files/node/diskFileSystemProvider.ts | 10 +- .../node/diskFileSystemProviderServer.ts | 8 +- .../node/watcher/nodejs/nodejsWatcher.ts | 439 +++++++++++++++--- .../node/watcher/parcel/parcelWatcher.ts | 36 +- .../node/nodejsWatcher.integrationTest.ts | 435 +++++++++++++++++ .../node/parcelWatcher.integrationTest.ts | 3 +- .../server/remoteFileSystemProviderServer.ts | 4 +- .../contrib/files/browser/workspaceWatcher.ts | 4 +- 15 files changed, 955 insertions(+), 395 deletions(-) delete mode 100644 src/vs/base/node/watcher.ts create mode 100644 src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts diff --git a/src/vs/base/node/extpath.ts b/src/vs/base/node/extpath.ts index 5e225fd9819..ee8f3f4eb31 100644 --- a/src/vs/base/node/extpath.ts +++ b/src/vs/base/node/extpath.ts @@ -58,6 +58,46 @@ export function realcaseSync(path: string): string | null { return null; } +export async function realcase(path: string): Promise { + if (isLinux) { + // This method is unsupported on OS that have case sensitive + // file system where the same path can exist in different forms + // (see also https://github.com/microsoft/vscode/issues/139709) + return path; + } + + const dir = dirname(path); + if (path === dir) { // end recursion + return path; + } + + const name = (basename(path) /* can be '' for windows drive letters */ || path).toLowerCase(); + try { + const entries = await Promises.readdir(dir); + const found = entries.filter(e => e.toLowerCase() === name); // use a case insensitive search + if (found.length === 1) { + // on a case sensitive filesystem we cannot determine here, whether the file exists or not, hence we need the 'file exists' precondition + const prefix = await realcase(dir); // recurse + if (prefix) { + return join(prefix, found[0]); + } + } else if (found.length > 1) { + // must be a case sensitive $filesystem + const ix = found.indexOf(name); + if (ix >= 0) { // case sensitive + const prefix = await realcase(dir); // recurse + if (prefix) { + return join(prefix, found[ix]); + } + } + } + } catch (error) { + // silently ignore error + } + + return null; +} + export async function realpath(path: string): Promise { try { // DO NOT USE `fs.promises.realpath` here as it internally @@ -91,6 +131,7 @@ export function realpathSync(path: string): string { // fs.realpath() is resolving symlinks and that can fail in certain cases. The workaround is // to not resolve links but to simply see if the path is read accessible or not. const normalizedPath = normalizePath(path); + fs.accessSync(normalizedPath, fs.constants.R_OK); // throws in case of an error return normalizedPath; diff --git a/src/vs/base/node/watcher.ts b/src/vs/base/node/watcher.ts deleted file mode 100644 index 58adf374943..00000000000 --- a/src/vs/base/node/watcher.ts +++ /dev/null @@ -1,264 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * 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 { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation'; -import { isEqualOrParent } from 'vs/base/common/extpath'; -import { Disposable, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; -import { normalizeNFC } from 'vs/base/common/normalization'; -import { basename, join } from 'vs/base/common/path'; -import { isMacintosh } from 'vs/base/common/platform'; -import { Promises } from 'vs/base/node/pfs'; - -export function watchFile(path: string, onChange: (type: 'added' | 'changed' | 'deleted', path: string) => void, onError: (error: string) => void): IDisposable { - return doWatchNonRecursive({ path, isDirectory: false }, onChange, onError); -} - -export function watchFolder(path: string, onChange: (type: 'added' | 'changed' | 'deleted', path: string) => void, onError: (error: string) => void): IDisposable { - return doWatchNonRecursive({ path, isDirectory: true }, onChange, onError); -} - -export const CHANGE_BUFFER_DELAY = 100; - -function doWatchNonRecursive(file: { path: string, isDirectory: boolean }, onChange: (type: 'added' | 'changed' | 'deleted', path: string) => void, onError: (error: string) => void): IDisposable { - - // 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(file.path, '/Volumes/')) { - onError(`Refusing to watch ${file.path} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`); - return Disposable.None; - } - - const originalFileName = basename(file.path); - const mapPathToStatDisposable = new Map(); - - let disposed = false; - let watcherDisposables: IDisposable[] = [toDisposable(() => { - mapPathToStatDisposable.forEach(disposable => dispose(disposable)); - mapPathToStatDisposable.clear(); - })]; - - try { - - // Creating watcher can fail with an exception - const watcher = watch(file.path); - watcherDisposables.push(toDisposable(() => { - watcher.removeAllListeners(); - watcher.close(); - })); - - // Folder: resolve children to emit proper events - const folderChildren: Set = new Set(); - if (file.isDirectory) { - Promises.readdir(file.path).then(children => children.forEach(child => folderChildren.add(child))); - } - - watcher.on('error', (code: number, signal: string) => { - if (!disposed) { - onError(`Failed to watch ${file.path} for changes using fs.watch() (${code}, ${signal})`); - } - }); - - watcher.on('change', (type, raw) => { - if (disposed) { - return; // ignore if already disposed - } - - // Normalize file name - let changedFileName: string = ''; - 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 - } - - // File path: use path directly for files and join with changed file name otherwise - const changedFilePath = file.isDirectory ? join(file.path, changedFileName) : file.path; - - // File - if (!file.isDirectory) { - if (type === 'rename' || changedFileName !== originalFileName) { - // The file was either deleted or renamed. Many tools apply changes to files in an - // atomic way ("Atomic Save") by first renaming the file to a temporary name and then - // renaming it back to the original name. Our watcher will detect this as a rename - // and then stops to work on Mac and Linux because the watcher is applied to the - // inode and not the name. The fix is to detect this case and trying to watch the file - // again after a certain delay. - // In addition, we send out a delete event if after a timeout we detect that the file - // does indeed not exist anymore. - - const timeoutHandle = setTimeout(async () => { - const fileExists = await Promises.exists(changedFilePath); - - if (disposed) { - return; // ignore if disposed by now - } - - // File still exists, so emit as change event and reapply the watcher - if (fileExists) { - onChange('changed', changedFilePath); - - watcherDisposables = [doWatchNonRecursive(file, onChange, onError)]; - } - - // File seems to be really gone, so emit a deleted event - else { - onChange('deleted', changedFilePath); - } - }, CHANGE_BUFFER_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 - dispose(watcherDisposables); - watcherDisposables = [toDisposable(() => clearTimeout(timeoutHandle))]; - } else { - onChange('changed', changedFilePath); - } - } - - // Folder - else { - - // Children add/delete - if (type === 'rename') { - - // Cancel any previous stats for this file path if existing - const statDisposable = mapPathToStatDisposable.get(changedFilePath); - if (statDisposable) { - dispose(statDisposable); - } - - // 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(changedFilePath); - - const fileExists = await Promises.exists(changedFilePath); - - if (disposed) { - return; // ignore if disposed by now - } - - // Figure out the correct event type: - // File Exists: either 'added' or 'changed' if known before - // File Does not Exist: always 'deleted' - let type: 'added' | 'deleted' | 'changed'; - if (fileExists) { - if (folderChildren.has(changedFileName)) { - type = 'changed'; - } else { - type = 'added'; - folderChildren.add(changedFileName); - } - } else { - folderChildren.delete(changedFileName); - type = 'deleted'; - } - - onChange(type, changedFilePath); - }, CHANGE_BUFFER_DELAY); - - mapPathToStatDisposable.set(changedFilePath, toDisposable(() => clearTimeout(timeoutHandle))); - } - - // Other events - else { - - // Figure out the correct event type: if this is the - // first time we see this child, it can only be added - let type: 'added' | 'changed'; - if (folderChildren.has(changedFileName)) { - type = 'changed'; - } else { - type = 'added'; - folderChildren.add(changedFileName); - } - - onChange(type, changedFilePath); - } - } - }); - } catch (error) { - Promises.exists(file.path).then(exists => { - if (exists && !disposed) { - onError(`Failed to watch ${file.path} for changes using fs.watch() (${error.toString()})`); - } - }); - } - - return toDisposable(() => { - disposed = true; - - watcherDisposables = dispose(watcherDisposables); - }); -} - -/** - * 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, 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 watcher = watchFile(path, async type => { - if (type === 'changed') { - - 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; - } - } - }, err => { - error = new Error(err); - cts.dispose(true); - }); - - return new Promise((resolve, reject) => { - cts.token.onCancellationRequested(async () => { - watcher.dispose(); - await Promises.close(handle); - - if (error) { - reject(error); - } else { - resolve(); - } - }); - }); -} diff --git a/src/vs/base/test/node/extpath.test.ts b/src/vs/base/test/node/extpath.test.ts index 66b616abc98..c4e2fd831fb 100644 --- a/src/vs/base/test/node/extpath.test.ts +++ b/src/vs/base/test/node/extpath.test.ts @@ -5,7 +5,7 @@ import * as assert from 'assert'; import { tmpdir } from 'os'; -import { realcaseSync, realpath, realpathSync } from 'vs/base/node/extpath'; +import { realcase, realcaseSync, realpath, realpathSync } from 'vs/base/node/extpath'; import { Promises } from 'vs/base/node/pfs'; import { flakySuite, getRandomTestPath } from 'vs/base/test/node/testUtils'; @@ -22,7 +22,7 @@ flakySuite('Extpath', () => { return Promises.rm(testDir); }); - test('realcase', async () => { + test('realcaseSync', async () => { // assume case insensitive file system if (process.platform === 'win32' || process.platform === 'darwin') { @@ -46,6 +46,30 @@ flakySuite('Extpath', () => { } }); + test('realcase', async () => { + + // assume case insensitive file system + if (process.platform === 'win32' || process.platform === 'darwin') { + const upper = testDir.toUpperCase(); + const real = await realcase(upper); + + if (real) { // can be null in case of permission errors + assert.notStrictEqual(real, upper); + assert.strictEqual(real.toUpperCase(), upper); + assert.strictEqual(real, testDir); + } + } + + // linux, unix, etc. -> assume case sensitive file system + else { + let real = await realcase(testDir); + assert.strictEqual(real, testDir); + + real = await realcase(testDir.toUpperCase()); + assert.strictEqual(real, testDir.toUpperCase()); + } + }); + test('realpath', async () => { const realpathVal = await realpath(testDir); assert.ok(realpathVal); diff --git a/src/vs/code/node/cli.ts b/src/vs/code/node/cli.ts index aa53f3f2dd2..7ddab6a0522 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/base/node/watcher'; +import { watchFileContents } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; 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 872d1032068..1e0741fabe5 100644 --- a/src/vs/platform/files/common/diskFileSystemProvider.ts +++ b/src/vs/platform/files/common/diskFileSystemProvider.ts @@ -7,11 +7,11 @@ 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 { combinedDisposable, Disposable, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, 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, IWatchRequest, toFileChanges } from 'vs/platform/files/common/watcher'; +import { AbstractRecursiveWatcherClient, IDiskFileChange, ILogMessage, INonRecursiveWatcher, IWatchRequest, toFileChanges } from 'vs/platform/files/common/watcher'; import { ILogService, LogLevel } from 'vs/platform/log/common/log'; export abstract class AbstractDiskFileSystemProvider extends Disposable { @@ -101,21 +101,32 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable { ): AbstractRecursiveWatcherClient; private watchNonRecursive(resource: URI, opts: IWatchOptions): IDisposable { - const watcher = this.createNonRecursiveWatcher( - this.toFilePath(resource), - opts.excludes, + 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 - ); + )); - const logLevelListener = this.logService.onDidChangeLogLevel(() => { + disposables.add(this.logService.onDidChangeLogLevel(() => { watcher.setVerboseLogging(this.logService.getLevel() === LogLevel.Trace); - }); + })); - return combinedDisposable(watcher, logLevelListener); + return disposables; } + protected abstract createNonRecursiveWatcher( + request: IWatchRequest, + onChange: (changes: IDiskFileChange[]) => void, + onLogMessage: (msg: ILogMessage) => void, + verboseLogging: boolean + ): INonRecursiveWatcher; + private onWatcherLogMessage(msg: ILogMessage): void { if (msg.type === 'error') { this._onDidWatchError.fire(msg.message); @@ -124,14 +135,6 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable { this.logService[msg.type](msg.message); } - protected abstract createNonRecursiveWatcher( - path: string, - excludes: string[], - onChange: (changes: IDiskFileChange[]) => void, - onLogMessage: (msg: ILogMessage) => void, - verboseLogging: boolean - ): IDisposable & { setVerboseLogging: (verboseLogging: boolean) => void }; - protected toFilePath(resource: URI): string { return normalize(resource.fsPath); } diff --git a/src/vs/platform/files/common/watcher.ts b/src/vs/platform/files/common/watcher.ts index 012a03fd592..f28d0815c85 100644 --- a/src/vs/platform/files/common/watcher.ts +++ b/src/vs/platform/files/common/watcher.ts @@ -4,12 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { Event } from 'vs/base/common/event'; -import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle'; +import { Disposable, DisposableStore, IDisposable, 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 IRecursiveWatcher { +export interface IRecursiveWatcher extends IDisposable { /** * A normalized file change event from the raw events @@ -48,6 +48,19 @@ export interface IRecursiveWatcher { 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 abstract class AbstractRecursiveWatcherClient extends Disposable { private static readonly MAX_RESTARTS = 5; @@ -201,13 +214,9 @@ class EventCoalescer { const currentChangeType = existingEvent.type; const newChangeType = event.type; - // macOS/Windows: track renames to different case but - // 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) { + // macOS/Windows: track renames to different case + // by keeping both CREATE and DELETE events + if (existingEvent.path !== event.path && (event.type === FileChangeType.DELETED || event.type === FileChangeType.ADDED)) { keepEvent = true; } diff --git a/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts b/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts index 0fd30ea96a5..f44da63f2f8 100644 --- a/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts +++ b/src/vs/platform/files/electron-main/diskFileSystemProviderServer.ts @@ -12,7 +12,7 @@ import { FileDeleteOptions, IFileChange, IWatchOptions, createFileSystemProvider 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, dispose, IDisposable, toDisposable } from 'vs/base/common/lifecycle'; +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'; @@ -88,8 +88,10 @@ class SessionFileWatcher extends Disposable implements ISessionFileWatcher { disposable.add(toDisposable(() => this.watcherRequests.delete(req))); const watcher = disposable.add(new NodeJSFileWatcher( - normalize(resource.fsPath), - opts.excludes, + { + path: normalize(resource.fsPath), + excludes: opts.excludes + }, changes => this.sessionEmitter.fire(toFileChanges(changes)), msg => this.onWatcherLogMessage(msg), this.logService.getLevel() === LogLevel.Trace @@ -113,7 +115,9 @@ class SessionFileWatcher extends Disposable implements ISessionFileWatcher { override dispose(): void { super.dispose(); - this.watcherRequests.forEach(disposable => dispose(disposable)); + for (const [, disposable] of this.watcherRequests) { + disposable.dispose(); + } this.watcherRequests.clear(); } } diff --git a/src/vs/platform/files/node/diskFileSystemProvider.ts b/src/vs/platform/files/node/diskFileSystemProvider.ts index ef66244e937..d913cc4d38b 100644 --- a/src/vs/platform/files/node/diskFileSystemProvider.ts +++ b/src/vs/platform/files/node/diskFileSystemProvider.ts @@ -23,7 +23,7 @@ import { createFileSystemProviderError, FileAtomicReadOptions, FileDeleteOptions 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, IWatchRequest } from 'vs/platform/files/common/watcher'; +import { AbstractRecursiveWatcherClient, IDiskFileChange, ILogMessage, INonRecursiveWatcher, IWatchRequest } from 'vs/platform/files/common/watcher'; import { ILogService } from 'vs/platform/log/common/log'; import { AbstractDiskFileSystemProvider } from 'vs/platform/files/common/diskFileSystemProvider'; import { toErrorMessage } from 'vs/base/common/errorMessage'; @@ -651,15 +651,13 @@ export class DiskFileSystemProvider extends AbstractDiskFileSystemProvider imple } protected createNonRecursiveWatcher( - path: string, - excludes: string[], + request: IWatchRequest, onChange: (changes: IDiskFileChange[]) => void, onLogMessage: (msg: ILogMessage) => void, verboseLogging: boolean - ): IDisposable & { setVerboseLogging: (verboseLogging: boolean) => void } { + ): INonRecursiveWatcher { return new NodeJSFileWatcher( - path, - excludes, + request, changes => onChange(changes), msg => onLogMessage(msg), verboseLogging diff --git a/src/vs/platform/files/node/diskFileSystemProviderServer.ts b/src/vs/platform/files/node/diskFileSystemProviderServer.ts index 9ae8f7abdd9..5670de0fa59 100644 --- a/src/vs/platform/files/node/diskFileSystemProviderServer.ts +++ b/src/vs/platform/files/node/diskFileSystemProviderServer.ts @@ -234,10 +234,14 @@ export abstract class AbstractDiskFileSystemProviderChannel extends Disposabl override dispose(): void { super.dispose(); - this.watchRequests.forEach(disposable => dispose(disposable)); + for (const [, disposable] of this.watchRequests) { + disposable.dispose(); + } this.watchRequests.clear(); - this.sessionToWatcher.forEach(disposable => dispose(disposable)); + for (const [, disposable] of this.sessionToWatcher) { + disposable.dispose(); + } this.sessionToWatcher.clear(); } } diff --git a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts index 841ca4b3904..45ae6bb505f 100644 --- a/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts +++ b/src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts @@ -3,82 +3,65 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { watch } from 'fs'; import { ThrottledDelayer } from 'vs/base/common/async'; -import { parse, ParsedPattern } from 'vs/base/common/glob'; -import { Disposable } from 'vs/base/common/lifecycle'; -import { basename, join } from 'vs/base/common/path'; -import { realpath } from 'vs/base/node/extpath'; -import { SymlinkSupport } from 'vs/base/node/pfs'; -import { CHANGE_BUFFER_DELAY, watchFile, watchFolder } from 'vs/base/node/watcher'; +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 } from 'vs/platform/files/common/watcher'; +import { IDiskFileChange, ILogMessage, coalesceEvents, IWatchRequest, INonRecursiveWatcher } from 'vs/platform/files/common/watcher'; -export class NodeJSFileWatcher extends Disposable { +export class NodeJSFileWatcher extends Disposable implements INonRecursiveWatcher { - private readonly fileChangesDelayer: ThrottledDelayer = this._register(new ThrottledDelayer(CHANGE_BUFFER_DELAY * 2 /* sync on delay from underlying library */)); + // 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 = 25; + + // 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; + + private readonly fileChangesDelayer = this._register(new ThrottledDelayer(NodeJSFileWatcher.FILE_CHANGES_HANDLER_DELAY)); private fileChangesBuffer: IDiskFileChange[] = []; - private isDisposed: boolean | undefined; - private readonly excludePatterns = this.excludes.map(exclude => parse(exclude)); + private readonly excludes = this.request.excludes.map(exclude => parse(exclude)); + + private readonly cts = new CancellationTokenSource(); + + readonly ready = this.watch(); constructor( - private path: string, - private excludes: string[], + private request: IWatchRequest, private onDidFilesChange: (changes: IDiskFileChange[]) => void, - private onLogMessage: (msg: ILogMessage) => void, - private verboseLogging: boolean + private onLogMessage?: (msg: ILogMessage) => void, + private verboseLogging?: boolean ) { super(); - - this.startWatching(); } - setVerboseLogging(verboseLogging: boolean): void { - this.verboseLogging = verboseLogging; - } - - private async startWatching(): Promise { + private async watch(): Promise { try { - const { stat, symbolicLink } = await SymlinkSupport.stat(this.path); + const realPath = await this.normalizePath(this.request); - if (this.isDisposed) { + if (this.cts.token.isCancellationRequested) { return; } - let pathToWatch = this.path; - if (symbolicLink) { - try { - pathToWatch = await realpath(pathToWatch); - } catch (error) { - this.error(error); + this.trace(`Request to start watching: ${realPath} (excludes: ${this.request.excludes}))}`); - if (symbolicLink.dangling) { - return; // give up if symbolic link is dangling - } - } - } + // Watch via node.js + const stat = await Promises.stat(realPath); + this._register(await this.doWatch(realPath, stat.isDirectory())); - this.trace(`Request to start watching: ${pathToWatch} (excludes: ${this.excludes}))}`); - - // Watch Folder - if (stat.isDirectory()) { - this._register(watchFolder(pathToWatch, (eventType, path) => { - this.onFileChange({ - type: eventType === 'changed' ? FileChangeType.UPDATED : eventType === 'added' ? FileChangeType.ADDED : FileChangeType.DELETED, - path: join(this.path, basename(path)) // ensure path is identical with what was passed in - }); - }, error => this.error(error))); - } - - // Watch File - else { - this._register(watchFile(pathToWatch, eventType => { - this.onFileChange({ - type: eventType === 'changed' ? FileChangeType.UPDATED : FileChangeType.DELETED, - path: this.path // ensure path is identical with what was passed in - }); - }, error => this.error(error))); - } } catch (error) { if (error.code !== 'ENOENT') { this.error(error); @@ -86,7 +69,240 @@ export class NodeJSFileWatcher extends Disposable { } } + private async normalizePath(request: IWatchRequest): 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 { + + // 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})`); + }); + + watcher.on('change', (type, raw) => { + if (cts.token.isCancellationRequested) { + return; // ignore if already disposed + } + + this.trace(`["${type}"] ${raw} (fs.watch() raw event)`); + + // 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 + } + + // File + if (!isDirectory) { + if (type === 'rename' || changedFileName !== basename(path)) { + + // The file was either deleted or renamed. Many tools apply changes to files in an + // atomic way ("Atomic Save") by first renaming the file to a temporary name and then + // renaming it back to the original name. Our watcher will detect this as a rename + // and then stops to work on Mac and Linux because the watcher is applied to the + // inode and not the name. The fix is to detect this case and trying to watch the file + // again after a certain delay. + // In addition, we send out a delete event if after a timeout we detect that the file + // does indeed not exist anymore. + + 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 }); + + disposables.add(await this.doWatch(path, false)); + } + + // File seems to be really gone, so emit a deleted event + else { + this.onFileChange({ path: this.request.path, type: FileChangeType.DELETED }); + } + }, 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))); + } else { + this.onFileChange({ path: this.request.path, type: FileChangeType.UPDATED }); + } + } + + // Folder + else { + + // Children add/delete + 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); + + // fs.watch() does not really help us figuring out + // if the root folder got deleted. As such we have + // to check if our watched path still exists and + // handle that accordingly. + // + // 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. + if (!await Promises.exists(path)) { + this.onFileChange({ path: this.request.path, type: FileChangeType.DELETED }); + } + + else { + + // 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 in the old form. + 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))); + } + + // Other events + 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 }); + } + } + }); + } 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 onFileChange(event: IDiskFileChange): void { + if (this.cts.token.isCancellationRequested) { + return; + } // Logging if (this.verboseLogging) { @@ -94,12 +310,12 @@ export class NodeJSFileWatcher extends Disposable { } // Add to buffer unless ignored - if (!this.isPathIgnored(event.path, this.excludePatterns)) { - this.fileChangesBuffer.push(event); - } else { + if (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 @@ -107,7 +323,7 @@ export class NodeJSFileWatcher extends Disposable { const fileChanges = this.fileChangesBuffer; this.fileChangesBuffer = []; - // Event coalsecer + // Coalesce events: merge events of same kind const coalescedFileChanges = coalesceEvents(fileChanges); // Logging @@ -117,32 +333,119 @@ export class NodeJSFileWatcher extends Disposable { } } - // Fire + // Broadcast to clients if (coalescedFileChanges.length > 0) { this.onDidFilesChange(coalescedFileChanges); } + }).catch(() => { + // ignore (we are likely disposed and cancelled) }); } - private isPathIgnored(absolutePath: string, ignored: ParsedPattern[]): boolean { - return ignored.some(ignore => ignore(absolutePath)); + private async existsChildStrictCase(path: string): Promise { + if (isLinux) { + return await Promises.exists(path); + } + + try { + const children = await Promises.readdir(dirname(path)); + return children.some(child => child === basename(path)); + } catch { + return false; + } + } + + setVerboseLogging(verboseLogging: boolean): void { + this.verboseLogging = verboseLogging; } private error(error: string): void { - if (!this.isDisposed) { - this.onLogMessage({ type: 'error', message: `[File Watcher (node.js)] ${error}` }); + 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.isDisposed && this.verboseLogging) { - this.onLogMessage({ type: 'trace', message: `[File Watcher (node.js)] ${message}` }); + if (!this.cts.token.isCancellationRequested && this.verboseLogging) { + this.onLogMessage?.({ type: 'trace', message: `[File Watcher (node.js)] ${message}` }); } } override dispose(): void { - this.isDisposed = true; + 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, 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: IWatchRequest = { 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; + } + } + } + })(); + }); + + 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 83678d3ca3a..158315cb280 100644 --- a/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts +++ b/src/vs/platform/files/node/watcher/parcel/parcelWatcher.ts @@ -19,7 +19,7 @@ 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 { NodeJSFileWatcher } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; import { FileChangeType } from 'vs/platform/files/common/files'; import { IDiskFileChange, ILogMessage, coalesceEvents, IWatchRequest, IRecursiveWatcher } from 'vs/platform/files/common/watcher'; @@ -397,12 +397,12 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { this.trace(`${type === FileChangeType.ADDED ? '[ADDED]' : type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${path}`); } - if (!this.isPathIgnored(path, excludes)) { - events.push({ type, path }); - } else { + if (excludes.some(exclude => exclude(path))) { if (this.verboseLogging) { this.trace(` >> ignored ${path}`); } + } else { + events.push({ type, path }); } } @@ -508,27 +508,29 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { const parentPath = dirname(watcher.request.path); if (existsSync(parentPath)) { - const disposable = watchFolder(parentPath, (type, path) => { + const nodeWatcher = new NodeJSFileWatcher({ path: parentPath, excludes: [] }, changes => { 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); + for (const { path, type } of changes) { + if (path === watcher.request.path && (type === FileChangeType.ADDED || type === FileChangeType.UPDATED)) { + this.warn('Watcher restarts because watched path got created again', watcher); - // Stop watching that parent folder - disposable.dispose(); + // Stop watching that parent folder + nodeWatcher.dispose(); - // Restart the file watching - this.restartWatching(watcher); + // Restart the file watching + this.restartWatching(watcher); + + break; + } } - }, error => { - // Ignore - }); + }, msg => this._onDidLogMessage.fire(msg), this.verboseLogging); // Make sure to stop watching when the watcher is disposed - watcher.token.onCancellationRequested(() => disposable.dispose()); + watcher.token.onCancellationRequested(() => nodeWatcher.dispose()); } } @@ -640,10 +642,6 @@ export class ParcelWatcher extends Disposable implements IRecursiveWatcher { 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; } diff --git a/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts new file mode 100644 index 00000000000..c5c8fbfdae7 --- /dev/null +++ b/src/vs/platform/files/test/node/nodejsWatcher.integrationTest.ts @@ -0,0 +1,435 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * 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 } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcher'; +import { timeout } from 'vs/base/common/async'; +import { isLinux, isMacintosh, isWindows } from 'vs/base/common/platform'; + +// this suite has shown flaky runs in Azure pipelines where +// tasks would just hang and timeout after a while (not in +// mocha but generally). as such they will run only on demand +// whenever we update the watcher library. + +((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('Recursive File Watcher (node.js)', () => { + + let testDir: string; + let watcher: NodeJSFileWatcher; + let event: Event; + + let loggingEnabled = false; + + function enableLogging(enable: boolean) { + loggingEnabled = enable; + watcher?.setVerboseLogging(enable); + } + + enableLogging(false); + + setup(async function () { + 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): Promise { + if (watcher) { + watcher.dispose(); + } + + const emitter = new Emitter(); + event = emitter.event; + + watcher = new NodeJSFileWatcher({ 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 () => { + watcher.dispose(); + + // Possible that the file watcher is still holding + // onto the folders on Windows specifically and the + // unlink would fail. In that case, do not fail the + // test suite. + return Promises.rm(testDir).catch(error => console.error(error)); + }); + + async function awaitEvent(onDidChangeFile: Event, path: string, type: FileChangeType, failOnEventReason?: string): 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(); + if (failOnEventReason) { + reject(new Error(`Unexpected file event: ${failOnEventReason}`)); + } else { + 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'; + case FileChangeType.DELETED: return 'deleted'; + default: return 'changed'; + } + } + + test('basics (folder watch)', async function () { + + // New file + const newFilePath = join(testDir, 'newFile.txt'); + let changeFuture: Promise = awaitEvent(event, 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); + 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) + ]); + await Promises.rename(newFilePath, renamedFilePath); + await changeFuture; + + // Rename folder + let renamedFolderPath = join(testDir, 'Renamed Folder'); + changeFuture = Promise.all([ + awaitEvent(event, newFolderPath, FileChangeType.DELETED), + awaitEvent(event, renamedFolderPath, FileChangeType.ADDED) + ]); + await Promises.rename(newFolderPath, renamedFolderPath); + await changeFuture; + + // 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) + ]); + await Promises.rename(renamedFilePath, caseRenamedFilePath); + await changeFuture; + renamedFilePath = caseRenamedFilePath; + + // 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) + ]); + await Promises.rename(renamedFolderPath, caseRenamedFolderPath); + await changeFuture; + renamedFolderPath = caseRenamedFolderPath; + + // Move file + const movedFilepath = join(testDir, 'movedFile.txt'); + changeFuture = Promise.all([ + awaitEvent(event, renamedFilePath, FileChangeType.DELETED), + awaitEvent(event, movedFilepath, FileChangeType.ADDED) + ]); + await Promises.rename(renamedFilePath, movedFilepath); + await changeFuture; + + // Move folder + const movedFolderpath = join(testDir, 'Moved Folder'); + changeFuture = Promise.all([ + awaitEvent(event, renamedFolderPath, FileChangeType.DELETED), + awaitEvent(event, movedFolderpath, FileChangeType.ADDED) + ]); + await Promises.rename(renamedFolderPath, movedFolderpath); + await changeFuture; + + // Copy file + const copiedFilepath = join(testDir, 'copiedFile.txt'); + changeFuture = awaitEvent(event, copiedFilepath, FileChangeType.ADDED); + await Promises.copyFile(movedFilepath, copiedFilepath); + await changeFuture; + + // Copy folder + const copiedFolderpath = join(testDir, 'Copied Folder'); + changeFuture = awaitEvent(event, copiedFolderpath, FileChangeType.ADDED); + await Promises.copy(movedFolderpath, copiedFolderpath, { preserveSymlinks: false }); + await changeFuture; + + // Change file + changeFuture = awaitEvent(event, 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); + await Promises.writeFile(anotherNewFilePath, 'Hello Another World'); + await changeFuture; + + // Read file does not emit event + changeFuture = awaitEvent(event, anotherNewFilePath, FileChangeType.UPDATED, 'unexpected-event-from-read-file'); + await Promises.readFile(anotherNewFilePath); + await Promise.race([timeout(100), changeFuture]); + + // Stat file does not emit event + changeFuture = awaitEvent(event, anotherNewFilePath, FileChangeType.UPDATED, 'unexpected-event-from-stat'); + await Promises.stat(anotherNewFilePath); + await Promise.race([timeout(100), changeFuture]); + + // Stat folder does not emit event + changeFuture = awaitEvent(event, copiedFolderpath, FileChangeType.UPDATED, 'unexpected-event-from-stat'); + await Promises.stat(copiedFolderpath); + await Promise.race([timeout(100), changeFuture]); + + // Delete file + changeFuture = awaitEvent(event, copiedFilepath, FileChangeType.DELETED); + await Promises.unlink(copiedFilepath); + await changeFuture; + + // Delete folder + changeFuture = awaitEvent(event, copiedFolderpath, FileChangeType.DELETED); + await Promises.rmdir(copiedFolderpath); + await changeFuture; + + watcher.dispose(); + }); + + test('basics (file watch)', async function () { + const filePath = join(testDir, 'lorem.txt'); + await createWatcher(filePath); + + // Change file + let changeFuture = awaitEvent(event, filePath, FileChangeType.UPDATED); + await Promises.writeFile(filePath, 'Hello Change'); + await changeFuture; + + // Delete file + changeFuture = awaitEvent(event, filePath, FileChangeType.DELETED); + await Promises.unlink(filePath); + await changeFuture; + + // Recreate watcher + await Promises.writeFile(filePath, 'Hello Change'); + await createWatcher(filePath); + + // Move file + changeFuture = awaitEvent(event, filePath, FileChangeType.DELETED); + await Promises.move(filePath, `${filePath}-moved`); + await changeFuture; + }); + + test('atomic writes (folder watch)', async function () { + + // Delete + Recreate file + const newFilePath = join(testDir, 'lorem.txt'); + let changeFuture: Promise = awaitEvent(event, newFilePath, FileChangeType.UPDATED); + await Promises.unlink(newFilePath); + Promises.writeFile(newFilePath, 'Hello Atomic World'); + await changeFuture; + }); + + test('atomic writes (file watch)', async function () { + const filePath = join(testDir, 'lorem.txt'); + await createWatcher(filePath); + + // Delete + Recreate file + const newFilePath = join(filePath); + let changeFuture: Promise = awaitEvent(event, newFilePath, FileChangeType.UPDATED); + await Promises.unlink(newFilePath); + Promises.writeFile(newFilePath, 'Hello Atomic World'); + await changeFuture; + }); + + test('multiple events (folder watch)', async function () { + + // multiple add + + const newFilePath1 = join(testDir, 'newFile-1.txt'); + 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); + + 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 Promise.all([addedFuture1, addedFuture2, addedFuture3]); + + // 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); + + 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 Promise.all([changeFuture1, changeFuture2, changeFuture3]); + + // 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); + + await Promise.all([ + Promises.copy(join(testDir, 'newFile-1.txt'), join(testDir, 'newFile-1-copy.txt'), { preserveSymlinks: false }), + Promises.copy(join(testDir, 'newFile-2.txt'), join(testDir, 'newFile-2-copy.txt'), { preserveSymlinks: false }), + Promises.copy(join(testDir, 'newFile-3.txt'), join(testDir, 'newFile-3-copy.txt'), { preserveSymlinks: false }) + ]); + + await Promise.all([copyFuture1, copyFuture2, copyFuture3]); + + // 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); + + await Promise.all([ + await Promises.unlink(newFilePath1), + await Promises.unlink(newFilePath2), + await Promises.unlink(newFilePath3) + ]); + + await Promise.all([deleteFuture1, deleteFuture2, deleteFuture3]); + }); + + test('multiple events (file watch)', async function () { + const filePath = join(testDir, 'lorem.txt'); + await createWatcher(filePath); + + // multiple change + + const changeFuture1: Promise = awaitEvent(event, filePath, FileChangeType.UPDATED); + + await Promise.all([ + await Promises.writeFile(filePath, 'Hello Update 1'), + await Promises.writeFile(filePath, 'Hello Update 2'), + await Promises.writeFile(filePath, 'Hello Update 3'), + ]); + + await Promise.all([changeFuture1]); + }); + + (isWindows /* windows: cannot create file symbolic link without elevated context */ ? test.skip : test)('symlink support (folder watch)', async function () { + const link = join(testDir, 'deep-linked'); + const linkTarget = join(testDir, 'deep'); + await Promises.symlink(linkTarget, link); + + await createWatcher(link); + + // New file + const newFilePath = join(link, 'newFile.txt'); + let changeFuture: Promise = awaitEvent(event, newFilePath, FileChangeType.ADDED); + await Promises.writeFile(newFilePath, 'Hello World'); + await changeFuture; + }); + + (isWindows /* windows: cannot create file symbolic link without elevated context */ ? test.skip : test)('symlink support (file watch)', async function () { + const link = join(testDir, 'lorem.txt-linked'); + const linkTarget = join(testDir, 'lorem.txt'); + await Promises.symlink(linkTarget, link); + + await createWatcher(link); + + // Change file + let changeFuture = awaitEvent(event, link, FileChangeType.UPDATED); + await Promises.writeFile(link, 'Hello Change'); + await changeFuture; + + // Delete file + changeFuture = awaitEvent(event, link, FileChangeType.DELETED); + await Promises.unlink(linkTarget); + await changeFuture; + }); + + (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); + + // New file + const newFilePath = join(wrongCase, 'newFile.txt'); + let changeFuture: Promise = awaitEvent(event, newFilePath, FileChangeType.ADDED); + await Promises.writeFile(newFilePath, 'Hello World'); + await changeFuture; + }); + + (isLinux /* linux: is case sensitive */ ? test.skip : test)('wrong casing (file watch)', async function () { + const filePath = join(testDir, 'LOREM.txt'); + await createWatcher(filePath); + + // Change file + let changeFuture = awaitEvent(event, filePath, FileChangeType.UPDATED); + await Promises.writeFile(filePath, 'Hello Change'); + await changeFuture; + + // Delete file + changeFuture = awaitEvent(event, filePath, FileChangeType.DELETED); + await Promises.unlink(filePath); + await changeFuture; + }); + + test('invalid path does not explode', async function () { + const invalidPath = join(testDir, 'invalid'); + + await createWatcher(invalidPath); + }); + + (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); + + // Delete watched path + const changeFuture = awaitEvent(event, watchedPath, FileChangeType.DELETED); + Promises.rm(watchedPath, RimRafMode.UNLINK); + await changeFuture; + }); + + test('deleting watched path is handled properly (file watch)', async function () { + const watchedPath = join(testDir, 'lorem.txt'); + await createWatcher(watchedPath); + + // Delete watched path + const changeFuture = awaitEvent(event, watchedPath, FileChangeType.DELETED); + Promises.unlink(watchedPath); + await changeFuture; + }); +}); diff --git a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts index d1ec83d19f2..777152eb950 100644 --- a/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts +++ b/src/vs/platform/files/test/node/parcelWatcher.integrationTest.ts @@ -20,7 +20,7 @@ import { IWatchRequest } from 'vs/platform/files/common/watcher'; // mocha but generally). as such they will run only on demand // whenever we update the watcher library. -((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('Recursive Watcher (parcel)', () => { +((process.env['BUILD_SOURCEVERSION'] || process.env['CI']) ? suite.skip : flakySuite)('Recursive File Watcher (parcel)', () => { class TestParcelWatcher extends ParcelWatcher { @@ -491,6 +491,7 @@ import { IWatchRequest } from 'vs/platform/files/common/watcher'; await warnFuture; // Restore watched path + await timeout(1500); // node.js watcher used for monitoring folder restore is async await Promises.mkdir(watchedPath); await timeout(1500); // restart is delayed await watcher.whenReady(); diff --git a/src/vs/server/remoteFileSystemProviderServer.ts b/src/vs/server/remoteFileSystemProviderServer.ts index ee129f2e27b..c4a4d33f7f9 100644 --- a/src/vs/server/remoteFileSystemProviderServer.ts +++ b/src/vs/server/remoteFileSystemProviderServer.ts @@ -122,7 +122,9 @@ class SessionFileWatcher extends Disposable implements ISessionFileWatcher { override dispose(): void { super.dispose(); - this.watcherRequests.forEach(disposable => dispose(disposable)); + for (const [, disposable] of this.watcherRequests) { + disposable.dispose(); + } this.watcherRequests.clear(); } } diff --git a/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts b/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts index 73697b42976..a54c88b84e5 100644 --- a/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts +++ b/src/vs/workbench/contrib/files/browser/workspaceWatcher.ts @@ -170,7 +170,9 @@ export class WorkspaceWatcher extends Disposable { } private unwatchWorkspaces(): void { - this.watchedWorkspaces.forEach(disposable => dispose(disposable)); + for (const [, disposable] of this.watchedWorkspaces) { + disposable.dispose(); + } this.watchedWorkspaces.clear(); }