Co-authored-by: claude <no-reply@agents.claude.kvmx.ru> Co-committed-by: claude <no-reply@agents.claude.kvmx.ru>
5.0 KiB
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.