Path: blob/main/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts
5240 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();9798const ignoreCase = !isLinux;99this.excludes = parseWatcherPatterns(this.request.path, this.request.excludes, ignoreCase);100this.includes = this.request.includes ? parseWatcherPatterns(this.request.path, this.request.includes, ignoreCase) : undefined;101this.filter = isWatchRequestWithCorrelation(this.request) ? this.request.filter : undefined; // filtering is only enabled when correlating because watchers are otherwise potentially reused102103this.ready = this.watch();104}105106private async watch(): Promise<void> {107try {108const stat = await promises.stat(this.request.path);109110if (this.cts.token.isCancellationRequested) {111return;112}113114this._register(await this.doWatch(stat.isDirectory()));115} catch (error) {116if (error.code !== 'ENOENT') {117this.error(error);118} else {119this.trace(`ignoring a path for watching who's stat info failed to resolve: ${this.request.path} (error: ${error})`);120}121122this.notifyWatchFailed();123}124}125126private notifyWatchFailed(): void {127this.didFail = true;128129this.onDidWatchFail?.();130}131132private async doWatch(isDirectory: boolean): Promise<IDisposable> {133const disposables = new DisposableStore();134135if (this.doWatchWithExistingWatcher(isDirectory, disposables)) {136this.trace(`reusing an existing recursive watcher for ${this.request.path}`);137this._isReusingRecursiveWatcher = true;138} else {139this._isReusingRecursiveWatcher = false;140await this.doWatchWithNodeJS(isDirectory, disposables);141}142143return disposables;144}145146private doWatchWithExistingWatcher(isDirectory: boolean, disposables: DisposableStore): boolean {147if (isDirectory) {148// Recursive watcher re-use is currently not enabled for when149// folders are watched. this is because the dispatching in the150// recursive watcher for non-recurive requests is optimized for151// file changes where we really only match on the exact path152// and not child paths.153return false;154}155156const resource = URI.file(this.request.path);157const subscription = this.recursiveWatcher?.subscribe(this.request.path, async (error, change) => {158if (disposables.isDisposed) {159return; // return early if already disposed160}161162if (error) {163await thenRegisterOrDispose(this.doWatch(isDirectory), disposables);164} else if (change) {165if (typeof change.cId === 'number' || typeof this.request.correlationId === 'number') {166// Re-emit this change with the correlation id of the request167// so that the client can correlate the event with the request168// properly. Without correlation, we do not have to do that169// because the event will appear on the global listener already.170this.onFileChange({ resource, type: change.type, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);171}172}173});174175if (subscription) {176disposables.add(subscription);177178return true;179}180181return false;182}183184private async doWatchWithNodeJS(isDirectory: boolean, disposables: DisposableStore): Promise<void> {185const realPath = await this.realPath.value;186187if (this.cts.token.isCancellationRequested) {188return;189}190191// macOS: watching samba shares can crash VSCode so we do192// a simple check for the file path pointing to /Volumes193// (https://github.com/microsoft/vscode/issues/106879)194// TODO@electron this needs a revisit when the crash is195// fixed or mitigated upstream.196if (isMacintosh && isEqualOrParent(realPath, '/Volumes/', true)) {197this.error(`Refusing to watch ${realPath} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`);198199return;200}201202const cts = new CancellationTokenSource(this.cts.token);203disposables.add(toDisposable(() => cts.dispose(true)));204205const watcherDisposables = new DisposableStore(); // we need a separate disposable store because we re-create the watcher from within in some cases206disposables.add(watcherDisposables);207208try {209const requestResource = URI.file(this.request.path);210const pathBasename = basename(realPath);211212// Creating watcher can fail with an exception213const watcher = watch(realPath);214watcherDisposables.add(toDisposable(() => {215watcher.removeAllListeners();216watcher.close();217}));218219this.trace(`Started watching: '${realPath}'`);220221// Folder: resolve children to emit proper events222const folderChildren = new Set<string>();223if (isDirectory) {224try {225for (const child of await Promises.readdir(realPath)) {226folderChildren.add(child);227}228} catch (error) {229this.error(error);230}231}232233if (cts.token.isCancellationRequested) {234return;235}236237const mapPathToStatDisposable = new Map<string, IDisposable>();238watcherDisposables.add(toDisposable(() => {239for (const [, disposable] of mapPathToStatDisposable) {240disposable.dispose();241}242mapPathToStatDisposable.clear();243}));244245watcher.on('error', (code: number, signal: string) => {246if (cts.token.isCancellationRequested) {247return;248}249250this.error(`Failed to watch ${realPath} for changes using fs.watch() (${code}, ${signal})`);251252this.notifyWatchFailed();253});254255watcher.on('change', (type, raw) => {256if (cts.token.isCancellationRequested) {257return; // ignore if already disposed258}259260if (this.verboseLogging) {261this.traceWithCorrelation(`[raw] ["${type}"] ${raw}`);262}263264// Normalize file name265let changedFileName = '';266if (raw) { // https://github.com/microsoft/vscode/issues/38191267changedFileName = raw.toString();268if (isMacintosh) {269// Mac: uses NFD unicode form on disk, but we want NFC270// See also https://github.com/nodejs/node/issues/2165271changedFileName = normalizeNFC(changedFileName);272}273}274275if (!changedFileName || (type !== 'change' && type !== 'rename')) {276return; // ignore unexpected events277}278279// Folder280if (isDirectory) {281282// Folder child added/deleted283if (type === 'rename') {284285// Cancel any previous stats for this file if existing286mapPathToStatDisposable.get(changedFileName)?.dispose();287288// Wait a bit and try see if the file still exists on disk289// to decide on the resulting event290const timeoutHandle = setTimeout(async () => {291mapPathToStatDisposable.delete(changedFileName);292293// Depending on the OS the watcher runs on, there294// is different behaviour for when the watched295// folder path is being deleted:296//297// - macOS: not reported but events continue to298// work even when the folder is brought299// back, though it seems every change300// to a file is reported as "rename"301// - Linux: "rename" event is reported with the302// name of the folder and events stop303// working304// - Windows: an EPERM error is thrown that we305// handle from the `on('error')` event306//307// We do not re-attach the watcher after timeout308// though as we do for file watches because for309// file watching specifically we want to handle310// the atomic-write cases where the file is being311// deleted and recreated with different contents.312if (isEqual(changedFileName, pathBasename, !isLinux) && !await Promises.exists(realPath)) {313this.onWatchedPathDeleted(requestResource);314315return;316}317318if (cts.token.isCancellationRequested) {319return;320}321322// In order to properly detect renames on a case-insensitive323// file system, we need to use `existsChildStrictCase` helper324// because otherwise we would wrongly assume a file exists325// when it was renamed to same name but different case.326const fileExists = await this.existsChildStrictCase(join(realPath, changedFileName));327328if (cts.token.isCancellationRequested) {329return; // ignore if disposed by now330}331332// Figure out the correct event type:333// File Exists: either 'added' or 'updated' if known before334// File Does not Exist: always 'deleted'335let type: FileChangeType;336if (fileExists) {337if (folderChildren.has(changedFileName)) {338type = FileChangeType.UPDATED;339} else {340type = FileChangeType.ADDED;341folderChildren.add(changedFileName);342}343} else {344folderChildren.delete(changedFileName);345type = FileChangeType.DELETED;346}347348this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId });349}, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY);350351mapPathToStatDisposable.set(changedFileName, toDisposable(() => clearTimeout(timeoutHandle)));352}353354// Folder child changed355else {356357// Figure out the correct event type: if this is the358// first time we see this child, it can only be added359let type: FileChangeType;360if (folderChildren.has(changedFileName)) {361type = FileChangeType.UPDATED;362} else {363type = FileChangeType.ADDED;364folderChildren.add(changedFileName);365}366367this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId });368}369}370371// File372else {373374// File added/deleted375if (type === 'rename' || !isEqual(changedFileName, pathBasename, !isLinux)) {376377// Depending on the OS the watcher runs on, there378// is different behaviour for when the watched379// file path is being deleted:380//381// - macOS: "rename" event is reported and events382// stop working383// - Linux: "rename" event is reported and events384// stop working385// - Windows: "rename" event is reported and events386// continue to work when file is restored387//388// As opposed to folder watching, we re-attach the389// watcher after brief timeout to support "atomic save"390// operations where a tool may decide to delete a file391// and then create it with the updated contents.392//393// Different to folder watching, we emit a delete event394// though we never detect when the file is brought back395// because the watcher is disposed then.396397const timeoutHandle = setTimeout(async () => {398const fileExists = await Promises.exists(realPath);399400if (cts.token.isCancellationRequested) {401return; // ignore if disposed by now402}403404// File still exists, so emit as change event and reapply the watcher405if (fileExists) {406this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);407408watcherDisposables.add(await this.doWatch(false));409}410411// File seems to be really gone, so emit a deleted and failed event412else {413this.onWatchedPathDeleted(requestResource);414}415}, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY);416417// Very important to dispose the watcher which now points to a stale inode418// and wire in a new disposable that tracks our timeout that is installed419watcherDisposables.clear();420watcherDisposables.add(toDisposable(() => clearTimeout(timeoutHandle)));421}422423// File changed424else {425this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);426}427}428});429} catch (error) {430if (cts.token.isCancellationRequested) {431return;432}433434this.error(`Failed to watch ${realPath} for changes using fs.watch() (${error.toString()})`);435436this.notifyWatchFailed();437}438}439440private onWatchedPathDeleted(resource: URI): void {441this.warn('Watcher shutdown because watched path got deleted');442443// Emit events and flush in case the watcher gets disposed444this.onFileChange({ resource, type: FileChangeType.DELETED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);445this.fileChangesAggregator.flush();446447this.notifyWatchFailed();448}449450private onFileChange(event: IFileChange, skipIncludeExcludeChecks = false): void {451if (this.cts.token.isCancellationRequested) {452return;453}454455// Logging456if (this.verboseLogging) {457this.traceWithCorrelation(`${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`);458}459460// Add to aggregator unless excluded or not included (not if explicitly disabled)461if (!skipIncludeExcludeChecks && this.excludes.some(exclude => exclude(event.resource.fsPath))) {462if (this.verboseLogging) {463this.traceWithCorrelation(` >> ignored (excluded) ${event.resource.fsPath}`);464}465} else if (!skipIncludeExcludeChecks && this.includes && this.includes.length > 0 && !this.includes.some(include => include(event.resource.fsPath))) {466if (this.verboseLogging) {467this.traceWithCorrelation(` >> ignored (not included) ${event.resource.fsPath}`);468}469} else {470this.fileChangesAggregator.work(event);471}472}473474private handleFileChanges(fileChanges: IFileChange[]): void {475476// Coalesce events: merge events of same kind477const coalescedFileChanges = coalesceEvents(fileChanges);478479// Filter events: based on request filter property480const filteredEvents: IFileChange[] = [];481for (const event of coalescedFileChanges) {482if (isFiltered(event, this.filter)) {483if (this.verboseLogging) {484this.traceWithCorrelation(` >> ignored (filtered) ${event.resource.fsPath}`);485}486487continue;488}489490filteredEvents.push(event);491}492493if (filteredEvents.length === 0) {494return;495}496497// Logging498if (this.verboseLogging) {499for (const event of filteredEvents) {500this.traceWithCorrelation(` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`);501}502}503504// Broadcast to clients via throttled emitter505const worked = this.throttledFileChangesEmitter.work(filteredEvents);506507// Logging508if (!worked) {509this.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).`);510} else {511if (this.throttledFileChangesEmitter.pending > 0) {512this.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).`);513}514}515}516517private async existsChildStrictCase(path: string): Promise<boolean> {518if (isLinux) {519return Promises.exists(path);520}521522try {523const pathBasename = basename(path);524const children = await Promises.readdir(dirname(path));525526return children.some(child => child === pathBasename);527} catch (error) {528this.trace(error);529530return false;531}532}533534setVerboseLogging(verboseLogging: boolean): void {535this.verboseLogging = verboseLogging;536}537538private error(error: string): void {539if (!this.cts.token.isCancellationRequested) {540this.onLogMessage?.({ type: 'error', message: `[File Watcher (node.js)] ${error}` });541}542}543544private warn(message: string): void {545if (!this.cts.token.isCancellationRequested) {546this.onLogMessage?.({ type: 'warn', message: `[File Watcher (node.js)] ${message}` });547}548}549550private trace(message: string): void {551if (!this.cts.token.isCancellationRequested && this.verboseLogging) {552this.onLogMessage?.({ type: 'trace', message: `[File Watcher (node.js)] ${message}` });553}554}555556private traceWithCorrelation(message: string): void {557if (!this.cts.token.isCancellationRequested && this.verboseLogging) {558this.trace(`${message}${typeof this.request.correlationId === 'number' ? ` <${this.request.correlationId}> ` : ``}`);559}560}561562override dispose(): void {563this.cts.dispose(true);564565super.dispose();566}567}568569/**570* Watch the provided `path` for changes and return571* the data in chunks of `Uint8Array` for further use.572*/573export async function watchFileContents(path: string, onData: (chunk: Uint8Array) => void, onReady: () => void, token: CancellationToken, bufferSize = 512): Promise<void> {574const handle = await Promises.open(path, 'r');575const buffer = Buffer.allocUnsafe(bufferSize);576577const cts = new CancellationTokenSource(token);578579let error: Error | undefined = undefined;580let isReading = false;581582const request: INonRecursiveWatchRequest = { path, excludes: [], recursive: false };583const watcher = new NodeJSFileWatcherLibrary(request, undefined, changes => {584(async () => {585for (const { type } of changes) {586if (type === FileChangeType.UPDATED) {587588if (isReading) {589return; // return early if we are already reading the output590}591592isReading = true;593594try {595// Consume the new contents of the file until finished596// everytime there is a change event signalling a change597while (!cts.token.isCancellationRequested) {598const { bytesRead } = await Promises.read(handle, buffer, 0, bufferSize, null);599if (!bytesRead || cts.token.isCancellationRequested) {600break;601}602603onData(buffer.slice(0, bytesRead));604}605} catch (err) {606error = new Error(err);607cts.dispose(true);608} finally {609isReading = false;610}611}612}613})();614});615616await watcher.ready;617onReady();618619return new Promise<void>((resolve, reject) => {620cts.token.onCancellationRequested(async () => {621watcher.dispose();622623try {624await Promises.close(handle);625} catch (err) {626error = new Error(err);627}628629if (error) {630reject(error);631} else {632resolve();633}634});635});636}637638639