Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
16 changes: 16 additions & 0 deletions src/hunk-session/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Response>((_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(
Expand Down
43 changes: 29 additions & 14 deletions src/hunk-session/cli.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<ResultType>(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() {
Expand Down Expand Up @@ -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) {
Expand Down
23 changes: 23 additions & 0 deletions src/session/capabilities.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { readHunkSessionDaemonCapabilities } from "./capabilities";
import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "./protocol";

const servers = new Set<ReturnType<typeof createServer>>();
const originalFetch = globalThis.fetch;

async function listen(
handler: (request: IncomingMessage, response: ServerResponse<IncomingMessage>) => void,
Expand All @@ -30,6 +31,7 @@ async function listen(
}

afterEach(async () => {
globalThis.fetch = originalFetch;
await Promise.all(
[...servers].map(
(server) =>
Expand All @@ -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<Response>((_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" });
Expand Down
56 changes: 33 additions & 23 deletions src/session/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.";
Expand All @@ -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<SessionDaemonCapabilities | null> {
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;
},
});
}
47 changes: 47 additions & 0 deletions src/session/daemonHttp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, test } from "bun:test";
import { requestSessionDaemonHttp, withSessionDaemonHttpTimeout } from "./daemonHttp";

function createLatePromise<ResultType>(value: ResultType, delayMs = 100) {
return new Promise<ResultType>((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;
}
});
});
82 changes: 82 additions & 0 deletions src/session/daemonHttp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
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<ResultType>({
operation,
timeoutMs = HUNK_SESSION_DAEMON_HTTP_TIMEOUT_MS,
task,
}: {
operation: string;
timeoutMs?: number;
task: (signal: AbortSignal) => Promise<ResultType>;
}) {
const controller = new AbortController();
const timeoutError = createTimeoutError(operation, timeoutMs);
let timeoutTriggered = false;
let timeout: ReturnType<typeof setTimeout>;
const timeoutPromise = new Promise<never>((_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);
});
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<ResultType>({
config = resolveSessionBrokerConfig(),
path,
init,
operation,
timeoutMs,
parse,
}: {
config?: ResolvedSessionBrokerConfig;
path: string;
init?: RequestInit;
operation: string;
timeoutMs?: number;
parse: (response: Response) => Promise<ResultType>;
}) {
return withSessionDaemonHttpTimeout({
operation,
timeoutMs,
task: async (signal) => parse(await fetch(`${config.httpOrigin}${path}`, { ...init, signal })),
});
}
Loading