Address stephentoub review feedback on HTTP request callback support (+ cross-SDK parity)#1775
Conversation
Mechanical perf/API refinements to the .NET CopilotRequestHandler in response to review comments on #1689: - CopilotWebSocketMessage: rename Text factory to FromText and remove the redundant Binary factory (the public primary constructor already covers it); decode via Encoding.GetString(Data.Span) instead of allocating with ToArray(). - Allow consumers to supply their own HttpClient via a new CopilotRequestHandler(HttpClient?) constructor, falling back to the shared process-wide instance. - Avoid unnecessary allocations on the hot paths: send/receive WebSocket frames through the ReadOnlyMemory/Memory overloads, rent the receive buffer from ArrayPool, and write request-body chunks via Span. - Don't cancel the response pump Task.Run before it can run its cleanup. - Forward the original HTTP method casing (drop ToUpperInvariant; HttpMethod comparison is already case-insensitive). - Drop the NETSTANDARD2_0 #if branches in StreamResponseAsync in favour of new netstandard2.0 polyfills (Encoding.GetString(ReadOnlySpan<byte>), HttpContent.ReadAsStreamAsync(CancellationToken), Stream.Write(ReadOnlySpan), and WebSocket Send/Receive Memory overloads + ValueWebSocketReceiveResult). Builds clean on net8.0/net10.0/netstandard2.0; CopilotRequest e2e tests pass on net8.0 and net472 (the polyfill path). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Follow-up to #1689 for the .NET SDK to address review feedback around HTTP request callback support, focusing on API cleanup, allocation/perf improvements on WebSocket and streaming paths, and consolidating netstandard2.0 compatibility via polyfills.
Changes:
- Refines WebSocket message APIs and reduces allocations (span-based UTF-8 decode, ReadOnlyMemory send, ArrayPool-based receive buffering).
- Adds an injectable
HttpClientoption toCopilotRequestHandlerwhile keeping a shared default. - Replaces conditional compilation in call sites with netstandard2.0 polyfills (Encoding/Stream/WebSocket/HttpContent shims).
Show a summary per file
| File | Description |
|---|---|
| dotnet/src/Polyfills/DownlevelExtensions.cs | Adds netstandard2.0 polyfills for Encoding span decode, HttpContent ReadAsStreamAsync(CancellationToken), Stream span write, and WebSocket Memory-based send/receive + receive result shim. |
| dotnet/src/CopilotRequestHandler.cs | Updates WebSocket message factories/decoding, optimizes send/receive paths, introduces injectable HttpClient, and removes TFM-specific branches in streaming code paths. |
Copilot's findings
Comments suppressed due to low confidence (1)
dotnet/src/CopilotRequestHandler.cs:503
hasBodyuses reference inequality (method != HttpMethod.Get/Head). Sincemethodis always a newHttpMethod, this comparison will never match and GET/HEAD requests will be treated as having a body (and may end up with a synthetic emptyContentwhen adding content headers). UseHttpMethod.Get.Equals(method)/HttpMethod.Head.Equals(method)(or compare the method string ordinal-ignore-case) instead.
var method = new HttpMethod(exchange.Method);
var message = new HttpRequestMessage(method, exchange.Context.Url);
var hasBody = method != HttpMethod.Get && method != HttpMethod.Head;
var body = await DrainAsync(exchange.RequestBody).ConfigureAwait(false);
if (hasBody && body.Length > 0)
- Files reviewed: 2/2 changed files
- Comments generated: 1
| public Task<IO.Stream> ReadAsStreamAsync(Threading.CancellationToken cancellationToken) => | ||
| content.ReadAsStreamAsync(); |
This comment has been minimized.
This comment has been minimized.
Propagate the .NET review-feedback changes to the other language SDKs and fix two efficiency/correctness nits: - Go: drop redundant per-chunk copy in streamResponseToSink (writeText copies eagerly); remove redundant NewBinaryMessage factory (exported fields suffice). - Rust: rename text -> from_text; remove redundant binary associated fn (pub fields suffice); construct binary frames via struct literal. - Java: rename static text -> fromText; remove redundant static binary factory (record canonical ctor suffices). - .NET: ns2.0 HttpContent.ReadAsStreamAsync(ct) polyfill now honors the token by throwing if already cancelled before the tokenless BCL call. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This comment has been minimized.
This comment has been minimized.
- Rust: send_request_message now moves the owned payload into String::from_utf8 (zero-copy on valid UTF-8, the common case), falling back to lossy decoding only on invalid bytes, instead of always allocating via from_utf8_lossy().into_owned(). - Java: add an internal LlmInferenceExchange.writeResponseBinary(byte[], offset, length) overload that base64-encodes a subrange directly, so streamResponse no longer allocates+arraycopies an exact-length frame per response chunk. The existing single-arg overload is retained for the WebSocket bridge. Both are internal-only; no public/consumer-facing API changes. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Cross-SDK Consistency Review ✅This PR maintains strong cross-SDK consistency. Here's a summary of what was verified:
|
| SDK | Old factory | New factory | Binary removal |
|---|---|---|---|
| .NET | Text(string) |
FromText(string) |
✅ Binary(ReadOnlyMemory<byte>) removed |
| Go | — | NewTextMessage kept (idiomatic New*) |
✅ NewBinaryMessage removed |
| Java | text(String) |
fromText(String) |
✅ binary(byte[]) removed |
| Rust | text(impl Into<String>) |
from_text(impl Into<String>) |
✅ binary(Vec<u8>) removed |
All renames follow each language's naming idiom (PascalCase/camelCase/snake_case) — consistent by design, not identical by accident.
Node.js and Python correctly omitted
Confirmed both SDKs use raw string | Uint8Array / str | bytes for WebSocket messages — no wrapper type exists, so there's nothing to rename or remove.
Custom HTTP client supply — already consistent across all SDKs
The new .NET constructor injection (CopilotRequestHandler(HttpClient?)) is .NET-idiomatic. All other SDKs already have their own idiomatic mechanism:
- Go:
Transport http.RoundTripperfield - Java:
protected HttpClient httpClient()override - Node.js / Python / Rust:
sendRequest/send_requestmethod override
No gaps here.
No stale references found
Searched docs, samples, and tests — no remaining calls to the removed Binary/NewBinaryMessage/binary/binary factories or the old Text/text names.
All changes are internally consistent and no other SDKs need follow-up updates from this PR.
Generated by SDK Consistency Review Agent for issue #1775 · sonnet46 2.2M · ◷
* HTTP request callback support (github#1689) * fix(rust): backdate extracted CLI mtime to stop build.rs self-invalidation (github#1776) * fix(rust): backdate extracted CLI mtime to stop self-invalidation When `bundled-cli` is off, `build.rs` watches the extracted CLI binary via `cargo:rerun-if-changed` so a deleted cache binary forces a re-extract. On a cold cache the same build script *creates* that binary mid-build, after the download — so its mtime ends up newer than cargo's build-script `output` reference (stamped when the script was spawned). The next identical `cargo` invocation then sees the watched file as "changed", reruns build.rs, recompiles the SDK crate, and relinks every downstream crate (observed ~9 min of wasted CI on a second cargo invocation in the same job). Backdate the staged binary's mtime to the Unix epoch before the atomic rename (rename preserves mtime), so it lands unambiguously older than any real build reference and a no-change rebuild stays a true no-op. Best-effort: on error we emit a `cargo:warning` and continue, reverting to the prior redundant-rebuild behaviour rather than breaking the build. The deleted-file recovery contract is untouched — a missing file still can't be stat'd, so cargo still reruns. Embed mode (`bundled-cli` on) is unaffected: it emits no `rerun-if-changed` on any build-created file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(rust): write staging binary through a single file handle Address review: open the staging file once and perform the write, permission-set, and mtime-backdate on one handle instead of reopening it for each step. Write and chmod failures stay fatal; the epoch backdate stays best-effort (warn and continue). Behavior is otherwise unchanged: the extracted binary still lands epoch-dated so a no-change rebuild is a true no-op. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on HTTP request callback support (+ cross-SDK parity) (github#1775) * Strong-name sign .NET SDK (github#1778) Use the canonical .NET Open.snk key to strong-name sign the GitHub.Copilot.SDK assembly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> Co-authored-by: Paolo Tranquilli <redsun82@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
* HTTP request callback support (github#1689) * fix(rust): backdate extracted CLI mtime to stop build.rs self-invalidation (github#1776) * fix(rust): backdate extracted CLI mtime to stop self-invalidation When `bundled-cli` is off, `build.rs` watches the extracted CLI binary via `cargo:rerun-if-changed` so a deleted cache binary forces a re-extract. On a cold cache the same build script *creates* that binary mid-build, after the download — so its mtime ends up newer than cargo's build-script `output` reference (stamped when the script was spawned). The next identical `cargo` invocation then sees the watched file as "changed", reruns build.rs, recompiles the SDK crate, and relinks every downstream crate (observed ~9 min of wasted CI on a second cargo invocation in the same job). Backdate the staged binary's mtime to the Unix epoch before the atomic rename (rename preserves mtime), so it lands unambiguously older than any real build reference and a no-change rebuild stays a true no-op. Best-effort: on error we emit a `cargo:warning` and continue, reverting to the prior redundant-rebuild behaviour rather than breaking the build. The deleted-file recovery contract is untouched — a missing file still can't be stat'd, so cargo still reruns. Embed mode (`bundled-cli` on) is unaffected: it emits no `rerun-if-changed` on any build-created file. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * refactor(rust): write staging binary through a single file handle Address review: open the staging file once and perform the write, permission-set, and mtime-backdate on one handle instead of reopening it for each step. Write and chmod failures stay fatal; the epoch backdate stays best-effort (warn and continue). Behavior is otherwise unchanged: the extracted binary still lands epoch-dated so a no-change rebuild is a true no-op. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * Address review feedback on HTTP request callback support (+ cross-SDK parity) (github#1775) * Strong-name sign .NET SDK (github#1778) Use the canonical .NET Open.snk key to strong-name sign the GitHub.Copilot.SDK assembly. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Steve Sanderson <SteveSandersonMS@users.noreply.github.com> Co-authored-by: Paolo Tranquilli <redsun82@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
Follow-up to #1689 addressing the review comments from @stephentoub. The bulk is .NET; a second commit propagates the applicable naming/efficiency changes to the other language SDKs so the
CopilotRequestHandlershape stays consistent across all six. No behavior changes to the public callback model.Implemented (12 comments)
CopilotWebSocketMessage
CopilotRequestHandler
Allocation/perf on the hot paths
TFM cleanup
#if NETSTANDARD2_0branches in StreamResponseAsync in favour of new netstandard2.0 polyfills in DownlevelExtensions.cs: Encoding.GetString(ReadOnlySpan), HttpContent.ReadAsStreamAsync(CancellationToken), Stream.Write(ReadOnlySpan), and WebSocket SendAsync(ReadOnlyMemory)/ReceiveAsync(Memory) plus a ValueWebSocketReceiveResult shim. On net8/net10 the native BCL methods are used; call sites are now TFM-uniform.Cross-SDK parity (Go, Rust, Java)
Since these APIs are unshipped, the same renames/cleanups land in the other SDKs using each language's idiom — no two SDKs carry a redundant binary-message factory anymore:
streamResponseToSink(the sink'swriteTextalready copies eagerly viastring(...)); remove the redundantNewBinaryMessagefactory (exportedData/Binaryfields make a struct literal sufficient).NewTextMessagekept —New*is the idiomatic Go constructor name.text->from_text; remove the redundantbinaryassociated fn (thepub data/pub binaryfields suffice); construct binary frames via a struct literal.text->fromText; remove the redundant staticbinaryfactory (the record's canonical constructor suffices).Node.js and Python are unaffected — they have no message wrapper type (raw
string | Uint8Array/str | bytes) and their forwarding paths are already copy-light.Two further per-frame copy removals, internal-only (no public API change):
send_request_messagenow moves the owned payload intoString::from_utf8(zero-copy on valid UTF-8 — the common case for LLM traffic), falling back to lossy decoding only on invalid bytes, instead of always allocating viafrom_utf8_lossy().into_owned().LlmInferenceExchange.writeResponseBinary(byte[], offset, length)overload base64-encodes a subrange directly, sostreamResponseno longer allocates + arraycopies an exact-length frame per response chunk. The single-arg overload is kept for the WebSocket bridge.Validation
CopilotRequest*e2e pass on net8.0 (5/5) and net472 (4/4, exercises the ns2.0 polyfill assembly at runtime).go build+ e2e green. Rust: build +cargo fmt --check+clippy -D warningsclean, e2e 4/4. Java: compiles clean (JDK 25).Not adopting the BCL WebSocket types (responding to @stephentoub)
System.Net.WebSockets.WebSocketas the base class.WebSocketmodels one endpoint of one connection:SendAsyncwrites to the peer,ReceiveAsyncreads from the peer. Our handler is a mediator on a relay with two directions to two different parties:A
WebSocketsubclass has a singleSendAsync/ReceiveAsyncwith no notion of which side a frame belongs to, so the relay can't be expressed cleanly. It's also pull-based (the consumer owns a receive loop with buffer management and fragment reassembly), whereas we hand the consumer a fully-assembledCopilotWebSocketMessageand pump for them. Deriving fromWebSocketwould also force implementations ofState,CloseStatus,SubProtocol,Abort,CloseOutputAsync, etc. — members with no meaning for a relay hook — and theMemoryoverloads are core-only, reintroducing the netstandard2.0 split this PR just removed.WebSocketCloseStatus(the enum). It can't replaceCopilotWebSocketCloseStatus, which carriesException? Errorandstring? ErrorCodeand is mapped to the runtime via end-of-stream / error-with-code — never an RFC numeric close code. Adding it alongside what we already have would only change the close code sent to the upstream provider, which providers don't act on, so it solves no consumer problem. A consumer who genuinely needed a specific upstream close can already overrideCloseAsync.Deferred — needs maintainer input (not in this PR)
runtimeShutdownCompletedremoval / graceful-shutdown wait reasoning — these question the shutdown redesign that landed in HTTP request callback support #1689; reverting would be a significant change, so flagging rather than acting.