From 7d40c937fb0aed1dea1518efeaafafca2ab39185 Mon Sep 17 00:00:00 2001 From: jedarden Date: Thu, 25 Jun 2026 07:29:46 -0400 Subject: [PATCH] feat(bf-2f5): add comprehensive watchdog timeout mechanism MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement a complete watchdog timeout system that ensures hung child processes are terminated cleanly with proper diagnostics and cleanup. Features: - PTY first-output timeout (default 90s): detects if child produces no PTY output - Stream-json first-output timeout (default 90s): detects if child produces no stream-json events - Overall session timeout (default 3600s): prevents indefinite hangs - Stop hook watchdog timeout (default 120s): detects if Stop hook doesn't fire after prompt injection Timeout handling: - Sends SIGTERM to child process when timeout fires - kill_child() ensures SIGTERM → SIGKILL sequence (2s grace period) - Writes clear diagnostic to stderr indicating timeout type - Emits stream-json error event for downstream consumers - CleanupGuard ensures temp dir/FIFO cleanup on all exit paths - Returns Error::Timeout and exits non-zero (code 3) for retry loop Fixes: - Pass temp_dir_path to Watchdog so stream-json monitoring works correctly - Remove unused constants (duplicates of watchdog module defaults) - Improve mock-claude binary path resolution for workspace builds This prevents the indefinite hang that occurs when Claude Code wedges during session initialization or tool use, ensuring marathon loops and NEEDLE can retry cleanly instead of blocking forever. Bead-Id: bf-2f5 --- src/session.rs | 17 ++++------------- tests/watchdog.rs | 18 ++++++++++++------ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/src/session.rs b/src/session.rs index 6665184..45123be 100644 --- a/src/session.rs +++ b/src/session.rs @@ -68,14 +68,6 @@ pub fn cleanup_temp_dir() { pub struct Session; impl Session { - /// Default first-output timeout in seconds. - /// If the child produces no output within this time, we assume it's hung. - const DEFAULT_FIRST_OUTPUT_TIMEOUT_SECS: u64 = 90; - - /// Default stream-json first-output timeout in seconds. - /// If the child produces no stream-json events within this time, we assume it's hung. - const DEFAULT_STREAM_JSON_TIMEOUT_SECS: u64 = 90; - /// Run a Claude Code session. /// /// # Arguments @@ -171,11 +163,10 @@ impl Session { stop_hook_timeout_secs, ); - // Get transcript path for stream-json monitoring (will be resolved from stop payload) - // For now, we don't know the transcript path, so we pass None - // The watchdog will monitor PTY output and overall timeout, and stream-json monitoring - // will be handled by the main thread via the emitter - let watchdog = Watchdog::new(watchdog_config, spawner.child_pid, None); + // Get temp directory path for stream-json monitoring + // The watchdog will monitor /transcript.jsonl for stream-json output + let temp_dir_path = installer.dir_path().to_path_buf(); + let watchdog = Watchdog::new(watchdog_config, spawner.child_pid, Some(temp_dir_path)); let watchdog_state = watchdog.state(); diff --git a/tests/watchdog.rs b/tests/watchdog.rs index 481a74d..680743e 100644 --- a/tests/watchdog.rs +++ b/tests/watchdog.rs @@ -8,14 +8,20 @@ use claude_print::error::Error; use claude_print::session::Session; use std::ffi::OsString; -/// Locate the mock-claude binary compiled alongside the test binary. -/// Test binaries live at `target//deps/`; other bins at `target//`. +/// Locate the mock-claude binary. +/// +/// In a workspace, binaries are built to the workspace target directory, not the +/// individual project's target directory. The test binary lives at `target//deps/` +/// (within the project), but mock-claude is built to `/target//`. fn mock_claude_bin() -> std::path::PathBuf { + // Get the test executable path let exe = std::env::current_exe().expect("current_exe"); - let profile_dir = exe - .parent() // deps/ - .and_then(|p| p.parent()) // target// - .expect("unexpected test binary path"); + + // Walk up from the test binary to find the workspace root + // Test binary: /target//deps/watchdog- + // We need: /target//mock-claude + let deps_dir = exe.parent().expect("no parent"); // deps/ + let profile_dir = deps_dir.parent().expect("no grandparent"); // target// profile_dir.join("mock-claude") }