Path: blob/main/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts
3296 views
/*---------------------------------------------------------------------------------------------1* Copyright (c) Microsoft Corporation. All rights reserved.2* Licensed under the MIT License. See License.txt in the project root for license information.3*--------------------------------------------------------------------------------------------*/45import { watch, promises } from 'fs';6import { RunOnceWorker, ThrottledWorker } from '../../../../../base/common/async.js';7import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';8import { isEqual, isEqualOrParent } from '../../../../../base/common/extpath.js';9import { Disposable, DisposableStore, IDisposable, thenRegisterOrDispose, toDisposable } from '../../../../../base/common/lifecycle.js';10import { normalizeNFC } from '../../../../../base/common/normalization.js';11import { basename, dirname, join } from '../../../../../base/common/path.js';12import { isLinux, isMacintosh } from '../../../../../base/common/platform.js';13import { joinPath } from '../../../../../base/common/resources.js';14import { URI } from '../../../../../base/common/uri.js';15import { Promises } from '../../../../../base/node/pfs.js';16import { FileChangeFilter, FileChangeType, IFileChange } from '../../../common/files.js';17import { ILogMessage, coalesceEvents, INonRecursiveWatchRequest, parseWatcherPatterns, IRecursiveWatcherWithSubscribe, isFiltered, isWatchRequestWithCorrelation } from '../../../common/watcher.js';18import { Lazy } from '../../../../../base/common/lazy.js';19import { ParsedPattern } from '../../../../../base/common/glob.js';2021export class NodeJSFileWatcherLibrary extends Disposable {2223// A delay in reacting to file deletes to support24// atomic save operations where a tool may chose25// to delete a file before creating it again for26// an update.27private static readonly FILE_DELETE_HANDLER_DELAY = 100;2829// A delay for collecting file changes from node.js30// before collecting them for coalescing and emitting31// Same delay as used for the recursive watcher.32private static readonly FILE_CHANGES_HANDLER_DELAY = 75;3334// Reduce likelyhood of spam from file events via throttling.35// These numbers are a bit more aggressive compared to the36// recursive watcher because we can have many individual37// node.js watchers per request.38// (https://github.com/microsoft/vscode/issues/124723)39private readonly throttledFileChangesEmitter = this._register(new ThrottledWorker<IFileChange>(40{41maxWorkChunkSize: 100, // only process up to 100 changes at once before...42throttleDelay: 200, // ...resting for 200ms until we process events again...43maxBufferedWork: 10000 // ...but never buffering more than 10000 events in memory44},45events => this.onDidFilesChange(events)46));4748// Aggregate file changes over FILE_CHANGES_HANDLER_DELAY49// to coalesce events and reduce spam.50private readonly fileChangesAggregator = this._register(new RunOnceWorker<IFileChange>(events => this.handleFileChanges(events), NodeJSFileWatcherLibrary.FILE_CHANGES_HANDLER_DELAY));5152private readonly excludes: ParsedPattern[];53private readonly includes: ParsedPattern[] | undefined;54private readonly filter: FileChangeFilter | undefined;5556private readonly cts = new CancellationTokenSource();5758private readonly realPath = new Lazy(async () => {5960// This property is intentionally `Lazy` and not using `realcase()` as the counterpart61// in the recursive watcher because of the amount of paths this watcher is dealing with.62// We try as much as possible to avoid even needing `realpath()` if we can because even63// that method does an `lstat()` per segment of the path.6465let result = this.request.path;6667try {68result = await Promises.realpath(this.request.path);6970if (this.request.path !== result) {71this.trace(`correcting a path to watch that seems to be a symbolic link (original: ${this.request.path}, real: ${result})`);72}73} catch (error) {74// ignore75}7677return result;78});7980readonly ready: Promise<void>;8182private _isReusingRecursiveWatcher = false;83get isReusingRecursiveWatcher(): boolean { return this._isReusingRecursiveWatcher; }8485private didFail = false;86get failed(): boolean { return this.didFail; }8788constructor(89private readonly request: INonRecursiveWatchRequest,90private readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined,91private readonly onDidFilesChange: (changes: IFileChange[]) => void,92private readonly onDidWatchFail?: () => void,93private readonly onLogMessage?: (msg: ILogMessage) => void,94private verboseLogging?: boolean95) {96super();9798this.excludes = parseWatcherPatterns(this.request.path, this.request.excludes);99this.includes = this.request.includes ? parseWatcherPatterns(this.request.path, this.request.includes) : undefined;100this.filter = isWatchRequestWithCorrelation(this.request) ? this.request.filter : undefined; // filtering is only enabled when correlating because watchers are otherwise potentially reused101102this.ready = this.watch();103}104105private async watch(): Promise<void> {106try {107const stat = await promises.stat(this.request.path);108109if (this.cts.token.isCancellationRequested) {110return;111}112113this._register(await this.doWatch(stat.isDirectory()));114} catch (error) {115if (error.code !== 'ENOENT') {116this.error(error);117} else {118this.trace(`ignoring a path for watching who's stat info failed to resolve: ${this.request.path} (error: ${error})`);119}120121this.notifyWatchFailed();122}123}124125private notifyWatchFailed(): void {126this.didFail = true;127128this.onDidWatchFail?.();129}130131private async doWatch(isDirectory: boolean): Promise<IDisposable> {132const disposables = new DisposableStore();133134if (this.doWatchWithExistingWatcher(isDirectory, disposables)) {135this.trace(`reusing an existing recursive watcher for ${this.request.path}`);136this._isReusingRecursiveWatcher = true;137} else {138this._isReusingRecursiveWatcher = false;139await this.doWatchWithNodeJS(isDirectory, disposables);140}141142return disposables;143}144145private doWatchWithExistingWatcher(isDirectory: boolean, disposables: DisposableStore): boolean {146if (isDirectory) {147// Recursive watcher re-use is currently not enabled for when148// folders are watched. this is because the dispatching in the149// recursive watcher for non-recurive requests is optimized for150// file changes where we really only match on the exact path151// and not child paths.152return false;153}154155const resource = URI.file(this.request.path);156const subscription = this.recursiveWatcher?.subscribe(this.request.path, async (error, change) => {157if (disposables.isDisposed) {158return; // return early if already disposed159}160161if (error) {162await thenRegisterOrDispose(this.doWatch(isDirectory), disposables);163} else if (change) {164if (typeof change.cId === 'number' || typeof this.request.correlationId === 'number') {165// Re-emit this change with the correlation id of the request166// so that the client can correlate the event with the request167// properly. Without correlation, we do not have to do that168// because the event will appear on the global listener already.169this.onFileChange({ resource, type: change.type, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);170}171}172});173174if (subscription) {175disposables.add(subscription);176177return true;178}179180return false;181}182183private async doWatchWithNodeJS(isDirectory: boolean, disposables: DisposableStore): Promise<void> {184const realPath = await this.realPath.value;185186if (this.cts.token.isCancellationRequested) {187return;188}189190// macOS: watching samba shares can crash VSCode so we do191// a simple check for the file path pointing to /Volumes192// (https://github.com/microsoft/vscode/issues/106879)193// TODO@electron this needs a revisit when the crash is194// fixed or mitigated upstream.195if (isMacintosh && isEqualOrParent(realPath, '/Volumes/', true)) {196this.error(`Refusing to watch ${realPath} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`);197198return;199}200201const cts = new CancellationTokenSource(this.cts.token);202disposables.add(toDisposable(() => cts.dispose(true)));203204const watcherDisposables = new DisposableStore(); // we need a separate disposable store because we re-create the watcher from within in some cases205disposables.add(watcherDisposables);206207try {208const requestResource = URI.file(this.request.path);209const pathBasename = basename(realPath);210211// Creating watcher can fail with an exception212const watcher = watch(realPath);213watcherDisposables.add(toDisposable(() => {214watcher.removeAllListeners();215watcher.close();216}));217218this.trace(`Started watching: '${realPath}'`);219220// Folder: resolve children to emit proper events221const folderChildren = new Set<string>();222if (isDirectory) {223try {224for (const child of await Promises.readdir(realPath)) {225folderChildren.add(child);226}227} catch (error) {228this.error(error);229}230}231232if (cts.token.isCancellationRequested) {233return;234}235236const mapPathToStatDisposable = new Map<string, IDisposable>();237watcherDisposables.add(toDisposable(() => {238for (const [, disposable] of mapPathToStatDisposable) {239disposable.dispose();240}241mapPathToStatDisposable.clear();242}));243244watcher.on('error', (code: number, signal: string) => {245if (cts.token.isCancellationRequested) {246return;247}248249this.error(`Failed to watch ${realPath} for changes using fs.watch() (${code}, ${signal})`);250251this.notifyWatchFailed();252});253254watcher.on('change', (type, raw) => {255if (cts.token.isCancellationRequested) {256return; // ignore if already disposed257}258259if (this.verboseLogging) {260this.traceWithCorrelation(`[raw] ["${type}"] ${raw}`);261}262263// Normalize file name264let changedFileName = '';265if (raw) { // https://github.com/microsoft/vscode/issues/38191266changedFileName = raw.toString();267if (isMacintosh) {268// Mac: uses NFD unicode form on disk, but we want NFC269// See also https://github.com/nodejs/node/issues/2165270changedFileName = normalizeNFC(changedFileName);271}272}273274if (!changedFileName || (type !== 'change' && type !== 'rename')) {275return; // ignore unexpected events276}277278// Folder279if (isDirectory) {280281// Folder child added/deleted282if (type === 'rename') {283284// Cancel any previous stats for this file if existing285mapPathToStatDisposable.get(changedFileName)?.dispose();286287// Wait a bit and try see if the file still exists on disk288// to decide on the resulting event289const timeoutHandle = setTimeout(async () => {290mapPathToStatDisposable.delete(changedFileName);291292// Depending on the OS the watcher runs on, there293// is different behaviour for when the watched294// folder path is being deleted:295//296// - macOS: not reported but events continue to297// work even when the folder is brought298// back, though it seems every change299// to a file is reported as "rename"300// - Linux: "rename" event is reported with the301// name of the folder and events stop302// working303// - Windows: an EPERM error is thrown that we304// handle from the `on('error')` event305//306// We do not re-attach the watcher after timeout307// though as we do for file watches because for308// file watching specifically we want to handle309// the atomic-write cases where the file is being310// deleted and recreated with different contents.311if (isEqual(changedFileName, pathBasename, !isLinux) && !await Promises.exists(realPath)) {312this.onWatchedPathDeleted(requestResource);313314return;315}316317if (cts.token.isCancellationRequested) {318return;319}320321// In order to properly detect renames on a case-insensitive322// file system, we need to use `existsChildStrictCase` helper323// because otherwise we would wrongly assume a file exists324// when it was renamed to same name but different case.325const fileExists = await this.existsChildStrictCase(join(realPath, changedFileName));326327if (cts.token.isCancellationRequested) {328return; // ignore if disposed by now329}330331// Figure out the correct event type:332// File Exists: either 'added' or 'updated' if known before333// File Does not Exist: always 'deleted'334let type: FileChangeType;335if (fileExists) {336if (folderChildren.has(changedFileName)) {337type = FileChangeType.UPDATED;338} else {339type = FileChangeType.ADDED;340folderChildren.add(changedFileName);341}342} else {343folderChildren.delete(changedFileName);344type = FileChangeType.DELETED;345}346347this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId });348}, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY);349350mapPathToStatDisposable.set(changedFileName, toDisposable(() => clearTimeout(timeoutHandle)));351}352353// Folder child changed354else {355356// Figure out the correct event type: if this is the357// first time we see this child, it can only be added358let type: FileChangeType;359if (folderChildren.has(changedFileName)) {360type = FileChangeType.UPDATED;361} else {362type = FileChangeType.ADDED;363folderChildren.add(changedFileName);364}365366this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId });367}368}369370// File371else {372373// File added/deleted374if (type === 'rename' || !isEqual(changedFileName, pathBasename, !isLinux)) {375376// Depending on the OS the watcher runs on, there377// is different behaviour for when the watched378// file path is being deleted:379//380// - macOS: "rename" event is reported and events381// stop working382// - Linux: "rename" event is reported and events383// stop working384// - Windows: "rename" event is reported and events385// continue to work when file is restored386//387// As opposed to folder watching, we re-attach the388// watcher after brief timeout to support "atomic save"389// operations where a tool may decide to delete a file390// and then create it with the updated contents.391//392// Different to folder watching, we emit a delete event393// though we never detect when the file is brought back394// because the watcher is disposed then.395396const timeoutHandle = setTimeout(async () => {397const fileExists = await Promises.exists(realPath);398399if (cts.token.isCancellationRequested) {400return; // ignore if disposed by now401}402403// File still exists, so emit as change event and reapply the watcher404if (fileExists) {405this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);406407watcherDisposables.add(await this.doWatch(false));408}409410// File seems to be really gone, so emit a deleted and failed event411else {412this.onWatchedPathDeleted(requestResource);413}414}, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY);415416// Very important to dispose the watcher which now points to a stale inode417// and wire in a new disposable that tracks our timeout that is installed418watcherDisposables.clear();419watcherDisposables.add(toDisposable(() => clearTimeout(timeoutHandle)));420}421422// File changed423else {424this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);425}426}427});428} catch (error) {429if (cts.token.isCancellationRequested) {430return;431}432433this.error(`Failed to watch ${realPath} for changes using fs.watch() (${error.toString()})`);434435this.notifyWatchFailed();436}437}438439private onWatchedPathDeleted(resource: URI): void {440this.warn('Watcher shutdown because watched path got deleted');441442// Emit events and flush in case the watcher gets disposed443this.onFileChange({ resource, type: FileChangeType.DELETED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);444this.fileChangesAggregator.flush();445446this.notifyWatchFailed();447}448449private onFileChange(event: IFileChange, skipIncludeExcludeChecks = false): void {450if (this.cts.token.isCancellationRequested) {451return;452}453454// Logging455if (this.verboseLogging) {456this.traceWithCorrelation(`${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`);457}458459// Add to aggregator unless excluded or not included (not if explicitly disabled)460if (!skipIncludeExcludeChecks && this.excludes.some(exclude => exclude(event.resource.fsPath))) {461if (this.verboseLogging) {462this.traceWithCorrelation(` >> ignored (excluded) ${event.resource.fsPath}`);463}464} else if (!skipIncludeExcludeChecks && this.includes && this.includes.length > 0 && !this.includes.some(include => include(event.resource.fsPath))) {465if (this.verboseLogging) {466this.traceWithCorrelation(` >> ignored (not included) ${event.resource.fsPath}`);467}468} else {469this.fileChangesAggregator.work(event);470}471}472473private handleFileChanges(fileChanges: IFileChange[]): void {474475// Coalesce events: merge events of same kind476const coalescedFileChanges = coalesceEvents(fileChanges);477478// Filter events: based on request filter property479const filteredEvents: IFileChange[] = [];480for (const event of coalescedFileChanges) {481if (isFiltered(event, this.filter)) {482if (this.verboseLogging) {483this.traceWithCorrelation(` >> ignored (filtered) ${event.resource.fsPath}`);484}485486continue;487}488489filteredEvents.push(event);490}491492if (filteredEvents.length === 0) {493return;494}495496// Logging497if (this.verboseLogging) {498for (const event of filteredEvents) {499this.traceWithCorrelation(` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`);500}501}502503// Broadcast to clients via throttled emitter504const worked = this.throttledFileChangesEmitter.work(filteredEvents);505506// Logging507if (!worked) {508this.warn(`started ignoring events due to too many file change events at once (incoming: ${filteredEvents.length}, most recent change: ${filteredEvents[0].resource.fsPath}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`);509} else {510if (this.throttledFileChangesEmitter.pending > 0) {511this.trace(`started throttling events due to large amount of file change events at once (pending: ${this.throttledFileChangesEmitter.pending}, most recent change: ${filteredEvents[0].resource.fsPath}). Use 'files.watcherExclude' setting to exclude folders with lots of changing files (e.g. compilation output).`);512}513}514}515516private async existsChildStrictCase(path: string): Promise<boolean> {517if (isLinux) {518return Promises.exists(path);519}520521try {522const pathBasename = basename(path);523const children = await Promises.readdir(dirname(path));524525return children.some(child => child === pathBasename);526} catch (error) {527this.trace(error);528529return false;530}531}532533setVerboseLogging(verboseLogging: boolean): void {534this.verboseLogging = verboseLogging;535}536537private error(error: string): void {538if (!this.cts.token.isCancellationRequested) {539this.onLogMessage?.({ type: 'error', message: `[File Watcher (node.js)] ${error}` });540}541}542543private warn(message: string): void {544if (!this.cts.token.isCancellationRequested) {545this.onLogMessage?.({ type: 'warn', message: `[File Watcher (node.js)] ${message}` });546}547}548549private trace(message: string): void {550if (!this.cts.token.isCancellationRequested && this.verboseLogging) {551this.onLogMessage?.({ type: 'trace', message: `[File Watcher (node.js)] ${message}` });552}553}554555private traceWithCorrelation(message: string): void {556if (!this.cts.token.isCancellationRequested && this.verboseLogging) {557this.trace(`${message}${typeof this.request.correlationId === 'number' ? ` <${this.request.correlationId}> ` : ``}`);558}559}560561override dispose(): void {562this.cts.dispose(true);563564super.dispose();565}566}567568/**569* Watch the provided `path` for changes and return570* the data in chunks of `Uint8Array` for further use.571*/572export async function watchFileContents(path: string, onData: (chunk: Uint8Array) => void, onReady: () => void, token: CancellationToken, bufferSize = 512): Promise<void> {573const handle = await Promises.open(path, 'r');574const buffer = Buffer.allocUnsafe(bufferSize);575576const cts = new CancellationTokenSource(token);577578let error: Error | undefined = undefined;579let isReading = false;580581const request: INonRecursiveWatchRequest = { path, excludes: [], recursive: false };582const watcher = new NodeJSFileWatcherLibrary(request, undefined, changes => {583(async () => {584for (const { type } of changes) {585if (type === FileChangeType.UPDATED) {586587if (isReading) {588return; // return early if we are already reading the output589}590591isReading = true;592593try {594// Consume the new contents of the file until finished595// everytime there is a change event signalling a change596while (!cts.token.isCancellationRequested) {597const { bytesRead } = await Promises.read(handle, buffer, 0, bufferSize, null);598if (!bytesRead || cts.token.isCancellationRequested) {599break;600}601602onData(buffer.slice(0, bytesRead));603}604} catch (err) {605error = new Error(err);606cts.dispose(true);607} finally {608isReading = false;609}610}611}612})();613});614615await watcher.ready;616onReady();617618return new Promise<void>((resolve, reject) => {619cts.token.onCancellationRequested(async () => {620watcher.dispose();621622try {623await Promises.close(handle);624} catch (err) {625error = new Error(err);626}627628if (error) {629reject(error);630} else {631resolve();632}633});634});635}636637638