From 1dd456a6ca00022cb96482ecd917ba59d8b67b0a Mon Sep 17 00:00:00 2001 From: Rod Boev Date: Thu, 5 Feb 2026 10:05:44 -0500 Subject: [PATCH] Add fetch timeouts to Stop hook and health checks Replace bare fetch() calls with fetchWithTimeout() using Promise.race instead of AbortSignal.timeout() which causes libuv assertion crashes in Bun on Windows. Applies the already-defined HEALTH_CHECK_TIMEOUT_MS (30s/45s) to isWorkerHealthy() and getWorkerVersion(), and HOOK_TIMEOUTS.DEFAULT to the summarize POST. Previously these fetches had no timeout at all, causing the Stop hook to hang for the full hook timeout (120s) when the worker was unreachable. Fixes #963 --- src/cli/handlers/summarize.ts | 27 ++++++++++++++++----------- src/shared/worker-utils.ts | 25 +++++++++++++++++++++---- 2 files changed, 37 insertions(+), 15 deletions(-) diff --git a/src/cli/handlers/summarize.ts b/src/cli/handlers/summarize.ts index 6d79a078..bd0756ae 100644 --- a/src/cli/handlers/summarize.ts +++ b/src/cli/handlers/summarize.ts @@ -7,10 +7,12 @@ */ import type { EventHandler, NormalizedHookInput, HookResult } from '../types.js'; -import { ensureWorkerRunning, getWorkerPort } from '../../shared/worker-utils.js'; +import { ensureWorkerRunning, getWorkerPort, fetchWithTimeout } from '../../shared/worker-utils.js'; import { logger } from '../../utils/logger.js'; import { extractLastMessage } from '../../shared/transcript-parser.js'; -import { HOOK_EXIT_CODES } from '../../shared/hook-constants.js'; +import { HOOK_EXIT_CODES, HOOK_TIMEOUTS, getTimeout } from '../../shared/hook-constants.js'; + +const SUMMARIZE_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.DEFAULT); export const summarizeHandler: EventHandler = { async execute(input: NormalizedHookInput): Promise { @@ -41,15 +43,18 @@ export const summarizeHandler: EventHandler = { }); // Send to worker - worker handles privacy check and database operations - const response = await fetch(`http://127.0.0.1:${port}/api/sessions/summarize`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - contentSessionId: sessionId, - last_assistant_message: lastAssistantMessage - }) - // Note: Removed signal to avoid Windows Bun cleanup issue (libuv assertion) - }); + const response = await fetchWithTimeout( + `http://127.0.0.1:${port}/api/sessions/summarize`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + contentSessionId: sessionId, + last_assistant_message: lastAssistantMessage + }), + }, + SUMMARIZE_TIMEOUT_MS + ); if (!response.ok) { // Return standard response even on failure (matches original behavior) diff --git a/src/shared/worker-utils.ts b/src/shared/worker-utils.ts index 406fae0f..b3985e50 100644 --- a/src/shared/worker-utils.ts +++ b/src/shared/worker-utils.ts @@ -10,6 +10,21 @@ const MARKETPLACE_ROOT = path.join(homedir(), '.claude', 'plugins', 'marketplace // Named constants for health checks const HEALTH_CHECK_TIMEOUT_MS = getTimeout(HOOK_TIMEOUTS.HEALTH_CHECK); +/** + * Fetch with a timeout using Promise.race instead of AbortSignal. + * AbortSignal.timeout() causes a libuv assertion crash in Bun on Windows, + * so we use a racing setTimeout pattern that avoids signal cleanup entirely. + * The orphaned fetch is harmless since the process exits shortly after. + */ +export function fetchWithTimeout(url: string, init: RequestInit = {}, timeoutMs: number): Promise { + return Promise.race([ + fetch(url, init), + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Request timed out after ${timeoutMs}ms`)), timeoutMs) + ), + ]); +} + // Cache to avoid repeated settings file reads let cachedPort: number | null = null; let cachedHost: string | null = null; @@ -65,8 +80,9 @@ export function clearPortCache(): void { */ async function isWorkerHealthy(): Promise { const port = getWorkerPort(); - // Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion) - const response = await fetch(`http://127.0.0.1:${port}/api/health`); + const response = await fetchWithTimeout( + `http://127.0.0.1:${port}/api/health`, {}, HEALTH_CHECK_TIMEOUT_MS + ); return response.ok; } @@ -84,8 +100,9 @@ function getPluginVersion(): string { */ async function getWorkerVersion(): Promise { const port = getWorkerPort(); - // Note: Removed AbortSignal.timeout to avoid Windows Bun cleanup issue (libuv assertion) - const response = await fetch(`http://127.0.0.1:${port}/api/version`); + const response = await fetchWithTimeout( + `http://127.0.0.1:${port}/api/version`, {}, HEALTH_CHECK_TIMEOUT_MS + ); if (!response.ok) { throw new Error(`Failed to get worker version: ${response.status}`); }