mcp: bound and abandon transport reads

The stdio reader ran inline under the transport lock, and bufio never
observes a context. A server that accepted a request and then wrote
nothing held that lock forever. alive() takes the same lock and Refresh
calls alive() while holding the manager lock, so one mute python server
wedged Tools, Status and every Call, including turns that touch no MCP
tool at all. The read now runs on its own goroutine feeding a channel,
the call selects on the context, and a call that gives up drops the
connection so the manager re-dials.

The frame bound was measured after the line had been assembled, which is
not a bound. A server emitting 500 MB with no newline had all 500 MB in
mavend before the check could reject it, which on the deploy target is
an OOM kill of the core daemon. The scanner's own buffer limit enforces
it now.

The HTTP transport never checked the response id. A server request sent
mid-stream, sampling/createMessage or roots/list, unmarshalled into a
response with neither result nor error, so the call reported success
with an empty string. The act was logged as done and the tool never ran.
The id must match and the frame must carry a result or an error.

Found in review of #70.
This commit is contained in:
kami
2026-08-01 14:11:24 +04:00
parent 7f42cc73be
commit 87d03cf8c6
3 changed files with 223 additions and 58 deletions
+67
View File
@@ -9,6 +9,7 @@ import (
"os/exec"
"strings"
"testing"
"time"
)
// The stdio transport is tested against a real subprocess — this test binary,
@@ -47,6 +48,20 @@ func fakeStdioServer() {
_ = out.Flush()
continue
}
// "mute" answers the handshake and then goes silent: a python server
// that hit an unhandled exception in its own read loop but did not
// exit is the ordinary way to get here.
if os.Getenv("MAVEN_MCP_FAKE") == "mute" && req.Method == "tools/call" {
select {} // never answer, never exit
}
// "flood" writes one enormous line with no newline in it.
if os.Getenv("MAVEN_MCP_FAKE") == "flood" && req.Method == "tools/call" {
for i := 0; i < 64; i++ {
_, _ = out.Write(make([]byte, 1<<20))
}
_ = out.Flush()
continue
}
result, rerr := h(req.Method, req.Params)
resp := map[string]any{"jsonrpc": "2.0", "id": *req.ID}
if rerr != nil {
@@ -147,3 +162,55 @@ func TestStdioMissingCommand(t *testing.T) {
t.Logf("err = %q", st[0].Err)
}
}
// A stdio server that accepts a call and then answers nothing must not wedge
// the manager. Before the read moved onto its own goroutine, the read held the
// transport lock, Refresh took that lock through alive() while holding the
// manager lock, and from then on Tools, Status and Call blocked for EVERY
// server — including turns that touch no MCP tool at all.
func TestStdioSilentServerDoesNotWedgeTheManager(t *testing.T) {
m := stdioManager(t, "mute")
defer m.Close()
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
done := make(chan error, 1)
go func() {
_, err := m.Call(ctx, "fake", "read_thing", nil)
done <- err
}()
select {
case err := <-done:
if err == nil {
t.Fatal("a call into a silent server must fail, not succeed")
}
case <-time.After(5 * time.Second):
t.Fatal("the call never returned: the context is not observed during the read")
}
// The manager must still answer while (and after) that call was stuck.
ready := make(chan struct{})
go func() {
m.Refresh(context.Background())
m.Tools()
m.Status()
close(ready)
}()
select {
case <-ready:
case <-time.After(5 * time.Second):
t.Fatal("Refresh/Tools/Status deadlocked behind the hung call")
}
}
// One frame is bounded by the reader's buffer, not measured after the whole
// thing has already been assembled in mavend's heap.
func TestStdioOversizedFrameIsRefused(t *testing.T) {
m := stdioManager(t, "flood")
defer m.Close()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if _, err := m.Call(ctx, "fake", "read_thing", nil); err == nil {
t.Fatal("a 64 MiB frame must be refused")
}
}