Conversation
uvicorn's workers share one listening socket, so each request goes to whichever worker accepts the connection, while session state lives in the memory of the worker that created the session. With the stateful counter resources server at 4 workers, 200 concurrent episodes through /run scored 1.0 on only 7; swe_rebench (8 workers) can lose a session's sandbox at /verify and score 0. With num_workers > 1, resources and agent servers now: - stamp each new session with the ID of the worker that created it, inside the signed session cookie; - serve the same app on a private Unix socket per worker, in a directory the main process creates under /tmp; - forward a request for a session another worker owns to that worker's private socket, unchanged, and stream the reply back unchanged. A forwarded request is always handled locally; - answer 410 when the owner's socket is gone, instead of running against empty state. The router sits outside SessionMiddleware and decodes the cookie with the same secret, so a forwarded reply carries only the owner's Set-Cookie. Single-worker servers and model servers are unchanged. The counter resources server gains the module-level app that multi-worker uvicorn imports, and an opt-in e2e test (NEMO_GYM_MULTIWORKER_E2E=1) runs it with 4 workers behind the legacy /run relay and Simple Agent. Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
CLI harnesses in sandboxes reach a resources server's tools over /mcp with the signed session token minted at /seed_session, and send no Gym session cookie. With several workers, those calls ran on whichever worker accepted them: 4 workers, 200 concurrent counter sequences over MCP, 65 of 200 correct on main and 72 of 200 with cookie routing alone. With routing active, the MCP token now also carries the owning worker's ID. The router reads the owner from the token on the MCP path, and on any request without a session cookie, verifying it with the same serializer and salt. A token without an owner (minted by a single-worker server or before this change) or with a bad signature is handled locally, where the MCP endpoint's own check applies as before. Single-worker tokens are unchanged. With this change, 200 of 200 are correct. Signed-off-by: Ananth Subramaniam <ansubramania@nvidia.com>
|
Auto-sync is disabled for draft pull requests in this repository. Workflows must be run manually. Contributors can view more details about this message here. |
ananthsub
requested review from
macandro96,
pthombre and
zyzhou5
and removed request for
macandro96
October 1, 2026 21:54
This was referenced Oct 1, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What changed and why
uvicorn's workers share one listening socket, and each request goes to whichever worker accepts the connection. Session state, keyed by the signed session cookie, lives in the memory of the worker that created the session. Nothing keeps a session's later requests on that worker.
Measured with the stateful counter resources server (
example_session_state_mgmt) and 200 concurrent episodes through/run:Servers on main with this shape:
swe_rebenchruns 8 workers. It stores each session's sandbox in an in-memory dict at/seed_sessionand pops it at/verify. A/verifyon another worker finds nothing, the patch is recorded as empty, and the reward is 0./mcp. They send the signed MCP session token minted at/seed_session, and no Gym session cookie. With 4 workers and 200 concurrent counter sequences over MCP, 65 of 200 ended with the right count.This change, for resources servers and agent servers running with
num_workers > 1:/seed_sessioncarries the same ID./tmp, which keeps paths short.example_session_state_mgmtalso gains the module-levelappthat multi-worker uvicorn imports. Without it, the server could not run with more than one worker.How it works
The router sits outside Starlette's
SessionMiddleware. It reads the owner by decoding the signed cookie with the same secret. A forwarded request never reaches the receiving worker'sSessionMiddleware, so the reply carries only the owner'sSet-Cookie, built from the session as the owner left it. If the router sat insideSessionMiddleware, the receiving worker would add its own stale copy of the session as a secondSet-Cookie.The owner travels in the cookie or token, so there is no central session table. Creating or ending a session needs no extra round trip, and nothing grows with the number of sessions a server has served.
sequenceDiagram participant C as Caller participant B as Worker B participant A as "Worker A (owner)" C->>B: POST /verify with session cookie B->>B: decode cookie and read owner A B->>A: same request over A's private socket A->>A: SessionMiddleware and handler with A's state A-->>B: reply with A's Set-Cookie B-->>C: reply streamed back unchangedAn MCP tool call takes the same path, with the token header instead of the cookie:
sequenceDiagram participant H as "CLI harness (no cookies)" participant B as Worker B participant A as "Worker A (owner)" H->>A: POST /seed_session A-->>H: MCP token signed with session ID and owner A H->>B: POST /mcp tools/call with token header B->>B: verify token and read owner A B->>A: same request over A's private socket A->>A: MCP endpoint runs the tool on A's state A-->>B: tool result B-->>H: tool result streamed back unchangedflowchart LR P["Main process (serves no requests)"] -->|creates| D["Socket directory under /tmp"] P -->|spawns| W1[Worker 1] P -->|spawns| W2[Worker 2] L[Shared listening port] --> W1 L --> W2 W1 -->|serves| S1["Private socket of worker 1"] W2 -->|serves| S2["Private socket of worker 2"] S1 --- D S2 --- D W1 -->|forwards sessions owned by worker 2| S2 W2 -->|forwards sessions owned by worker 1| S1 T["Session cookie or MCP token, naming the owner"] -.-> W1 T -.-> W2Details:
SimpleResourcesServerandSimpleResponsesAPIAgent, through a class attribute. Model servers keep no session state, so they opt out. Routing them would only concentrate load.UnixConnectorper owner socket. Its keep-alive is shorter than the private server's, so the forwarding side always closes idle connections first.dateorserverheaders. It starts after the app's startup finishes and stops before the app's shutdown.Related work
This fix stands on its own: it targets main and does not depend on the partial-rollout checkpointing stack (#3882 onward). That stack will build on it to support environment, agent, and resources servers with several workers; restoring a checkpoint will then also re-assign each restored session to a worker.
Issue
None known. The bug was found while measuring multi-worker behavior for partial-rollout checkpointing. This is the fix for main, separate from that work.
Validation
pytest tests/unit_tests/test_session_routing.py: 13 passed. Two in-process workers share a real socket directory. The tests cover:Set-Cookie;/mcptool call with a token minted on worker A, received by worker B with no cookies, is forwarded to A, and A's tool sees the session state;pytest tests/unit_tests -n 8: 6233 passed, 160 skipped, 13 failed. The same 13 fail on main without this change: 11 intest_opensandbox_compose.py, 1 intest_sandbox_stop_retry.py, and 1 intest_slurm_script.py.pytestfor theexample_session_state_mgmt,simple_agent, andlegacy_agentserver tests: 1, 20, and 5 passed.gym env test --resources-server example_session_state_mgmt: 1 passed.NEMO_GYM_MULTIWORKER_E2E=1 pytest tests/e2e/session_routing: 4 passed, in about 22 seconds. Before this change, the same test gave 7 of 200 for the/runepisodes, 127 of 200 for seed then verify, and 65 of 200 for the MCP sequences.pre-commit run --files <changed files>: passed.Rollout evidence
The opt-in e2e test runs real server processes against a fake OpenAI-compatible backend:
The legacy
/runrelay, Simple Agent, and the vLLM model server each run with 1 worker. The counter resources server runs with 4 workers.200 concurrent
/runepisodes, each scripted to increment the counter by 1 and then by 2: 200 of 200 score 1.0. Before this change, 7 of 200 did.The
swe_rebenchshape: 200 concurrent sessions, where state is stored at/seed_sessionand checked at/verify. All 200 score 1.0, and the sessions are spread over several workers.MCP without cookies, on the same server with
expose_tools_over_mcp. 200 concurrent clients each:increment_counterby 1 and by 2 over/mcp;get_counter_value.All 200 counts are right. On main, 65 of 200 were. With cookie routing alone, 72 of 200 were.
One worker is killed with SIGKILL after seeding. Every session of that worker gets a 410, and every other session still scores 1.0.
Pending:
swe_rebenchrun at 8 workers with real sandboxes;Compatibility
nemo_gym_worker, and so do MCP session tokens. Handlers that readrequest.session[SESSION_ID_KEY], and the MCP endpoint's token check, are unaffected. Tokens minted before this change still work and are handled by whichever worker receives them.Hostheader and everything else are unchanged.resources_session_idandagent_session_id). They are routed because every Gym caller also sends the session cookie. A caller that sends only the body ID is not routed. The duplicate-seed and already-closed checks on a first, cookieless seed also see only the local worker.