Skip to content

Commit ef4fb1e

Browse files
authored
feat: harden MCP daemon lifecycle (#36)
1 parent f83882a commit ef4fb1e

8 files changed

Lines changed: 524 additions & 128 deletions

File tree

src/mcp/client.ts

Lines changed: 89 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
import type { AppliedCommentResult, HunkSessionRegistration, HunkSessionSnapshot, SessionClientMessage, SessionServerMessage } from "./types";
22
import { HUNK_SESSION_SOCKET_PATH, resolveHunkMcpConfig } from "./config";
3-
import { isHunkDaemonHealthy, launchHunkDaemon, waitForHunkDaemonHealth } from "./daemonLauncher";
3+
import { isHunkDaemonHealthy, isLoopbackPortReachable, launchHunkDaemon, waitForHunkDaemonHealth } from "./daemonLauncher";
4+
5+
const DAEMON_LAUNCH_COOLDOWN_MS = 5_000;
6+
const DAEMON_STARTUP_TIMEOUT_MS = 3_000;
7+
const RECONNECT_DELAY_MS = 3_000;
8+
const HEARTBEAT_INTERVAL_MS = 10_000;
49

510
export interface HunkAppBridge {
611
applyComment: (message: Extract<SessionServerMessage, { command: "comment" }>) => Promise<AppliedCommentResult>;
@@ -12,9 +17,11 @@ export class HunkHostClient {
1217
private bridge: HunkAppBridge | null = null;
1318
private queuedMessages: SessionServerMessage[] = [];
1419
private reconnectTimer: Timer | null = null;
20+
private heartbeatTimer: Timer | null = null;
1521
private stopped = false;
1622
private startupPromise: Promise<void> | null = null;
1723
private lastDaemonLaunchStartedAt = 0;
24+
private lastConnectionWarning: string | null = null;
1825
private readonly config = resolveHunkMcpConfig();
1926

2027
constructor(
@@ -31,9 +38,18 @@ export class HunkHostClient {
3138
return;
3239
}
3340

34-
this.startupPromise = this.ensureDaemonAndConnect().finally(() => {
35-
this.startupPromise = null;
36-
});
41+
this.startupPromise = this.ensureDaemonAndConnect()
42+
.catch((error) => {
43+
if (this.stopped) {
44+
return;
45+
}
46+
47+
this.warnUnavailable(error);
48+
this.scheduleReconnect();
49+
})
50+
.finally(() => {
51+
this.startupPromise = null;
52+
});
3753
}
3854

3955
stop() {
@@ -43,6 +59,7 @@ export class HunkHostClient {
4359
this.reconnectTimer = null;
4460
}
4561

62+
this.stopHeartbeat();
4663
this.websocket?.close();
4764
this.websocket = null;
4865
}
@@ -54,19 +71,38 @@ export class HunkHostClient {
5471

5572
private async ensureDaemonAvailable() {
5673
if (await isHunkDaemonHealthy(this.config)) {
74+
this.lastConnectionWarning = null;
5775
return;
5876
}
5977

60-
const launchCooldownMs = 5_000;
61-
if (Date.now() - this.lastDaemonLaunchStartedAt < launchCooldownMs) {
62-
return;
78+
const shouldLaunch = Date.now() - this.lastDaemonLaunchStartedAt >= DAEMON_LAUNCH_COOLDOWN_MS;
79+
if (shouldLaunch) {
80+
this.lastDaemonLaunchStartedAt = Date.now();
81+
launchHunkDaemon();
6382
}
6483

65-
this.lastDaemonLaunchStartedAt = Date.now();
66-
launchHunkDaemon();
67-
await waitForHunkDaemonHealth({
84+
const ready = await waitForHunkDaemonHealth({
6885
config: this.config,
86+
timeoutMs: shouldLaunch ? DAEMON_STARTUP_TIMEOUT_MS : 1_500,
6987
});
88+
89+
if (ready) {
90+
this.lastConnectionWarning = null;
91+
return;
92+
}
93+
94+
const portReachable = await isLoopbackPortReachable(this.config);
95+
if (portReachable) {
96+
throw new Error(
97+
`Hunk MCP port ${this.config.host}:${this.config.port} is already in use by another process. ` +
98+
`Stop the conflicting process or set HUNK_MCP_PORT to a different loopback port.`,
99+
);
100+
}
101+
102+
throw new Error(
103+
`Timed out waiting for the Hunk MCP daemon on ${this.config.host}:${this.config.port}. ` +
104+
`Hunk will retry in the background.`,
105+
);
70106
}
71107

72108
setBridge(bridge: HunkAppBridge | null) {
@@ -93,6 +129,8 @@ export class HunkHostClient {
93129

94130
websocket.onopen = () => {
95131
this.lastDaemonLaunchStartedAt = 0;
132+
this.lastConnectionWarning = null;
133+
this.startHeartbeat();
96134
this.send({
97135
type: "register",
98136
registration: this.registration,
@@ -117,7 +155,11 @@ export class HunkHostClient {
117155
};
118156

119157
websocket.onclose = () => {
120-
this.websocket = null;
158+
if (this.websocket === websocket) {
159+
this.websocket = null;
160+
}
161+
162+
this.stopHeartbeat();
121163
if (!this.stopped) {
122164
this.scheduleReconnect();
123165
}
@@ -128,18 +170,41 @@ export class HunkHostClient {
128170
};
129171
}
130172

131-
private scheduleReconnect() {
173+
private scheduleReconnect(delayMs = RECONNECT_DELAY_MS) {
132174
if (this.reconnectTimer || this.stopped) {
133175
return;
134176
}
135177

136178
this.reconnectTimer = setTimeout(() => {
137179
this.reconnectTimer = null;
138-
void this.ensureDaemonAndConnect();
139-
}, 3_000);
180+
this.start();
181+
}, delayMs);
140182
this.reconnectTimer.unref?.();
141183
}
142184

185+
private startHeartbeat() {
186+
if (this.heartbeatTimer) {
187+
return;
188+
}
189+
190+
this.heartbeatTimer = setInterval(() => {
191+
this.send({
192+
type: "heartbeat",
193+
sessionId: this.registration.sessionId,
194+
});
195+
}, HEARTBEAT_INTERVAL_MS);
196+
this.heartbeatTimer.unref?.();
197+
}
198+
199+
private stopHeartbeat() {
200+
if (!this.heartbeatTimer) {
201+
return;
202+
}
203+
204+
clearInterval(this.heartbeatTimer);
205+
this.heartbeatTimer = null;
206+
}
207+
143208
private send(message: SessionClientMessage) {
144209
if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) {
145210
return;
@@ -184,4 +249,14 @@ export class HunkHostClient {
184249
await this.handleServerMessage(message);
185250
}
186251
}
252+
253+
private warnUnavailable(error: unknown) {
254+
const message = error instanceof Error ? error.message : "Unknown Hunk MCP connection error.";
255+
if (message === this.lastConnectionWarning) {
256+
return;
257+
}
258+
259+
this.lastConnectionWarning = message;
260+
console.error(`[hunk:mcp] ${message}`);
261+
}
187262
}

src/mcp/daemonLauncher.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { spawn } from "node:child_process";
22
import type { ChildProcess } from "node:child_process";
3+
import { connect } from "node:net";
34
import { resolveHunkMcpConfig, type ResolvedHunkMcpConfig } from "./config";
45

56
const SCRIPT_ENTRYPOINT_PATTERN = /[\\/]|\.(?:[cm]?js|tsx?)$/;
@@ -45,6 +46,36 @@ export async function isHunkDaemonHealthy(config: ResolvedHunkMcpConfig = resolv
4546
}
4647
}
4748

49+
/** Check whether some local process is already accepting TCP connections on the daemon port. */
50+
export function isLoopbackPortReachable(
51+
config: Pick<ResolvedHunkMcpConfig, "host" | "port"> = resolveHunkMcpConfig(),
52+
timeoutMs = 500,
53+
) {
54+
return new Promise<boolean>((resolve) => {
55+
let settled = false;
56+
const socket = connect({
57+
host: config.host,
58+
port: config.port,
59+
});
60+
61+
const finish = (value: boolean) => {
62+
if (settled) {
63+
return;
64+
}
65+
66+
settled = true;
67+
socket.destroy();
68+
resolve(value);
69+
};
70+
71+
socket.setTimeout(timeoutMs);
72+
socket.unref?.();
73+
socket.once("connect", () => finish(true));
74+
socket.once("timeout", () => finish(false));
75+
socket.once("error", () => finish(false));
76+
});
77+
}
78+
4879
/** Wait briefly for a just-launched daemon to become reachable on its health endpoint. */
4980
export async function waitForHunkDaemonHealth({
5081
config = resolveHunkMcpConfig(),

0 commit comments

Comments
 (0)