Skip to content

Commit 11eb4bb

Browse files
authored
mcp: exit the daemon process after shutdown (#98)
1 parent 5373c73 commit 11eb4bb

3 files changed

Lines changed: 115 additions & 4 deletions

File tree

src/main.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,9 @@ async function main() {
2121
}
2222

2323
if (startupPlan.kind === "mcp-serve") {
24-
serveHunkMcpServer();
25-
await new Promise<never>(() => {});
24+
const server = serveHunkMcpServer();
25+
await server.stopped;
26+
return;
2627
}
2728

2829
if (startupPlan.kind === "session-command") {

src/mcp/server.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,10 @@ export interface ServeHunkMcpServerOptions {
3333
staleSessionSweepIntervalMs?: number;
3434
}
3535

36+
export type RunningHunkMcpServer = ReturnType<typeof Bun.serve<{}>> & {
37+
stopped: Promise<void>;
38+
};
39+
3640
function formatDaemonServeError(error: unknown, host: string, port: number) {
3741
const message = error instanceof Error ? error.message : String(error);
3842
const normalized = message.toLowerCase();
@@ -161,14 +165,18 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request)
161165
}
162166

163167
/** Serve the local Hunk session daemon and websocket session broker. */
164-
export function serveHunkMcpServer(options: ServeHunkMcpServerOptions = {}) {
168+
export function serveHunkMcpServer(options: ServeHunkMcpServerOptions = {}): RunningHunkMcpServer {
165169
const config = resolveHunkMcpConfig();
166170
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
167171
const staleSessionTtlMs = options.staleSessionTtlMs ?? DEFAULT_STALE_SESSION_TTL_MS;
168172
const staleSessionSweepIntervalMs =
169173
options.staleSessionSweepIntervalMs ?? DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS;
170174
const state = new HunkDaemonState();
171175
const startedAt = Date.now();
176+
let resolveStopped: (() => void) | null = null;
177+
const stopped = new Promise<void>((resolve) => {
178+
resolveStopped = resolve;
179+
});
172180
let lastActivityAt = startedAt;
173181
let shuttingDown = false;
174182
let sweepTimer: Timer | null = null;
@@ -203,6 +211,8 @@ export function serveHunkMcpServer(options: ServeHunkMcpServerOptions = {}) {
203211

204212
state.shutdown();
205213
server?.stop(true);
214+
resolveStopped?.();
215+
resolveStopped = null;
206216
};
207217

208218
const refreshIdleShutdownTimer = () => {
@@ -354,5 +364,5 @@ export function serveHunkMcpServer(options: ServeHunkMcpServerOptions = {}) {
354364
console.log(`Hunk session daemon listening on ${config.httpOrigin}${HUNK_SESSION_API_PATH}`);
355365
console.log(`Hunk session websocket listening on ${config.wsOrigin}${HUNK_SESSION_SOCKET_PATH}`);
356366

357-
return server;
367+
return Object.assign(server, { stopped }) as RunningHunkMcpServer;
358368
}

test/mcp-serve-process.test.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import type { Subprocess } from "bun";
3+
import { createServer } from "node:net";
4+
5+
const repoRoot = process.cwd();
6+
const spawned: Subprocess[] = [];
7+
8+
async function reserveLoopbackPort() {
9+
const listener = createServer(() => undefined);
10+
await new Promise<void>((resolve, reject) => {
11+
listener.once("error", reject);
12+
listener.listen(0, "127.0.0.1", () => resolve());
13+
});
14+
15+
const address = listener.address();
16+
const port = typeof address === "object" && address ? address.port : 0;
17+
await new Promise<void>((resolve) => listener.close(() => resolve()));
18+
return port;
19+
}
20+
21+
async function waitUntil<T>(
22+
label: string,
23+
fn: () => Promise<T | null> | T | null,
24+
timeoutMs = 1_500,
25+
intervalMs = 20,
26+
) {
27+
const deadline = Date.now() + timeoutMs;
28+
29+
for (;;) {
30+
const value = await fn();
31+
if (value !== null) {
32+
return value;
33+
}
34+
35+
if (Date.now() >= deadline) {
36+
throw new Error(`Timed out waiting for ${label}.`);
37+
}
38+
39+
await Bun.sleep(intervalMs);
40+
}
41+
}
42+
43+
async function readHealth(port: number) {
44+
try {
45+
const response = await fetch(`http://127.0.0.1:${port}/health`);
46+
if (!response.ok) {
47+
return null;
48+
}
49+
50+
return (await response.json()) as { ok: boolean; pid: number };
51+
} catch {
52+
return null;
53+
}
54+
}
55+
56+
afterEach(async () => {
57+
await Promise.allSettled(
58+
spawned.splice(0).map(async (proc) => {
59+
try {
60+
proc.kill();
61+
} catch {
62+
// Ignore processes that already exited.
63+
}
64+
65+
await proc.exited.catch(() => undefined);
66+
}),
67+
);
68+
});
69+
70+
describe("mcp serve process lifecycle", () => {
71+
test("exits cleanly after SIGTERM instead of hot-looping after server shutdown", async () => {
72+
const port = await reserveLoopbackPort();
73+
const proc = Bun.spawn(["bun", "run", "src/main.tsx", "mcp", "serve"], {
74+
cwd: repoRoot,
75+
stdin: "ignore",
76+
stdout: "pipe",
77+
stderr: "pipe",
78+
env: {
79+
...process.env,
80+
HUNK_MCP_PORT: String(port),
81+
},
82+
});
83+
spawned.push(proc);
84+
85+
const health = await waitUntil("daemon health", () => readHealth(port), 3_000, 50);
86+
expect(health).toMatchObject({ ok: true, pid: proc.pid });
87+
88+
let exited = false;
89+
void proc.exited.then(() => {
90+
exited = true;
91+
});
92+
93+
process.kill(proc.pid, "SIGTERM");
94+
95+
await waitUntil("mcp serve process exit", () => (exited ? true : null), 1_500, 25);
96+
await waitUntil("daemon port close", async () =>
97+
(await readHealth(port)) === null ? true : null,
98+
);
99+
}, 10_000);
100+
});

0 commit comments

Comments
 (0)