fix(http): dispose HTTP responses the client fully consumes - #492
Conversation
ClickHouseClient obtained HttpResponseMessages it never handed to a caller and never disposed them: ExecuteNonQueryAsync read the row count and returned, the internal binary-insert batch senders discarded the streamed response entirely, and both PostSqlQueryAsync and the streamed-insert PostStreamAsync threw without releasing the response they had already received. ExtendedBinaryReader does not propagate Dispose through the PeekableStreamWrapper chain, so nothing else released them either and the response (and its pooled connection) stayed alive until finalization, degrading HttpClient connection pooling under load. Paths that transfer ownership are unchanged: ExecuteReaderAsync (ClickHouseDataReader.Dispose), ExecuteRawResultAsync (ClickHouseRawResult.Dispose), and the public InsertRawStreamAsync / PostStreamAsync overloads that return the response to the caller.
Codecov Report✅ All modified and coverable lines are covered by tests. 📢 Thoughts on this report? Let us know! |
There was a problem hiding this comment.
Pull request overview
This PR fixes an HTTP resource-lifetime bug in ClickHouseClient where HttpResponseMessage instances (and thus pooled connections) could be left undisposed on code paths that fully consume responses or throw before handing ownership to a caller, degrading HttpClient connection pooling under load.
Changes:
- Dispose fully-consumed
HttpResponseMessages inExecuteNonQueryAsyncand binary-insert batch senders. - Ensure responses are disposed on exceptional paths in
PostSqlQueryAsyncand the internal streamed insertPostStreamAsyncoverload. - Add integration-style tests that observe response disposal behavior; update CHANGELOG/RELEASENOTES with the bug fix.
Reviewed changes
Copilot reviewed 4 out of 4 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
| ClickHouse.Driver/ClickHouseClient.cs | Ensures HttpResponseMessage disposal on fully-consumed and exception-only ownership paths. |
| ClickHouse.Driver.Tests/ResponseDisposalTests.cs | Adds real-server tests to verify response disposal/ownership across key query/insert APIs. |
| CHANGELOG.md | Adds unreleased bug-fix entry describing the disposal fix. |
| RELEASENOTES.md | Adds unreleased release-note entry describing the disposal fix. |
Suppressed comments (2)
ClickHouse.Driver.Tests/ResponseDisposalTests.cs:92
- These tests hand-roll table names (TestUtilities.TestDatabase + Guid). The established convention in this repo is to use TestUtilities.CreateTableName(...) so names are always unique, framework-attributable, and follow the suite’s cleanup/debugging expectations (see ClickHouse.Driver.Tests/AbstractConnectionTestFixture.cs:25-43).
var targetTable = $"{TestUtilities.TestDatabase}.response_disposal_poco_{Guid.NewGuid():N}";
ClickHouse.Driver.Tests/ResponseDisposalTests.cs:117
- This test relies on a hard-coded missing table name. If any concurrently running suite (net6/net8/net9/net10) or other test happens to create that table, this can become flaky. Prefer a unique name via TestUtilities.CreateTableName(...).
Assert.ThrowsAsync<ClickHouseServerException>(
() => client.InsertRawStreamAsync("no_such_table_for_response_disposal_test", payload, "TSV"));
Review feedback on #492: - dispose the HttpClient the fixture injects (ClickHouseClient deliberately does not own a caller-provided HttpClient, so each test leaked a handler) - use TestUtilities.CreateTableName() for every table identifier, including the deliberately-missing ones, so names carry the target framework moniker and cannot collide with concurrently executing suites
|
…onnectionTestFixture - ResponseDisposalTrackingHandler now derives from the existing test-project TrackingHandler (request tracking + forwarding) and only adds the response-content disposal observation, instead of hand-rolling a second DelegatingHandler. - ResponseDisposalTests derives from AbstractConnectionTestFixture: its client (with a separate HttpClient) does table setup and auto-drop, so the tracking client only issues the requests of the operation under test, and every assertion now pins an exact response count. - Shortened the CHANGELOG/RELEASENOTES entry.
|
Thanks @alex-clickhouse — all three done in 5395d8a.
Verified in the devbox (net10.0): the 7 tests pass with the fix and the same 5 still fail with the production change reverted (the 2 ownership-transfer contrast tests pass either way); full suite 9605 passed / 0 failed. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 5 out of 5 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ClickHouse.Driver.Tests/Utilities/ResponseDisposalTrackingHandler.cs:49
- In the tracking handler,
response.Contentis replaced with a newStreamContent, which means the originalHttpContentinstance is abandoned and never disposed. Even though the underlying stream will be disposed, leaving the originalHttpContentundisposed can still leak resources and also changes disposal semantics compared to a normalHttpResponseMessage.Dispose()(which disposes its original content). Consider retaining and disposing the original content as part of the tracking content's Dispose().
var response = await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
var trackingContent = new DisposalTrackingContent(
await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false));
foreach (var header in response.Content.Headers)
Resolves the conflict with the read-path work merged on main (#472, #492). ClickHouseDataReader.FromHttpResponseAsync — both sides moved a stream in the same chain, and the resolution keeps both invariants: - #472 moved ExceptionTagAwareStream BELOW PooledReadBufferStream so it observes one read per buffer refill instead of one per scalar decoded. - this branch requires the decompressor to be INNERMOST, so every layer above it sees plaintext. Both hold at once, because the exception scanner has to sit above the decompressor anyway: the server writes its in-band exception marker into the response body, so the marker only exists in the decoded plaintext. Final chain: rawStream -> decompressor -> ExceptionTagAwareStream? -> PooledReadBufferStream -> ExtendedBinaryReader ClickHouseClient.ExecuteNonQueryAsync — took main's response disposal from #492 and kept this branch's decompression over the same stream. #492 fixes exactly the disposal gap the Copilot review raised on this PR and it was declined here as pre-existing, so it is now fixed upstream rather than in this PR. Verified in the devbox: build 0 errors; full suite 9759 passed / 0 failed / 142 skipped (net10.0 Release); examples project builds. Notably main's own new ExceptionTagAwareStreamTests, PooledReadBufferStreamTests and ResponseDisposalTests (152 tests across those suites, 0 skipped) pass with the decompressor inserted, which is the direct check that this resolution is semantically correct and not just textually clean.
main added 22 new Unreleased entries since this branch was cut. Each is now its own changelog.d/ fragment, extracted verbatim by line number rather than retyped, so the assembled Unreleased section reproduces main's exactly (as a set of lines; sorting by PR number reorders entries within their sections). New fragments, one per (PR, category): #390 improvements multidim blittable inserts #472 improvements per-scalar Span<byte> reads #484 fixes byte[]/TimeOnly HTTP parameters #485 fixes JSON strings under ReadStringsAsByteArrays #490 breaking raw results return compressed bytes #490 features AcceptEncoding response compression #490 improvements lz4 by default, HttpClient, errors, deflate #492 fixes HTTP response disposal #493 fixes Enum type declarations #494 fixes raw-stream double dispose #497 fixes GetSchema("Columns") restrictions #498 fixes JSON paths starting with setting names #503 fixes quoted JSON typed paths #504 fixes quoted Tuple/Nested element names #509 fixes {name:Type} scanner vs server lexer #511 fixes {name:Type} hints after a non-hint brace #513 fixes @name placeholders, heredocs, $ in names #390's entry was appended to the *released* v1.3.0 section on main (v1.3.0 shipped 2026-06-29), so it would have documented an unreleased change under a shipped version and never appeared in 1.4.0's notes. It moves to Unreleased as a fragment; the rest of v1.3.0 is byte-identical. RELEASENOTES.md regenerated with --sync-notes. `--check` passes, the solution builds, and the packed .nupkg's releaseNotes open on v1.3.0 with no Unreleased stub and no #390 bullet.
Description
Reported by Copilot on #490 (#490 (comment)) and confirmed pre-existing on
main@6c40174, so it is fixed here rather than bundled into that compression PR.ClickHouseClientobtainedHttpResponseMessages that it never handed to a caller and never disposed.ExecuteNonQueryAsyncread the 7-bit-encoded row count out ofresponse.HttpResponseMessage.Contentand returned;ExtendedBinaryReaderdoes not propagateDisposeto its inner stream (it goes throughPeekableStreamWrapper), so nothing released the response. Because the query path usesHttpCompletionOption.ResponseHeadersRead, the response and its pooled connection then stayed alive until GC finalization, which degradesHttpClientconnection pooling under load. Auditing the rest of the class found the same defect on three more paths: the two internal binary-insert batch senders discarded the streamed response entirely, and bothPostSqlQueryAsyncand the streamed-insertPostStreamAsyncthrew (server error or transport failure) without releasing the response they had already received — on those paths ownership never reaches the caller, so nobody could dispose it.Paths that transfer ownership are deliberately unchanged:
ExecuteReaderAsync(released byClickHouseDataReader.Dispose, and byFromHttpResponseAsync's own catch on failure),ExecuteRawResultAsync(ClickHouseRawResult.Dispose), the publicInsertRawStreamAsync/PostStreamAsyncoverloads that return theHttpResponseMessage, and the ADOClickHouseCommandpaths, which already usedusing var response.Changes
ClickHouse.Driver/ClickHouseClient.csExecuteNonQueryAsync:using var response = result.HttpResponseMessage— mirrorsClickHouseCommand.ExecuteNonQueryAsync. The reader is still disposed first, then the response.PostSqlQueryAsync: dispose the response in thecatchbefore rethrowing.HandleErrormaterializes the error body into a string before throwing andClickHouseServerExceptionkeeps no reference to the response, so nothing needs it afterwards.PostStreamAsync(string, HttpContent, …): hoistresponseout of thetryand dispose it in thecatch, same rationale.SendBatchAsync/SendPocoBatchAsync:using var response = await PostStreamAsync(...). Disposal runs beforeRethrowSerializationError, which never touches the response.CHANGELOG.md/RELEASENOTES.md: bug-fix entry.Test
New
ClickHouse.Driver.Tests/ResponseDisposalTests.cs, against a real server. ADelegatingHandlerswaps each response's content for aStreamContentsubclass that records its ownDispose, so "the response was disposed" (disposing a response disposes its content) becomes observable:ExecuteNonQueryAsyncsuccess and server-error paths dispose the response.InsertBinaryAsync(object[]and POCO overloads) disposes every response it issues.InsertRawStreamAsyncagainst a missing table disposes the response on the failed streamed request.ExecuteReaderAsyncandExecuteRawResultAsynckeep the response alive while the reader/raw result is open and release it only on itsDispose.Verification: the 5 disposal tests fail on unpatched
main(False/< False, True, False >) and pass with the fix; the 2 contrast tests pass both before and after. Full suite green on net10.0: 9592 passed, 0 failed, 142 skipped. No existing test was changed.Pre-PR validation gate
main, pass on this branch)AGENTS.md(integration-style tests, existingTestUtilitiesconfig, test naming, no new build warnings)PublicAPI/*.txtupdate needed)