feat(daemon): idle timeout with MCP heartbeat — stop holding the embedding model in RAM forever - #215
Conversation
27f477d to
6815206
Compare
| if sys.platform == "win32": | ||
| try: | ||
| Client(sock_path, family=connection_family()).close() | ||
| except OSError: | ||
| pass |
There was a problem hiding this comment.
Proposal: ungate this and do it unconditionally?
POSIX doesn't need it, but it's harmless there too.
And if anything breaks in this path, ungating surfaces it locally on any platform instead of waiting for windows-latest.
There was a problem hiding this comment.
Agree, this makes sense. Let's make it unconditional. Thanks!
|
This work surfaced a real bug on Windows. Fix attempt in 16800f6, with a question about an implementation choice (see inline comment). |
…settings The daemon now tracks client activity (every accepted connection, plus each handler task's completion so long streaming index runs count to their end) and exits after daemon.idle_timeout_minutes (default 180, 0 = never) without it, instead of holding the embedding model in RAM forever. The idle exit goes through the existing graceful shutdown path, so it leaves a last_exit marker (reason "idle_timeout") and clients transparently and silently restart the daemon on the next request. - settings: new DaemonSettings dataclass with idle_timeout_minutes, parsed from an optional daemon: section in global_settings.yml (absent section and missing file both fall back to the default) - protocol: HeartbeatRequest/HeartbeatResponse (daemon-side dispatch only; the MCP client loop comes separately) and idle observability fields on DaemonStatusResponse (idle_seconds, idle_timeout_minutes, last_heartbeat_seconds) - daemon: IdleReaper with a pure should_exit predicate — exit requires a positive timeout, no external supervisor (COCOINDEX_CODE_DAEMON_SUPERVISED), no live handler task, no project indexing (new ProjectRegistry.any_indexing), and idle time past the timeout. A periodic asyncio task (60 s interval, injectable for tests along with a seconds-scale timeout on run_daemon) triggers the shutdown. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JaFKwEvJXo7WQhZvrW5Qoe
Both MCP entry points (ccc mcp and the legacy cocoindex-code serve) now run a background heartbeat loop alongside the initial background index. Each heartbeat is one short-lived daemon connection that counts as activity for the idle reaper, so the daemon never idle-exits while an MCP session is live; when the MCP process dies (even SIGKILL) the heartbeats stop and the daemon exits on the normal timeout. - client.send_heartbeat(): uses the raw non-auto-starting connect path and returns False when no compatible daemon answers — a heartbeat must never start or restart a daemon, or `ccc daemon stop` mid-session would fight the loop - server.run_heartbeat_loop(): interval = timeout/3 clamped to [30 s, 300 s], reading the timeout from global_settings.yml (default when missing); a 0 timeout disables the loop entirely Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JaFKwEvJXo7WQhZvrW5Qoe
…ment idle-exit - ccc daemon status now prints idle duration with the configured timeout (or "disabled" when 0) and the age of the last MCP heartbeat (or "never") - README: document daemon.idle_timeout_minutes in the global settings reference - CLAUDE.md: note the idle-exit + heartbeat + crash-marker behavior in the process model section Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JaFKwEvJXo7WQhZvrW5Qoe
… accept thread PipeListener.close() only closes the queued next-instance handle; it cannot cancel an in-flight ConnectNamedPipe wait, so a blocked accept() kept a pipe instance open after the daemon exited. The named pipe therefore still existed when the in-process idle tests asserted cleanup, and the test's own os.path.exists() probe completed the pending connection, making the accept thread call call_soon_threadsafe on a closed loop (the PytestUnhandledThreadExceptionWarning noise in CI). Shutdown now sets a shutting_down flag, wakes a blocked accept() on Windows with a dummy client connection, and joins the accept thread before returning, so the last pipe-instance handle is closed before run_daemon exits. POSIX behavior is unchanged (listener.close() already unblocks accept() there). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| other connection. | ||
| """ | ||
|
|
||
| def __init__(self, timeout_s: float, *, supervised: bool) -> None: |
There was a problem hiding this comment.
nit: may consider using a stronger type (e.g. timedelta) here, with clearer semantic
| self.timeout_s = timeout_s | ||
| self.supervised = supervised | ||
| self.last_activity = time.monotonic() | ||
| self.last_heartbeat: float | None = None |
There was a problem hiding this comment.
last_heartbeat is specific to MCP. It's not interesting for the daemon, and not related to idle reaping. Also the invariance of "heartbeat connections refresh last_activity like any other connection" is not guaranteed here as well.
So I feel for separation of concern, last_heartbeat may not belong here.
If we really want to track and report it, we may consider doing it on the MCP side.
WDYT?
16800f6 to
6bf123f
Compare
Review feedback on #215: a bare float-with-suffix-naming carries the unit only by convention. IdleReaper and run_daemon now take timedelta, converting to seconds only at the comparison against monotonic timestamps and on the wire. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQpbhCAsYRJuwL7X83fzDu
…activity Review feedback on #215: last_heartbeat is MCP-specific state that the daemon has no use for — it played no part in idle reaping and existed only so 'ccc daemon status' could echo it back. A heartbeat's real effect is the connection itself, which the accept path already records as activity. Remove the field from IdleReaper, the wire protocol, and the status output. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EQpbhCAsYRJuwL7X83fzDu
|
@georgeh0 addressed both inline comments. My ungating proposal is purely optional, can follow up later if needed. |
POSIX doesn't need it (listener.close() makes accept() raise), but it's harmless there and keeps the path exercised on every platform instead of only on Windows CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vgj6XtKW6dHST75F5VtavC
|
Thanks for saving unnecessary cost for cocoindex-code! |
|
thanks @junzh0u ! |
Summary
The daemon currently lives forever once auto-started, pinning the embedding model in memory indefinitely — after the last search of the day, that memory is held all night for nobody.
Measured on my machine (macOS, Apple Silicon): a freshly started daemon idles at ~51 MB RSS. After a single search with
Snowflake/snowflake-arctic-embed-xs— one of the smallest usable models — it sits at 588 MB RSS / 1.4 GB macOS footprint, of which ~1.0 GB is GPU-shared (MPSIOAccelerator) memory that competes with everything else using the GPU. That 1.4 GB is held until reboot. With mid/large models (arctic-embed-m,nomic-embed, Qwen embedders) the number is multiple GB, and it hurts most on the 16 GB laptops that can least afford it.This PR makes the daemon exit after a configurable idle period and lets clients revive it transparently on the next request (a few seconds of model reload):
daemon.idle_timeout_minutesinglobal_settings.yml— default 180,0= never (old behavior).COCOINDEX_CODE_DAEMON_SUPERVISED=1, e.g. the Docker entrypoint restart loop, where idle-exit would just cause respawn churn).ccc mcpsession (either entry point) pings the daemon everytimeout/3(clamped 30 s–5 min), so an open editor session keeps the model warm across a workday's pauses. Heartbeats never start a daemon —ccc daemon stopwins. If the MCP process dies, even by SIGKILL, heartbeats stop and the daemon exits on the normal timeout — self-healing, no stale-lease problem.ccc daemon statusnow shows idle time and the configured timeout.The default and overall shape follow established practice for auto-started dev-tool daemons: Gradle stops its daemon after 3 h idle, Bazel's server defaults to
--max_idle_secs=10800(3 h), sccache exits after 10 min, and the Nx daemon shuts down after ~3 h of inactivity. The cautionary tale in the other direction is watchman-style forever-resident processes that users discover squatting on RAM weeks later.0(never exit) is the least surprising default in terms of backwards-compatibility. I went with 180 because it matches the Gradle/Bazel/Nx convention, the change is self-healing (the daemon transparently restarts on the next request, costing only a few seconds of model reload), and a memory-saving feature that's off by default would help exactly nobody who doesn't already know about it. Happy to flip the default to0if maintainers prefer strict compatibility.Builds on #214 (now merged): the graceful-exit marker is what lets clients treat an idle-exit as routine (silent restart) while still surfacing real crashes loudly.
Tests
Settings parsing (absent section → default, explicit
0), 7 reaper-predicate unit tests, heartbeat-interval formula, and in-process daemon E2Es with seconds-scale injected timeouts: idle-exit with socket/PID cleanup and correct marker reason; live connection blocks exit; an in-flight index run survives past the timeout; heartbeats keep the daemon alive and it exits after they stop; a heartbeat against a stopped daemon returnsFalseand spawns nothing. Full suite green (pytest, mypy, ruff). Also verified live against a real daemon.🤖 Generated with Claude Code