Skip to content

Commit 80423b5

Browse files
authored
fix: clean up stale MCP sessions to bound memory growth (#71)
* fix: clean up stale MCP sessions * fix: await MCP cleanup during shutdown
1 parent b027795 commit 80423b5

7 files changed

Lines changed: 371 additions & 24 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
"dev": "node scripts/dev-server.mjs",
2929
"postinstall": "node scripts/fix-node-pty-permissions.mjs",
3030
"start": "node dist/cli.js serve",
31-
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
31+
"test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts",
3232
"typecheck": "tsc -p tsconfig.json --noEmit"
3333
},
3434
"keywords": [],

src/cli.ts

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ import {
3838
type DevspaceUserConfig,
3939
} from "./user-config.js";
4040
import { expandHomePath } from "./roots.js";
41+
import { shutdownHttpServer } from "./server-shutdown.js";
4142

4243
type Command = "serve" | "init" | "doctor" | "config" | "agents" | "help" | "version";
4344
const require = createRequire(import.meta.url);
@@ -228,14 +229,21 @@ async function serve(): Promise<void> {
228229
}
229230
});
230231

231-
const shutdown = () => {
232-
httpServer.close(() => {
233-
close();
234-
process.exit(0);
232+
let shuttingDown = false;
233+
const shutdown = async () => {
234+
if (shuttingDown) return;
235+
shuttingDown = true;
236+
await shutdownHttpServer(httpServer, close);
237+
process.exit(0);
238+
};
239+
const handleShutdown = () => {
240+
void shutdown().catch((error) => {
241+
console.error("devspace shutdown failed", error);
242+
process.exit(1);
235243
});
236244
};
237-
process.once("SIGINT", shutdown);
238-
process.once("SIGTERM", shutdown);
245+
process.once("SIGINT", handleShutdown);
246+
process.once("SIGTERM", handleShutdown);
239247
}
240248

241249
async function runDoctor(): Promise<void> {

src/mcp-sessions.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
import assert from "node:assert/strict";
2+
import { McpSessionRegistry } from "./mcp-sessions.js";
3+
4+
interface FakeTransport {
5+
closeCalls: number;
6+
close(): Promise<void>;
7+
}
8+
9+
function createTransport(closeError?: Error): FakeTransport {
10+
return {
11+
closeCalls: 0,
12+
async close() {
13+
this.closeCalls += 1;
14+
if (closeError) throw closeError;
15+
},
16+
};
17+
}
18+
19+
let now = 0;
20+
const registry = new McpSessionRegistry<FakeTransport>({ now: () => now });
21+
const staleTransport = createTransport();
22+
const activeTransport = createTransport();
23+
24+
registry.register("stale", staleTransport);
25+
now = 1_000;
26+
registry.register("active", activeTransport);
27+
now = 1_500;
28+
assert.equal(registry.get("active"), activeTransport);
29+
now = 2_000;
30+
31+
const idleResults = await registry.closeIdle(1_500);
32+
assert.deepEqual(idleResults, [{ sessionId: "stale" }]);
33+
assert.equal(staleTransport.closeCalls, 1);
34+
assert.equal(activeTransport.closeCalls, 0);
35+
assert.equal(registry.size, 1);
36+
assert.equal(registry.get("stale"), undefined);
37+
assert.equal(registry.get("active"), activeTransport);
38+
39+
const closeError = new Error("close failed");
40+
const failingTransport = createTransport(closeError);
41+
registry.register("failing", failingTransport);
42+
now = 10_000;
43+
44+
const failingResults = await registry.closeIdle(1);
45+
assert.equal(failingResults.length, 2);
46+
assert.deepEqual(failingResults.map((result) => result.sessionId).sort(), ["active", "failing"]);
47+
assert.equal(failingResults.find((result) => result.sessionId === "failing")?.error, closeError);
48+
assert.equal(failingTransport.closeCalls, 1);
49+
assert.equal(registry.size, 0);
50+
51+
const first = createTransport();
52+
const second = createTransport();
53+
registry.register("first", first);
54+
registry.register("second", second);
55+
registry.remove("first");
56+
57+
const shutdownResults = await registry.closeAll();
58+
assert.deepEqual(shutdownResults, [{ sessionId: "second" }]);
59+
assert.equal(first.closeCalls, 0);
60+
assert.equal(second.closeCalls, 1);
61+
assert.equal(registry.size, 0);
62+
63+
let finishDelayedClose: (() => void) | undefined;
64+
let delayedCloseResolved = false;
65+
const delayedTransport: FakeTransport = {
66+
closeCalls: 0,
67+
close() {
68+
this.closeCalls += 1;
69+
return new Promise<void>((resolve) => {
70+
finishDelayedClose = resolve;
71+
});
72+
},
73+
};
74+
registry.register("delayed", delayedTransport);
75+
const delayedClose = registry.closeAll();
76+
void delayedClose.then(() => {
77+
delayedCloseResolved = true;
78+
});
79+
80+
await Promise.resolve();
81+
assert.equal(delayedCloseResolved, false);
82+
assert.equal(delayedTransport.closeCalls, 1);
83+
finishDelayedClose?.();
84+
await delayedClose;
85+
assert.equal(delayedCloseResolved, true);
86+
assert.equal(registry.size, 0);

src/mcp-sessions.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
export interface ClosableMcpTransport {
2+
close(): Promise<void>;
3+
}
4+
5+
export interface McpSessionCloseResult {
6+
sessionId: string;
7+
error?: unknown;
8+
}
9+
10+
interface McpSessionEntry<TTransport> {
11+
transport: TTransport;
12+
lastActivityAt: number;
13+
}
14+
15+
export interface McpSessionRegistryOptions {
16+
now?: () => number;
17+
}
18+
19+
export class McpSessionRegistry<TTransport extends ClosableMcpTransport> {
20+
private readonly sessions = new Map<string, McpSessionEntry<TTransport>>();
21+
private readonly now: () => number;
22+
23+
constructor(options: McpSessionRegistryOptions = {}) {
24+
this.now = options.now ?? Date.now;
25+
}
26+
27+
get size(): number {
28+
return this.sessions.size;
29+
}
30+
31+
register(sessionId: string, transport: TTransport): void {
32+
this.sessions.set(sessionId, {
33+
transport,
34+
lastActivityAt: this.now(),
35+
});
36+
}
37+
38+
get(sessionId: string): TTransport | undefined {
39+
const entry = this.sessions.get(sessionId);
40+
if (!entry) return undefined;
41+
42+
entry.lastActivityAt = this.now();
43+
return entry.transport;
44+
}
45+
46+
remove(sessionId: string): boolean {
47+
return this.sessions.delete(sessionId);
48+
}
49+
50+
async closeIdle(idleTimeoutMs: number): Promise<McpSessionCloseResult[]> {
51+
const cutoff = this.now() - idleTimeoutMs;
52+
const idleSessions: Array<{ sessionId: string; transport: TTransport }> = [];
53+
54+
for (const [sessionId, entry] of this.sessions) {
55+
if (entry.lastActivityAt > cutoff) continue;
56+
57+
this.sessions.delete(sessionId);
58+
idleSessions.push({ sessionId, transport: entry.transport });
59+
}
60+
61+
return closeSessions(idleSessions);
62+
}
63+
64+
async closeAll(): Promise<McpSessionCloseResult[]> {
65+
const sessions = Array.from(this.sessions, ([sessionId, entry]) => ({
66+
sessionId,
67+
transport: entry.transport,
68+
}));
69+
this.sessions.clear();
70+
return closeSessions(sessions);
71+
}
72+
}
73+
74+
async function closeSessions<TTransport extends ClosableMcpTransport>(
75+
sessions: Array<{ sessionId: string; transport: TTransport }>,
76+
): Promise<McpSessionCloseResult[]> {
77+
return Promise.all(
78+
sessions.map(async ({ sessionId, transport }) => {
79+
try {
80+
await transport.close();
81+
return { sessionId };
82+
} catch (error) {
83+
return { sessionId, error };
84+
}
85+
}),
86+
);
87+
}

src/server-shutdown.test.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import assert from "node:assert/strict";
2+
import { shutdownHttpServer } from "./server-shutdown.js";
3+
4+
let finishHttpClose: (() => void) | undefined;
5+
let applicationCloseStarted = false;
6+
7+
const drainingHttpServer = {
8+
close(callback: (error?: Error) => void) {
9+
finishHttpClose = () => callback();
10+
},
11+
};
12+
13+
const drainingShutdown = shutdownHttpServer(drainingHttpServer, async () => {
14+
applicationCloseStarted = true;
15+
assert.ok(
16+
finishHttpClose,
17+
"HTTP draining must start before application cleanup",
18+
);
19+
finishHttpClose();
20+
});
21+
22+
await Promise.resolve();
23+
assert.equal(
24+
applicationCloseStarted,
25+
true,
26+
"application cleanup must start while the HTTP server is draining",
27+
);
28+
await drainingShutdown;
29+
30+
let finishApplicationClose: (() => void) | undefined;
31+
let shutdownResolved = false;
32+
33+
const immediatelyClosedHttpServer = {
34+
close(callback: (error?: Error) => void) {
35+
callback();
36+
},
37+
};
38+
39+
const delayedApplicationClose = () =>
40+
new Promise<void>((resolve) => {
41+
finishApplicationClose = resolve;
42+
});
43+
44+
const delayedShutdown = shutdownHttpServer(
45+
immediatelyClosedHttpServer,
46+
delayedApplicationClose,
47+
);
48+
void delayedShutdown.then(() => {
49+
shutdownResolved = true;
50+
});
51+
52+
await Promise.resolve();
53+
assert.equal(
54+
shutdownResolved,
55+
false,
56+
"shutdown must wait for asynchronous application cleanup",
57+
);
58+
finishApplicationClose?.();
59+
await delayedShutdown;
60+
assert.equal(shutdownResolved, true);
61+
62+
let finishDelayedHttpClose: (() => void) | undefined;
63+
let httpDrainResolved = false;
64+
const delayedHttpDrain = shutdownHttpServer(
65+
{
66+
close(callback: (error?: Error) => void) {
67+
finishDelayedHttpClose = () => callback();
68+
},
69+
},
70+
async () => {},
71+
);
72+
void delayedHttpDrain.then(() => {
73+
httpDrainResolved = true;
74+
});
75+
76+
await Promise.resolve();
77+
assert.equal(
78+
httpDrainResolved,
79+
false,
80+
"shutdown must wait for active HTTP responses to drain",
81+
);
82+
finishDelayedHttpClose?.();
83+
await delayedHttpDrain;
84+
assert.equal(httpDrainResolved, true);
85+
86+
const httpCloseError = new Error("http close failed");
87+
await assert.rejects(
88+
shutdownHttpServer(
89+
{
90+
close(callback: (error?: Error) => void) {
91+
callback(httpCloseError);
92+
},
93+
},
94+
async () => {},
95+
),
96+
httpCloseError,
97+
);

src/server-shutdown.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
export interface ClosableHttpServer {
2+
close(callback: (error?: Error) => void): void;
3+
}
4+
5+
export async function shutdownHttpServer(
6+
httpServer: ClosableHttpServer,
7+
closeApplication: () => Promise<void>,
8+
): Promise<void> {
9+
const httpClosed = new Promise<void>((resolve, reject) => {
10+
httpServer.close((error) => {
11+
if (error) reject(error);
12+
else resolve();
13+
});
14+
});
15+
16+
await closeApplication();
17+
await httpClosed;
18+
}

0 commit comments

Comments
 (0)