From 746c1b7f1026783549f1f88ce652e1a266a3d354 Mon Sep 17 00:00:00 2001 From: Aaron Fields Date: Sat, 28 Feb 2026 08:21:36 -0500 Subject: [PATCH 1/3] fix: add timeout to SDK metadata extraction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit extractSDKMetadata() has no timeout — if the spawned SDK process hangs (e.g. due to inaccessible filesystems or missing credentials), the extraction waits indefinitely, leaking a process for the session lifetime. Add a 10-second timeout via setTimeout + AbortController. If the SDK query doesn't produce an init message within the timeout, the query is aborted and empty metadata is returned gracefully. Normal extraction completes in ~1-2 seconds, so 10 seconds provides ample headroom. --- .../backends/claude/sdk/metadataExtractor.ts | 38 +++++++++++++------ 1 file changed, 26 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/backends/claude/sdk/metadataExtractor.ts b/apps/cli/src/backends/claude/sdk/metadataExtractor.ts index 5b4a2195f..512dd9876 100644 --- a/apps/cli/src/backends/claude/sdk/metadataExtractor.ts +++ b/apps/cli/src/backends/claude/sdk/metadataExtractor.ts @@ -12,16 +12,27 @@ export interface SDKMetadata { slashCommands?: string[] } +/** Maximum time to wait for SDK metadata extraction before giving up. */ +const METADATA_EXTRACTION_TIMEOUT_MS = 10_000 + /** - * Extract SDK metadata by running a minimal query and capturing the init message + * Extract SDK metadata by running a minimal query and capturing the init message. + * + * Times out after METADATA_EXTRACTION_TIMEOUT_MS to prevent indefinite hangs + * when the spawned SDK process blocks (e.g. on slow or inaccessible filesystems). + * * @returns SDK metadata containing tools and slash commands */ export async function extractSDKMetadata(): Promise { const abortController = new AbortController() - + const timeoutId = setTimeout(() => { + logger.debug(`[metadataExtractor] Extraction timed out after ${METADATA_EXTRACTION_TIMEOUT_MS}ms`) + abortController.abort() + }, METADATA_EXTRACTION_TIMEOUT_MS) + try { logger.debug('[metadataExtractor] Starting SDK metadata extraction') - + // Run SDK with minimal tools allowed const sdkQuery = query({ prompt: 'hello', @@ -36,28 +47,31 @@ export async function extractSDKMetadata(): Promise { for await (const message of sdkQuery) { if (message.type === 'system' && message.subtype === 'init') { const systemMessage = message as SDKSystemMessage - + const metadata: SDKMetadata = { tools: systemMessage.tools, slashCommands: systemMessage.slash_commands } - + logger.debug('[metadataExtractor] Captured SDK metadata:', metadata) - + // Abort the query since we got what we need + clearTimeout(timeoutId) abortController.abort() - + return metadata } } - + + clearTimeout(timeoutId) logger.debug('[metadataExtractor] No init message received from SDK') return {} - + } catch (error) { - // Check if it's an abort error (expected) + clearTimeout(timeoutId) + // Check if it's an abort error (expected — either from timeout or after capture) if (error instanceof Error && error.name === 'AbortError') { - logger.debug('[metadataExtractor] SDK query aborted after capturing metadata') + logger.debug('[metadataExtractor] SDK query aborted (timeout or after capturing metadata)') return {} } logger.debug('[metadataExtractor] Error extracting SDK metadata:', error) @@ -79,4 +93,4 @@ export function extractSDKMetadataAsync(onComplete: (metadata: SDKMetadata) => v .catch(error => { logger.debug('[metadataExtractor] Async extraction failed:', error) }) -} \ No newline at end of file +} From f20fc788fbb69dd3c4cf3acb9f88f23e9b223830 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 1 Mar 2026 10:00:11 +0100 Subject: [PATCH 2/3] fix(cli): make claude SDK metadata extraction timeout configurable --- .../claude/sdk/metadataExtractor.test.ts | 151 ++++++++++++++++++ .../backends/claude/sdk/metadataExtractor.ts | 29 +++- 2 files changed, 172 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts diff --git a/apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts b/apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts new file mode 100644 index 000000000..8c52ee004 --- /dev/null +++ b/apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts @@ -0,0 +1,151 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +function withEnv(vars: Record, fn: () => Promise): Promise { + const prev: Record = {}; + for (const [key, value] of Object.entries(vars)) { + prev[key] = process.env[key]; + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + return fn().finally(() => { + for (const [key, value] of Object.entries(prev)) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + }); +} + +async function waitForFile(path: string, timeoutMs: number): Promise { + const start = Date.now(); + for (;;) { + if (existsSync(path)) return; + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for file: ${path}`); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +async function waitForPidToExit(pid: number, timeoutMs: number): Promise { + const start = Date.now(); + for (;;) { + try { + process.kill(pid, 0); + } catch { + return; + } + if (Date.now() - start > timeoutMs) { + throw new Error(`Timed out waiting for PID ${pid} to exit`); + } + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + let timeoutId: NodeJS.Timeout | null = null; + const timeoutPromise = new Promise((_resolve, reject) => { + timeoutId = setTimeout(() => reject(new Error(`Timed out waiting for ${label} after ${timeoutMs}ms`)), timeoutMs); + }); + return Promise.race([promise, timeoutPromise]).finally(() => { + if (timeoutId) clearTimeout(timeoutId); + }); +} + +describe.sequential('claude sdk metadata extractor', () => { + let tmpRoot = ''; + + beforeEach(() => { + vi.resetModules(); + if (tmpRoot) { + rmSync(tmpRoot, { recursive: true, force: true }); + tmpRoot = ''; + } + }); + + it('aborts the claude process after capturing init metadata (no leak)', { timeout: 20_000 }, async () => { + tmpRoot = mkdtempSync(join(tmpdir(), 'happier-claude-metadata-extractor-init-')); + const pidFile = join(tmpRoot, 'pid.txt'); + const fakeClaude = join(tmpRoot, 'fake-claude.js'); + writeFileSync( + fakeClaude, + ` + const { writeFileSync } = require('node:fs'); + const pidFile = ${JSON.stringify(pidFile)}; + writeFileSync(pidFile, String(process.pid), 'utf8'); + + process.stdout.write(JSON.stringify({ + type: 'system', + subtype: 'init', + tools: ['tool-a'], + slash_commands: ['/cmd'], + }) + '\\n'); + + process.on('SIGTERM', () => process.exit(0)); + setInterval(() => {}, 1000); + `, + 'utf8', + ); + + await withEnv( + { + HAPPIER_CLAUDE_PATH: fakeClaude, + }, + async () => { + const { extractSDKMetadata } = await import('./metadataExtractor'); + const metadata = await withTimeout(extractSDKMetadata(), 2_000, 'extractSDKMetadata to resolve'); + expect(metadata).toEqual({ tools: ['tool-a'], slashCommands: ['/cmd'] }); + + await waitForFile(pidFile, 1_000); + const pid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10); + await waitForPidToExit(pid, 2_000); + }, + ); + }); + + it('respects a configurable extraction timeout and aborts on hang', { timeout: 20_000 }, async () => { + tmpRoot = mkdtempSync(join(tmpdir(), 'happier-claude-metadata-extractor-timeout-')); + const pidFile = join(tmpRoot, 'pid.txt'); + const fakeClaude = join(tmpRoot, 'fake-claude.js'); + writeFileSync( + fakeClaude, + ` + const { writeFileSync } = require('node:fs'); + const pidFile = ${JSON.stringify(pidFile)}; + writeFileSync(pidFile, String(process.pid), 'utf8'); + process.on('SIGTERM', () => process.exit(0)); + setInterval(() => {}, 1000); + `, + 'utf8', + ); + + await withEnv( + { + HAPPIER_CLAUDE_PATH: fakeClaude, + HAPPIER_CLAUDE_SDK_METADATA_EXTRACTION_TIMEOUT_MS: '75', + }, + async () => { + const { extractSDKMetadata } = await import('./metadataExtractor'); + const resultPromise = extractSDKMetadata(); + + await waitForFile(pidFile, 1_000); + const pid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10); + + try { + await expect(withTimeout(resultPromise, 750, 'extractSDKMetadata to timeout')).resolves.toEqual({}); + } finally { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // ignore + } + await waitForPidToExit(pid, 2_000); + } + }, + ); + }); +}); + diff --git a/apps/cli/src/backends/claude/sdk/metadataExtractor.ts b/apps/cli/src/backends/claude/sdk/metadataExtractor.ts index 512dd9876..0304578ec 100644 --- a/apps/cli/src/backends/claude/sdk/metadataExtractor.ts +++ b/apps/cli/src/backends/claude/sdk/metadataExtractor.ts @@ -12,23 +12,37 @@ export interface SDKMetadata { slashCommands?: string[] } -/** Maximum time to wait for SDK metadata extraction before giving up. */ -const METADATA_EXTRACTION_TIMEOUT_MS = 10_000 +const DEFAULT_METADATA_EXTRACTION_TIMEOUT_MS = 10_000 +const MIN_METADATA_EXTRACTION_TIMEOUT_MS = 10 +const MAX_METADATA_EXTRACTION_TIMEOUT_MS = 120_000 + +function resolveMetadataExtractionTimeoutMs(): number { + const raw = typeof process.env.HAPPIER_CLAUDE_SDK_METADATA_EXTRACTION_TIMEOUT_MS === 'string' + ? process.env.HAPPIER_CLAUDE_SDK_METADATA_EXTRACTION_TIMEOUT_MS.trim() + : '' + const parsed = Number.parseInt(raw, 10) + if (!Number.isFinite(parsed)) return DEFAULT_METADATA_EXTRACTION_TIMEOUT_MS + return Math.max(MIN_METADATA_EXTRACTION_TIMEOUT_MS, Math.min(MAX_METADATA_EXTRACTION_TIMEOUT_MS, parsed)) +} /** * Extract SDK metadata by running a minimal query and capturing the init message. * - * Times out after METADATA_EXTRACTION_TIMEOUT_MS to prevent indefinite hangs + * Times out after the configured extraction timeout to prevent indefinite hangs * when the spawned SDK process blocks (e.g. on slow or inaccessible filesystems). * * @returns SDK metadata containing tools and slash commands */ export async function extractSDKMetadata(): Promise { const abortController = new AbortController() + const timeoutMs = resolveMetadataExtractionTimeoutMs() const timeoutId = setTimeout(() => { - logger.debug(`[metadataExtractor] Extraction timed out after ${METADATA_EXTRACTION_TIMEOUT_MS}ms`) + logger.debug(`[metadataExtractor] Extraction timed out after ${timeoutMs}ms`) abortController.abort() - }, METADATA_EXTRACTION_TIMEOUT_MS) + }, timeoutMs) + if (typeof timeoutId.unref === 'function') { + timeoutId.unref() + } try { logger.debug('[metadataExtractor] Starting SDK metadata extraction') @@ -56,19 +70,16 @@ export async function extractSDKMetadata(): Promise { logger.debug('[metadataExtractor] Captured SDK metadata:', metadata) // Abort the query since we got what we need - clearTimeout(timeoutId) abortController.abort() return metadata } } - clearTimeout(timeoutId) logger.debug('[metadataExtractor] No init message received from SDK') return {} } catch (error) { - clearTimeout(timeoutId) // Check if it's an abort error (expected — either from timeout or after capture) if (error instanceof Error && error.name === 'AbortError') { logger.debug('[metadataExtractor] SDK query aborted (timeout or after capturing metadata)') @@ -76,6 +87,8 @@ export async function extractSDKMetadata(): Promise { } logger.debug('[metadataExtractor] Error extracting SDK metadata:', error) return {} + } finally { + clearTimeout(timeoutId) } } From a8990bf9efa593f6ea453d3269cba372c2999982 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sun, 1 Mar 2026 10:09:01 +0100 Subject: [PATCH 3/3] test(cli): stabilize metadata extractor timeout test --- .../claude/sdk/metadataExtractor.test.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts b/apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts index 8c52ee004..a9a1f63ec 100644 --- a/apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts +++ b/apps/cli/src/backends/claude/sdk/metadataExtractor.test.ts @@ -125,27 +125,35 @@ describe.sequential('claude sdk metadata extractor', () => { await withEnv( { HAPPIER_CLAUDE_PATH: fakeClaude, - HAPPIER_CLAUDE_SDK_METADATA_EXTRACTION_TIMEOUT_MS: '75', + // Allow enough time for the subprocess to start under load, but keep the test fast. + HAPPIER_CLAUDE_SDK_METADATA_EXTRACTION_TIMEOUT_MS: '500', }, async () => { const { extractSDKMetadata } = await import('./metadataExtractor'); const resultPromise = extractSDKMetadata(); - await waitForFile(pidFile, 1_000); - const pid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10); + // Best-effort: if the child starts before the timeout triggers, ensure it exits. + let pid: number | null = null; + try { + await waitForFile(pidFile, 2_000); + pid = Number.parseInt(readFileSync(pidFile, 'utf8').trim(), 10); + } catch { + // ignore (abort may fire before the child starts) + } try { - await expect(withTimeout(resultPromise, 750, 'extractSDKMetadata to timeout')).resolves.toEqual({}); + await expect(withTimeout(resultPromise, 3_000, 'extractSDKMetadata to timeout')).resolves.toEqual({}); } finally { - try { - process.kill(pid, 'SIGTERM'); - } catch { - // ignore + if (pid) { + try { + process.kill(pid, 'SIGTERM'); + } catch { + // ignore + } + await waitForPidToExit(pid, 2_000); } - await waitForPidToExit(pid, 2_000); } }, ); }); }); -