Skip to content

Commit ee372e7

Browse files
committed
fix: await MCP cleanup during shutdown
1 parent 40b081a commit ee372e7

6 files changed

Lines changed: 180 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/mcp-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: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,3 +59,28 @@ assert.deepEqual(shutdownResults, [{ sessionId: "second" }]);
5959
assert.equal(first.closeCalls, 0);
6060
assert.equal(second.closeCalls, 1);
6161
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/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+
}

src/server.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import {
4242
} from "./mcp-sessions.js";
4343
import { ProcessSessionManager, type ProcessSnapshot } from "./process-sessions.js";
4444
import { createReviewCheckpointManager } from "./review-checkpoints.js";
45+
import { shutdownHttpServer } from "./server-shutdown.js";
4546
import { formatPathForPrompt } from "./skills.js";
4647
import { createWorkspaceStore } from "./workspace-store.js";
4748
import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js";
@@ -82,7 +83,7 @@ interface RunningServer {
8283
app: ReturnType<typeof createMcpExpressApp>;
8384
config: ServerConfig;
8485
localAgentProviders: LocalAgentProviderAvailability[];
85-
close(): void;
86+
close(): Promise<void>;
8687
}
8788

8889
type ToolContent =
@@ -1799,21 +1800,21 @@ export function createServer(config = loadConfig()): RunningServer {
17991800
}
18001801
});
18011802

1802-
let closed = false;
1803+
let closePromise: Promise<void> | undefined;
18031804
return {
18041805
app,
18051806
config,
18061807
localAgentProviders,
18071808
close: () => {
1808-
if (closed) return;
1809-
closed = true;
1810-
clearInterval(sessionCleanupTimer);
1811-
void transports
1812-
.closeAll()
1813-
.then((results) => logSessionCloseResults("server_shutdown", results));
1814-
processSessions.shutdown();
1815-
oauthProvider.close();
1816-
workspaceStore.close?.();
1809+
closePromise ??= (async () => {
1810+
clearInterval(sessionCleanupTimer);
1811+
const results = await transports.closeAll();
1812+
logSessionCloseResults("server_shutdown", results);
1813+
processSessions.shutdown();
1814+
oauthProvider.close();
1815+
workspaceStore.close?.();
1816+
})();
1817+
return closePromise;
18171818
},
18181819
};
18191820
}
@@ -1843,12 +1844,19 @@ if (await isMainModule()) {
18431844
}
18441845
});
18451846

1846-
const shutdown = () => {
1847-
httpServer.close(() => {
1848-
close();
1849-
process.exit(0);
1847+
let shuttingDown = false;
1848+
const shutdown = async () => {
1849+
if (shuttingDown) return;
1850+
shuttingDown = true;
1851+
await shutdownHttpServer(httpServer, close);
1852+
process.exit(0);
1853+
};
1854+
const handleShutdown = () => {
1855+
void shutdown().catch((error) => {
1856+
console.error("devspace shutdown failed", error);
1857+
process.exit(1);
18501858
});
18511859
};
1852-
process.once("SIGINT", shutdown);
1853-
process.once("SIGTERM", shutdown);
1860+
process.once("SIGINT", handleShutdown);
1861+
process.once("SIGTERM", handleShutdown);
18541862
}

0 commit comments

Comments
 (0)