Skip to content

Commit 6743d27

Browse files
authored
fix(session): refresh stale daemons after upgrades (#178)
1 parent 5431c49 commit 6743d27

13 files changed

+384
-114
lines changed

src/mcp/client.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ function createRegistration() {
2121
repoRoot: process.cwd(),
2222
sourceLabel: "before.ts -> after.ts",
2323
title: "before.ts ↔ after.ts",
24-
reviewFiles: [createTestSessionReviewFile({ path: "after.ts" })],
24+
files: [createTestSessionReviewFile({ path: "after.ts" })],
2525
});
2626
}
2727

@@ -101,6 +101,35 @@ describe("Hunk MCP client", () => {
101101
}
102102
}, 10_000);
103103

104+
test("restartIncompatibleDaemon lets startup recover when the stale daemon already exited", async () => {
105+
const server = createServer((_request, response) => {
106+
response.writeHead(404, { "content-type": "text/plain" });
107+
response.end("gone");
108+
});
109+
await new Promise<void>((resolve, reject) => {
110+
server.once("error", reject);
111+
server.listen(0, "127.0.0.1", () => resolve());
112+
});
113+
114+
const address = server.address();
115+
const port = typeof address === "object" && address ? address.port : 0;
116+
const config = {
117+
host: "127.0.0.1",
118+
port,
119+
httpOrigin: `http://127.0.0.1:${port}`,
120+
wsOrigin: `ws://127.0.0.1:${port}`,
121+
};
122+
123+
const client = new HunkHostClient(createRegistration(), createSnapshot());
124+
125+
try {
126+
await expect((client as any).restartIncompatibleDaemon(config)).resolves.toBeUndefined();
127+
} finally {
128+
client.stop();
129+
await new Promise<void>((resolve) => server.close(() => resolve()));
130+
}
131+
});
132+
104133
test("logs one actionable warning when a non-Hunk listener owns the MCP port", async () => {
105134
const conflictingListener = createServer((_request, response) => {
106135
response.writeHead(404, { "content-type": "text/plain" });

src/mcp/client.ts

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,15 @@ import {
1515
resolveHunkMcpConfig,
1616
type ResolvedHunkMcpConfig,
1717
} from "./config";
18-
import { ensureHunkDaemonAvailable } from "./daemonLauncher";
18+
import {
19+
ensureHunkDaemonAvailable,
20+
readHunkDaemonHealth,
21+
waitForHunkDaemonShutdown,
22+
} from "./daemonLauncher";
23+
import {
24+
readHunkSessionDaemonCapabilities,
25+
reportHunkDaemonUpgradeRestart,
26+
} from "../session/capabilities";
1927

2028
const DAEMON_STARTUP_TIMEOUT_MS = 3_000;
2129
const RECONNECT_DELAY_MS = 3_000;
@@ -119,9 +127,62 @@ export class HunkHostClient {
119127
config,
120128
timeoutMs: DAEMON_STARTUP_TIMEOUT_MS,
121129
});
130+
131+
const capabilities = await readHunkSessionDaemonCapabilities(config);
132+
if (!capabilities) {
133+
await this.restartIncompatibleDaemon(config);
134+
await ensureHunkDaemonAvailable({
135+
config,
136+
timeoutMs: DAEMON_STARTUP_TIMEOUT_MS,
137+
});
138+
139+
if (!(await readHunkSessionDaemonCapabilities(config))) {
140+
throw new Error(
141+
"The running Hunk session daemon is incompatible with this Hunk build. " +
142+
"Restart Hunk so it can launch a fresh daemon from the current source tree.",
143+
);
144+
}
145+
}
146+
122147
this.lastConnectionWarning = null;
123148
}
124149

150+
private async restartIncompatibleDaemon(config: ResolvedHunkMcpConfig) {
151+
reportHunkDaemonUpgradeRestart();
152+
const health = await readHunkDaemonHealth(config);
153+
const pid = health?.pid;
154+
if (pid === process.pid) {
155+
throw new Error(
156+
"The running Hunk session daemon is incompatible with this Hunk build. " +
157+
"Restart Hunk so it can launch a fresh daemon from the current source tree.",
158+
);
159+
}
160+
161+
// If the stale daemon already disappeared on its own, let the normal startup path launch a
162+
// fresh one instead of turning that race into a manual restart error.
163+
if (!pid) {
164+
return;
165+
}
166+
167+
try {
168+
process.kill(pid, "SIGTERM");
169+
} catch (error) {
170+
if (!(error instanceof Error) || !("code" in error) || error.code !== "ESRCH") {
171+
throw error;
172+
}
173+
}
174+
175+
const shutDown = await waitForHunkDaemonShutdown({
176+
config,
177+
timeoutMs: DAEMON_STARTUP_TIMEOUT_MS,
178+
});
179+
if (!shutDown) {
180+
throw new Error(
181+
"Stopped waiting for the old Hunk session daemon to exit after it was found incompatible.",
182+
);
183+
}
184+
}
185+
125186
setBridge(bridge: HunkAppBridge | null) {
126187
this.bridge = bridge;
127188
void this.flushQueuedMessages();

src/mcp/daemonLauncher.ts

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -286,8 +286,21 @@ export function resolveHunkDaemonRuntimePaths(
286286
};
287287
}
288288

289-
/** Check whether the loopback Hunk daemon already answers health probes. */
290-
export async function isHunkDaemonHealthy(
289+
export interface HunkDaemonHealth {
290+
ok: boolean;
291+
pid?: number;
292+
sessions?: number;
293+
pendingCommands?: number;
294+
startedAt?: string;
295+
uptimeMs?: number;
296+
sessionApi?: string;
297+
sessionCapabilities?: string;
298+
sessionSocket?: string;
299+
staleSessionTtlMs?: number;
300+
}
301+
302+
/** Read the daemon's health payload when one is reachable on the configured loopback port. */
303+
export async function readHunkDaemonHealth(
291304
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
292305
timeoutMs = 500,
293306
) {
@@ -299,15 +312,26 @@ export async function isHunkDaemonHealthy(
299312
const response = await fetch(`${config.httpOrigin}/health`, {
300313
signal: controller.signal,
301314
});
315+
if (!response.ok) {
316+
return null;
317+
}
302318

303-
return response.ok;
319+
return (await response.json()) as HunkDaemonHealth;
304320
} catch {
305-
return false;
321+
return null;
306322
} finally {
307323
clearTimeout(timeout);
308324
}
309325
}
310326

327+
/** Check whether the loopback Hunk daemon already answers health probes. */
328+
export async function isHunkDaemonHealthy(
329+
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
330+
timeoutMs = 500,
331+
) {
332+
return (await readHunkDaemonHealth(config, timeoutMs))?.ok === true;
333+
}
334+
311335
/** Check whether some local process is already accepting TCP connections on the daemon port. */
312336
export function isLoopbackPortReachable(
313337
config: Pick<ResolvedHunkMcpConfig, "host" | "port"> = resolveHunkMcpConfig(),
@@ -338,6 +362,29 @@ export function isLoopbackPortReachable(
338362
});
339363
}
340364

365+
/** Wait for the running daemon to stop responding on its health endpoint. */
366+
export async function waitForHunkDaemonShutdown({
367+
config = resolveHunkMcpConfig(),
368+
timeoutMs = 3_000,
369+
intervalMs = 100,
370+
}: {
371+
config?: ResolvedHunkMcpConfig;
372+
timeoutMs?: number;
373+
intervalMs?: number;
374+
} = {}) {
375+
const deadline = Date.now() + timeoutMs;
376+
377+
while (Date.now() < deadline) {
378+
if (!(await isHunkDaemonHealthy(config))) {
379+
return true;
380+
}
381+
382+
await Bun.sleep(intervalMs);
383+
}
384+
385+
return false;
386+
}
387+
341388
/** Wait briefly for a just-launched daemon to become reachable on its health endpoint. */
342389
export async function waitForHunkDaemonHealth({
343390
config = resolveHunkMcpConfig(),

src/mcp/daemonState.registration.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ function createRegistration(overrides = {}) {
1010
inputKind: "diff",
1111
launchedAt: "2026-03-23T00:00:00.000Z",
1212
pid: 1234,
13-
reviewFiles: [],
13+
files: [],
1414
sessionId: "test-session",
1515
title: "repo diff",
1616
...overrides,

src/mcp/daemonState.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -63,9 +63,9 @@ function findSelectedFile(session: ListedSession) {
6363
}
6464

6565
/** Match one review-export file against the live snapshot's current file selection. */
66-
function findSelectedReviewFile(reviewFiles: SessionReviewFile[], snapshot: HunkSessionSnapshot) {
66+
function findSelectedReviewFile(files: SessionReviewFile[], snapshot: HunkSessionSnapshot) {
6767
return (
68-
reviewFiles.find(
68+
files.find(
6969
(file) =>
7070
file.id === snapshot.selectedFileId ||
7171
file.path === snapshot.selectedFilePath ||
@@ -177,8 +177,8 @@ export class HunkDaemonState {
177177
sourceLabel: entry.registration.sourceLabel,
178178
launchedAt: entry.registration.launchedAt,
179179
terminal: entry.registration.terminal,
180-
fileCount: entry.registration.reviewFiles.length,
181-
files: entry.registration.reviewFiles.map(summarizeReviewFile),
180+
fileCount: entry.registration.files.length,
181+
files: entry.registration.files.map(summarizeReviewFile),
182182
snapshot: entry.snapshot,
183183
}))
184184
.sort((left, right) => right.snapshot.updatedAt.localeCompare(left.snapshot.updatedAt));
@@ -195,7 +195,7 @@ export class HunkDaemonState {
195195
): SessionReview {
196196
const entry = this.getSessionEntry(selector);
197197
const { registration, snapshot } = entry;
198-
const selectedFile = findSelectedReviewFile(registration.reviewFiles, snapshot);
198+
const selectedFile = findSelectedReviewFile(registration.files, snapshot);
199199
const includePatch = options.includePatch ?? false;
200200

201201
return {
@@ -209,7 +209,7 @@ export class HunkDaemonState {
209209
selectedHunk: selectedFile ? (selectedFile.hunks[snapshot.selectedHunkIndex] ?? null) : null,
210210
showAgentNotes: snapshot.showAgentNotes,
211211
liveCommentCount: snapshot.liveCommentCount,
212-
files: registration.reviewFiles.map((file) => serializeReviewFile(file, includePatch)),
212+
files: registration.files.map((file) => serializeReviewFile(file, includePatch)),
213213
};
214214
}
215215

src/mcp/sessionRegistration.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function inferRepoRoot(bootstrap: AppBootstrap) {
3131
}
3232

3333
/** Convert the loaded changeset into the daemon's file-and-hunk review export model. */
34-
function buildSessionReviewFiles(bootstrap: AppBootstrap): SessionReviewFile[] {
34+
function buildSessionFiles(bootstrap: AppBootstrap): SessionReviewFile[] {
3535
return bootstrap.changeset.files.map((file) => ({
3636
id: file.id,
3737
path: file.path,
@@ -62,7 +62,7 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR
6262
sourceLabel: bootstrap.changeset.sourceLabel,
6363
launchedAt: new Date().toISOString(),
6464
terminal,
65-
reviewFiles: buildSessionReviewFiles(bootstrap),
65+
files: buildSessionFiles(bootstrap),
6666
};
6767
}
6868

@@ -77,7 +77,7 @@ export function updateSessionRegistration(
7777
inputKind: bootstrap.input.kind,
7878
title: bootstrap.changeset.title,
7979
sourceLabel: bootstrap.changeset.sourceLabel,
80-
reviewFiles: buildSessionReviewFiles(bootstrap),
80+
files: buildSessionFiles(bootstrap),
8181
};
8282
}
8383

src/mcp/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ export interface HunkSessionRegistration {
6060
sourceLabel: string;
6161
launchedAt: string;
6262
terminal?: SessionTerminalMetadata;
63-
reviewFiles: SessionReviewFile[];
63+
files: SessionReviewFile[];
6464
}
6565

6666
export interface HunkSessionSnapshot {

src/session/capabilities.test.ts

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { afterEach, describe, expect, test } from "bun:test";
2+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
3+
import { readHunkSessionDaemonCapabilities } from "./capabilities";
4+
5+
const servers = new Set<ReturnType<typeof createServer>>();
6+
7+
async function listen(
8+
handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void,
9+
) {
10+
const server = createServer(handler);
11+
servers.add(server);
12+
13+
await new Promise<void>((resolve, reject) => {
14+
server.once("error", reject);
15+
server.listen(0, "127.0.0.1", () => resolve());
16+
});
17+
18+
const address = server.address();
19+
const port = typeof address === "object" && address ? address.port : 0;
20+
return {
21+
server,
22+
config: {
23+
host: "127.0.0.1",
24+
port,
25+
httpOrigin: `http://127.0.0.1:${port}`,
26+
wsOrigin: `ws://127.0.0.1:${port}`,
27+
},
28+
};
29+
}
30+
31+
afterEach(async () => {
32+
await Promise.all(
33+
[...servers].map(
34+
(server) =>
35+
new Promise<void>((resolve) => {
36+
server.close(() => resolve());
37+
}),
38+
),
39+
);
40+
servers.clear();
41+
});
42+
43+
describe("readHunkSessionDaemonCapabilities", () => {
44+
test("returns null for non-ok capability responses so callers can trigger daemon refresh", async () => {
45+
const { config } = await listen((_request: IncomingMessage, response: ServerResponse) => {
46+
response.writeHead(500, { "content-type": "application/json" });
47+
response.end(JSON.stringify({ error: "boom" }));
48+
});
49+
50+
await expect(readHunkSessionDaemonCapabilities(config)).resolves.toBeNull();
51+
});
52+
});

src/session/capabilities.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { resolveHunkMcpConfig, type ResolvedHunkMcpConfig } from "../mcp/config";
2+
import {
3+
HUNK_SESSION_API_VERSION,
4+
HUNK_SESSION_CAPABILITIES_PATH,
5+
type SessionDaemonCapabilities,
6+
} from "./protocol";
7+
8+
export const HUNK_DAEMON_UPGRADE_RESTART_NOTICE =
9+
"[hunk:mcp] Restarting stale session daemon after upgrade.";
10+
11+
/** Tell the user that Hunk is refreshing an old daemon left running across an upgrade. */
12+
export function reportHunkDaemonUpgradeRestart(log: (message: string) => void = console.error) {
13+
log(HUNK_DAEMON_UPGRADE_RESTART_NOTICE);
14+
}
15+
16+
/** Read the live daemon's session API capabilities, returning null for incompatible daemons. */
17+
export async function readHunkSessionDaemonCapabilities(
18+
config: ResolvedHunkMcpConfig = resolveHunkMcpConfig(),
19+
): Promise<SessionDaemonCapabilities | null> {
20+
const response = await fetch(`${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`);
21+
if (response.status === 404 || response.status === 410) {
22+
return null;
23+
}
24+
25+
if (!response.ok) {
26+
return null;
27+
}
28+
29+
let capabilities: unknown;
30+
try {
31+
capabilities = await response.json();
32+
} catch {
33+
return null;
34+
}
35+
36+
if (
37+
!capabilities ||
38+
typeof capabilities !== "object" ||
39+
(capabilities as { version?: unknown }).version !== HUNK_SESSION_API_VERSION ||
40+
!Array.isArray((capabilities as { actions?: unknown }).actions)
41+
) {
42+
return null;
43+
}
44+
45+
return capabilities as SessionDaemonCapabilities;
46+
}

0 commit comments

Comments
 (0)