Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/files/node/watcher/nodejs/nodejsWatcherLib.ts
3296 views
1
/*---------------------------------------------------------------------------------------------
2
* Copyright (c) Microsoft Corporation. All rights reserved.
3
* Licensed under the MIT License. See License.txt in the project root for license information.
4
*--------------------------------------------------------------------------------------------*/
5
6
import { watch, promises } from 'fs';
7
import { RunOnceWorker, ThrottledWorker } from '../../../../../base/common/async.js';
8
import { CancellationToken, CancellationTokenSource } from '../../../../../base/common/cancellation.js';
9
import { isEqual, isEqualOrParent } from '../../../../../base/common/extpath.js';
10
import { Disposable, DisposableStore, IDisposable, thenRegisterOrDispose, toDisposable } from '../../../../../base/common/lifecycle.js';
11
import { normalizeNFC } from '../../../../../base/common/normalization.js';
12
import { basename, dirname, join } from '../../../../../base/common/path.js';
13
import { isLinux, isMacintosh } from '../../../../../base/common/platform.js';
14
import { joinPath } from '../../../../../base/common/resources.js';
15
import { URI } from '../../../../../base/common/uri.js';
16
import { Promises } from '../../../../../base/node/pfs.js';
17
import { FileChangeFilter, FileChangeType, IFileChange } from '../../../common/files.js';
18
import { ILogMessage, coalesceEvents, INonRecursiveWatchRequest, parseWatcherPatterns, IRecursiveWatcherWithSubscribe, isFiltered, isWatchRequestWithCorrelation } from '../../../common/watcher.js';
19
import { Lazy } from '../../../../../base/common/lazy.js';
20
import { ParsedPattern } from '../../../../../base/common/glob.js';
21
22
export class NodeJSFileWatcherLibrary extends Disposable {
23
24
// A delay in reacting to file deletes to support
25
// atomic save operations where a tool may chose
26
// to delete a file before creating it again for
27
// an update.
28
private static readonly FILE_DELETE_HANDLER_DELAY = 100;
29
30
// A delay for collecting file changes from node.js
31
// before collecting them for coalescing and emitting
32
// Same delay as used for the recursive watcher.
33
private static readonly FILE_CHANGES_HANDLER_DELAY = 75;
34
35
// Reduce likelyhood of spam from file events via throttling.
36
// These numbers are a bit more aggressive compared to the
37
// recursive watcher because we can have many individual
38
// node.js watchers per request.
39
// (https://github.com/microsoft/vscode/issues/124723)
40
private readonly throttledFileChangesEmitter = this._register(new ThrottledWorker<IFileChange>(
41
{
42
maxWorkChunkSize: 100, // only process up to 100 changes at once before...
43
throttleDelay: 200, // ...resting for 200ms until we process events again...
44
maxBufferedWork: 10000 // ...but never buffering more than 10000 events in memory
45
},
46
events => this.onDidFilesChange(events)
47
));
48
49
// Aggregate file changes over FILE_CHANGES_HANDLER_DELAY
50
// to coalesce events and reduce spam.
51
private readonly fileChangesAggregator = this._register(new RunOnceWorker<IFileChange>(events => this.handleFileChanges(events), NodeJSFileWatcherLibrary.FILE_CHANGES_HANDLER_DELAY));
52
53
private readonly excludes: ParsedPattern[];
54
private readonly includes: ParsedPattern[] | undefined;
55
private readonly filter: FileChangeFilter | undefined;
56
57
private readonly cts = new CancellationTokenSource();
58
59
private readonly realPath = new Lazy(async () => {
60
61
// This property is intentionally `Lazy` and not using `realcase()` as the counterpart
62
// in the recursive watcher because of the amount of paths this watcher is dealing with.
63
// We try as much as possible to avoid even needing `realpath()` if we can because even
64
// that method does an `lstat()` per segment of the path.
65
66
let result = this.request.path;
67
68
try {
69
result = await Promises.realpath(this.request.path);
70
71
if (this.request.path !== result) {
72
this.trace(`correcting a path to watch that seems to be a symbolic link (original: ${this.request.path}, real: ${result})`);
73
}
74
} catch (error) {
75
// ignore
76
}
77
78
return result;
79
});
80
81
readonly ready: Promise<void>;
82
83
private _isReusingRecursiveWatcher = false;
84
get isReusingRecursiveWatcher(): boolean { return this._isReusingRecursiveWatcher; }
85
86
private didFail = false;
87
get failed(): boolean { return this.didFail; }
88
89
constructor(
90
private readonly request: INonRecursiveWatchRequest,
91
private readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined,
92
private readonly onDidFilesChange: (changes: IFileChange[]) => void,
93
private readonly onDidWatchFail?: () => void,
94
private readonly onLogMessage?: (msg: ILogMessage) => void,
95
private verboseLogging?: boolean
96
) {
97
super();
98
99
this.excludes = parseWatcherPatterns(this.request.path, this.request.excludes);
100
this.includes = this.request.includes ? parseWatcherPatterns(this.request.path, this.request.includes) : undefined;
101
this.filter = isWatchRequestWithCorrelation(this.request) ? this.request.filter : undefined; // filtering is only enabled when correlating because watchers are otherwise potentially reused
102
103
this.ready = this.watch();
104
}
105
106
private async watch(): Promise<void> {
107
try {
108
const stat = await promises.stat(this.request.path);
109
110
if (this.cts.token.isCancellationRequested) {
111
return;
112
}
113
114
this._register(await this.doWatch(stat.isDirectory()));
115
} catch (error) {
116
if (error.code !== 'ENOENT') {
117
this.error(error);
118
} else {
119
this.trace(`ignoring a path for watching who's stat info failed to resolve: ${this.request.path} (error: ${error})`);
120
}
121
122
this.notifyWatchFailed();
123
}
124
}
125
126
private notifyWatchFailed(): void {
127
this.didFail = true;
128
129
this.onDidWatchFail?.();
130
}
131
132
private async doWatch(isDirectory: boolean): Promise<IDisposable> {
133
const disposables = new DisposableStore();
134
135
if (this.doWatchWithExistingWatcher(isDirectory, disposables)) {
136
this.trace(`reusing an existing recursive watcher for ${this.request.path}`);
137
this._isReusingRecursiveWatcher = true;
138
} else {
139
this._isReusingRecursiveWatcher = false;
140
await this.doWatchWithNodeJS(isDirectory, disposables);
141
}
142
143
return disposables;
144
}
145
146
private doWatchWithExistingWatcher(isDirectory: boolean, disposables: DisposableStore): boolean {
147
if (isDirectory) {
148
// Recursive watcher re-use is currently not enabled for when
149
// folders are watched. this is because the dispatching in the
150
// recursive watcher for non-recurive requests is optimized for
151
// file changes where we really only match on the exact path
152
// and not child paths.
153
return false;
154
}
155
156
const resource = URI.file(this.request.path);
157
const subscription = this.recursiveWatcher?.subscribe(this.request.path, async (error, change) => {
158
if (disposables.isDisposed) {
159
return; // return early if already disposed
160
}
161
162
if (error) {
163
await thenRegisterOrDispose(this.doWatch(isDirectory), disposables);
164
} else if (change) {
165
if (typeof change.cId === 'number' || typeof this.request.correlationId === 'number') {
166
// Re-emit this change with the correlation id of the request
167
// so that the client can correlate the event with the request
168
// properly. Without correlation, we do not have to do that
169
// because the event will appear on the global listener already.
170
this.onFileChange({ resource, type: change.type, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);
171
}
172
}
173
});
174
175
if (subscription) {
176
disposables.add(subscription);
177
178
return true;
179
}
180
181
return false;
182
}
183
184
private async doWatchWithNodeJS(isDirectory: boolean, disposables: DisposableStore): Promise<void> {
185
const realPath = await this.realPath.value;
186
187
if (this.cts.token.isCancellationRequested) {
188
return;
189
}
190
191
// macOS: watching samba shares can crash VSCode so we do
192
// a simple check for the file path pointing to /Volumes
193
// (https://github.com/microsoft/vscode/issues/106879)
194
// TODO@electron this needs a revisit when the crash is
195
// fixed or mitigated upstream.
196
if (isMacintosh && isEqualOrParent(realPath, '/Volumes/', true)) {
197
this.error(`Refusing to watch ${realPath} for changes using fs.watch() for possibly being a network share where watching is unreliable and unstable.`);
198
199
return;
200
}
201
202
const cts = new CancellationTokenSource(this.cts.token);
203
disposables.add(toDisposable(() => cts.dispose(true)));
204
205
const watcherDisposables = new DisposableStore(); // we need a separate disposable store because we re-create the watcher from within in some cases
206
disposables.add(watcherDisposables);
207
208
try {
209
const requestResource = URI.file(this.request.path);
210
const pathBasename = basename(realPath);
211
212
// Creating watcher can fail with an exception
213
const watcher = watch(realPath);
214
watcherDisposables.add(toDisposable(() => {
215
watcher.removeAllListeners();
216
watcher.close();
217
}));
218
219
this.trace(`Started watching: '${realPath}'`);
220
221
// Folder: resolve children to emit proper events
222
const folderChildren = new Set<string>();
223
if (isDirectory) {
224
try {
225
for (const child of await Promises.readdir(realPath)) {
226
folderChildren.add(child);
227
}
228
} catch (error) {
229
this.error(error);
230
}
231
}
232
233
if (cts.token.isCancellationRequested) {
234
return;
235
}
236
237
const mapPathToStatDisposable = new Map<string, IDisposable>();
238
watcherDisposables.add(toDisposable(() => {
239
for (const [, disposable] of mapPathToStatDisposable) {
240
disposable.dispose();
241
}
242
mapPathToStatDisposable.clear();
243
}));
244
245
watcher.on('error', (code: number, signal: string) => {
246
if (cts.token.isCancellationRequested) {
247
return;
248
}
249
250
this.error(`Failed to watch ${realPath} for changes using fs.watch() (${code}, ${signal})`);
251
252
this.notifyWatchFailed();
253
});
254
255
watcher.on('change', (type, raw) => {
256
if (cts.token.isCancellationRequested) {
257
return; // ignore if already disposed
258
}
259
260
if (this.verboseLogging) {
261
this.traceWithCorrelation(`[raw] ["${type}"] ${raw}`);
262
}
263
264
// Normalize file name
265
let changedFileName = '';
266
if (raw) { // https://github.com/microsoft/vscode/issues/38191
267
changedFileName = raw.toString();
268
if (isMacintosh) {
269
// Mac: uses NFD unicode form on disk, but we want NFC
270
// See also https://github.com/nodejs/node/issues/2165
271
changedFileName = normalizeNFC(changedFileName);
272
}
273
}
274
275
if (!changedFileName || (type !== 'change' && type !== 'rename')) {
276
return; // ignore unexpected events
277
}
278
279
// Folder
280
if (isDirectory) {
281
282
// Folder child added/deleted
283
if (type === 'rename') {
284
285
// Cancel any previous stats for this file if existing
286
mapPathToStatDisposable.get(changedFileName)?.dispose();
287
288
// Wait a bit and try see if the file still exists on disk
289
// to decide on the resulting event
290
const timeoutHandle = setTimeout(async () => {
291
mapPathToStatDisposable.delete(changedFileName);
292
293
// Depending on the OS the watcher runs on, there
294
// is different behaviour for when the watched
295
// folder path is being deleted:
296
//
297
// - macOS: not reported but events continue to
298
// work even when the folder is brought
299
// back, though it seems every change
300
// to a file is reported as "rename"
301
// - Linux: "rename" event is reported with the
302
// name of the folder and events stop
303
// working
304
// - Windows: an EPERM error is thrown that we
305
// handle from the `on('error')` event
306
//
307
// We do not re-attach the watcher after timeout
308
// though as we do for file watches because for
309
// file watching specifically we want to handle
310
// the atomic-write cases where the file is being
311
// deleted and recreated with different contents.
312
if (isEqual(changedFileName, pathBasename, !isLinux) && !await Promises.exists(realPath)) {
313
this.onWatchedPathDeleted(requestResource);
314
315
return;
316
}
317
318
if (cts.token.isCancellationRequested) {
319
return;
320
}
321
322
// In order to properly detect renames on a case-insensitive
323
// file system, we need to use `existsChildStrictCase` helper
324
// because otherwise we would wrongly assume a file exists
325
// when it was renamed to same name but different case.
326
const fileExists = await this.existsChildStrictCase(join(realPath, changedFileName));
327
328
if (cts.token.isCancellationRequested) {
329
return; // ignore if disposed by now
330
}
331
332
// Figure out the correct event type:
333
// File Exists: either 'added' or 'updated' if known before
334
// File Does not Exist: always 'deleted'
335
let type: FileChangeType;
336
if (fileExists) {
337
if (folderChildren.has(changedFileName)) {
338
type = FileChangeType.UPDATED;
339
} else {
340
type = FileChangeType.ADDED;
341
folderChildren.add(changedFileName);
342
}
343
} else {
344
folderChildren.delete(changedFileName);
345
type = FileChangeType.DELETED;
346
}
347
348
this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId });
349
}, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY);
350
351
mapPathToStatDisposable.set(changedFileName, toDisposable(() => clearTimeout(timeoutHandle)));
352
}
353
354
// Folder child changed
355
else {
356
357
// Figure out the correct event type: if this is the
358
// first time we see this child, it can only be added
359
let type: FileChangeType;
360
if (folderChildren.has(changedFileName)) {
361
type = FileChangeType.UPDATED;
362
} else {
363
type = FileChangeType.ADDED;
364
folderChildren.add(changedFileName);
365
}
366
367
this.onFileChange({ resource: joinPath(requestResource, changedFileName), type, cId: this.request.correlationId });
368
}
369
}
370
371
// File
372
else {
373
374
// File added/deleted
375
if (type === 'rename' || !isEqual(changedFileName, pathBasename, !isLinux)) {
376
377
// Depending on the OS the watcher runs on, there
378
// is different behaviour for when the watched
379
// file path is being deleted:
380
//
381
// - macOS: "rename" event is reported and events
382
// stop working
383
// - Linux: "rename" event is reported and events
384
// stop working
385
// - Windows: "rename" event is reported and events
386
// continue to work when file is restored
387
//
388
// As opposed to folder watching, we re-attach the
389
// watcher after brief timeout to support "atomic save"
390
// operations where a tool may decide to delete a file
391
// and then create it with the updated contents.
392
//
393
// Different to folder watching, we emit a delete event
394
// though we never detect when the file is brought back
395
// because the watcher is disposed then.
396
397
const timeoutHandle = setTimeout(async () => {
398
const fileExists = await Promises.exists(realPath);
399
400
if (cts.token.isCancellationRequested) {
401
return; // ignore if disposed by now
402
}
403
404
// File still exists, so emit as change event and reapply the watcher
405
if (fileExists) {
406
this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);
407
408
watcherDisposables.add(await this.doWatch(false));
409
}
410
411
// File seems to be really gone, so emit a deleted and failed event
412
else {
413
this.onWatchedPathDeleted(requestResource);
414
}
415
}, NodeJSFileWatcherLibrary.FILE_DELETE_HANDLER_DELAY);
416
417
// Very important to dispose the watcher which now points to a stale inode
418
// and wire in a new disposable that tracks our timeout that is installed
419
watcherDisposables.clear();
420
watcherDisposables.add(toDisposable(() => clearTimeout(timeoutHandle)));
421
}
422
423
// File changed
424
else {
425
this.onFileChange({ resource: requestResource, type: FileChangeType.UPDATED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);
426
}
427
}
428
});
429
} catch (error) {
430
if (cts.token.isCancellationRequested) {
431
return;
432
}
433
434
this.error(`Failed to watch ${realPath} for changes using fs.watch() (${error.toString()})`);
435
436
this.notifyWatchFailed();
437
}
438
}
439
440
private onWatchedPathDeleted(resource: URI): void {
441
this.warn('Watcher shutdown because watched path got deleted');
442
443
// Emit events and flush in case the watcher gets disposed
444
this.onFileChange({ resource, type: FileChangeType.DELETED, cId: this.request.correlationId }, true /* skip excludes/includes (file is explicitly watched) */);
445
this.fileChangesAggregator.flush();
446
447
this.notifyWatchFailed();
448
}
449
450
private onFileChange(event: IFileChange, skipIncludeExcludeChecks = false): void {
451
if (this.cts.token.isCancellationRequested) {
452
return;
453
}
454
455
// Logging
456
if (this.verboseLogging) {
457
this.traceWithCorrelation(`${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`);
458
}
459
460
// Add to aggregator unless excluded or not included (not if explicitly disabled)
461
if (!skipIncludeExcludeChecks && this.excludes.some(exclude => exclude(event.resource.fsPath))) {
462
if (this.verboseLogging) {
463
this.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))) {
466
if (this.verboseLogging) {
467
this.traceWithCorrelation(` >> ignored (not included) ${event.resource.fsPath}`);
468
}
469
} else {
470
this.fileChangesAggregator.work(event);
471
}
472
}
473
474
private handleFileChanges(fileChanges: IFileChange[]): void {
475
476
// Coalesce events: merge events of same kind
477
const coalescedFileChanges = coalesceEvents(fileChanges);
478
479
// Filter events: based on request filter property
480
const filteredEvents: IFileChange[] = [];
481
for (const event of coalescedFileChanges) {
482
if (isFiltered(event, this.filter)) {
483
if (this.verboseLogging) {
484
this.traceWithCorrelation(` >> ignored (filtered) ${event.resource.fsPath}`);
485
}
486
487
continue;
488
}
489
490
filteredEvents.push(event);
491
}
492
493
if (filteredEvents.length === 0) {
494
return;
495
}
496
497
// Logging
498
if (this.verboseLogging) {
499
for (const event of filteredEvents) {
500
this.traceWithCorrelation(` >> normalized ${event.type === FileChangeType.ADDED ? '[ADDED]' : event.type === FileChangeType.DELETED ? '[DELETED]' : '[CHANGED]'} ${event.resource.fsPath}`);
501
}
502
}
503
504
// Broadcast to clients via throttled emitter
505
const worked = this.throttledFileChangesEmitter.work(filteredEvents);
506
507
// Logging
508
if (!worked) {
509
this.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 {
511
if (this.throttledFileChangesEmitter.pending > 0) {
512
this.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
}
516
517
private async existsChildStrictCase(path: string): Promise<boolean> {
518
if (isLinux) {
519
return Promises.exists(path);
520
}
521
522
try {
523
const pathBasename = basename(path);
524
const children = await Promises.readdir(dirname(path));
525
526
return children.some(child => child === pathBasename);
527
} catch (error) {
528
this.trace(error);
529
530
return false;
531
}
532
}
533
534
setVerboseLogging(verboseLogging: boolean): void {
535
this.verboseLogging = verboseLogging;
536
}
537
538
private error(error: string): void {
539
if (!this.cts.token.isCancellationRequested) {
540
this.onLogMessage?.({ type: 'error', message: `[File Watcher (node.js)] ${error}` });
541
}
542
}
543
544
private warn(message: string): void {
545
if (!this.cts.token.isCancellationRequested) {
546
this.onLogMessage?.({ type: 'warn', message: `[File Watcher (node.js)] ${message}` });
547
}
548
}
549
550
private trace(message: string): void {
551
if (!this.cts.token.isCancellationRequested && this.verboseLogging) {
552
this.onLogMessage?.({ type: 'trace', message: `[File Watcher (node.js)] ${message}` });
553
}
554
}
555
556
private traceWithCorrelation(message: string): void {
557
if (!this.cts.token.isCancellationRequested && this.verboseLogging) {
558
this.trace(`${message}${typeof this.request.correlationId === 'number' ? ` <${this.request.correlationId}> ` : ``}`);
559
}
560
}
561
562
override dispose(): void {
563
this.cts.dispose(true);
564
565
super.dispose();
566
}
567
}
568
569
/**
570
* Watch the provided `path` for changes and return
571
* the data in chunks of `Uint8Array` for further use.
572
*/
573
export async function watchFileContents(path: string, onData: (chunk: Uint8Array) => void, onReady: () => void, token: CancellationToken, bufferSize = 512): Promise<void> {
574
const handle = await Promises.open(path, 'r');
575
const buffer = Buffer.allocUnsafe(bufferSize);
576
577
const cts = new CancellationTokenSource(token);
578
579
let error: Error | undefined = undefined;
580
let isReading = false;
581
582
const request: INonRecursiveWatchRequest = { path, excludes: [], recursive: false };
583
const watcher = new NodeJSFileWatcherLibrary(request, undefined, changes => {
584
(async () => {
585
for (const { type } of changes) {
586
if (type === FileChangeType.UPDATED) {
587
588
if (isReading) {
589
return; // return early if we are already reading the output
590
}
591
592
isReading = true;
593
594
try {
595
// Consume the new contents of the file until finished
596
// everytime there is a change event signalling a change
597
while (!cts.token.isCancellationRequested) {
598
const { bytesRead } = await Promises.read(handle, buffer, 0, bufferSize, null);
599
if (!bytesRead || cts.token.isCancellationRequested) {
600
break;
601
}
602
603
onData(buffer.slice(0, bytesRead));
604
}
605
} catch (err) {
606
error = new Error(err);
607
cts.dispose(true);
608
} finally {
609
isReading = false;
610
}
611
}
612
}
613
})();
614
});
615
616
await watcher.ready;
617
onReady();
618
619
return new Promise<void>((resolve, reject) => {
620
cts.token.onCancellationRequested(async () => {
621
watcher.dispose();
622
623
try {
624
await Promises.close(handle);
625
} catch (err) {
626
error = new Error(err);
627
}
628
629
if (error) {
630
reject(error);
631
} else {
632
resolve();
633
}
634
});
635
});
636
}
637
638