Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
microsoft
GitHub Repository: microsoft/vscode
Path: blob/main/src/vs/platform/files/common/watcher.ts
3294 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 { Event } from '../../../base/common/event.js';
7
import { GLOBSTAR, IRelativePattern, parse, ParsedPattern } from '../../../base/common/glob.js';
8
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from '../../../base/common/lifecycle.js';
9
import { isAbsolute } from '../../../base/common/path.js';
10
import { isLinux } from '../../../base/common/platform.js';
11
import { URI } from '../../../base/common/uri.js';
12
import { FileChangeFilter, FileChangeType, IFileChange, isParent } from './files.js';
13
14
interface IWatchRequest {
15
16
/**
17
* The path to watch.
18
*/
19
readonly path: string;
20
21
/**
22
* Whether to watch recursively or not.
23
*/
24
readonly recursive: boolean;
25
26
/**
27
* A set of glob patterns or paths to exclude from watching.
28
*/
29
readonly excludes: string[];
30
31
/**
32
* An optional set of glob patterns or paths to include for
33
* watching. If not provided, all paths are considered for
34
* events.
35
*/
36
readonly includes?: Array<string | IRelativePattern>;
37
38
/**
39
* If provided, file change events from the watcher that
40
* are a result of this watch request will carry the same
41
* id.
42
*/
43
readonly correlationId?: number;
44
45
/**
46
* If provided, allows to filter the events that the watcher should consider
47
* for emitting. If not provided, all events are emitted.
48
*
49
* For example, to emit added and updated events, set to:
50
* `FileChangeFilter.ADDED | FileChangeFilter.UPDATED`.
51
*/
52
readonly filter?: FileChangeFilter;
53
}
54
55
export interface IWatchRequestWithCorrelation extends IWatchRequest {
56
readonly correlationId: number;
57
}
58
59
export function isWatchRequestWithCorrelation(request: IWatchRequest): request is IWatchRequestWithCorrelation {
60
return typeof request.correlationId === 'number';
61
}
62
63
export interface INonRecursiveWatchRequest extends IWatchRequest {
64
65
/**
66
* The watcher will be non-recursive.
67
*/
68
readonly recursive: false;
69
}
70
71
export interface IRecursiveWatchRequest extends IWatchRequest {
72
73
/**
74
* The watcher will be recursive.
75
*/
76
readonly recursive: true;
77
78
/**
79
* @deprecated this only exists for WSL1 support and should never
80
* be used in any other case.
81
*/
82
pollingInterval?: number;
83
}
84
85
export function isRecursiveWatchRequest(request: IWatchRequest): request is IRecursiveWatchRequest {
86
return request.recursive === true;
87
}
88
89
export type IUniversalWatchRequest = IRecursiveWatchRequest | INonRecursiveWatchRequest;
90
91
export interface IWatcherErrorEvent {
92
readonly error: string;
93
readonly request?: IUniversalWatchRequest;
94
}
95
96
export interface IWatcher {
97
98
/**
99
* A normalized file change event from the raw events
100
* the watcher emits.
101
*/
102
readonly onDidChangeFile: Event<IFileChange[]>;
103
104
/**
105
* An event to indicate a message that should get logged.
106
*/
107
readonly onDidLogMessage: Event<ILogMessage>;
108
109
/**
110
* An event to indicate an error occurred from the watcher
111
* that is unrecoverable. Listeners should restart the
112
* watcher if possible.
113
*/
114
readonly onDidError: Event<IWatcherErrorEvent>;
115
116
/**
117
* Configures the watcher to watch according to the
118
* requests. Any existing watched path that is not
119
* in the array, will be removed from watching and
120
* any new path will be added to watching.
121
*/
122
watch(requests: IWatchRequest[]): Promise<void>;
123
124
/**
125
* Enable verbose logging in the watcher.
126
*/
127
setVerboseLogging(enabled: boolean): Promise<void>;
128
129
/**
130
* Stop all watchers.
131
*/
132
stop(): Promise<void>;
133
}
134
135
export interface IRecursiveWatcher extends IWatcher {
136
watch(requests: IRecursiveWatchRequest[]): Promise<void>;
137
}
138
139
export interface IRecursiveWatcherWithSubscribe extends IRecursiveWatcher {
140
141
/**
142
* Subscribe to file events for the given path. The callback is called
143
* whenever a file event occurs for the path. If the watcher failed,
144
* the error parameter is set to `true`.
145
*
146
* @returns an `IDisposable` to stop listening to events or `undefined`
147
* if no events can be watched for the path given the current set of
148
* recursive watch requests.
149
*/
150
subscribe(path: string, callback: (error: true | null, change?: IFileChange) => void): IDisposable | undefined;
151
}
152
153
export interface IRecursiveWatcherOptions {
154
155
/**
156
* If `true`, will enable polling for all watchers, otherwise
157
* will enable it for paths included in the string array.
158
*
159
* @deprecated this only exists for WSL1 support and should never
160
* be used in any other case.
161
*/
162
readonly usePolling: boolean | string[];
163
164
/**
165
* If polling is enabled (via `usePolling`), defines the duration
166
* in which the watcher will poll for changes.
167
*
168
* @deprecated this only exists for WSL1 support and should never
169
* be used in any other case.
170
*/
171
readonly pollingInterval?: number;
172
}
173
174
export interface INonRecursiveWatcher extends IWatcher {
175
watch(requests: INonRecursiveWatchRequest[]): Promise<void>;
176
}
177
178
export interface IUniversalWatcher extends IWatcher {
179
watch(requests: IUniversalWatchRequest[]): Promise<void>;
180
}
181
182
export abstract class AbstractWatcherClient extends Disposable {
183
184
private static readonly MAX_RESTARTS = 5;
185
186
private watcher: IWatcher | undefined;
187
private readonly watcherDisposables = this._register(new MutableDisposable());
188
189
private requests: IWatchRequest[] | undefined = undefined;
190
191
private restartCounter = 0;
192
193
constructor(
194
private readonly onFileChanges: (changes: IFileChange[]) => void,
195
private readonly onLogMessage: (msg: ILogMessage) => void,
196
private verboseLogging: boolean,
197
private options: {
198
readonly type: string;
199
readonly restartOnError: boolean;
200
}
201
) {
202
super();
203
}
204
205
protected abstract createWatcher(disposables: DisposableStore): IWatcher;
206
207
protected init(): void {
208
209
// Associate disposables to the watcher
210
const disposables = new DisposableStore();
211
this.watcherDisposables.value = disposables;
212
213
// Ask implementors to create the watcher
214
this.watcher = this.createWatcher(disposables);
215
this.watcher.setVerboseLogging(this.verboseLogging);
216
217
// Wire in event handlers
218
disposables.add(this.watcher.onDidChangeFile(changes => this.onFileChanges(changes)));
219
disposables.add(this.watcher.onDidLogMessage(msg => this.onLogMessage(msg)));
220
disposables.add(this.watcher.onDidError(e => this.onError(e.error, e.request)));
221
}
222
223
protected onError(error: string, failedRequest?: IUniversalWatchRequest): void {
224
225
// Restart on error (up to N times, if possible)
226
if (this.canRestart(error, failedRequest)) {
227
if (this.restartCounter < AbstractWatcherClient.MAX_RESTARTS && this.requests) {
228
this.error(`restarting watcher after unexpected error: ${error}`);
229
this.restart(this.requests);
230
} else {
231
this.error(`gave up attempting to restart watcher after unexpected error: ${error}`);
232
}
233
}
234
235
// Do not attempt to restart otherwise, report the error
236
else {
237
this.error(error);
238
}
239
}
240
241
private canRestart(error: string, failedRequest?: IUniversalWatchRequest): boolean {
242
if (!this.options.restartOnError) {
243
return false; // disabled by options
244
}
245
246
if (failedRequest) {
247
// do not treat a failing request as a reason to restart the entire
248
// watcher. it is possible that from a large amount of watch requests
249
// some fail and we would constantly restart all requests only because
250
// of that. rather, continue the watcher and leave the failed request
251
return false;
252
}
253
254
if (
255
error.indexOf('No space left on device') !== -1 ||
256
error.indexOf('EMFILE') !== -1
257
) {
258
// do not restart when the error indicates that the system is running
259
// out of handles for file watching. this is not recoverable anyway
260
// and needs changes to the system before continuing
261
return false;
262
}
263
264
return true;
265
}
266
267
private restart(requests: IUniversalWatchRequest[]): void {
268
this.restartCounter++;
269
270
this.init();
271
this.watch(requests);
272
}
273
274
async watch(requests: IUniversalWatchRequest[]): Promise<void> {
275
this.requests = requests;
276
277
await this.watcher?.watch(requests);
278
}
279
280
async setVerboseLogging(verboseLogging: boolean): Promise<void> {
281
this.verboseLogging = verboseLogging;
282
283
await this.watcher?.setVerboseLogging(verboseLogging);
284
}
285
286
private error(message: string) {
287
this.onLogMessage({ type: 'error', message: `[File Watcher (${this.options.type})] ${message}` });
288
}
289
290
protected trace(message: string) {
291
this.onLogMessage({ type: 'trace', message: `[File Watcher (${this.options.type})] ${message}` });
292
}
293
294
override dispose(): void {
295
296
// Render the watcher invalid from here
297
this.watcher = undefined;
298
299
return super.dispose();
300
}
301
}
302
303
export abstract class AbstractNonRecursiveWatcherClient extends AbstractWatcherClient {
304
305
constructor(
306
onFileChanges: (changes: IFileChange[]) => void,
307
onLogMessage: (msg: ILogMessage) => void,
308
verboseLogging: boolean
309
) {
310
super(onFileChanges, onLogMessage, verboseLogging, { type: 'node.js', restartOnError: false });
311
}
312
313
protected abstract override createWatcher(disposables: DisposableStore): INonRecursiveWatcher;
314
}
315
316
export abstract class AbstractUniversalWatcherClient extends AbstractWatcherClient {
317
318
constructor(
319
onFileChanges: (changes: IFileChange[]) => void,
320
onLogMessage: (msg: ILogMessage) => void,
321
verboseLogging: boolean
322
) {
323
super(onFileChanges, onLogMessage, verboseLogging, { type: 'universal', restartOnError: true });
324
}
325
326
protected abstract override createWatcher(disposables: DisposableStore): IUniversalWatcher;
327
}
328
329
export interface ILogMessage {
330
readonly type: 'trace' | 'warn' | 'error' | 'info' | 'debug';
331
readonly message: string;
332
}
333
334
export function reviveFileChanges(changes: IFileChange[]): IFileChange[] {
335
return changes.map(change => ({
336
type: change.type,
337
resource: URI.revive(change.resource),
338
cId: change.cId
339
}));
340
}
341
342
export function coalesceEvents(changes: IFileChange[]): IFileChange[] {
343
344
// Build deltas
345
const coalescer = new EventCoalescer();
346
for (const event of changes) {
347
coalescer.processEvent(event);
348
}
349
350
return coalescer.coalesce();
351
}
352
353
export function normalizeWatcherPattern(path: string, pattern: string | IRelativePattern): string | IRelativePattern {
354
355
// Patterns are always matched on the full absolute path
356
// of the event. As such, if the pattern is not absolute
357
// and is a string and does not start with a leading
358
// `**`, we have to convert it to a relative pattern with
359
// the given `base`
360
361
if (typeof pattern === 'string' && !pattern.startsWith(GLOBSTAR) && !isAbsolute(pattern)) {
362
return { base: path, pattern };
363
}
364
365
return pattern;
366
}
367
368
export function parseWatcherPatterns(path: string, patterns: Array<string | IRelativePattern>): ParsedPattern[] {
369
const parsedPatterns: ParsedPattern[] = [];
370
371
for (const pattern of patterns) {
372
parsedPatterns.push(parse(normalizeWatcherPattern(path, pattern)));
373
}
374
375
return parsedPatterns;
376
}
377
378
class EventCoalescer {
379
380
private readonly coalesced = new Set<IFileChange>();
381
private readonly mapPathToChange = new Map<string, IFileChange>();
382
383
private toKey(event: IFileChange): string {
384
if (isLinux) {
385
return event.resource.fsPath;
386
}
387
388
return event.resource.fsPath.toLowerCase(); // normalise to file system case sensitivity
389
}
390
391
processEvent(event: IFileChange): void {
392
const existingEvent = this.mapPathToChange.get(this.toKey(event));
393
394
let keepEvent = false;
395
396
// Event path already exists
397
if (existingEvent) {
398
const currentChangeType = existingEvent.type;
399
const newChangeType = event.type;
400
401
// macOS/Windows: track renames to different case
402
// by keeping both CREATE and DELETE events
403
if (existingEvent.resource.fsPath !== event.resource.fsPath && (event.type === FileChangeType.DELETED || event.type === FileChangeType.ADDED)) {
404
keepEvent = true;
405
}
406
407
// Ignore CREATE followed by DELETE in one go
408
else if (currentChangeType === FileChangeType.ADDED && newChangeType === FileChangeType.DELETED) {
409
this.mapPathToChange.delete(this.toKey(event));
410
this.coalesced.delete(existingEvent);
411
}
412
413
// Flatten DELETE followed by CREATE into CHANGE
414
else if (currentChangeType === FileChangeType.DELETED && newChangeType === FileChangeType.ADDED) {
415
existingEvent.type = FileChangeType.UPDATED;
416
}
417
418
// Do nothing. Keep the created event
419
else if (currentChangeType === FileChangeType.ADDED && newChangeType === FileChangeType.UPDATED) { }
420
421
// Otherwise apply change type
422
else {
423
existingEvent.type = newChangeType;
424
}
425
}
426
427
// Otherwise keep
428
else {
429
keepEvent = true;
430
}
431
432
if (keepEvent) {
433
this.coalesced.add(event);
434
this.mapPathToChange.set(this.toKey(event), event);
435
}
436
}
437
438
coalesce(): IFileChange[] {
439
const addOrChangeEvents: IFileChange[] = [];
440
const deletedPaths: string[] = [];
441
442
// This algorithm will remove all DELETE events up to the root folder
443
// that got deleted if any. This ensures that we are not producing
444
// DELETE events for each file inside a folder that gets deleted.
445
//
446
// 1.) split ADD/CHANGE and DELETED events
447
// 2.) sort short deleted paths to the top
448
// 3.) for each DELETE, check if there is a deleted parent and ignore the event in that case
449
return Array.from(this.coalesced).filter(e => {
450
if (e.type !== FileChangeType.DELETED) {
451
addOrChangeEvents.push(e);
452
453
return false; // remove ADD / CHANGE
454
}
455
456
return true; // keep DELETE
457
}).sort((e1, e2) => {
458
return e1.resource.fsPath.length - e2.resource.fsPath.length; // shortest path first
459
}).filter(e => {
460
if (deletedPaths.some(deletedPath => isParent(e.resource.fsPath, deletedPath, !isLinux /* ignorecase */))) {
461
return false; // DELETE is ignored if parent is deleted already
462
}
463
464
// otherwise mark as deleted
465
deletedPaths.push(e.resource.fsPath);
466
467
return true;
468
}).concat(addOrChangeEvents);
469
}
470
}
471
472
export function isFiltered(event: IFileChange, filter: FileChangeFilter | undefined): boolean {
473
if (typeof filter === 'number') {
474
switch (event.type) {
475
case FileChangeType.ADDED:
476
return (filter & FileChangeFilter.ADDED) === 0;
477
case FileChangeType.DELETED:
478
return (filter & FileChangeFilter.DELETED) === 0;
479
case FileChangeType.UPDATED:
480
return (filter & FileChangeFilter.UPDATED) === 0;
481
}
482
}
483
484
return false;
485
}
486
487
export function requestFilterToString(filter: FileChangeFilter | undefined): string {
488
if (typeof filter === 'number') {
489
const filters = [];
490
if (filter & FileChangeFilter.ADDED) {
491
filters.push('Added');
492
}
493
if (filter & FileChangeFilter.DELETED) {
494
filters.push('Deleted');
495
}
496
if (filter & FileChangeFilter.UPDATED) {
497
filters.push('Updated');
498
}
499
500
if (filters.length === 0) {
501
return '<all>';
502
}
503
504
return `[${filters.join(', ')}]`;
505
}
506
507
return '<none>';
508
}
509
510