Skip to content

Commit 4b90a20

Browse files
authored
watcher - deduplicate non-recursive file watch requests if possible (microsoft#208325)
1 parent 16a32fa commit 4b90a20

File tree

13 files changed

+1008
-227
lines changed

13 files changed

+1008
-227
lines changed

src/vs/platform/files/common/diskFileSystemProvider.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,10 @@ export abstract class AbstractDiskFileSystemProvider extends Disposable implemen
211211
this._onDidWatchError.fire(msg.message);
212212
}
213213

214+
this.logWatcherMessage(msg);
215+
}
216+
217+
protected logWatcherMessage(msg: ILogMessage): void {
214218
this.logService[msg.type](msg.message);
215219
}
216220

src/vs/platform/files/common/watcher.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
import { Event } from 'vs/base/common/event';
77
import { GLOBSTAR, IRelativePattern, parse, ParsedPattern } from 'vs/base/common/glob';
8-
import { Disposable, DisposableStore, MutableDisposable } from 'vs/base/common/lifecycle';
8+
import { Disposable, DisposableStore, IDisposable, MutableDisposable } from 'vs/base/common/lifecycle';
99
import { isAbsolute } from 'vs/base/common/path';
1010
import { isLinux } from 'vs/base/common/platform';
1111
import { URI } from 'vs/base/common/uri';
@@ -122,6 +122,20 @@ export interface IRecursiveWatcher extends IWatcher {
122122
watch(requests: IRecursiveWatchRequest[]): Promise<void>;
123123
}
124124

125+
export interface IRecursiveWatcherWithSubscribe extends IRecursiveWatcher {
126+
127+
/**
128+
* Subscribe to file events for the given path. The callback is called
129+
* whenever a file event occurs for the path. I fthe watcher failed,
130+
* the error parameter is set to `true`.
131+
*
132+
* @returns an `IDisposable` to stop listening to events or `undefined`
133+
* if no events can be watched for the path given the current set of
134+
* recursive watch requests.
135+
*/
136+
subscribe(path: string, callback: (error: boolean, change?: IFileChange) => void): IDisposable | undefined;
137+
}
138+
125139
export interface IRecursiveWatcherOptions {
126140

127141
/**

src/vs/platform/files/node/watcher/baseWatcher.ts

Lines changed: 105 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,11 @@
55

66
import { watchFile, unwatchFile, Stats } from 'fs';
77
import { Disposable, DisposableMap, DisposableStore, toDisposable } from 'vs/base/common/lifecycle';
8-
import { ILogMessage, IUniversalWatchRequest, IWatchRequestWithCorrelation, IWatcher, isWatchRequestWithCorrelation } from 'vs/platform/files/common/watcher';
8+
import { ILogMessage, IRecursiveWatcherWithSubscribe, IUniversalWatchRequest, IWatchRequestWithCorrelation, IWatcher, isWatchRequestWithCorrelation } from 'vs/platform/files/common/watcher';
99
import { Emitter, Event } from 'vs/base/common/event';
1010
import { FileChangeType, IFileChange } from 'vs/platform/files/common/files';
1111
import { URI } from 'vs/base/common/uri';
12+
import { DeferredPromise } from 'vs/base/common/async';
1213

1314
export abstract class BaseWatcher extends Disposable implements IWatcher {
1415

@@ -25,9 +26,12 @@ export abstract class BaseWatcher extends Disposable implements IWatcher {
2526
private readonly allCorrelatedWatchRequests = new Map<number /* correlation ID */, IWatchRequestWithCorrelation>();
2627

2728
private readonly suspendedWatchRequests = this._register(new DisposableMap<number /* correlation ID */>());
29+
private readonly suspendedWatchRequestsWithPolling = new Set<number /* correlation ID */>();
2830

2931
protected readonly suspendedWatchRequestPollingInterval: number = 5007; // node.js default
3032

33+
private joinWatch = new DeferredPromise<void>();
34+
3135
constructor() {
3236
super();
3337

@@ -41,6 +45,11 @@ export abstract class BaseWatcher extends Disposable implements IWatcher {
4145
// to experiment with this feature in a controlled way. Monitoring requests
4246
// requires us to install polling watchers (via `fs.watchFile()`) and thus
4347
// should be used sparingly.
48+
//
49+
// TODO@bpasero revisit this in the future to have a more general approach
50+
// for suspend/resume and drop the `legacyMonitorRequest` in parcel.
51+
// One issue is that we need to be able to uniquely identify a request and
52+
// without correlation that is actually harder...
4453

4554
return;
4655
}
@@ -53,26 +62,36 @@ export abstract class BaseWatcher extends Disposable implements IWatcher {
5362
}
5463

5564
async watch(requests: IUniversalWatchRequest[]): Promise<void> {
56-
this.allCorrelatedWatchRequests.clear();
57-
this.allNonCorrelatedWatchRequests.clear();
58-
59-
// Figure out correlated vs. non-correlated requests
60-
for (const request of requests) {
61-
if (this.isCorrelated(request)) {
62-
this.allCorrelatedWatchRequests.set(request.correlationId, request);
63-
} else {
64-
this.allNonCorrelatedWatchRequests.add(request);
65-
}
65+
if (!this.joinWatch.isSettled) {
66+
this.joinWatch.complete();
6667
}
68+
this.joinWatch = new DeferredPromise<void>();
6769

68-
// Remove all suspended correlated watch requests that are no longer watched
69-
for (const [correlationId] of this.suspendedWatchRequests) {
70-
if (!this.allCorrelatedWatchRequests.has(correlationId)) {
71-
this.suspendedWatchRequests.deleteAndDispose(correlationId);
70+
try {
71+
this.allCorrelatedWatchRequests.clear();
72+
this.allNonCorrelatedWatchRequests.clear();
73+
74+
// Figure out correlated vs. non-correlated requests
75+
for (const request of requests) {
76+
if (this.isCorrelated(request)) {
77+
this.allCorrelatedWatchRequests.set(request.correlationId, request);
78+
} else {
79+
this.allNonCorrelatedWatchRequests.add(request);
80+
}
7281
}
73-
}
7482

75-
return this.updateWatchers();
83+
// Remove all suspended correlated watch requests that are no longer watched
84+
for (const [correlationId] of this.suspendedWatchRequests) {
85+
if (!this.allCorrelatedWatchRequests.has(correlationId)) {
86+
this.suspendedWatchRequests.deleteAndDispose(correlationId);
87+
this.suspendedWatchRequestsWithPolling.delete(correlationId);
88+
}
89+
}
90+
91+
return await this.updateWatchers();
92+
} finally {
93+
this.joinWatch.complete();
94+
}
7695
}
7796

7897
private updateWatchers(): Promise<void> {
@@ -82,29 +101,78 @@ export abstract class BaseWatcher extends Disposable implements IWatcher {
82101
]);
83102
}
84103

85-
private suspendWatchRequest(request: IWatchRequestWithCorrelation): void {
104+
isSuspended(request: IUniversalWatchRequest): 'polling' | boolean {
105+
if (typeof request.correlationId !== 'number') {
106+
return false;
107+
}
108+
109+
return this.suspendedWatchRequestsWithPolling.has(request.correlationId) ? 'polling' : this.suspendedWatchRequests.has(request.correlationId);
110+
}
111+
112+
private async suspendWatchRequest(request: IWatchRequestWithCorrelation): Promise<void> {
86113
if (this.suspendedWatchRequests.has(request.correlationId)) {
87114
return; // already suspended
88115
}
89116

90117
const disposables = new DisposableStore();
91118
this.suspendedWatchRequests.set(request.correlationId, disposables);
92119

120+
// It is possible that a watch request fails right during watch()
121+
// phase while other requests succeed. To increase the chance of
122+
// reusing another watcher for suspend/resume tracking, we await
123+
// all watch requests having processed.
124+
125+
await this.joinWatch.p;
126+
127+
if (disposables.isDisposed) {
128+
return;
129+
}
130+
93131
this.monitorSuspendedWatchRequest(request, disposables);
94132

95133
this.updateWatchers();
96134
}
97135

98136
private resumeWatchRequest(request: IWatchRequestWithCorrelation): void {
99137
this.suspendedWatchRequests.deleteAndDispose(request.correlationId);
138+
this.suspendedWatchRequestsWithPolling.delete(request.correlationId);
100139

101140
this.updateWatchers();
102141
}
103142

104-
private monitorSuspendedWatchRequest(request: IWatchRequestWithCorrelation, disposables: DisposableStore) {
105-
const resource = URI.file(request.path);
106-
const that = this;
143+
private monitorSuspendedWatchRequest(request: IWatchRequestWithCorrelation, disposables: DisposableStore): void {
144+
if (this.doMonitorWithExistingWatcher(request, disposables)) {
145+
this.trace(`reusing an existing recursive watcher to monitor ${request.path}`);
146+
this.suspendedWatchRequestsWithPolling.delete(request.correlationId);
147+
} else {
148+
this.doMonitorWithNodeJS(request, disposables);
149+
this.suspendedWatchRequestsWithPolling.add(request.correlationId);
150+
}
151+
}
152+
153+
private doMonitorWithExistingWatcher(request: IWatchRequestWithCorrelation, disposables: DisposableStore): boolean {
154+
const subscription = this.recursiveWatcher?.subscribe(request.path, (error, change) => {
155+
if (disposables.isDisposed) {
156+
return; // return early if already disposed
157+
}
158+
159+
if (error) {
160+
this.monitorSuspendedWatchRequest(request, disposables);
161+
} else if (change?.type === FileChangeType.ADDED) {
162+
this.onMonitoredPathAdded(request);
163+
}
164+
});
165+
166+
if (subscription) {
167+
disposables.add(subscription);
168+
169+
return true;
170+
}
171+
172+
return false;
173+
}
107174

175+
private doMonitorWithNodeJS(request: IWatchRequestWithCorrelation, disposables: DisposableStore): void {
108176
let pathNotFound = false;
109177

110178
const watchFileCallback: (curr: Stats, prev: Stats) => void = (curr, prev) => {
@@ -119,15 +187,7 @@ export abstract class BaseWatcher extends Disposable implements IWatcher {
119187

120188
// Watch path created: resume watching request
121189
if (!currentPathNotFound && (previousPathNotFound || oldPathNotFound)) {
122-
this.trace(`fs.watchFile() detected ${request.path} exists again, resuming watcher (correlationId: ${request.correlationId})`);
123-
124-
// Emit as event
125-
const event: IFileChange = { resource, type: FileChangeType.ADDED, cId: request.correlationId };
126-
that._onDidChangeFile.fire([event]);
127-
this.traceEvent(event, request);
128-
129-
// Resume watching
130-
this.resumeWatchRequest(request);
190+
this.onMonitoredPathAdded(request);
131191
}
132192
};
133193

@@ -149,12 +209,25 @@ export abstract class BaseWatcher extends Disposable implements IWatcher {
149209
}));
150210
}
151211

212+
private onMonitoredPathAdded(request: IWatchRequestWithCorrelation) {
213+
this.trace(`detected ${request.path} exists again, resuming watcher (correlationId: ${request.correlationId})`);
214+
215+
// Emit as event
216+
const event: IFileChange = { resource: URI.file(request.path), type: FileChangeType.ADDED, cId: request.correlationId };
217+
this._onDidChangeFile.fire([event]);
218+
this.traceEvent(event, request);
219+
220+
// Resume watching
221+
this.resumeWatchRequest(request);
222+
}
223+
152224
private isPathNotFound(stats: Stats): boolean {
153225
return stats.ctimeMs === 0 && stats.ino === 0;
154226
}
155227

156228
async stop(): Promise<void> {
157229
this.suspendedWatchRequests.clearAndDisposeAll();
230+
this.suspendedWatchRequestsWithPolling.clear();
158231
}
159232

160233
protected traceEvent(event: IFileChange, request: IUniversalWatchRequest): void {
@@ -168,6 +241,8 @@ export abstract class BaseWatcher extends Disposable implements IWatcher {
168241

169242
protected abstract doWatch(requests: IUniversalWatchRequest[]): Promise<void>;
170243

244+
protected abstract readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined;
245+
171246
protected abstract trace(message: string): void;
172247
protected abstract warn(message: string): void;
173248

src/vs/platform/files/node/watcher/nodejs/nodejsClient.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,6 @@ export class NodeJSWatcherClient extends AbstractNonRecursiveWatcherClient {
2121
}
2222

2323
protected override createWatcher(disposables: DisposableStore): INonRecursiveWatcher {
24-
return disposables.add(new NodeJSWatcher()) satisfies INonRecursiveWatcher;
24+
return disposables.add(new NodeJSWatcher(undefined /* no recursive watching support here */)) satisfies INonRecursiveWatcher;
2525
}
2626
}

src/vs/platform/files/node/watcher/nodejs/nodejsWatcher.ts

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Event } from 'vs/base/common/event';
77
import { patternsEquals } from 'vs/base/common/glob';
88
import { BaseWatcher } from 'vs/platform/files/node/watcher/baseWatcher';
99
import { isLinux } from 'vs/base/common/platform';
10-
import { INonRecursiveWatchRequest, INonRecursiveWatcher } from 'vs/platform/files/common/watcher';
10+
import { INonRecursiveWatchRequest, INonRecursiveWatcher, IRecursiveWatcherWithSubscribe } from 'vs/platform/files/common/watcher';
1111
import { NodeJSFileWatcherLibrary } from 'vs/platform/files/node/watcher/nodejs/nodejsWatcherLib';
1212
import { isEqual } from 'vs/base/common/extpath';
1313

@@ -28,10 +28,14 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher {
2828

2929
readonly onDidError = Event.None;
3030

31-
protected readonly watchers = new Set<INodeJSWatcherInstance>();
31+
readonly watchers = new Set<INodeJSWatcherInstance>();
3232

3333
private verboseLogging = false;
3434

35+
constructor(protected readonly recursiveWatcher: IRecursiveWatcherWithSubscribe | undefined) {
36+
super();
37+
}
38+
3539
protected override async doWatch(requests: INonRecursiveWatchRequest[]): Promise<void> {
3640

3741
// Figure out duplicates to remove from the requests
@@ -47,7 +51,6 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher {
4751
} else {
4852
requestsToStart.push(request); // start watching
4953
}
50-
5154
}
5255

5356
// Logging
@@ -95,7 +98,7 @@ export class NodeJSWatcher extends BaseWatcher implements INonRecursiveWatcher {
9598
private startWatching(request: INonRecursiveWatchRequest): void {
9699

97100
// Start via node.js lib
98-
const instance = new NodeJSFileWatcherLibrary(request, changes => this._onDidChangeFile.fire(changes), () => this._onDidWatchFail.fire(request), msg => this._onDidLogMessage.fire(msg), this.verboseLogging);
101+
const instance = new NodeJSFileWatcherLibrary(request, this.recursiveWatcher, changes => this._onDidChangeFile.fire(changes), () => this._onDidWatchFail.fire(request), msg => this._onDidLogMessage.fire(msg), this.verboseLogging);
99102

100103
// Remember as watcher instance
101104
const watcher: INodeJSWatcherInstance = { request, instance };

0 commit comments

Comments
 (0)