Daemon robustness: handshake starvation locks out every client (A); a failed COMMIT leaves the writer inside a never-committed transaction (B) #512

Closed
opened 2026-11-09 08:03:45 +01:00 by buildagent · 2 comments
Member

Consolidated scope (triage 2026-11-09)

Priority: Critical: (A) makes the product unusable under load; (B) wedges or silently loses writes after a transient disk-full or BUSY.

This issue is now the canonical tracker for:

  • generations::transition/fail do Ok(()) => conn.execute_batch("COMMIT")? with no ROLLBACK on COMMIT failure (generations.rs:623,658), then later writes go into the open transaction. Fix: one owned-transaction helper and a registry test against bare "COMMIT")? (from #513, closed and merged here; its full report stays readable there)

Original report

Summary

A busy daemon can be locked out by its own attaching clients. When every read-pool connection is held, a new session's handshake times out, gets retried every ~50 ms for 60 s, and each abandoned request keeps holding one of the 64 connection slots until the cap is reached. From then on every client gets BUSY, and the new session drops to the read-only in-process fallback.

Failure trace

  1. Every read-pool connection (2–8, crates/daemon/src/main.rs:795-799) is held by slow queries, for example several agents running change_impact on a large repo.
  2. A new MCP session runs try_handshake, which sends rpc.health() under HANDSHAKE_TIMEOUT = 2 s (crates/mcp-server/src/main.rs:1432, 1457-1460).
  3. LocalIndex::health starts with self.conn.get().await (crates/daemon/src/local_index.rs:3828). ReadPool::get waits on a semaphore with no deadline (crates/daemon/src/read_pool.rs:85-92). The handshake times out.
  4. The client tries to spawn a daemon. The takeover probe uses the database-free capabilities call, sees the old daemon as healthy, and the new daemon gives up (crates/daemon/src/takeover.rs:270-283).
  5. The attach loop retries the handshake about every DAEMON_POLL_INTERVAL (50 ms) for up to DAEMON_READY_TIMEOUT = 60 s (crates/mcp-server/src/main.rs:1764-1803).
  6. Each timed-out client is gone, but the server still waits on its spawn_blocking job and only notices the dead socket when it writes the reply (crates/daemon/src/server.rs:383-393, 431). Each one holds a connection slot.
  7. Within seconds the slot cap is full and all clients, healthy ones included, get BUSY.

#67 already removed the stats() scans from this handshake for the same class of reason ("condemned, evicted, respawned, condemned again"). The handshake is still gated on a database connection, though.

Proposal (one liveness rule)

  • The handshake and liveness check never touch the database. Use the same database-free capabilities reply the takeover probe already trusts, plus identity (root and schema) from the lockfile or a constant the daemon holds.
  • Give ReadPool::get a deadline that returns a typed busy refusal instead of waiting forever.
  • Abandon a request when its peer closes: race the dispatch future against a read on the socket, and drop the job on EOF.

Acceptance

  • An e2e test holds every pool connection, then attaches N=20 clients. Every handshake succeeds within its bound, and no slot leaks once the clients disconnect.
  • Mutation: put conn.get() back into the handshake path, and the test must go red.

Found by an automated robustness review (2026-11-09) and verified by reading read_pool.rs:85-92 and main.rs:1457. Not reproduced at runtime.


Filed from a five-agent review of master @ e3ec6ead (bloat, robustness, agent usefulness, docs, architecture). Every finding was spot-checked in the main session before filing.

## Consolidated scope (triage 2026-11-09) **Priority: Critical**: (A) makes the product unusable under load; (B) wedges or silently loses writes after a transient disk-full or BUSY. This issue is now the canonical tracker for: - [ ] `generations::transition`/`fail` do `Ok(()) => conn.execute_batch("COMMIT")?` with no ROLLBACK on COMMIT failure (`generations.rs:623,658`), then later writes go into the open transaction. Fix: one owned-transaction helper and a registry test against bare `"COMMIT")?` (from #513, closed and merged here; its full report stays readable there) --- ## Original report ## Summary A busy daemon can be locked out by its own attaching clients. When every read-pool connection is held, a new session's handshake times out, gets retried every ~50 ms for 60 s, and each abandoned request keeps holding one of the 64 connection slots until the cap is reached. From then on every client gets BUSY, and the new session drops to the read-only in-process fallback. ## Failure trace 1. Every read-pool connection (2–8, `crates/daemon/src/main.rs:795-799`) is held by slow queries, for example several agents running `change_impact` on a large repo. 2. A new MCP session runs `try_handshake`, which sends `rpc.health()` under `HANDSHAKE_TIMEOUT` = 2 s (`crates/mcp-server/src/main.rs:1432, 1457-1460`). 3. `LocalIndex::health` starts with `self.conn.get().await` (`crates/daemon/src/local_index.rs:3828`). `ReadPool::get` waits on a semaphore with **no deadline** (`crates/daemon/src/read_pool.rs:85-92`). The handshake times out. 4. The client tries to spawn a daemon. The takeover probe uses the database-free `capabilities` call, sees the old daemon as healthy, and the new daemon gives up (`crates/daemon/src/takeover.rs:270-283`). 5. The attach loop retries the handshake about every `DAEMON_POLL_INTERVAL` (50 ms) for up to `DAEMON_READY_TIMEOUT` = 60 s (`crates/mcp-server/src/main.rs:1764-1803`). 6. Each timed-out client is gone, but the server still waits on its `spawn_blocking` job and only notices the dead socket when it writes the reply (`crates/daemon/src/server.rs:383-393, 431`). Each one holds a connection slot. 7. Within seconds the slot cap is full and all clients, healthy ones included, get BUSY. #67 already removed the `stats()` scans from this handshake for the same class of reason ("condemned, evicted, respawned, condemned again"). The handshake is still gated on a database connection, though. ## Proposal (one liveness rule) - The handshake and liveness check never touch the database. Use the same database-free `capabilities` reply the takeover probe already trusts, plus identity (root and schema) from the lockfile or a constant the daemon holds. - Give `ReadPool::get` a deadline that returns a typed `busy` refusal instead of waiting forever. - Abandon a request when its peer closes: race the dispatch future against a read on the socket, and drop the job on EOF. ## Acceptance - An e2e test holds every pool connection, then attaches N=20 clients. Every handshake succeeds within its bound, and no slot leaks once the clients disconnect. - Mutation: put `conn.get()` back into the handshake path, and the test must go red. Found by an automated robustness review (2026-11-09) and verified by reading `read_pool.rs:85-92` and `main.rs:1457`. Not reproduced at runtime. --- _Filed from a five-agent review of master @ e3ec6ead (bloat, robustness, agent usefulness, docs, architecture). Every finding was spot-checked in the main session before filing._
buildagent changed title from Daemon handshake starvation: health() waits on the read pool with no deadline, and abandoned requests pin connection slots until every client gets BUSY to Daemon robustness: handshake starvation locks out every client (A); a failed COMMIT leaves the writer inside a never-committed transaction (B) 2026-11-09 09:20:56 +01:00
Author
Member

Both parts fixed on branch codex-512, merged into integration-1109 (B: f892a305, A: f1f4796e)

B: busy::with_owned_transaction returns the connection to autocommit after a BEGIN, body or COMMIT failure. Every hand-written production transaction goes through it (generations transition/fail/promote_controlled, wal_maintenance, perms probe), and the read-pool snapshot is a documented exception. Registry test: a bare COMMIT reintroduced in generations.rs goes red with its file:line. Behaviour test: a real SQLITE_BUSY on COMMIT (DELETE journal, a second connection holding SHARED) leaves autocommit and a later durable write. Mutation: rollback removed → red.

A:

  • health takes no pool connection, runs no SQL, and uses the socket-runtime fast path like capabilities. schema_version is captured at pool open. activation is reported as None: the lead verified there is no production consumer.
  • ReadPool::get waits ≤ 1 s (below the 2 s handshake), then returns the typed RpcError::BUSY (-7). That error is request-scoped and does not trigger connection-cap handling.
  • On peer EOF, queued jobs are aborted before they take a connection. SQL that is already running may finish.
  • Acceptance e2e twenty_clients_attach_with_every_read_connection_held: every read connection is held, 20 handshakes succeed within the bound, all permits are recovered, and a normal query works afterwards. Seven mutations were run, each red at its intended assertion. Both MCP legs and private-item rustdoc are green.
## Both parts fixed on branch `codex-512`, merged into `integration-1109` (B: `f892a305`, A: `f1f4796e`) **B:** `busy::with_owned_transaction` returns the connection to autocommit after a BEGIN, body or COMMIT failure. Every hand-written production transaction goes through it (generations transition/fail/promote_controlled, wal_maintenance, perms probe), and the read-pool snapshot is a documented exception. Registry test: a bare `COMMIT` reintroduced in `generations.rs` goes red with its file:line. Behaviour test: a real `SQLITE_BUSY` on COMMIT (DELETE journal, a second connection holding SHARED) leaves autocommit and a later durable write. Mutation: rollback removed → red. **A:** - `health` takes no pool connection, runs no SQL, and uses the socket-runtime fast path like `capabilities`. `schema_version` is captured at pool open. `activation` is reported as `None`: the lead verified there is no production consumer. - `ReadPool::get` waits ≤ 1 s (below the 2 s handshake), then returns the typed `RpcError::BUSY` (-7). That error is request-scoped and does not trigger connection-cap handling. - On peer EOF, queued jobs are aborted before they take a connection. SQL that is already running may finish. - Acceptance e2e `twenty_clients_attach_with_every_read_connection_held`: every read connection is held, 20 handshakes succeed within the bound, all permits are recovered, and a normal query works afterwards. Seven mutations were run, each red at its intended assertion. Both MCP legs and private-item rustdoc are green.
Author
Member

Both parts are released since v0.41.48 (f892a305, f1f4796e). Checked again on master 3d0a584c (v0.41.50): twenty_clients_attach_with_every_read_connection_held (crates/daemon/tests/handshake_saturation_e2e.rs) and failed_commit_rolls_back_and_the_connection_can_commit_durably (crates/indexer/tests/owned_transaction.rs) are in the tree and run in CI. Closing.

Both parts are released since v0.41.48 (`f892a305`, `f1f4796e`). Checked again on master `3d0a584c` (v0.41.50): `twenty_clients_attach_with_every_read_connection_held` (`crates/daemon/tests/handshake_saturation_e2e.rs`) and `failed_commit_rolls_back_and_the_connection_can_commit_durably` (`crates/indexer/tests/owned_transaction.rs`) are in the tree and run in CI. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
h-dv/code-index#512
No description provided.