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}`); }