Skip to content

fix: command-backed MCP services get one subprocess per session, not a shared multiplexed bridge - #387

Merged
aojea merged 11 commits into
google:mainfrom
fer-marino:fix/stdio-bridge-subscriber-drop
Sep 14, 2026
Merged

aojea merged 11 commits into
google:mainfrom
fer-marino:fix/stdio-bridge-subscriber-drop

Conversation

@fer-marino

@fer-marino fer-marino commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Reworked following @aojea's review below - the original approach (an unbounded per-subscriber queue on a shared StdioBridge) has been replaced entirely. History of how this PR got here is preserved in the commits and the comment thread; this description now describes the current state.

The bug is in the sharing model, not the buffer. StdioBridge multiplexed every session of a command backend over one child process and one stdout stream, broadcasting every line to every subscriber and relying on each session's own JSON-RPC id to sort out which reply was whose. That doesn't hold: the go-sdk client numbers requests from 1 per connection, so two concurrent sessions on the same bridge could both send id: 1 and each read the other's reply - a silent cross-session data leak, not just the drop bug this PR originally set out to fix. The queue fix kept all of that and added its own failure mode: a peer that stops reading its stream retains every other session's traffic indefinitely (unbounded growth instead of a drop).

Stdio MCP is single-session by spec, and no SDK multiplexes it, because a server-initiated request (sampling, elicitation) has no attributable destination on a shared process. So instead of teaching the bridge a per-session id space, MCPService.backendTransport now builds a fresh mcp.CommandTransport - one subprocess per call - for command backends, the same "fresh transport per session" shape it already used for URL backends via StreamableClientTransport. Probe, Tools and HandleStreamPassThrough are unchanged: they only ever see an mcp.Transport.

StdioBridge and bridgeTransport are deleted. Command backends lose the local SSE/POST HTTP ingress route StdioBridge.ServeHTTP used to serve (baseService.Init no longer builds a handler for them) - the mesh stream path is what actually matters for cross-partner discovery/invocation and is unaffected. Handler()'s existing callers already treat a nil handler as "no local ingress for this service" (a 404), not a crash. Restoring a local-ingress equivalent is left for a follow-up, per @aojea's own scoping below.

New tests (mcp_service_command_test.go) pin the property this exists for: two concurrent command-backend sessions, both using id: 1 the way independent go-sdk clients naturally would, each only ever see their own subprocess's replies.

Tests: go test ./internal/node/... - full suite unaffected beyond the five pre-existing, unrelated Windows-environment failures noted in the original description (reproduce identically on unmodified main). The new tests pass reliably under repeated runs (-count=10).

Subscribe()'s only caller is bridgeTransport, i.e. an MCP request/response
session: every line matters, since the one dropped could be the exact
reply a Read() call is blocked on, with nothing left to ever wake it.
It shared the same bounded (cap 10), drop-on-full channel as the SSE feed
in ServeHTTP, where dropping is fine - a browser tab just misses a live
update. A periodic health-check Probe() and a live tools/call session
subscribing to the same backend concurrently was enough to lose a message
that way, surfacing as a client-side EOF even though the backend had
answered correctly.

Every Subscribe() caller now gets its own unbounded, order-preserving
queue instead, pushed to non-blockingly from the bridge's single
stdout-scanning goroutine and drained by the session's own reader. The
SSE feed is untouched - it keeps its own bounded, drop-tolerant channel,
appropriate for a best-effort live stream.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request replaces the channel-based subscription mechanism in StdioBridge with an unbounded, order-preserving subscriberQueue to prevent dropping stdout lines on slow consumers. Feedback on the changes points out a potential memory retention issue in subscriberQueue.pop where slicing the buffer does not release the memory of the popped strings, and suggests explicitly zeroing out the popped element and resetting the slice when empty.

Comment thread internal/node/stdio_bridge.go Outdated
Re-slicing alone (q.buf = q.buf[1:]) leaves the popped element's string
header live in the backing array: the array is one GC-tracked object, so
as long as any slice into it is reachable, every element within it is too,
including ones logically before the current slice's start. For a
long-lived session relaying many messages, that's every line ever queued,
retained for the queue's whole lifetime regardless of how many have
actually been popped.

pop now clears the popped slot before advancing, releasing that string,
and drops the backing array entirely once the queue drains to empty
rather than let it sit at its peak size indefinitely. New test asserts
both: a second reference into the backing array reads back "" after its
line is popped, and buf is nil after a full drain - confirmed to fail
without the fix (backing array slot still holds "a" after being popped).
Comment thread internal/node/stdio_bridge.go Outdated
…ribe

Both prior tests for this fix drove StdioBridge/bridgeTransport through an
in-memory io.Pipe and never exercised two concurrent subscribers, so neither
would have caught a regression in the exact scenario Subscribe's own doc
comment describes (a health probe racing a live session). These two tests
spawn a real subprocess and drive it through the actual bridgeTransport path:

- TestIntegration_ProbeAndLiveSessionConcurrently: a live session bursting
  calls without reading between them, racing a continuously-reconnecting
  probe, asserting neither ever loses a reply. Fails deterministically on
  the pre-fix code (verified against 17da7f6~1) and passes on this branch.

- TestIntegration_StuckSubscriberDoesNotStallOthers: one subscriber that
  connects and never reads, asserting a second, healthy subscriber on the
  same backend is unaffected. This is the test that rules out the obvious
  simpler alternative to the unbounded queue (a private bounded channel per
  subscriber with a blocking send) - verified separately that it deadlocks
  this exact test, since the scan loop dispatches to every subscriber under
  one lock that Send also takes.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
@fer-marino

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @aojea — and for the earlier patience on #375 while I chased that one down to its actual cause.

Pushed one more commit (d951715) adding two integration tests that drive a real subprocess through the actual StdioBridge → bridgeTransport path (not the in-memory pipe the other tests use), since that's the level the original bug and the memory-retention fix both actually live at:

  • TestIntegration_ProbeAndLiveSessionConcurrently: a live session bursting calls without reading between them, racing a continuously-reconnecting probe — the exact scenario Subscribe's doc comment describes. Fails deterministically against the pre-fix code, passes here.
  • TestIntegration_StuckSubscriberDoesNotStallOthers: one subscriber that connects and never reads, asserting a second healthy subscriber on the same backend is unaffected. I added this one specifically to check the obvious simpler alternative to an unbounded per-subscriber queue — a fixed-size channel per subscriber with a blocking send instead of drop. It's much less code, but this test catches why it doesn't work: the scan loop dispatches to every subscriber under one lock that Send also takes, so one stuck consumer deadlocks the whole bridge for every other session on that backend. Confirmed that failure mode directly (goroutine dump showed the scan loop blocked on the full channel, holding the lock, with a second session's Write() stuck waiting on the same lock). The unbounded queue avoids that by construction — it never blocks the producer, only grows for the one subscriber that isn't keeping up.

This is ready for another look whenever you have a chance.

@aojea

aojea commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator

@fer-marino I was looking into this and I think we are working in a direction that will cost us later.

The bug here is in the sharing model, not in the buffer. Subscribe() fans out every stdout line to every session, so a session's channel fills with replies that belong to other sessions. Worse, the go-sdk numbers requests from 1 per connection, so two concurrent sessions on the same bridge both send id: 1 and both receive both replies — session A can consume session B's tools/call result today. An unbounded per-subscriber queue keeps all of that, and adds a new failure mode: a peer that stops reading its stream (HandleStreamPassThrough has no timeout while the backend leg is alive) retains every other session's traffic indefinitely. "Silent drop → hang" becomes "unbounded growth → node OOM", triggered by an authorized peer.

I don't feel sam-node should own a JSON-RPC multiplexer. Stdio MCP is single-session by spec; no SDK multiplexes it, because there's no correct way to do so (server-initiated requests like sampling or elicitation have no attributable destination on a shared process). Any mux here — the current bridge, this queue, or a smarter id-rewriting version I prototyped — is protocol-level code we'd maintain and get subtly wrong over years. We've been there in Kubernetes.

What I'd like to do instead: use mcp.CommandTransport from the go-sdk we already depend on — one child process per session, exactly mirroring what backendTransport() already does for URL backends with StreamableClientTransport:

case *api.RegisterServiceRequest_Command:
    cmd := exec.Command(x.Command.Command[0], x.Command.Command[1:]...)
    // env setup as today
    return &mcp.CommandTransport{Command: cmd}, nil

Probe, Tools and HandleStreamPassThrough only see an mcp.Transport, so they don't change. StdioBridge and bridgeTransport get deleted. Each peer gets its own process: no shared id space, no shared buffer, no cross-peer state or failure — which is what the stdio protocol assumes and what our Zero Trust guideline asks for.

Two things to keep in mind while doing it:

  • StdioBridge.ServeHTTP (the SSE+POST handler the ingress serves for command backends) loses its home. Let's handle that separately; the streams path is what matters here.
  • Slow-starting backends (npx …) will pay startup per session but we can optimize later if needed.

Would you be up for reworking this PR along those lines, keeping your subprocess-based regression tests adapted to the new transport? If you'd rather not, no problem — say so and I'll open it myself.

…a shared multiplexed bridge

StdioBridge/bridgeTransport multiplexed every session of a command
backend over one child process and one stdout stream, broadcasting
every line to every subscriber and relying on each session's own
JSON-RPC id to sort out which reply was whose. That doesn't hold: the
go-sdk client numbers requests from 1 per connection, so two
concurrent sessions on the same bridge could both send id:1 and each
read the other's reply, not just their own - a silent cross-session
data leak, independent of and worse than the drop bug fixed earlier on
this branch. The earlier fix (an unbounded per-subscriber queue) kept
all of that and added its own failure mode: a peer that stops reading
its stream retains every other session's traffic indefinitely, turning
"silent drop" into "unbounded growth" under the same trigger.

Stdio MCP is single-session by spec, and no SDK multiplexes it, because
a server-initiated request (sampling, elicitation) has no attributable
destination on a shared process. Rather than teach the bridge a
per-session id space (protocol-level code this package shouldn't own,
and would still be new code to get subtly wrong), MCPService.backendTransport
now builds a fresh mcp.CommandTransport - one subprocess per call - for
command backends, the same "fresh transport per session" shape it
already used for URL backends via StreamableClientTransport. Probe,
Tools and HandleStreamPassThrough are unchanged: they only ever see an
mcp.Transport.

StdioBridge and bridgeTransport are deleted. Command backends lose the
local SSE/POST HTTP ingress route StdioBridge.ServeHTTP used to serve
(baseService.Init no longer builds a handler for them); the mesh stream
path is what actually matters for cross-partner discovery/invocation
and is unaffected. Handler()'s existing callers already treat a nil
handler as "no local ingress for this service" (a 404), not a crash.
Restoring a local-ingress equivalent is left for a follow-up.

New tests (mcp_service_command_test.go) pin the property this exists
for: two concurrent command-backend sessions, both using id:1 the way
independent go-sdk clients naturally would, each only ever see their
own subprocess's replies.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
@fer-marino fer-marino changed the title fix: stdio_bridge - never drop a subscriber's line on a slow consumer fix: command-backed MCP services get one subprocess per session, not a shared multiplexed bridge Sep 12, 2026
@fer-marino

Copy link
Copy Markdown
Contributor Author

@aojea You're right, and the id-collision you found is worse than what I was chasing - a shared process silently handing session A session B's tools/call result is a real correctness bug, not just a liveness one. Went with reworking it rather than leaving it to you, since it's a fairly contained change once you see it your way.

Pushed the rework (commit 82a6a86): StdioBridge and bridgeTransport are deleted. MCPService.backendTransport now builds a fresh mcp.CommandTransport per call for command backends - one subprocess per session, matching what it already did for URL backends via StreamableClientTransport, exactly as you sketched. Probe, Tools and HandleStreamPassThrough didn't need to change.

Two notes on the parts you flagged:

  • StdioBridge.ServeHTTP's local SSE/POST ingress route for command backends is gone with it - baseService.Init no longer builds a handler for them, so Handler() returns nil and the existing nil-checks in the ingress path 404 instead of crashing, per your "handle that separately." The one test that exercised that route (TestStdioDatapathIntegration) is removed, with a comment in its place pointing at this thread for why.
  • Slow-starting backends now pay startup per session, as you noted. Not optimized in this PR.

New tests in mcp_service_command_test.go replace the old subscriber-queue ones: two concurrent command-backend sessions, both sending id: 1 the way independent go-sdk clients naturally would, asserting each only ever sees its own subprocess's replies (tagged by PID). That's the direct regression test for the bug you found - it fails against the old shared-bridge design and passes now, by construction rather than by getting a shared id space right.

PR title/description updated to match. Full internal/node suite still passes (same five pre-existing, unrelated Windows-environment failures as before - none of them touch this code).

@aojea

aojea commented Sep 12, 2026

Copy link
Copy Markdown
Collaborator

failures look related

not ok 12 Datapath: HTTP and Stdio services are reachable across nodes

Comment thread internal/node/mcp_service.go Outdated
Comment on lines +81 to +105
// A command backend gets its own subprocess, via mcp.CommandTransport, for
// every call - the same "one fresh transport per session" shape as the URL
// case, not a shared one. An earlier version of this multiplexed every
// session for a command backend over one child process and one stdout
// stream (StdioBridge/bridgeTransport, since deleted): every line
// broadcast to every subscriber, relying on each session's own JSON-RPC id
// to sort out which reply was whose. That doesn't hold - the go-sdk client
// numbers requests from 1 per connection, so two concurrent sessions on the
// same bridge could both send id:1 and each read the other's reply, not
// just their own. Fixing the multiplexer to not do that (id rewriting, a
// bridge-wide id space) is exactly the kind of protocol-level code this
// package shouldn't own: stdio MCP is single-session by spec, and no SDK
// multiplexes it, because a server-initiated request (sampling,
// elicitation) has no attributable destination on a shared process. One
// subprocess per session sidesteps all of it by construction, matching
// what backendTransport already does for URL backends.
//
// The cost is a fresh process per Probe/Tools call and per mesh stream
// instead of one long-lived one; command backends with slow startup pay
// that repeatedly. Command backends also lose the local SSE/POST HTTP
// ingress route StdioBridge.ServeHTTP used to provide (baseService.Init
// no longer builds a handler for them) - the mesh stream path
// (HandleStreamPassThrough) is what actually matters and is unaffected;
// restoring a local-ingress equivalent is left for later, not folded into
// this change.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

try to tell the AI agent to not create this very large and verbose comments, I like the comments but this is a book :)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair, noted - trimmed that one and a couple others down in 0f40c80.

The bridgeTransport removal also deleted StdioBridge.ServeHTTP, the
local ingress route command backends use for direct HTTP+SSE access
(distinct from the mesh path via HandleStreamPassThrough). Deleting it
broke a real, CI-tested feature - tests/e2e/datapath.bats' "Stdio
services are reachable across nodes" - not just a hypothetical.

StdioBridge is back, trimmed to only what that route needs: its own
bounded, drop-tolerant broadcast for ServeHTTP's GET/SSE and POST/call
handling. Subscribe() and Send() are gone for good - those existed only
for bridgeTransport, which stays deleted. This route was never the part
of the id-collision bug aojea flagged: it already used a fresh
callCh per POST, not a shared per-connection id space multiplexed
across sessions the way bridgeTransport did.

baseService goes back to building a StdioBridge handler and owning its
process for command backends; MCPService.backendTransport is
unchanged from the previous commit (still a fresh mcp.CommandTransport
per session for Probe/Tools/HandleStreamPassThrough).

Also trimmed several comments down that were too long for what they
needed to say.

Signed-off-by: Fernando Marino <fernando.marino85@gmail.com>
@fer-marino

Copy link
Copy Markdown
Contributor Author

Yep - deleting StdioBridge.ServeHTTP along with bridgeTransport broke that route for real, not just hypothetically (the bats test proved it). Fixed in 0f40c80: StdioBridge is back, but trimmed to only what ServeHTTP needs. That route was never part of the id-collision bug - it already used a fresh channel per POST, no shared connection-wide id space. bridgeTransport/Subscribe() are still gone; MCPService.backendTransport is unchanged from 82a6a86.

@aojea

aojea commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors the MCP service's command backend to use a fresh subprocess per call via mcp.CommandTransport instead of a shared StdioBridge, preventing session crosstalk. Unused bridge transport code and tests have been removed. Feedback highlights a potential nil pointer dereference or out-of-bounds panic when starting the command, suggesting a defensive check to avoid a denial-of-service vulnerability.

Comment on lines 91 to +92
case *api.RegisterServiceRequest_Command:
bridge, ok := m.handler.(*StdioBridge)
if !ok {
return nil, fmt.Errorf("expected *StdioBridge handler for command-backed MCP service %q, got %T", m.info.GetName(), m.handler)
cmd := exec.Command(x.Command.Command[0], x.Command.Command[1:]...)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Accessing x.Command.Command[0] directly without checking if x.Command is nil or if x.Command.Command is empty can lead to a nil pointer dereference or an out-of-bounds panic. Since this registration request can be triggered by external API clients or configurations, this represents a potential denial-of-service (DoS) vulnerability.

Adding a defensive check to ensure x.Command is non-nil and contains at least one command element prevents these panics and improves the robustness of the service.

case *api.RegisterServiceRequest_Command:
		if x.Command == nil || len(x.Command.Command) == 0 {
			return nil, fmt.Errorf("missing command for command-backed MCP service %q", m.info.GetName())
		}
		cmd := exec.Command(x.Command.Command[0], x.Command.Command[1:]...)

gemini-code-assist flagged this on review: x.Command.Command[0] was
read without checking x.Command for nil or Command for empty, so a
RegisterService call with a command-backed MCP service but no actual
command panicked backendTransport() instead of returning an error.
Reachable from an external RegisterService call, not just local
misconfiguration.
@aojea aojea added this to the v0.1.0 milestone Sep 14, 2026
@fer-marino

Copy link
Copy Markdown
Contributor Author

Fixed in ac74055, per your suggested check: x.Command == nil || len(x.Command.Command) == 0 before indexing into it, with a regression test (nil CommandBackend and empty command slice, both asserting an error instead of a panic). All checks are green.

ac74055 guarded backendTransport, but RegisterService reaches
baseService.Init first, and createStdioBridgeHandler indexes Command[0]
there: the same request still panicked before the new check ran. Reject
it in NewServiceFromRequest, which every registration path goes through
and which covers the Inference and A2A command backends too. The
backendTransport check stays as defense in depth.
One subprocess per session turns an authorized peer holding streams open
into a fork bomb; the only limits were the per-peer stream rate and the
libp2p resource manager. Bound it per service with a slot pool: Connect
takes a slot or fails fast, Close returns it after the go-sdk has reaped
the child. Probe, Tools and HandleStreamPassThrough inherit the bound
through backendTransport unchanged.
@aojea

aojea commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

/gemini review

added 3 commits for completeness and preserved the previous

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request refactors command-backed MCP services to run each session in its own subprocess rather than sharing a single StdioBridge, preventing cross-talk between concurrent sessions. To prevent resource exhaustion, a session limit of 16 concurrent subprocesses is introduced using a bounded transport. Additionally, validation is added to reject command backends missing a command during service construction. The review feedback highlights potential flakiness in the new tests due to arbitrary time.Sleep calls and suggests replacing them with deterministic polling.

Comment thread internal/node/stdio_bridge_test.go Outdated
Comment on lines +58 to +62
// Give the handler a moment to register as a client before writing.
time.Sleep(50 * time.Millisecond)
_, _ = stdoutWriter.Write([]byte("hello\n"))
time.Sleep(50 * time.Millisecond)
cancel()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Rule 3: Test coverage must follow the pyramid

Using arbitrary time.Sleep calls to wait for client registration and response processing can lead to flaky tests in slow or resource-constrained CI environments. We can make this test 100% deterministic and faster by polling b.clients and rec.Body instead of sleeping.

Suggested change
// Give the handler a moment to register as a client before writing.
time.Sleep(50 * time.Millisecond)
_, _ = stdoutWriter.Write([]byte("hello\n"))
time.Sleep(50 * time.Millisecond)
cancel()
// Wait for the client to be registered to avoid flakiness.
for {
b.mu.Lock()
registered := len(b.clients) > 0
b.mu.Unlock()
if registered {
break
}
time.Sleep(1 * time.Millisecond)
}
_, _ = stdoutWriter.Write([]byte("hello\n"))
// Wait for the data to be written to the response.
for {
if strings.Contains(rec.Body.String(), "data: hello\n\n") {
break
}
time.Sleep(1 * time.Millisecond)
}
cancel()
References
  1. Integration tests must be time bounded. Look for unbounded waits, time.Sleep longer than a few hundred milliseconds, or polling without a deadline. (link)

Comment thread internal/node/stdio_bridge_test.go Outdated
Comment on lines +106 to +108
time.Sleep(50 * time.Millisecond)
reply := `{"jsonrpc":"2.0","id":1,"result":{}}`
_, _ = stdoutWriter.Write([]byte(reply + "\n"))

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Rule 3: Test coverage must follow the pyramid

Using an arbitrary time.Sleep to wait for the call to be registered in b.calls can cause flakiness in CI. We can poll b.calls to ensure the call is registered before writing the reply, making the test deterministic and faster.

	// Wait for the call to be registered to avoid flakiness.
	for {
		b.mu.Lock()
		registered := len(b.calls) > 0
		b.mu.Unlock()
		if registered {
			break
		}
		time.Sleep(1 * time.Millisecond)
	}
	reply := "{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}"
	_, _ = stdoutWriter.Write([]byte(reply + "\n"))
References
  1. Integration tests must be time bounded. Look for unbounded waits, time.Sleep longer than a few hundred milliseconds, or polling without a deadline. (link)

The GET and POST tests synchronized with the handler goroutine via
time.Sleep. Poll the bridge's client/call registration under its lock
instead, and for the SSE test observe the handler's Write through a
recorder wrapper rather than reading rec.Body while it is being written.
Both waits fail the test after 2s instead of hanging.
@aojea

aojea commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

once CI passes I merge, thanks @fer-marino

@aojea

aojea commented Sep 14, 2026

Copy link
Copy Markdown
Collaborator

not ok 1 a2a: stock SDK client chats with a mesh-hosted agent via the regenerated card

investigating flake, but seems unrelated to the PR

@aojea
aojea merged commit af295d7 into google:main Sep 14, 2026
20 of 22 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants