Skip to content

Commit ed6a647

Browse files
egavrindevagent
andcommitted
fix: bound lsp shutdown cleanup
- Add shutdown deadlines and unref LSP timers - Share TUI test rendering helpers after simplification Co-Authored-By: devagent <devagent@egavrin>
1 parent 4eca27d commit ed6a647

9 files changed

Lines changed: 223 additions & 222 deletions

File tree

packages/cli/src/double-check-wiring.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ describe("LSPRouter", () => {
240240
expect(router.getClientForFile("src/foo.ts")).toBeNull();
241241
expect(router.getClients()).toHaveLength(0);
242242
expect(router.getLanguages()).toHaveLength(0);
243-
expect(mockClient.stop).toHaveBeenCalled();
243+
expect(mockClient.stop).toHaveBeenCalledWith({ deadlineMs: 2000 });
244244
});
245245
});
246246

packages/cli/src/double-check-wiring.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ export class LSPRouter {
165165
/** Map from file extension to language ID. */
166166
private extensionMap = new Map<string, string>();
167167
private readonly rootPath: string;
168+
private readonly shutdownDeadlineMs = 2_000;
168169

169170
constructor(rootPath: string) {
170171
this.rootPath = rootPath;
@@ -223,13 +224,13 @@ export class LSPRouter {
223224
/** Stop all LSP servers. */
224225
async stopAll(): Promise<void> {
225226
const uniqueClients = new Set(this.clients.values());
226-
for (const client of uniqueClients) {
227+
await Promise.all([...uniqueClients].map(async (client) => {
227228
try {
228-
await client.stop();
229+
await client.stop({ deadlineMs: this.shutdownDeadlineMs });
229230
} catch {
230231
/* already dead */
231232
}
232-
}
233+
}));
233234
this.clients.clear();
234235
this.extensionMap.clear();
235236
}

packages/cli/src/main.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -429,14 +429,14 @@ function toInteractiveQueryResult(result: import("@devagent/runtime").TaskLoopRe
429429
async function finalizeAgentSession(ctx: AgentSessionContext): Promise<void> {
430430
writeLspUsageSummary(ctx.tools, ctx.cliArgs);
431431
writeSessionSummary(ctx);
432+
ctx.persistence.close();
432433
if (ctx.lsp.lspRouter) {
433434
try {
434435
await ctx.lsp.lspRouter.stopAll();
435436
} catch {
436437
// Servers might already be dead.
437438
}
438439
}
439-
ctx.persistence.close();
440440
}
441441

442442
function writeLspUsageSummary(tools: ToolsSetupResult, cliArgs: CliArgs): void {

packages/cli/src/tui/App.commands.test.ts

Lines changed: 11 additions & 94 deletions
Original file line numberDiff line numberDiff line change
@@ -1,102 +1,19 @@
11
import { EventBus } from "@devagent/runtime";
2-
import { render } from "ink";
3-
import { PassThrough, Writable } from "node:stream";
42
import React from "react";
53
import { afterEach, describe, expect, it } from "vitest";
64

75
import { App, handleCancelShortcut } from "./App.js";
8-
9-
class TestInput extends PassThrough {
10-
readonly isTTY = true;
11-
12-
setRawMode(_value: boolean): void {}
13-
14-
ref(): this {
15-
return this;
16-
}
17-
18-
unref(): this {
19-
return this;
20-
}
21-
}
22-
23-
class TestOutput extends Writable {
24-
readonly isTTY = true;
25-
readonly columns: number;
26-
readonly rows = 40;
27-
private readonly chunks: string[] = [];
28-
29-
constructor(columns: number = 120) {
30-
super();
31-
this.columns = columns;
32-
}
33-
34-
override _write(
35-
chunk: string | Uint8Array,
36-
_encoding: BufferEncoding,
37-
callback: (error?: Error | null) => void,
38-
): void {
39-
this.chunks.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
40-
callback();
41-
}
42-
43-
readAll(): string {
44-
return this.chunks.join("");
45-
}
46-
47-
clear(): void {
48-
this.chunks.length = 0;
49-
}
50-
}
51-
52-
const instances: Array<{ unmount: () => void; cleanup: () => void }> = [];
53-
54-
afterEach(() => {
55-
while (instances.length > 0) {
56-
const instance = instances.pop();
57-
instance?.unmount();
58-
instance?.cleanup();
59-
}
60-
});
61-
62-
async function settle(): Promise<void> {
63-
await new Promise((resolve) => setTimeout(resolve, 20));
64-
}
65-
66-
async function waitForRenders(cycles: number = 6): Promise<void> {
67-
for (let index = 0; index < cycles; index++) {
68-
await settle();
69-
}
70-
}
71-
72-
function stripAnsi(text: string): string {
73-
return text.replace(/\x1b\[[0-9;]*m/g, "");
74-
}
75-
76-
function countPromptPlaceholders(text: string): number {
77-
return (stripAnsi(text).match(/Ask anything/g) ?? []).length;
78-
}
79-
80-
async function typeAndSubmit(stdin: TestInput, text: string): Promise<void> {
81-
stdin.write(text);
82-
await settle();
83-
stdin.write("\r");
84-
}
85-
86-
function renderForTest(node: React.ReactElement): { readonly stdout: TestOutput; readonly stdin: TestInput } {
87-
const stdout = new TestOutput();
88-
const stdin = new TestInput();
89-
const stderr = new TestOutput();
90-
instances.push(render(node, {
91-
stdout: stdout as unknown as NodeJS.WriteStream,
92-
stdin: stdin as unknown as NodeJS.ReadStream,
93-
stderr: stderr as unknown as NodeJS.WriteStream,
94-
debug: true,
95-
exitOnCtrlC: false,
96-
patchConsole: false,
97-
}));
98-
return { stdout, stdin };
99-
}
6+
import {
7+
cleanupRenderedInstances,
8+
countPromptPlaceholders,
9+
renderForTest,
10+
settle,
11+
stripAnsi,
12+
typeAndSubmit,
13+
waitForRenders,
14+
} from "./App.test-utils.js";
15+
16+
afterEach(cleanupRenderedInstances);
10017

10118
function makeIdleAppProps(overrides?: { readonly bus?: EventBus; readonly onClear?: () => void }) {
10219
return {
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { render } from "ink";
2+
import { PassThrough, Writable } from "node:stream";
3+
4+
import type React from "react";
5+
6+
export class TestInput extends PassThrough {
7+
readonly isTTY = true;
8+
9+
setRawMode(_value: boolean): void {}
10+
11+
ref(): this {
12+
return this;
13+
}
14+
15+
unref(): this {
16+
return this;
17+
}
18+
}
19+
20+
export class TestOutput extends Writable {
21+
readonly isTTY = true;
22+
readonly columns: number;
23+
readonly rows = 40;
24+
private readonly chunks: string[] = [];
25+
26+
constructor(columns: number = 120) {
27+
super();
28+
this.columns = columns;
29+
}
30+
31+
override _write(
32+
chunk: string | Uint8Array,
33+
_encoding: BufferEncoding,
34+
callback: (error?: Error | null) => void,
35+
): void {
36+
const text = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
37+
this.chunks.push(text);
38+
callback();
39+
}
40+
41+
readAll(): string {
42+
return this.chunks.join("");
43+
}
44+
45+
clear(): void {
46+
this.chunks.length = 0;
47+
}
48+
}
49+
50+
const instances: Array<{ unmount: () => void; cleanup: () => void }> = [];
51+
52+
export function cleanupRenderedInstances(): void {
53+
while (instances.length > 0) {
54+
const instance = instances.pop();
55+
instance?.unmount();
56+
instance?.cleanup();
57+
}
58+
}
59+
60+
export async function settle(): Promise<void> {
61+
await new Promise((resolve) => setTimeout(resolve, 20));
62+
}
63+
64+
export async function waitForRenders(cycles: number = 6): Promise<void> {
65+
for (let index = 0; index < cycles; index++) {
66+
await settle();
67+
}
68+
}
69+
70+
export function stripAnsi(text: string): string {
71+
return text.replace(/\x1b\[[0-9;]*m/g, "");
72+
}
73+
74+
export function countPromptPlaceholders(text: string): number {
75+
return (stripAnsi(text).match(/Ask anything/g) ?? []).length;
76+
}
77+
78+
export async function typeAndSubmit(stdin: TestInput, text: string): Promise<void> {
79+
stdin.write(text);
80+
await settle();
81+
stdin.write("\r");
82+
}
83+
84+
export async function insertModifiedReturn(stdin: TestInput): Promise<void> {
85+
stdin.write("\x1b\r");
86+
await settle();
87+
}
88+
89+
export function renderForTest(
90+
node: React.ReactElement,
91+
options?: { readonly columns?: number },
92+
): {
93+
readonly stdout: TestOutput;
94+
readonly stdin: TestInput;
95+
readonly rerender: (tree: React.ReactElement) => void;
96+
readonly unmount: () => void;
97+
readonly cleanup: () => void;
98+
} {
99+
const stdout = new TestOutput(options?.columns);
100+
const stdin = new TestInput();
101+
const stderr = new TestOutput(options?.columns);
102+
const instance = render(node, {
103+
stdout: stdout as unknown as NodeJS.WriteStream,
104+
stdin: stdin as unknown as NodeJS.ReadStream,
105+
stderr: stderr as unknown as NodeJS.WriteStream,
106+
debug: true,
107+
exitOnCtrlC: false,
108+
patchConsole: false,
109+
});
110+
instances.push(instance);
111+
return {
112+
stdout,
113+
stdin,
114+
rerender: instance.rerender,
115+
unmount: instance.unmount,
116+
cleanup: instance.cleanup,
117+
};
118+
}

0 commit comments

Comments
 (0)