Register a subscription before awaiting its snapshot - #330775
Register a subscription before awaiting its snapshot#330775Ryan Ewen (RyanEwen) wants to merge 3 commits into
Conversation
A client subscribes to a channel and dispatches actions on it immediately afterwards over the same connection, without waiting for the subscribe response. The handler registered the subscription only after awaiting `IAgentService.subscribe`, so any envelope emitted during that await was routed to nobody. The client therefore never received the echo of its own write-ahead action. For a chat turn that means `chat/turnStarted` never comes back, the renderer never installs the turn observer, and the response renders as an empty bubble with correct metadata while every later envelope (deltas, tool calls, turn completion) arrives and is ignored. The state itself is untouched, so reloading the window shows the whole response. Register before the await, and drop the registration if the subscribe rejects. The two branches above already register synchronously before returning; this one was the outlier. Observed on a dev container, where the websocket hop widens the window: subscribe and dispatch landed 1ms apart and the echo was lost; a run where they landed 2ms apart was fine.
There was a problem hiding this comment.
Pull request overview
Prevents lost action echoes while state snapshots are resolving.
Changes:
- Registers subscriptions before awaiting snapshots.
- Rolls back registrations on failure.
- Adds regression coverage for concurrent dispatch.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.
| File | Description |
|---|---|
protocolServerHandler.ts |
Adjusts subscription registration ordering. |
protocolServerHandler.test.ts |
Tests dispatch during pending subscription. |
Suppressed comments (1)
src/vs/platform/agentHost/node/protocolServerHandler.ts:1325
- A stale failed subscribe can delete a newer subscription for the same URI. Request handlers run concurrently, and the client subscription manager can unsubscribe/reacquire while the first request is pending; the old catch then removes the replacement map entry, while
AgentService.subscribealso rolls back the shared client-id registration. Make subscribe cleanup generation-aware (and preserve/re-register the current agent-service subscription) and cover the unsubscribe/resubscribe race.
client.subscriptions.delete(classified.uri);
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…urn id Applies the review patterns already raised on the earlier PRs in this series. Comments: the inline explanation ran to five lines, well past the one-line limit for comments inside a method; the test carried a three-line version of the same. Both condensed. Re-subscribe: deleting the subscription when `subscribe` rejects removed a pre-existing valid subscription if the client was already subscribed to that channel. Only remove the entry this call added. Assertion: matching on action type alone would pass for any echoed turnStarted. Assert the echoed turn ids instead, so the test proves the client's own action came back.
|
Both comment findings were already addressed in 55e28eb, pushed about a minute after this review ran. The inline comment in That commit also covers two things this review did not flag, applying patterns raised on the earlier PRs in this series: removing the subscription on a failed |
…ation-race # Conflicts: # src/vs/platform/agentHost/test/node/protocolServerHandler.test.ts
A chat response intermittently renders as an empty bubble: the request shows, the response header shows the right duration and model, and no content ever appears. Reloading the window shows the entire response, so the state was always correct.
Cause
subscriberegisters the client's subscription only after awaitingIAgentService.subscribe:A client subscribes and then dispatches on the same connection without waiting for the response. Any envelope emitted during that await is routed to no subscriber, so the client never receives the echo of its own write-ahead action.
For a chat turn the lost echo is
chat/turnStarted. The renderer installs its turn observer when that action comes back, so it is never installed: no markdown part is ever set up, no tool call is ever rendered, and every later envelope arrives and is ignored. The agent host runs the turn normally throughout.Evidence
Captured on a dev container over websocket. Same client, same prompt, two consecutive turns:
Instrumenting
_setupMarkdownPartin the workbench confirmed the consequence: on the painted turn it logged setup and 16 emits; on the unpainted turn it was never called at all, while 11chat/deltaevents crossed the wire. Measured DOM text was 1591 chars vs 319 (the user's prompt alone), reproducing in 3 of 8 unattended runs.Fix
Register before the await, and remove the registration if the subscribe rejects. The
OtlpLogsandResourceWatchbranches directly above already register synchronously before returning; this branch was the outlier.Testing
an action dispatched while subscribe is still resolving is still echoed, which gates the fake service'ssubscribeon a promise, dispatcheschat/turnStartedduring the await, then releases it. Without the fix it fails with "turnStarted must be echoed to the client that dispatched it".ProtocolServerHandler94 passing,AgentSideEffects219 passing.npm run typecheck-clientclean.