Skip to content

Commit fb0a041

Browse files
authored
mcp: shut down idle session daemons (#96)
1 parent 0d30d39 commit fb0a041

3 files changed

Lines changed: 304 additions & 25 deletions

File tree

src/mcp/daemonState.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,10 @@ export class HunkDaemonState {
163163
return comments.filter((comment) => comment.filePath === filter.filePath);
164164
}
165165

166+
getSessionCount() {
167+
return this.sessions.size;
168+
}
169+
166170
getPendingCommandCount() {
167171
return this.pendingCommands.size;
168172
}

src/mcp/server.ts

Lines changed: 107 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,9 @@ import {
1111
type SessionDaemonResponse,
1212
} from "../session/protocol";
1313

14-
const STALE_SESSION_TTL_MS = 45_000;
15-
const STALE_SESSION_SWEEP_INTERVAL_MS = 15_000;
14+
const DEFAULT_STALE_SESSION_TTL_MS = 45_000;
15+
const DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS = 15_000;
16+
const DEFAULT_IDLE_TIMEOUT_MS = 60_000;
1617

1718
const SUPPORTED_SESSION_ACTIONS: SessionDaemonAction[] = [
1819
"list",
@@ -26,6 +27,12 @@ const SUPPORTED_SESSION_ACTIONS: SessionDaemonAction[] = [
2627
"comment-clear",
2728
];
2829

30+
export interface ServeHunkMcpServerOptions {
31+
idleTimeoutMs?: number;
32+
staleSessionTtlMs?: number;
33+
staleSessionSweepIntervalMs?: number;
34+
}
35+
2936
function formatDaemonServeError(error: unknown, host: string, port: number) {
3037
const message = error instanceof Error ? error.message : String(error);
3138
const normalized = message.toLowerCase();
@@ -154,18 +161,90 @@ async function handleSessionApiRequest(state: HunkDaemonState, request: Request)
154161
}
155162

156163
/** Serve the local Hunk session daemon and websocket session broker. */
157-
export function serveHunkMcpServer() {
164+
export function serveHunkMcpServer(options: ServeHunkMcpServerOptions = {}) {
158165
const config = resolveHunkMcpConfig();
166+
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS;
167+
const staleSessionTtlMs = options.staleSessionTtlMs ?? DEFAULT_STALE_SESSION_TTL_MS;
168+
const staleSessionSweepIntervalMs =
169+
options.staleSessionSweepIntervalMs ?? DEFAULT_STALE_SESSION_SWEEP_INTERVAL_MS;
159170
const state = new HunkDaemonState();
160171
const startedAt = Date.now();
172+
let lastActivityAt = startedAt;
161173
let shuttingDown = false;
174+
let sweepTimer: Timer | null = null;
175+
let idleTimer: Timer | null = null;
176+
let server: ReturnType<typeof Bun.serve<{}>> | null = null;
177+
178+
const hasActiveWork = () => state.getSessionCount() > 0 || state.getPendingCommandCount() > 0;
179+
180+
const clearIdleShutdownTimer = () => {
181+
if (!idleTimer) {
182+
return;
183+
}
184+
185+
clearTimeout(idleTimer);
186+
idleTimer = null;
187+
};
188+
189+
const shutdown = () => {
190+
if (shuttingDown) {
191+
return;
192+
}
193+
194+
shuttingDown = true;
195+
if (sweepTimer) {
196+
clearInterval(sweepTimer);
197+
sweepTimer = null;
198+
}
199+
200+
clearIdleShutdownTimer();
201+
process.off("SIGINT", shutdown);
202+
process.off("SIGTERM", shutdown);
203+
204+
state.shutdown();
205+
server?.stop(true);
206+
};
207+
208+
const refreshIdleShutdownTimer = () => {
209+
clearIdleShutdownTimer();
210+
211+
if (shuttingDown || idleTimeoutMs <= 0 || hasActiveWork()) {
212+
return;
213+
}
214+
215+
const idleForMs = Date.now() - lastActivityAt;
216+
const remainingMs = Math.max(0, idleTimeoutMs - idleForMs);
162217

163-
const sweepTimer = setInterval(() => {
164-
state.pruneStaleSessions({ ttlMs: STALE_SESSION_TTL_MS });
165-
}, STALE_SESSION_SWEEP_INTERVAL_MS);
218+
idleTimer = setTimeout(() => {
219+
idleTimer = null;
220+
221+
if (shuttingDown || hasActiveWork()) {
222+
return;
223+
}
224+
225+
if (Date.now() - lastActivityAt < idleTimeoutMs) {
226+
refreshIdleShutdownTimer();
227+
return;
228+
}
229+
230+
shutdown();
231+
}, remainingMs);
232+
idleTimer.unref?.();
233+
};
234+
235+
const noteActivity = () => {
236+
lastActivityAt = Date.now();
237+
refreshIdleShutdownTimer();
238+
};
239+
240+
sweepTimer = setInterval(() => {
241+
const removed = state.pruneStaleSessions({ ttlMs: staleSessionTtlMs });
242+
if (removed > 0) {
243+
noteActivity();
244+
}
245+
}, staleSessionSweepIntervalMs);
166246
sweepTimer.unref?.();
167247

168-
let server: ReturnType<typeof Bun.serve<{}>>;
169248
try {
170249
server = Bun.serve<{}>({
171250
hostname: config.host,
@@ -174,7 +253,11 @@ export function serveHunkMcpServer() {
174253
const url = new URL(request.url);
175254

176255
if (url.pathname === "/health") {
177-
state.pruneStaleSessions({ ttlMs: STALE_SESSION_TTL_MS });
256+
const removed = state.pruneStaleSessions({ ttlMs: staleSessionTtlMs });
257+
if (removed > 0) {
258+
noteActivity();
259+
}
260+
178261
return Response.json({
179262
ok: true,
180263
pid: process.pid,
@@ -183,17 +266,19 @@ export function serveHunkMcpServer() {
183266
sessionApi: `${config.httpOrigin}${HUNK_SESSION_API_PATH}`,
184267
sessionCapabilities: `${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`,
185268
sessionSocket: `${config.wsOrigin}${HUNK_SESSION_SOCKET_PATH}`,
186-
sessions: state.listSessions().length,
269+
sessions: state.getSessionCount(),
187270
pendingCommands: state.getPendingCommandCount(),
188-
staleSessionTtlMs: STALE_SESSION_TTL_MS,
271+
staleSessionTtlMs,
189272
});
190273
}
191274

192275
if (url.pathname === HUNK_SESSION_CAPABILITIES_PATH) {
276+
noteActivity();
193277
return Response.json(sessionCapabilities());
194278
}
195279

196280
if (url.pathname === HUNK_SESSION_API_PATH) {
281+
noteActivity();
197282
return handleSessionApiRequest(state, request);
198283
}
199284

@@ -230,44 +315,41 @@ export function serveHunkMcpServer() {
230315
switch (parsed.type) {
231316
case "register":
232317
state.registerSession(socket, parsed.registration, parsed.snapshot);
318+
noteActivity();
233319
break;
234320
case "snapshot":
235321
state.updateSnapshot(parsed.sessionId, parsed.snapshot);
322+
noteActivity();
236323
break;
237324
case "heartbeat":
238325
state.markSessionSeen(parsed.sessionId);
326+
noteActivity();
239327
break;
240328
case "command-result":
241329
state.handleCommandResult(parsed);
330+
noteActivity();
242331
break;
243332
}
244333
},
245334
close: (socket) => {
246335
state.unregisterSocket(socket);
336+
noteActivity();
247337
},
248338
},
249339
});
250340
} catch (error) {
251-
clearInterval(sweepTimer);
252-
throw formatDaemonServeError(error, config.host, config.port);
253-
}
254-
255-
const shutdown = () => {
256-
if (shuttingDown) {
257-
return;
341+
if (sweepTimer) {
342+
clearInterval(sweepTimer);
343+
sweepTimer = null;
258344
}
259345

260-
shuttingDown = true;
261-
clearInterval(sweepTimer);
262-
process.off("SIGINT", shutdown);
263-
process.off("SIGTERM", shutdown);
264-
265-
state.shutdown();
266-
server.stop(true);
267-
};
346+
clearIdleShutdownTimer();
347+
throw formatDaemonServeError(error, config.host, config.port);
348+
}
268349

269350
process.once("SIGINT", shutdown);
270351
process.once("SIGTERM", shutdown);
352+
refreshIdleShutdownTimer();
271353

272354
console.log(`Hunk session daemon listening on ${config.httpOrigin}${HUNK_SESSION_API_PATH}`);
273355
console.log(`Hunk session websocket listening on ${config.wsOrigin}${HUNK_SESSION_SOCKET_PATH}`);

0 commit comments

Comments
 (0)