-
Notifications
You must be signed in to change notification settings - Fork 0
Implement P4: Cancellation and Deduplication Handlers #2
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
…tilities Add thread-safe cancellation and request deduplication to the HTTP handler pipeline: - ActiveRequestTracker: ConcurrentDictionary-backed CTS tracker with Track/Untrack/CancelAll for centralized request cancellation - CancellationHandler: DelegatingHandler that creates linked CancellationTokenSources, enabling per-request and bulk cancellation - DeduplicationHandler: Deduplicates concurrent GET/HEAD requests using ConcurrentDictionary<string, Lazy<Task>> with SHA256-based keys from method+URL+sorted headers; clones buffered responses for each subscriber - HttpRequestMessageExtensions.CloneAsync: Deep clone for HttpRequestMessage including method, URI, headers, content, and Options - AcdcRequestOptions.Deduplicate: Per-request opt-out key for dedup - InternalsVisibleTo for test access to internal BuildDeduplicationKey All 101 tests pass (37 new tests for P4 components). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds HTTP pipeline components to support bulk cancellation of in-flight requests and deduplication of concurrent GET/HEAD calls, plus supporting request option(s), cloning helpers, and unit tests.
Changes:
- Introduces
CancellationHandler+ActiveRequestTrackerto enableCancelAll()over active requests while preserving caller cancellation. - Introduces
DeduplicationHandlerto coalesce concurrent GET/HEAD requests and clone buffered responses to each subscriber. - Adds
HttpRequestMessage.CloneAsync, request option key(s), and a new unit test suite for the above.
Reviewed changes
Copilot reviewed 10 out of 10 changed files in this pull request and generated 36 comments.
Show a summary per file
| File | Description |
|---|---|
src/CSharpAcdc/Handlers/DeduplicationHandler.cs |
Implements concurrent request deduplication and response buffering/cloning. |
src/CSharpAcdc/Handlers/CancellationHandler.cs |
Wraps requests in a linked CTS and tracks them for bulk cancellation. |
src/CSharpAcdc/Cancellation/ActiveRequestTracker.cs |
Tracks CTS instances and provides CancelAll() support. |
src/CSharpAcdc/Extensions/HttpRequestMessageExtensions.cs |
Adds deep-ish cloning for HttpRequestMessage (headers/content/options). |
src/CSharpAcdc/Extensions/AcdcRequestOptions.cs |
Adds AcdcRequestOptions.Deduplicate opt-out. |
src/CSharpAcdc/CSharpAcdc.csproj |
Adds InternalsVisibleTo for tests to access internal APIs. |
tests/CSharpAcdc.Tests/Handlers/DeduplicationHandlerTests.cs |
Adds test coverage for dedup behavior and key determinism. |
tests/CSharpAcdc.Tests/Handlers/CancellationHandlerTests.cs |
Adds test coverage for linked token behavior and bulk cancellation. |
tests/CSharpAcdc.Tests/Extensions/HttpRequestMessageExtensionsTests.cs |
Adds test coverage for request cloning behavior. |
tests/CSharpAcdc.Tests/Cancellation/ActiveRequestTrackerTests.cs |
Adds test coverage for tracking and cancel-all semantics under concurrency. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false); | ||
| return await DeduplicatedResponse.FromResponseAsync(response).ConfigureAwait(false); | ||
| } |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The response returned by base.SendAsync is buffered into DeduplicatedResponse but never disposed. That can leak sockets/handlers because HttpResponseMessage/HttpContent are IDisposable. After buffering headers/content bytes, dispose the original HttpResponseMessage (e.g., via using/try-finally in ExecuteAndBufferAsync or inside FromResponseAsync).
| var sortedHeaders = request.Headers | ||
| .OrderBy(h => h.Key, StringComparer.OrdinalIgnoreCase) | ||
| .SelectMany(h => h.Value.Select(v => $"{h.Key}:{v}")); | ||
|
|
||
| var headerString = string.Join(",", sortedHeaders); | ||
| var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(headerString)); | ||
| sb.Append(Convert.ToHexString(hashBytes)); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
BuildDeduplicationKey sorts by header name, but the key still depends on header value insertion order (and potentially header name casing). Two requests with the same logical headers added in different orders can produce different keys and skip deduplication. To make the key deterministic, normalize the header name (e.g., lowercase) and sort the values for each header before hashing.
| foreach (var cts in _activeSources.Keys) | ||
| { | ||
| try | ||
| { | ||
| cts.Cancel(); | ||
| } | ||
| catch (ObjectDisposedException) | ||
| { | ||
| // CTS may have been disposed between enumeration and cancel | ||
| } | ||
| } | ||
|
|
||
| _activeSources.Clear(); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
CancelAll() calls _activeSources.Clear() after enumerating keys. With concurrent Track() calls, a CTS can be added after enumeration but before Clear(), resulting in it being removed from tracking without being canceled (request continues but is no longer tracked). Prefer canceling via TryRemove in a loop (remove-then-cancel) and avoid Clear(), so newly tracked sources aren’t accidentally dropped uncanceled.
| foreach (var cts in _activeSources.Keys) | |
| { | |
| try | |
| { | |
| cts.Cancel(); | |
| } | |
| catch (ObjectDisposedException) | |
| { | |
| // CTS may have been disposed between enumeration and cancel | |
| } | |
| } | |
| _activeSources.Clear(); | |
| // Remove-then-cancel pattern to avoid dropping newly tracked CTS instances. | |
| while (!_activeSources.IsEmpty) | |
| { | |
| foreach (var kvp in _activeSources.ToArray()) | |
| { | |
| var cts = kvp.Key; | |
| if (!_activeSources.TryRemove(cts, out _)) | |
| { | |
| // Already removed by another operation; skip. | |
| continue; | |
| } | |
| try | |
| { | |
| cts.Cancel(); | |
| } | |
| catch (ObjectDisposedException) | |
| { | |
| // CTS may have been disposed between removal and cancel. | |
| } | |
| } | |
| } |
| [Fact] | ||
| public async Task ResponseCloning_ProducesIndependentResponses() | ||
| { | ||
| var innerHandler = new DelegateHandler((req, ct) => | ||
| Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("shared-body"), | ||
| })); | ||
|
|
||
| var handler = new DeduplicationHandler { InnerHandler = innerHandler }; | ||
| using var client = new HttpClient(handler) | ||
| { | ||
| BaseAddress = new Uri("https://api.example.com"), | ||
| }; | ||
|
|
||
| var response1 = await client.GetAsync("/data"); | ||
| var response2 = await client.GetAsync("/data"); | ||
|
|
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This test name suggests it validates response cloning for deduplicated subscribers, but the two GetAsync calls are sequential, so the handler will likely issue two separate upstream requests (no in-flight overlap). Make the requests concurrent (and assert the inner handler call count is 1) so the test actually exercises cloning of a shared buffered response.
| // because it's a linked token | ||
| capturedToken.Should().NotBe(CancellationToken.None); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
SendAsync_PassesLinkedToken_Downstream doesn’t currently verify that the downstream token is actually linked to the caller token (it only checks it’s not CancellationToken.None). Consider asserting that canceling the caller CTS cancels the captured token, or at least that capturedToken != cts.Token, so the test fails if the handler accidentally forwards the original token unchanged.
| // because it's a linked token | |
| capturedToken.Should().NotBe(CancellationToken.None); | |
| // because it's a linked token, not the original caller token | |
| capturedToken.Should().NotBe(CancellationToken.None); | |
| capturedToken.Should().NotBe(cts.Token); |
| var innerHandler = new DelegateHandler((req, ct) => | ||
| { | ||
| Interlocked.Increment(ref callCount); | ||
| return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK)); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disposable 'HttpResponseMessage' is created but not disposed.
| return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("ok"), | ||
| }); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disposable 'HttpResponseMessage' is created but not disposed.
| return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("ok"), | ||
| }); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disposable 'HttpResponseMessage' is created but not disposed.
| Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent("shared-body"), | ||
| })); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disposable 'HttpResponseMessage' is created but not disposed.
| return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) | ||
| { | ||
| Content = new StringContent($"response-{callCount}"), | ||
| }); |
Copilot
AI
Feb 9, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Disposable 'HttpResponseMessage' is created but not disposed.
Comprehensive PR Review -- P4: Cancellation and Deduplication HandlersReviewed by 5 specialized agents: code-reviewer, thread-safety-reviewer, silent-failure-hunter, type-design-analyzer, and test-coverage-analyzer. Findings are deduplicated and ranked by cross-agent convergence. Critical Issues1. DeduplicationHandler captures first caller's CancellationToken (5/5 agents converge) The Fix: Decouple the upstream request from individual caller tokens. Use a handler-owned 2. ActiveRequestTracker.CancelAll race condition (4/5 agents converge) The enumerate-then-Clear pattern at lines 21-36 has a race: a CTS Fix: Replace with a remove-then-cancel loop: while (!_activeSources.IsEmpty)
{
foreach (var cts in _activeSources.Keys)
{
if (_activeSources.TryRemove(cts, out _))
{
try { cts.Cancel(); }
catch (ObjectDisposedException) { }
}
}
}3. Original HttpResponseMessage not disposed in ExecuteAndBufferAsync (3/5 agents converge) At lines 37-43, the Fix: Add using var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);4.
Fix: Inject the Important Issues5. CloneAsync doesn't copy Version/VersionPolicy (3/5 agents) HttpRequestMessageExtensions.cs does not copy 6. Missing null guards on constructors (3/5 agents)
Fix: Add 7. Handlers not Both 8. CLAUDE.md states "Use Test Coverage Gaps
Strengths
Recommended Action Plan
Generated with Claude Code Review produced by: code-reviewer, thread-safety-reviewer, silent-failure-hunter, type-design-analyzer, pr-test-analyzer |
Summary
CancellationTokenSource, enabling both per-request cancellation (via the caller's token) and bulk cancellation (viaActiveRequestTracker.CancelAll())ConcurrentDictionary<string, Lazy<Task>>keyed by method+URL+SHA256(sorted headers); buffers and clones responses for each subscriberHttpRequestMessage(method, URI, headers, content, Options)InternalsVisibleTofor test project access to internalBuildDeduplicationKeyTest plan
ActiveRequestTrackerTests— track/untrack/cancel-all, disposed source handling, concurrent thread safety (1000 iterations)CancellationHandlerTests— linked token propagation, cleanup on success/failure,CancelAllcancels in-flight, external cancellationDeduplicationHandlerTests— concurrent GET dedup, POST/PUT/DELETE passthrough, HEAD dedup, per-request opt-out, different URLs/headers not deduped, response cloning independence, cleanup allows new waves, error propagation to all subscribersHttpRequestMessageExtensionsTests— copies method/URI/headers/content/options, null content, independent copy verification🤖 Generated with Claude Code