From 46281f02057851a048f641bc35b6e4df6d517703 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 9 Jun 2026 16:16:18 -0400 Subject: [PATCH 1/2] fix(session): time out daemon CLI calls --- CHANGELOG.md | 1 + src/hunk-session/cli.test.ts | 16 +++++++ src/hunk-session/cli.ts | 43 +++++++++++------ src/session/capabilities.test.ts | 23 +++++++++ src/session/capabilities.ts | 56 +++++++++++++--------- src/session/daemonHttp.test.ts | 47 ++++++++++++++++++ src/session/daemonHttp.ts | 81 ++++++++++++++++++++++++++++++++ 7 files changed, 230 insertions(+), 37 deletions(-) create mode 100644 src/session/daemonHttp.test.ts create mode 100644 src/session/daemonHttp.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a5201e31..6bd29ce2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ All notable user-visible changes to Hunk are documented in this file. ### Fixed +- Added timeouts to `hunk session *` daemon capability and API calls so unresponsive daemons fail instead of hanging indefinitely. - Updated OpenTUI so light and dark theme backgrounds render without the native renderer's color shift. - Prevented Git watch polling from taking optional index locks while discovering untracked files. diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts index 2e6273ab..440fc5c3 100644 --- a/src/hunk-session/cli.test.ts +++ b/src/hunk-session/cli.test.ts @@ -95,6 +95,7 @@ describe("HTTP Hunk session CLI client", () => { globalThis.fetch = (async (input, init) => { const url = String(input); + expect(init?.signal).toBeInstanceOf(AbortSignal); if (url.endsWith(`${HUNK_SESSION_API_PATH}/capabilities`)) { return Response.json({ version: HUNK_SESSION_API_VERSION, @@ -245,6 +246,21 @@ describe("HTTP Hunk session CLI client", () => { ]); }); + test("times out hung daemon session API requests", async () => { + globalThis.fetch = (async (_input, init) => { + const signal = init?.signal as AbortSignal | undefined; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }) as typeof fetch; + + const client = createHttpHunkSessionCliClient({ timeoutMs: 10 }); + + await expect(client.listSessions()).rejects.toThrow( + "Timed out waiting for the Hunk session daemon to complete session list.", + ); + }); + test("throws daemon response errors with JSON messages or status text fallbacks", async () => { globalThis.fetch = (async () => Response.json( diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index 2248c02b..ac512c06 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -1,6 +1,10 @@ import { resolveSessionBrokerConfig } from "../session-broker/brokerConfig"; import type { SessionTerminalLocation, SessionTerminalMetadata } from "@hunk/session-broker-core"; import { readHunkSessionDaemonCapabilities } from "../session/capabilities"; +import { + HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS, + requestSessionDaemonHttp, +} from "../session/daemonHttp"; import { HUNK_SESSION_API_PATH, type SessionDaemonCapabilities, @@ -65,24 +69,33 @@ async function extractResponseError(response: Response) { class HttpHunkSessionCliClient implements HunkSessionCliClient { private readonly config = resolveSessionBrokerConfig(); + constructor(private readonly timeoutMs = HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS) {} + private async request(input: SessionDaemonRequest) { - const response = await fetch(`${this.config.httpOrigin}${HUNK_SESSION_API_PATH}`, { - method: "POST", - headers: { - "content-type": "application/json", + return requestSessionDaemonHttp({ + config: this.config, + path: HUNK_SESSION_API_PATH, + operation: `complete session ${input.action}`, + timeoutMs: this.timeoutMs, + init: { + method: "POST", + headers: { + "content-type": "application/json", + }, + body: JSON.stringify(input), }, - body: JSON.stringify(input), - }); + parse: async (response) => { + if (!response.ok) { + throw new Error(await extractResponseError(response)); + } - if (!response.ok) { - throw new Error(await extractResponseError(response)); - } - - return (await response.json()) as ResultType; + return (await response.json()) as ResultType; + }, + }); } async getCapabilities() { - return readHunkSessionDaemonCapabilities(this.config); + return readHunkSessionDaemonCapabilities(this.config, this.timeoutMs); } async listSessions() { @@ -197,8 +210,10 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { } /** Create the concrete Hunk session CLI client that speaks to the broker-backed HTTP API. */ -export function createHttpHunkSessionCliClient(): HunkSessionCliClient { - return new HttpHunkSessionCliClient(); +export function createHttpHunkSessionCliClient({ + timeoutMs, +}: { timeoutMs?: number } = {}): HunkSessionCliClient { + return new HttpHunkSessionCliClient(timeoutMs); } export function stringifyJson(value: unknown) { diff --git a/src/session/capabilities.test.ts b/src/session/capabilities.test.ts index 6fe1fafa..879ee678 100644 --- a/src/session/capabilities.test.ts +++ b/src/session/capabilities.test.ts @@ -4,6 +4,7 @@ import { readHunkSessionDaemonCapabilities } from "./capabilities"; import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "./protocol"; const servers = new Set>(); +const originalFetch = globalThis.fetch; async function listen( handler: (request: IncomingMessage, response: ServerResponse) => void, @@ -30,6 +31,7 @@ async function listen( } afterEach(async () => { + globalThis.fetch = originalFetch; await Promise.all( [...servers].map( (server) => @@ -42,6 +44,27 @@ afterEach(async () => { }); describe("readHunkSessionDaemonCapabilities", () => { + test("times out hung capability requests", async () => { + globalThis.fetch = (async (_input, init) => { + const signal = init?.signal as AbortSignal | undefined; + return new Promise((_resolve, reject) => { + signal?.addEventListener("abort", () => reject(signal.reason), { once: true }); + }); + }) as typeof fetch; + + await expect( + readHunkSessionDaemonCapabilities( + { + host: "127.0.0.1", + port: 47657, + httpOrigin: "http://127.0.0.1:47657", + wsOrigin: "ws://127.0.0.1:47657", + }, + 10, + ), + ).rejects.toThrow("Timed out waiting for the Hunk session daemon to report capabilities."); + }); + test("returns null for non-ok capability responses so callers can trigger daemon refresh", async () => { const { config } = await listen((_request: IncomingMessage, response: ServerResponse) => { response.writeHead(500, { "content-type": "application/json" }); diff --git a/src/session/capabilities.ts b/src/session/capabilities.ts index 010090b4..b746a33a 100644 --- a/src/session/capabilities.ts +++ b/src/session/capabilities.ts @@ -8,6 +8,7 @@ import { HUNK_SESSION_DAEMON_VERSION, type SessionDaemonCapabilities, } from "./protocol"; +import { HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS, requestSessionDaemonHttp } from "./daemonHttp"; export const HUNK_DAEMON_UPGRADE_RESTART_NOTICE = "[hunk:session] Restarting stale session daemon after upgrade."; @@ -23,32 +24,41 @@ export function reportHunkDaemonUpgradeRestart(log: (message: string) => void = */ export async function readHunkSessionDaemonCapabilities( config: ResolvedSessionBrokerConfig = resolveSessionBrokerConfig(), + timeoutMs = HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS, ): Promise { - const response = await fetch(`${config.httpOrigin}${HUNK_SESSION_CAPABILITIES_PATH}`); - if (response.status === 404 || response.status === 410) { - return null; - } + return requestSessionDaemonHttp({ + config, + path: HUNK_SESSION_CAPABILITIES_PATH, + operation: "report capabilities", + timeoutMs, + parse: async (response) => { + if (response.status === 404 || response.status === 410) { + return null; + } - if (!response.ok) { - return null; - } + if (!response.ok) { + return null; + } - let capabilities: unknown; - try { - capabilities = await response.json(); - } catch { - return null; - } + let capabilities: unknown; + try { + capabilities = await response.json(); + } catch { + return null; + } - if ( - !capabilities || - typeof capabilities !== "object" || - (capabilities as { version?: unknown }).version !== HUNK_SESSION_API_VERSION || - (capabilities as { daemonVersion?: unknown }).daemonVersion !== HUNK_SESSION_DAEMON_VERSION || - !Array.isArray((capabilities as { actions?: unknown }).actions) - ) { - return null; - } + if ( + !capabilities || + typeof capabilities !== "object" || + (capabilities as { version?: unknown }).version !== HUNK_SESSION_API_VERSION || + (capabilities as { daemonVersion?: unknown }).daemonVersion !== + HUNK_SESSION_DAEMON_VERSION || + !Array.isArray((capabilities as { actions?: unknown }).actions) + ) { + return null; + } - return capabilities as SessionDaemonCapabilities; + return capabilities as SessionDaemonCapabilities; + }, + }); } diff --git a/src/session/daemonHttp.test.ts b/src/session/daemonHttp.test.ts new file mode 100644 index 00000000..2b90d3c9 --- /dev/null +++ b/src/session/daemonHttp.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, test } from "bun:test"; +import { requestSessionDaemonHttp, withSessionDaemonHttpTimeout } from "./daemonHttp"; + +function createLatePromise(value: ResultType, delayMs = 100) { + return new Promise((resolve) => { + const timeout = setTimeout(() => resolve(value), delayMs); + timeout.unref?.(); + }); +} + +describe("session daemon HTTP timeout wrapper", () => { + test("times out tasks that ignore abort signals", async () => { + await expect( + withSessionDaemonHttpTimeout({ + operation: "finish a stubborn request", + timeoutMs: 10, + task: async () => createLatePromise("late"), + }), + ).rejects.toThrow( + "Timed out waiting for the Hunk session daemon to finish a stubborn request.", + ); + }); + + test("keeps the timeout active while callers parse the response body", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => new Response("ok")) as unknown as typeof fetch; + + try { + await expect( + requestSessionDaemonHttp({ + config: { + host: "127.0.0.1", + port: 47657, + httpOrigin: "http://127.0.0.1:47657", + wsOrigin: "ws://127.0.0.1:47657", + }, + path: "/session-api", + operation: "parse a stuck body", + timeoutMs: 10, + parse: async () => createLatePromise("late"), + }), + ).rejects.toThrow("Timed out waiting for the Hunk session daemon to parse a stuck body."); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); diff --git a/src/session/daemonHttp.ts b/src/session/daemonHttp.ts new file mode 100644 index 00000000..5b55638c --- /dev/null +++ b/src/session/daemonHttp.ts @@ -0,0 +1,81 @@ +import { HunkUserError } from "../core/errors"; +import { + resolveSessionBrokerConfig, + type ResolvedSessionBrokerConfig, +} from "../session-broker/brokerConfig"; + +export const HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS = 5_000; + +function createTimeoutError(operation: string, timeoutMs: number) { + return new HunkUserError(`Timed out waiting for the Hunk session daemon to ${operation}.`, [ + `The daemon did not respond within ${timeoutMs}ms.`, + 'Run "hunk daemon serve" or open a Hunk window, then retry.', + ]); +} + +function isAbortError(error: unknown) { + return error instanceof DOMException && error.name === "AbortError"; +} + +/** Run one daemon HTTP operation with a timeout that covers connect, headers, and body parsing. */ +export async function withSessionDaemonHttpTimeout({ + operation, + timeoutMs = HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS, + task, +}: { + operation: string; + timeoutMs?: number; + task: (signal: AbortSignal) => Promise; +}) { + const controller = new AbortController(); + const timeoutError = createTimeoutError(operation, timeoutMs); + let timeoutTriggered = false; + let timeout: ReturnType; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => { + timeoutTriggered = true; + controller.abort(timeoutError); + reject(timeoutError); + }, timeoutMs); + timeout.unref?.(); + }); + const taskPromise = task(controller.signal); + + try { + return await Promise.race([taskPromise, timeoutPromise]); + } catch (error) { + if (timeoutTriggered || controller.signal.aborted || isAbortError(error)) { + throw timeoutError; + } + + throw error; + } finally { + clearTimeout(timeout!); + // If the daemon ignores abort and later rejects, do not surface an unhandled rejection after + // the CLI has already returned the timeout error to the user. + taskPromise.catch(() => undefined); + } +} + +/** Fetch one daemon HTTP endpoint and keep the timeout active until the response is consumed. */ +export function requestSessionDaemonHttp({ + config = resolveSessionBrokerConfig(), + path, + init, + operation, + timeoutMs, + parse, +}: { + config?: ResolvedSessionBrokerConfig; + path: string; + init?: RequestInit; + operation: string; + timeoutMs?: number; + parse: (response: Response) => Promise; +}) { + return withSessionDaemonHttpTimeout({ + operation, + timeoutMs, + task: async (signal) => parse(await fetch(`${config.httpOrigin}${path}`, { ...init, signal })), + }); +} From 32956b94038cea3cd2248a3f5bc47d10a4b22f12 Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Tue, 9 Jun 2026 22:19:20 -0400 Subject: [PATCH 2/2] fix(session): keep daemon timeout guard referenced --- src/session/daemonHttp.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/session/daemonHttp.ts b/src/session/daemonHttp.ts index 5b55638c..b38a2dcf 100644 --- a/src/session/daemonHttp.ts +++ b/src/session/daemonHttp.ts @@ -32,12 +32,13 @@ export async function withSessionDaemonHttpTimeout({ let timeoutTriggered = false; let timeout: ReturnType; const timeoutPromise = new Promise((_resolve, reject) => { + // Keep this timer referenced: Bun 1.3.x on Windows can skip unref'ed timeout guards, + // which leaves CLI requests and tests hung forever. timeout = setTimeout(() => { timeoutTriggered = true; controller.abort(timeoutError); reject(timeoutError); }, timeoutMs); - timeout.unref?.(); }); const taskPromise = task(controller.signal);