Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@
| Auto-registration in Claude, Codex, Gemini, Cursor | |
| Polling file watcher with filtered directory walker | |
| Portable snapshot for instant MCP startup | |
| Singleton MCP with PID lock + 10min idle timeout | |
| Singleton MCP with PID lock + 1h idle timeout | |
| Sensitive file blocking (.env, credentials, keys) | |
| Codesigned + notarized macOS binaries | |
| SHA256 checksum verification in installer | |
Expand Down
14 changes: 6 additions & 8 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -945,15 +945,11 @@ fn scanBg(io: std.Io, store: *Store, explorer: *Explorer, root: []const u8, allo
}
fn idleWatchdog(shutdown: *std.atomic.Value(bool)) void {
const mcp = @import("mcp.zig");
const stdin = cio.File.stdin();
while (!shutdown.load(.acquire)) {
// Sleep in 1s increments for responsive shutdown
for (0..10) |_| {
if (shutdown.load(.acquire)) return;
cio.sleepMs(1000);
}

// Quick liveness check: poll stdin for POLLHUP (client disconnected)
const stdin = cio.File.stdin();
// Quick liveness check: poll stdin for POLLHUP (client disconnected).
// This stays independent from the longer idle timeout so dead MCP
// clients are reaped promptly.
var poll_fds = [_]std.posix.pollfd{.{
.fd = stdin.handle,
.events = std.posix.POLL.IN | std.posix.POLL.HUP,
Expand All @@ -977,5 +973,7 @@ fn idleWatchdog(shutdown: *std.atomic.Value(bool)) void {
shutdown.store(true, .release);
return;
}

cio.sleepMs(mcp.dead_client_poll_ms);
}
}
5 changes: 4 additions & 1 deletion src/mcp.zig
Original file line number Diff line number Diff line change
Expand Up @@ -446,7 +446,10 @@ pub var last_activity: std.atomic.Value(i64) = std.atomic.Value(i64).init(0);

/// How long (ms) the server may sit idle before auto-exiting.
/// Claude Code restarts MCP servers on demand, so this is safe.
pub const idle_timeout_ms: i64 = 10 * 60 * 1000; // 10 minutes — allows long debugging sessions; stdin EOF is detected by the watchdog poll
pub const idle_timeout_ms: i64 = 60 * 60 * 1000; // 1 hour — allows long debugging sessions; stdin EOF is still detected separately.

/// How often the watchdog checks whether the MCP client disconnected.
pub const dead_client_poll_ms: u64 = 1000;

// ── Serve-first scan state (issue #207) ─────────────────────────────────────
//
Expand Down
35 changes: 20 additions & 15 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -5136,9 +5136,14 @@ test "issue-116: getGitHead returns valid SHA for git repos" {
}
}

test "issue-148: idle timeout is 10 minutes" {
test "issue-148: idle timeout is 1 hour" {
const mcp = @import("mcp.zig");
try testing.expectEqual(@as(i64, 10 * 60 * 1000), mcp.idle_timeout_ms);
try testing.expectEqual(@as(i64, 60 * 60 * 1000), mcp.idle_timeout_ms);
}

test "issue-148: dead MCP clients are polled every second" {
const mcp = @import("mcp.zig");
try testing.expectEqual(@as(u64, 1000), mcp.dead_client_poll_ms);
}

test "issue-148: POLLHUP detects closed pipe" {
Expand Down Expand Up @@ -5204,31 +5209,31 @@ test "issue-148: idle watchdog respects activity timestamp" {
// Set activity to "just now"
mcp.last_activity.store(cio.milliTimestamp(), .release);

// With 10-minute timeout, checking now should NOT trigger exit
// With 1-hour timeout, checking now should NOT trigger exit
const last = mcp.last_activity.load(.acquire);
const now = cio.milliTimestamp();
try testing.expect(now - last < mcp.idle_timeout_ms);
}

test "issue-148: MCP session survives 2-minute idle" {
test "issue-148: MCP session survives 30-minute idle" {
const mcp = @import("mcp.zig");
// With the old 2-min timeout, an activity 3 minutes ago would trigger exit.
// With the new 10-min timeout, it should be fine.
const three_min_ago = cio.milliTimestamp() - (3 * 60 * 1000);
// With the old 10-min timeout, an activity 30 minutes ago would trigger exit.
// With the new 1-hour timeout, it should be fine.
const thirty_min_ago = cio.milliTimestamp() - (30 * 60 * 1000);

// Save and restore
const saved = mcp.last_activity.load(.acquire);
defer mcp.last_activity.store(saved, .release);

mcp.last_activity.store(three_min_ago, .release);
mcp.last_activity.store(thirty_min_ago, .release);
const last = mcp.last_activity.load(.acquire);
const now = cio.milliTimestamp();

// Should NOT exceed 10-minute timeout
// Should NOT exceed 1-hour timeout
try testing.expect(now - last < mcp.idle_timeout_ms);

// Should have exceeded old 2-minute timeout
try testing.expect(now - last > 2 * 60 * 1000);
// Should have exceeded old 10-minute timeout
try testing.expect(now - last > 10 * 60 * 1000);
}

test "issue-148: open pipe does not trigger HUP" {
Expand Down Expand Up @@ -5270,8 +5275,8 @@ test "issue-148: codedb mcp exits when stdin is closed" {
child.stdin = null;
}

// Wait up to 15 seconds for the process to exit
// (watchdog polls every 10s, so it should detect POLLHUP within ~10s)
// Wait for the process to exit. The main read loop exits on stdin EOF;
// the watchdog also polls dead clients every second as a backup.
const start = cio.milliTimestamp();
const term = child.wait(io) catch {
// If wait fails, the process is stuck — test fails
Expand All @@ -5287,8 +5292,8 @@ test "issue-148: codedb mcp exits when stdin is closed" {
else => {},
}

// Should exit within 15 seconds (10s poll interval + margin)
try testing.expect(elapsed < 15_000);
// Should exit promptly after stdin closes.
try testing.expect(elapsed < 5_000);
}

const MmapTrigramIndex = @import("index.zig").MmapTrigramIndex;
Expand Down
Loading