Skip to content

Commit 7d067e5

Browse files
authored
Eagerly compute SHA's for watched files (#2861)
### Motivation This PR fixes two issues related to automated restarts. The first one is preventing undesired restarts immediately after the server finishes booting. The second one is preventing unwanted restarts if a create event is fired for an existing file. In theory, this shouldn't happen, but our tests tell a different story and we consistently see a create event getting fired for updates. ### Implementation The two fixes are: 1. Unified all of our watchers to only check the top level configuration files and then started eagerly computing the SHAs for all of them (if they are present). This will prevent unwanted restarts if these files are touched immediately after boot 2. Started using the hash check for `didCreate` as well. If the file being updated fires a create event and the content SHA matches ### Automated Tests Added a new test and reduced some of the sleeps on the other test, which is taking a bit too long.
1 parent 6bdc52d commit 7d067e5

File tree

2 files changed

+96
-43
lines changed

2 files changed

+96
-43
lines changed

vscode/src/test/suite/workspace.test.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,15 +59,15 @@ suite("Workspace", () => {
5959

6060
await workspace.activate();
6161

62-
for (let i = 0; i < 5; i++) {
63-
await new Promise((resolve) => setTimeout(resolve, 200));
62+
for (let i = 0; i < 4; i++) {
63+
await new Promise((resolve) => setTimeout(resolve, 50));
6464
fs.writeFileSync(path.join(gitDir, "rebase-apply"), "1");
65-
await new Promise((resolve) => setTimeout(resolve, 200));
65+
await new Promise((resolve) => setTimeout(resolve, 50));
6666
fs.rmSync(path.join(gitDir, "rebase-apply"));
6767
}
6868

6969
// Give enough time for all watchers to fire and all debounces to run off
70-
await new Promise((resolve) => setTimeout(resolve, 10000));
70+
await new Promise((resolve) => setTimeout(resolve, 6000));
7171

7272
startStub.restore();
7373
restartSpy.restore();
@@ -77,4 +77,40 @@ suite("Workspace", () => {
7777
// The restart call only happens once because of the debounce
7878
assert.strictEqual(restartSpy.callCount, 1);
7979
}).timeout(60000);
80+
81+
test("does not restart when watched files are touched without modifying contents", async () => {
82+
const lockfileUri = vscode.Uri.file(
83+
path.join(workspacePath, "Gemfile.lock"),
84+
);
85+
const contents = Buffer.from("hello");
86+
await vscode.workspace.fs.writeFile(lockfileUri, contents);
87+
88+
const workspace = new Workspace(
89+
context,
90+
workspaceFolder,
91+
FAKE_TELEMETRY,
92+
() => {},
93+
new Map(),
94+
);
95+
96+
await workspace.activate();
97+
98+
const startStub = sinon.stub(workspace, "start");
99+
const restartSpy = sinon.spy(workspace, "restart");
100+
101+
for (let i = 0; i < 4; i++) {
102+
const updateDate = new Date();
103+
fs.utimesSync(lockfileUri.fsPath, updateDate, updateDate);
104+
await new Promise((resolve) => setTimeout(resolve, 50));
105+
}
106+
107+
// Give enough time for all watchers to fire and all debounces to run off
108+
await new Promise((resolve) => setTimeout(resolve, 6000));
109+
110+
startStub.restore();
111+
restartSpy.restore();
112+
113+
assert.strictEqual(startStub.callCount, 0);
114+
assert.strictEqual(restartSpy.callCount, 0);
115+
}).timeout(10000);
80116
});

vscode/src/workspace.ts

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import {
1414
} from "./common";
1515
import { WorkspaceChannel } from "./workspaceChannel";
1616

17+
const WATCHED_FILES = [
18+
"Gemfile.lock",
19+
"gems.locked",
20+
".rubocop.yml",
21+
".rubocop",
22+
];
23+
1724
export class Workspace implements WorkspaceInterface {
1825
public lspClient?: Client;
1926
public readonly ruby: Ruby;
@@ -58,8 +65,6 @@ export class Workspace implements WorkspaceInterface {
5865
this.createTestItems = createTestItems;
5966
this.isMainWorkspace = isMainWorkspace;
6067
this.virtualDocuments = virtualDocuments;
61-
62-
this.registerRestarts(context);
6368
}
6469

6570
// Activate this workspace. This method is intended to be invoked only once, unlikely `start` which may be invoked
@@ -82,6 +87,20 @@ export class Workspace implements WorkspaceInterface {
8287
rootGitUri,
8388
".git/{rebase-merge,rebase-apply,BISECT_START,CHERRY_PICK_HEAD}",
8489
);
90+
91+
this.registerRestarts();
92+
93+
// Eagerly calculate SHAs for the watched files to avoid unnecessary restarts
94+
for (const file of WATCHED_FILES) {
95+
const uri = vscode.Uri.joinPath(this.workspaceFolder.uri, file);
96+
const currentSha = await this.fileContentsSha(uri);
97+
98+
if (!currentSha) {
99+
continue;
100+
}
101+
102+
this.restartDocumentShas.set(uri.fsPath, currentSha);
103+
}
85104
}
86105

87106
async start(debugMode?: boolean) {
@@ -164,7 +183,9 @@ export class Workspace implements WorkspaceInterface {
164183
// server can now handle shutdown requests
165184
if (this.needsRestart) {
166185
this.needsRestart = false;
167-
await this.restart();
186+
await this.debouncedRestart(
187+
"a restart was requested while the server was still booting",
188+
);
168189
}
169190
} catch (error: any) {
170191
this.error = true;
@@ -342,67 +363,49 @@ export class Workspace implements WorkspaceInterface {
342363
return result;
343364
}
344365

345-
private registerRestarts(context: vscode.ExtensionContext) {
346-
this.createRestartWatcher(context, "Gemfile.lock");
347-
this.createRestartWatcher(context, "gems.locked");
348-
this.createRestartWatcher(context, "**/.rubocop.yml");
349-
this.createRestartWatcher(context, ".rubocop");
366+
private registerRestarts() {
367+
this.createRestartWatcher(`{${WATCHED_FILES.join(",")}}`);
350368

351369
// If a configuration that affects the Ruby LSP has changed, update the client options using the latest
352370
// configuration and restart the server
353-
context.subscriptions.push(
371+
this.context.subscriptions.push(
354372
vscode.workspace.onDidChangeConfiguration(async (event) => {
355373
if (event.affectsConfiguration("rubyLsp")) {
356-
// Re-activate Ruby if the version manager changed
357-
if (
358-
event.affectsConfiguration("rubyLsp.rubyVersionManager") ||
359-
event.affectsConfiguration("rubyLsp.bundleGemfile") ||
360-
event.affectsConfiguration("rubyLsp.customRubyCommand")
361-
) {
362-
await this.ruby.activateRuby();
363-
}
364-
365-
this.outputChannel.info(
366-
"Restarting the Ruby LSP because configuration changed",
367-
);
368-
await this.restart();
374+
await this.debouncedRestart("configuration changed");
369375
}
370376
}),
371377
);
372378
}
373379

374-
private createRestartWatcher(
375-
context: vscode.ExtensionContext,
376-
pattern: string,
377-
) {
380+
private createRestartWatcher(pattern: string) {
378381
const watcher = vscode.workspace.createFileSystemWatcher(
379382
new vscode.RelativePattern(this.workspaceFolder, pattern),
380383
);
381384

382385
// Handler for only triggering restart if the contents of the file have been modified. If the file was just touched,
383386
// but the contents are the same, we don't want to restart
384387
const debouncedRestartWithHashCheck = async (uri: vscode.Uri) => {
385-
const fileContents = await vscode.workspace.fs.readFile(uri);
386388
const fsPath = uri.fsPath;
389+
const currentSha = await this.fileContentsSha(uri);
387390

388-
const hash = createHash("sha256");
389-
hash.update(fileContents.toString());
390-
const currentSha = hash.digest("hex");
391-
392-
if (this.restartDocumentShas.get(fsPath) !== currentSha) {
391+
if (currentSha && this.restartDocumentShas.get(fsPath) !== currentSha) {
393392
this.restartDocumentShas.set(fsPath, currentSha);
394-
await this.debouncedRestart(`${pattern} changed`);
393+
await this.debouncedRestart(`${fsPath} changed, matching ${pattern}`);
395394
}
396395
};
397396

398-
const debouncedRestart = async () => {
399-
await this.debouncedRestart(`${pattern} changed`);
397+
const debouncedRestart = async (uri: vscode.Uri) => {
398+
this.restartDocumentShas.delete(uri.fsPath);
399+
await this.debouncedRestart(`${uri.fsPath} changed, matching ${pattern}`);
400400
};
401401

402-
context.subscriptions.push(
402+
this.context.subscriptions.push(
403403
watcher,
404404
watcher.onDidChange(debouncedRestartWithHashCheck),
405-
watcher.onDidCreate(debouncedRestart),
405+
// Interestingly, we are seeing create events being fired even when the file already exists. If a create event is
406+
// fired for an update to an existing file, then we need to check if the contents still match to prevent unwanted
407+
// restarts
408+
watcher.onDidCreate(debouncedRestartWithHashCheck),
406409
watcher.onDidDelete(debouncedRestart),
407410
);
408411
}
@@ -416,9 +419,9 @@ export class Workspace implements WorkspaceInterface {
416419
this.#inhibitRestart = true;
417420
};
418421

419-
const stop = async () => {
422+
const stop = async (uri: vscode.Uri) => {
420423
this.#inhibitRestart = false;
421-
await this.debouncedRestart(`${glob} changed`);
424+
await this.debouncedRestart(`${uri.fsPath} changed, matching ${glob}`);
422425
};
423426

424427
this.context.subscriptions.push(
@@ -429,4 +432,18 @@ export class Workspace implements WorkspaceInterface {
429432
workspaceWatcher.onDidDelete(stop),
430433
);
431434
}
435+
436+
private async fileContentsSha(uri: vscode.Uri): Promise<string | undefined> {
437+
let fileContents;
438+
439+
try {
440+
fileContents = await vscode.workspace.fs.readFile(uri);
441+
} catch (error: any) {
442+
return undefined;
443+
}
444+
445+
const hash = createHash("sha256");
446+
hash.update(fileContents.toString());
447+
return hash.digest("hex");
448+
}
432449
}

0 commit comments

Comments
 (0)