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