You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 39d4587
Browse filesBrowse the repository at this point in the historyBrowse files
fix(upstream): serialize session re-initialization with in-flight requests (#1650)
## Root cause
Spec 113-e (#1501) re-initializes a terminated Streamable HTTP session
in place: `managed.reinitSession -> runReinit ->
core.(*Client).ReinitializeSession` calls mcp-go `Initialize` on the
**shared** mcp-go client. `Initialize` writes the client's negotiated
protocol state (`applyNegotiatedVersion`, server/client capabilities,
client info) without synchronization. Every other request on the same
client reads that state: before it is sent (`sendRequest ->
applyRequestMeta -> isModern`), and for tools/call again on the response
(`callToolOnce`). So one caller re-initializing while another call is in
flight, or just starting, is a data race in production, not only in the
test.
The CI "Unit Tests (shuffle)" lane (`go test -race -shuffle=on`) caught
it in `TestSessionReinit_ConcurrentCallsSingleFlight`:
```
Read: mcp-go client.(*Client).isModern <- applyRequestMeta <- sendRequest <- callToolOnce <- CallTool
<- core.(*Client).CallTool (internal/upstream/core/client.go:613)
<- managed.callToolWithSession (internal/upstream/managed/session_reinit.go:323)
Write: mcp-go client.(*Client).applyNegotiatedVersion <- initializeLegacy <- Initialize
<- core.(*Client).ReinitializeSession (internal/upstream/core/session_reinit.go:57)
<- managed.runReinit (:197) <- reinitSession (:156) <- callToolWithSession (:327)
```
The same race exists for tools/list, ping, prompts/list and prompts/get,
because they use the same client.
## Fix (mcp-go is not modified)
**core**
- A per-client **session gate** (`golang.org/x/sync/semaphore`). The
package was already in the module graph and is promoted from indirect to
direct. `CallTool`, `ListTools`, `Ping`, `ListPrompts` and `GetPrompt`
hold the gate shared for the whole mcp-go call. `ReinitializeSession`
holds it exclusively around `Initialize` only.
- Both sides honour their context: a re-init waiting behind a long call
gives up at its 30s flight deadline instead of hanging.
- The gate is FIFO (writer-preferring).
- No self-deadlock: the managed layer triggers a re-init only after
`core.CallTool` has returned and released the gate.
- On tools/call, the gate is taken before the Spec 117 dispatch re-check
and before `MarkDispatched`, so waiting at the gate sends nothing.
- A **session generation**, bumped under the exclusive gate on every
re-init. A request pinned to a generation with `WithSessionGeneration`
is refused with `ErrSessionChanged` if a re-init ran after the pin was
taken. Nothing is sent in that case.
**managed**
- tools/call sends go through `sendVerified`. It pins each send to the
generation its identity and epoch checks ran against. A call that queued
behind `Initialize` is therefore re-checked against the re-listed
toolset (FR-082/FR-083a), instead of going out on a session whose
tools/list has not been verified yet. The loop is bounded
(`maxSessionAdmits`).
- `publishBaseline` publishes the identity baseline and its verified
generation together. A listing that a re-init overtook is dropped. If a
re-init's verifying tools/list failed, its generation is listed again
before any call is checked against it.
## Evidence
- **Failing first:** the new
`TestSessionReinit_RequestsDoNotRaceInitialize` (concurrent re-init +
tools/call + tools/list + ping) reported `WARNING: DATA RACE` on every
run before the fix, with the same stacks as CI. After the fix it passes.
Each new overtaken-call or stale-listing test was also checked to fail
with its guard disabled.
- `go test -race -shuffle=on ./internal/upstream/managed/ -run
TestSessionReinit -count=50`: ok
- `go test -race ./internal/upstream/...` and `-tags server`: ok
- The CI internal/server race suite (`-tags server`, unit-tests.yml
`-skip` regex): all 13 packages ok, no races.
- `golangci-lint` (`.github/.golangci.yml`), bare and with `--build-tags
server`: no findings in the changed files.
- **Live check:** a `-race` build of `mcpproxy serve` on a scratch port,
data dir and config, plus a legacy Streamable HTTP upstream that issues
session ids and answers 404 for unknown ones. Over 3 rounds, 40
concurrent `call_tool_read` calls each went through mcpproxy `/mcp`, and
the upstream terminated all sessions before and in the middle of each
burst. Result: **120/120 calls ok, 0 failed, 3 in-place re-inits, 0
`DATA RACE`** in the proxy log, and the server stayed connected and
healthy.
- Cross-review: 4 rounds of opencode `gpt-6.1-sol`. Rounds 1–3 each
found one genuine High issue in the fix as it stood, and each was fixed:
1. A call queued behind `Initialize` skipped the identity re-check.
2. A failed verifying listing left the new session unverified.
3. A stale listing could overwrite a verified baseline.
Round 4: **VERDICT: CLEAN**.
No new dependencies.
0 commit comments