# No deadline on the turn path Last verified: 06-08-2026 @ 60e64dd **All four steps landed on 06-08-2026.** What follows describes the defect as it was and the work as it was planned. Two things came out differently. `Client.Close` read the conn field with no lock while `roundtrip` re-dialed and dropped it. `-race` caught that on the new cancellation test. So the conn field now has a mutex of its own, held only across a read or an assignment. And `/api/ptt` needed nothing: it proxies to the voice port and never touches the shared client, so only `/api/chat` got the extra connection. The pool inside `ipc.Client` is still unbuilt and still waiting on a second module measured queueing. V-638. Sibling of V-607, which is the same class of bug in `internal/worker`. Reads with `docs/offload.md` and `docs/protocol.md`. ## What is missing A chat turn starts in a mavweb HTTP handler and ends at llama-server. Nothing between those two points can be cancelled, and one hop has a timeout. Four places, all on the same path. `voice.Replier.Reply` takes no context (`internal/voice/replier.go:41`). So `llmReplier` calls `PhraseReply(context.Background(), d)` at `cmd/mavend/replier_llm.go:42`. The turn cannot deadline its own reply. The only bound is `phraser.timeout`, 60s in deploy. `ipc.Client.roundtrip` sets no connection deadline (`internal/ipc/client.go:202`). A daemon that stops answering parks the caller for as long as the socket stays open. `ipc.Client.call` checks the context once, before sending (`client.go:149`), then blocks in `roundtrip`. Cancelling mid-call does nothing. `ipc.Server.serveConn` dispatches under `context.Background()` (`internal/ipc/server.go:253`). A client that hangs up does not cancel the turn, and neither does `Server.Close`. ## And every call queues behind the slowest one `ipc.Client` serialises on one connection and one mutex. mavweb routes `/api/chat` and `/api/ptt` through the shared client, so one turn blocks all 28 handlers while it runs. Worst case is a 60s page load. This is understood for exactly one route already. `cmd/mavweb/main.go:57` opens a second connection for `/models`, and the comment there says why. A model swap is a multi-minute call, and sharing the connection would freeze every other page. ## The pattern is already in the repo `internal/voice/client.go:101` derives a connection deadline from the caller's context, falls back to 120s, and clears it with a defer. `internal/ipc/client.go` never learned it. Copy that rather than inventing a second convention. ## The work One commit each. **Context on the reply seam.** `phraser.Replier.PhraseReply` already takes a context and the interface has two implementations, so this is small. Change `Reply` to take a context, have `StubReplier` ignore it, and pass it through `llmReplier` to `PhraseReply`. Both call sites already hold one: `cmd/mavend/voice.go:461` and `cmd/mavend/clarify.go:574`. **Deadlines and cancellation on the client.** Pass the context into `roundtrip` and set `SetDeadline` from it. For cancellation mid-call, a watchdog goroutine that calls `c.drop()` on `ctx.Done()` is enough. `drop` exists, and the retry split already separates a lost write from a lost read. So a cancelled call lands in `errReadLost` and is never retried for a mutation. Check that against `internal/ipc/maperr_test.go`. **A request context on the server.** `serveConn` should derive from a server-scoped context so `Close` cancels a dispatch in flight. `Server` already carries `done` and a conn registry for this class of problem. The registry comment records what the last version of it cost: eleven days of stale ciphertext. **Stop serialising mavweb.** Give `/api/chat` and `/api/ptt` their own connection, the way `/models` has one. Roughly ten lines, and it changes no shared code. A connection pool inside `ipc.Client` is the general form and is deliberately not the first step. Each connection is already its own request and response stream. So a pool preserves frame pairing by construction. It still has to keep re-dial on drop, the `errWriteLost` and `errReadLost` split, and `Close`. Do the narrow fix, measure, and reach for the pool only if a second module turns out to queue. ## How it is judged `make test` stays green. It is green at `06c1cf2`. Nothing here changes routing or recall, so `make eval-router` and `make eval-recall` are unchanged rather than re-measured. By hand: load `/dash` while a chat turn is in flight. Before the change it waits for the length of the turn. There is no test today that a cancelled context aborts an in-flight `ipc.Client` call. That absence is why two of these four went unnoticed, so the test is part of the work. ## What is not done here The store is still `SetMaxOpenConns(1)` (`internal/store/store.go:99`) under WAL. WAL is built for concurrent readers against one writer, and the cap makes every read queue. `Store.DB(ctx)` hands the digestion worker a read transaction on that same connection. This plan does not touch it. It is measurable first and should be measured before it is changed.