diff --git a/src/cli/stdin-reader.ts b/src/cli/stdin-reader.ts index 04173de8..83090034 100644 --- a/src/cli/stdin-reader.ts +++ b/src/cli/stdin-reader.ts @@ -1,12 +1,11 @@ -// Stdin reading utility extracted from hook patterns -// See src/hooks/save-hook.ts for the original pattern - -// Inactivity timeout for stdin reading - if no data arrives within this time, -// we parse whatever data we have. This fixes issue #727 where hooks hang at "1/2 done" -// because stdin.on('end') never fires. -// Using inactivity timeout (reset on each data chunk) instead of absolute timeout -// to avoid truncating large/slow payloads. -const STDIN_INACTIVITY_TIMEOUT_MS = 5000; +// Stdin reading utility for Claude Code hooks +// +// Problem: Claude Code doesn't close stdin after writing hook input, +// so stdin.on('end') never fires and hooks hang indefinitely (#727). +// +// Solution: JSON is self-delimiting. We detect complete JSON by attempting +// to parse after each chunk. Once we have valid JSON, we resolve immediately +// without waiting for EOF. This is the proper fix, not a timeout workaround. /** * Check if stdin is available and readable. @@ -17,8 +16,6 @@ const STDIN_INACTIVITY_TIMEOUT_MS = 5000; */ function isStdinAvailable(): boolean { try { - // Accessing stdin properties can trigger Bun's lazy fstat() call - // which crashes if the fd is invalid const stdin = process.stdin; // If stdin is a TTY, we're running interactively (not from Claude Code hook) @@ -39,6 +36,33 @@ function isStdinAvailable(): boolean { } } +/** + * Try to parse the accumulated input as JSON. + * Returns the parsed value if successful, undefined if incomplete/invalid. + */ +function tryParseJson(input: string): { success: true; value: unknown } | { success: false } { + const trimmed = input.trim(); + if (!trimmed) { + return { success: false }; + } + + try { + const value = JSON.parse(trimmed); + return { success: true, value }; + } catch { + // JSON is incomplete or invalid + return { success: false }; + } +} + +// Safety timeout - only kicks in if JSON never completes (malformed input). +// This should rarely/never be hit in normal operation since we detect complete JSON. +const SAFETY_TIMEOUT_MS = 30000; + +// Short delay after last data chunk to try parsing +// This handles the case where JSON arrives in multiple chunks +const PARSE_DELAY_MS = 50; + export async function readJsonFromStdin(): Promise { // First, check if stdin is even available // This catches the Bun EINVAL crash from issue #646 @@ -49,7 +73,7 @@ export async function readJsonFromStdin(): Promise { return new Promise((resolve, reject) => { let input = ''; let resolved = false; - let timeoutId: ReturnType; + let parseDelayId: ReturnType | null = null; const cleanup = () => { try { @@ -61,55 +85,92 @@ export async function readJsonFromStdin(): Promise { } }; - const resolveWithData = () => { + const resolveWith = (value: unknown) => { if (resolved) return; resolved = true; - clearTimeout(timeoutId); + if (parseDelayId) clearTimeout(parseDelayId); + clearTimeout(safetyTimeoutId); cleanup(); - try { - resolve(input.trim() ? JSON.parse(input) : undefined); - } catch (e) { - reject(new Error(`Failed to parse hook input: ${e}`)); + resolve(value); + }; + + const rejectWith = (error: Error) => { + if (resolved) return; + resolved = true; + if (parseDelayId) clearTimeout(parseDelayId); + clearTimeout(safetyTimeoutId); + cleanup(); + reject(error); + }; + + const tryResolveWithJson = () => { + const result = tryParseJson(input); + if (result.success) { + resolveWith(result.value); + return true; } + return false; }; - // Reset the inactivity timeout - called on each data chunk - const resetTimeout = () => { - clearTimeout(timeoutId); - timeoutId = setTimeout(() => { - if (!resolved) { - resolveWithData(); + // Safety timeout - fallback if JSON never completes + const safetyTimeoutId = setTimeout(() => { + if (!resolved) { + // Try one final parse attempt + if (!tryResolveWithJson()) { + // If we have data but it's not valid JSON, that's an error + if (input.trim()) { + rejectWith(new Error(`Incomplete JSON after ${SAFETY_TIMEOUT_MS}ms: ${input.slice(0, 100)}...`)); + } else { + // No data received - resolve with undefined + resolveWith(undefined); + } } - }, STDIN_INACTIVITY_TIMEOUT_MS); - }; - - // Start initial timeout - resetTimeout(); + } + }, SAFETY_TIMEOUT_MS); try { process.stdin.on('data', (chunk) => { input += chunk; - // Reset timeout on each data chunk to avoid truncating large/slow payloads - resetTimeout(); + + // Clear any pending parse delay + if (parseDelayId) { + clearTimeout(parseDelayId); + parseDelayId = null; + } + + // Try to parse immediately - if JSON is complete, resolve now + if (tryResolveWithJson()) { + return; + } + + // If immediate parse failed, set a short delay and try again + // This handles multi-chunk delivery where the last chunk completes the JSON + parseDelayId = setTimeout(() => { + tryResolveWithJson(); + }, PARSE_DELAY_MS); }); process.stdin.on('end', () => { - resolveWithData(); + // stdin closed - parse whatever we have + if (!resolved) { + if (!tryResolveWithJson()) { + // Empty or invalid - resolve with undefined + resolveWith(input.trim() ? undefined : undefined); + } + } }); process.stdin.on('error', () => { - if (resolved) return; - resolved = true; - clearTimeout(timeoutId); - cleanup(); - // Don't reject on stdin errors - just return undefined - // This is more graceful for hook execution - resolve(undefined); + if (!resolved) { + // Don't reject on stdin errors - just return undefined + // This is more graceful for hook execution + resolveWith(undefined); + } }); } catch { // If attaching listeners fails (Bun stdin issue), resolve with undefined resolved = true; - clearTimeout(timeoutId); + clearTimeout(safetyTimeoutId); cleanup(); resolve(undefined); }