Merge remote-tracking branch 'origin/farm/a18062d9/clangd-clean-shutdown'

This commit is contained in:
can1357
2026-05-31 17:38:30 +02:00
2 changed files with 118 additions and 5 deletions
+28 -5
View File
@@ -415,6 +415,10 @@ export const WARMUP_TIMEOUT_MS = 5000;
/** Max time to wait for the server to report project loading completion via $/progress */
const PROJECT_LOAD_TIMEOUT_MS = 15_000;
/** Max time to wait for graceful LSP shutdown and process exit. */
const SHUTDOWN_TIMEOUT_MS = 5_000;
const EXIT_TIMEOUT_MS = 1_000;
/**
* Get or create an LSP client for the given server configuration and working directory.
* @param config - Server configuration
@@ -768,8 +772,18 @@ export async function refreshFile(client: LspClient, filePath: string, signal?:
}
}
async function waitForExit(client: LspClient, timeoutMs: number): Promise<boolean> {
return await Promise.race([
client.proc.exited.then(
() => true,
() => true,
),
Bun.sleep(timeoutMs).then(() => false),
]);
}
/**
* Shutdown a specific client by key.
* Shutdown a specific client instance using the LSP shutdown/exit handshake.
*/
async function shutdownClientInstance(client: LspClient): Promise<void> {
const err = new Error("LSP client shutdown");
@@ -778,13 +792,22 @@ async function shutdownClientInstance(client: LspClient): Promise<void> {
}
client.pendingRequests.clear();
const timeout = Bun.sleep(5_000);
const shutdown = sendRequest(client, "shutdown", null).catch(() => {});
await Promise.race([shutdown, timeout]);
const shutdownCompleted = await sendRequest(client, "shutdown", null, undefined, SHUTDOWN_TIMEOUT_MS).then(
() => true,
() => false,
);
if (shutdownCompleted) {
await sendNotification(client, "exit", undefined).catch(() => {});
if (await waitForExit(client, EXIT_TIMEOUT_MS)) return;
}
client.proc.kill();
await Promise.race([client.proc.exited.catch(() => {}), Bun.sleep(1_000)]);
await waitForExit(client, EXIT_TIMEOUT_MS);
}
/**
* Shutdown a specific client by key.
*/
export async function shutdownClient(key: string): Promise<void> {
const client = clients.get(key);
if (!client) return;
@@ -55,6 +55,96 @@ describe("lsp regressions", () => {
expect(clampTimeout("lsp", 1000)).toBe(60);
});
async function markerExists(filePath: string): Promise<boolean> {
try {
await Bun.file(filePath).bytes();
return true;
} catch (error) {
if (piUtils.isEnoent(error)) return false;
throw error;
}
}
it("sends the LSP exit notification after shutdown completes", async () => {
const tempDir = TempDir.createSync("@omp-lsp-shutdown-");
try {
const markerDir = tempDir.path();
const serverPath = path.join(markerDir, "server.ts");
await Bun.write(
serverPath,
`
const markerDir = process.argv[2];
const decoder = new TextDecoder();
let buffer = "";
async function mark(name) {
await Bun.write(\`\${markerDir}/\${name}\`, "1\\n");
}
function send(message) {
const content = JSON.stringify(message);
process.stdout.write(\`Content-Length: \${Buffer.byteLength(content, "utf8")}\\r\\n\\r\\n\${content}\`);
}
process.on("SIGTERM", () => {
void mark("sigterm").finally(() => process.abort());
});
for await (const chunk of Bun.stdin.stream()) {
buffer += decoder.decode(chunk, { stream: true });
while (true) {
const headerEnd = buffer.indexOf("\\r\\n\\r\\n");
if (headerEnd === -1) break;
const header = buffer.slice(0, headerEnd);
const match = /Content-Length: (\\d+)/i.exec(header);
if (!match) process.exit(2);
const contentLength = Number(match[1]);
const contentStart = headerEnd + 4;
const contentEnd = contentStart + contentLength;
if (buffer.length < contentEnd) break;
const message = JSON.parse(buffer.slice(contentStart, contentEnd));
buffer = buffer.slice(contentEnd);
if (message.method === "initialize") {
await mark("initialize");
send({ jsonrpc: "2.0", id: message.id, result: { capabilities: {} } });
} else if (message.method === "shutdown") {
await mark("shutdown");
send({ jsonrpc: "2.0", id: message.id, result: null });
} else if (message.method === "exit") {
await mark("exit");
process.exit(0);
}
}
}
await mark("stdin-closed");
process.abort();
`,
);
const server: ServerConfig = {
command: process.execPath,
args: [serverPath, markerDir],
fileTypes: ["ts"],
rootMarkers: [],
};
await lspClient.getOrCreateClient(server, tempDir.path(), 1_000);
await lspClient.shutdownAll();
expect(await markerExists(path.join(markerDir, "shutdown"))).toBe(true);
expect(await markerExists(path.join(markerDir, "exit"))).toBe(true);
expect(await markerExists(path.join(markerDir, "sigterm"))).toBe(false);
} finally {
await lspClient.shutdownAll();
tempDir.removeSync();
}
});
it("limits glob collection to avoid large diagnostic stalls", async () => {
const tempDir = TempDir.createSync("@omp-lsp-glob-");
try {