mirror of
https://github.com/l0ng-ai/openidea.git
synced 2026-11-07 02:06:05 -10:00
fix(smoke): reap the server's child processes on teardown (#1873)
run.test.ts's afterEach kills openidea-server and removes its home directory, but a language server's own process (jdtls's JVM) outlives the server and keeps writing .metadata underneath it, so the removal races its writes (ENOTEMPTY). Retries only narrow the window: three occurrences after the retry bump prove the JVM can linger past them. The server now spawns detached (its own process group, which its children inherit), and close() reaps the whole group after the server exits (SIGKILL + gone-poll on POSIX, taskkill /T /F on Windows), so the directory removal can't race anyone. debug.test.ts's teardown now awaits close() too.
This commit is contained in:
1 parent
b1171ecd23
commit
d36cf83f78
2 files changed
+54
-5
No files matched your search
+4
-2
@@ -105,10 +105,12 @@ beforeAll(() => {
|
||||
});
|
||||
afterAll(() => deactivate?.());
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
resetRuns();
|
||||
disconnect?.();
|
||||
server?.close();
|
||||
// `close` kills the server and what it started (debug adapters, language servers)
|
||||
// and waits until they're gone, so the removal below can't race their writes.
|
||||
await server?.close();
|
||||
if (base) rmSync(base, { recursive: true, force: true, maxRetries: 5, retryDelay: 200 });
|
||||
});
|
||||
|
||||
|
||||
+50
-3
@@ -2,7 +2,7 @@
|
||||
* `openidea-server --stdio`, spoken to as the desktop app does in remote development:
|
||||
* JSON-RPC with `Content-Length` framing over the server's stdin / stdout.
|
||||
*/
|
||||
import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
|
||||
import { type ChildProcessWithoutNullStreams, execFile, spawn } from "node:child_process";
|
||||
import { appendFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
|
||||
@@ -23,6 +23,28 @@ interface Pending {
|
||||
reject: (error: unknown) => void;
|
||||
}
|
||||
|
||||
/** `taskkill /T /F`: the tree rooted at `pid` (see `Server.close`). Always resolves. */
|
||||
function treeKill(pid: number | undefined): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (pid === undefined) return resolve();
|
||||
execFile("taskkill", ["/pid", String(pid), "/T", "/F"], () => resolve());
|
||||
});
|
||||
}
|
||||
|
||||
/** Resolves once no process group `pid` exists anymore (signal 0 only checks). */
|
||||
async function waitForGroupExit(pid: number, timeoutMs = 5000): Promise<void> {
|
||||
const start = Date.now();
|
||||
for (;;) {
|
||||
try {
|
||||
process.kill(-pid, 0);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (Date.now() - start > timeoutMs) return;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
}
|
||||
|
||||
export class Server {
|
||||
private nextId = 1;
|
||||
private readonly pending = new Map<number, Pending>();
|
||||
@@ -52,6 +74,9 @@ export class Server {
|
||||
const child = spawn(serverBinary(), ["--stdio"], {
|
||||
env: { ...process.env, OPENIDEA_SERVER_HOME: home, ...env },
|
||||
stdio: ["pipe", "pipe", "pipe"],
|
||||
// Its own process group: the language servers it starts (jdtls's JVM) stay in
|
||||
// it, so `close` can reap them together with the server (see below).
|
||||
detached: true,
|
||||
});
|
||||
const server = new Server(child);
|
||||
await server.request("initialize", { version: PROTOCOL_VERSION });
|
||||
@@ -78,14 +103,36 @@ export class Server {
|
||||
}
|
||||
}
|
||||
|
||||
/** Kills the server; resolves once the process is gone (its children may still be exiting). */
|
||||
/**
|
||||
* Kills the server and everything it started; resolves once they're all gone.
|
||||
* The server alone is not enough: a language server's own process (jdtls's JVM)
|
||||
* outlives it and keeps writing under the test's home directory, so deleting that
|
||||
* directory afterwards raced its writes (`ENOTEMPTY` on teardown). The server runs
|
||||
* detached (its own process group), so the group is only it and its children.
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.child.exitCode === null && !this.child.killed) {
|
||||
const exited = new Promise<void>((resolve) => this.child.once("exit", () => resolve()));
|
||||
this.child.stdin.end();
|
||||
this.child.kill();
|
||||
if (process.platform === "win32") {
|
||||
// `child.kill()` stops only the server; take the whole tree while it is
|
||||
// still enumerable, then wait for the exit below.
|
||||
await treeKill(this.child.pid);
|
||||
} else {
|
||||
this.child.kill();
|
||||
}
|
||||
await exited;
|
||||
}
|
||||
if (process.platform !== "win32" && this.child.pid !== undefined) {
|
||||
// Reap what the server started: `SIGKILL` to the group, then wait until it is
|
||||
// gone (termination is asynchronous; files held open can't be deleted meanwhile).
|
||||
try {
|
||||
process.kill(-this.child.pid, "SIGKILL");
|
||||
} catch {
|
||||
// Nothing left running.
|
||||
}
|
||||
await waitForGroupExit(this.child.pid);
|
||||
}
|
||||
}
|
||||
|
||||
private request(method: string, params: unknown): Promise<unknown> {
|
||||
|
||||
Reference in new issue
Block a user