Skip to content

Commit 06e72a3

Browse files
committed
When restarting the query server wait for the old one to stop properly
1 parent 52672e6 commit 06e72a3

4 files changed

Lines changed: 130 additions & 2 deletions

File tree

extensions/ql-vscode/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
## [UNRELEASED]
44

5+
- Fix a bug where restarting the query server could intermittently fail because a new server was started before the previous one had fully exited. The extension now waits for the old process to terminate first. [#4457](https://github.com/github/vscode-codeql/pull/4457)
56
- Fix a bug where installing or updating the CodeQL CLI could hang indefinitely while extracting the downloaded archive. Extraction now reports an error if a file cannot be written, and aborts with a clear message if no progress is made within the download timeout (for example due to slow or networked storage, or security software). [#4455](https://github.com/github/vscode-codeql/pull/4455)
67
- Remove support for CodeQL CLI versions older than 2.23.9. [#4448](https://github.com/github/vscode-codeql/pull/4448)
78
- Added support for selection-based result filtering via a checkbox in the result viewer. When enabled, only results from the currently-viewed file are shown. Additionally, if the editor selection is non-empty, only results within the selection range are shown. [#4362](https://github.com/github/vscode-codeql/pull/4362)

extensions/ql-vscode/src/query-server/query-server-client.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,19 @@ export class QueryServerClient extends DisposableObject {
113113
}
114114
}
115115

116+
/**
117+
* Stops the query server and waits for its process to fully exit before
118+
* returning. Use this (rather than `stopQueryServer`) when a new server is
119+
* about to be started, so that the old process has released any file locks
120+
* first. This avoids intermittent failures on Windows where the OS keeps
121+
* locks until the process has actually terminated.
122+
*/
123+
private async stopQueryServerAndWait(): Promise<void> {
124+
const serverProcess = this.serverProcess;
125+
this.stopQueryServer();
126+
await serverProcess?.waitForExit();
127+
}
128+
116129
/**
117130
* Restarts the query server by disposing of the current server process and then starting a new one.
118131
* This resets the unexpected termination count. As hopefully it is an indication that the user has fixed the
@@ -154,7 +167,9 @@ export class QueryServerClient extends DisposableObject {
154167
private async restartQueryServerInternal(
155168
progress: ProgressCallback,
156169
): Promise<void> {
157-
this.stopQueryServer();
170+
// Wait for the old process to fully exit before starting a new one, so
171+
// that it has released any file locks (important on Windows).
172+
await this.stopQueryServerAndWait();
158173
await this.startQueryServer();
159174

160175
// Ensure we await all responses from event handlers so that

extensions/ql-vscode/src/query-server/server-process.ts

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,57 @@ export class ServerProcess implements Disposable {
2727
this.child.stdin!.end();
2828
this.child.stderr!.destroy();
2929
this.child.removeAllListeners();
30-
// TODO kill the process if it doesn't terminate after a certain time limit.
30+
31+
// `dispose()` is synchronous, so we only signal the process to stop (by
32+
// closing its streams) and don't wait for it to actually exit. Callers that
33+
// need the process to have terminated — e.g. before starting a replacement
34+
// server — should await `waitForExit()` afterwards.
3135

3236
// On Windows, we usually have to terminate the process before closing its stdout.
3337
this.child.stdout!.destroy();
3438
void this.logger.log(`Stopped ${this.name}.`);
3539
}
40+
41+
/**
42+
* Waits for the underlying child process to fully exit, forcibly killing it
43+
* after `timeoutMs` if it has not exited on its own.
44+
*
45+
* Call this after `dispose()` when you need the OS to have actually released
46+
* the process before continuing. This matters on Windows, where the OS can
47+
* keep file locks (for example on the database or disk cache) until the
48+
* process has terminated — so starting a replacement server before the old
49+
* one has exited can intermittently fail.
50+
*/
51+
async waitForExit(timeoutMs = 5000): Promise<void> {
52+
const hasExited = () =>
53+
this.child.exitCode !== null || this.child.signalCode !== null;
54+
55+
if (hasExited()) {
56+
return;
57+
}
58+
59+
await new Promise<void>((resolve) => {
60+
const timer = setTimeout(() => {
61+
void this.logger.log(
62+
`${this.name} did not exit within ${timeoutMs}ms; killing it.`,
63+
);
64+
this.child.kill("SIGKILL");
65+
resolve();
66+
}, timeoutMs);
67+
68+
const done = () => {
69+
clearTimeout(timer);
70+
resolve();
71+
};
72+
73+
this.child.once("exit", done);
74+
75+
// Guard against the process having exited between the check above and
76+
// attaching the listener.
77+
if (hasExited()) {
78+
this.child.removeListener("exit", done);
79+
done();
80+
}
81+
});
82+
}
3683
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { EventEmitter } from "events";
2+
import { ServerProcess } from "../../../src/query-server/server-process";
3+
4+
function createFakeChild(): any {
5+
const child = new EventEmitter() as any;
6+
child.exitCode = null;
7+
child.signalCode = null;
8+
child.kill = jest.fn((signal?: string) => {
9+
child.exitCode = 1;
10+
child.emit("exit", null, signal ?? "SIGKILL");
11+
return true;
12+
});
13+
return child;
14+
}
15+
16+
const fakeConnection = { dispose: jest.fn(), end: jest.fn() } as any;
17+
const fakeLogger = { log: jest.fn() } as any;
18+
19+
function createServerProcess(child: any): ServerProcess {
20+
return new ServerProcess(
21+
child,
22+
fakeConnection,
23+
"test query server",
24+
fakeLogger,
25+
);
26+
}
27+
28+
describe("ServerProcess.waitForExit", () => {
29+
it("resolves when the process exits", async () => {
30+
const child = createFakeChild();
31+
const serverProcess = createServerProcess(child);
32+
33+
const promise = serverProcess.waitForExit();
34+
child.exitCode = 0;
35+
child.emit("exit", 0, null);
36+
37+
await expect(promise).resolves.toBeUndefined();
38+
expect(child.kill).not.toHaveBeenCalled();
39+
});
40+
41+
it("resolves immediately if the process has already exited", async () => {
42+
const child = createFakeChild();
43+
child.exitCode = 0;
44+
const serverProcess = createServerProcess(child);
45+
46+
await expect(serverProcess.waitForExit()).resolves.toBeUndefined();
47+
expect(child.kill).not.toHaveBeenCalled();
48+
});
49+
50+
it("force-kills the process if it does not exit within the timeout", async () => {
51+
jest.useFakeTimers();
52+
try {
53+
const child = createFakeChild();
54+
const serverProcess = createServerProcess(child);
55+
56+
const promise = serverProcess.waitForExit(1000);
57+
jest.advanceTimersByTime(1000);
58+
59+
await expect(promise).resolves.toBeUndefined();
60+
expect(child.kill).toHaveBeenCalledWith("SIGKILL");
61+
} finally {
62+
jest.useRealTimers();
63+
}
64+
});
65+
});

0 commit comments

Comments
 (0)