Fix: CancellationToken not propagating to provideLanguageModelChatResponse on chat stop#319098
Fix: CancellationToken not propagating to provideLanguageModelChatResponse on chat stop#319098tamuratak wants to merge 3 commits into
provideLanguageModelChatResponse on chat stop#319098Conversation
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds an explicit cross-process cancellation channel for language model chat requests so cancellation can still be signalled after the RPC's built-in token cancel handler has been removed (e.g., once the request promise resolves but streaming continues).
Changes:
- Introduces a new
$cancelLanguageModelChatRequestRPC on bothMainThreadLanguageModelsShapeandExtHostLanguageModelsShape. - In ext host and main thread, wraps the incoming
CancellationTokenin a localCancellationTokenSourcekeyed byrequestId, and registers a cancel listener on the caller-side token to forward cancellation via the new RPC. - Ensures the local CTS and cancel listeners are disposed on completion, error, and global dispose; reports a
CancellationErrorwhen the provider finished but the token was cancelled.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| src/vs/workbench/api/common/extHost.protocol.ts | Adds $cancelLanguageModelChatRequest to both main-thread and ext-host language model shapes. |
| src/vs/workbench/api/browser/mainThreadLanguageModels.ts | Tracks a per-request CTS, forwards cancellation via the new RPC, and disposes CTS/listeners on completion/error/dispose. |
| src/vs/workbench/api/common/extHostLanguageModels.ts | Mirrors the same per-request CTS + cancel-listener pattern in the ext host side, and surfaces cancellation as a CancellationError when applicable. |
| $cancelLanguageModelChatRequest(requestId: number): void { | ||
| this._pendingCancelCTS.get(requestId)?.cancel(); | ||
| } |
There was a problem hiding this comment.
The reviewer's concern is based on a misunderstanding. There are two separate $cancelLanguageModelChatRequest methods — one on MainThreadLanguageModelsShape (called from ExtHost) and one on ExtHostLanguageModelsShape (called from MainThread) — each operating on its own request ID namespace.
The call chain is:
ExtHost.sendRequestgenerates requestIdA, passes it to$tryStartChatRequest(MainThread)MainThread.$tryStartChatRequeststores a CTS in_pendingCancelCTSkeyed by requestIdA — the same ID it received as a parameter, not a different one- When cancelled, the ExtHost cancel listener calls
this._proxy.$cancelLanguageModelChatRequest(requestIdA)— which looks up the correct CTS in_pendingCancelCTSby that same requestIdA ✓ - The CTS token fires, which triggers the
sendChatRequestcallback's cancel listener, which callsthis._proxy.$cancelLanguageModelChatRequest(requestIdM)on the ExtHost side, matching the ExtHost's_pendingCancelTokensby requestIdM ✓
The two ID namespaces are intentionally separate and correctly bridged. Each $cancelLanguageModelChatRequest looks up its own map using the same ID that was used to populate it.
Created with GitHub Copilot + MiMO-V2.5-Pro
There was a problem hiding this comment.
| $cancelLanguageModelChatRequest(requestId: number): void { | |
| this._pendingCancelCTS.get(requestId)?.cancel(); | |
| } | |
| $cancelLanguageModelChatRequest(requestId: number): void { | |
| this._pendingCancelCTS.get(requestId)?.cancel(); | |
| } |
|
@tamuratak Thanks for the PR! Would you mind taking a look at the Copilot PR feedback and then from there we can do a human review as well |
Close #291713 #277872
The stop button does not propagate cancellation to the provider's
CancellationTokeninprovideLanguageModelChatRequest. This is caused by two issues: (1) the RPC cancel handler for$startChatRequestis deleted immediately because the promise resolves before the provider returns, and (2) request IDs differ across MainThread and ExtensionHost processes, so a single CTS cannot bridge the gap. Fix this by introducing local CancellationTokenSources at both the MainThread and ExtensionHost levels, connected via a new$cancelLanguageModelChatRequestRPC method, and ensure proper cleanup of listeners on disposal.How
rpcProtocol.tscancellation works and why it breaks:In
rpcProtocol.ts, the_cancelInvokedHandlersmap stores a cancel callback keyed bycallId(the RPC request number). When a caller sends a cancel message,_receiveCancellooks up the callback and fires it. However, the callback is deleted inpromise.then()as soon as the RPC call resolves:$startChatRequestis intentionally designed to return immediately (before streaming completes) so the RPC handler stays alive long enough to forward streaming data. But the promise still resolves right away, which deletes_cancelInvokedHandlers[callId]before the user can press the stop button. After that, any incoming cancel message finds no handler and is silently ignored — this is the root cause of the bug.Created with GitHub Copilot + MiMO-V2.5-Pro
CC: @connor4312 @lramos15