Skip to content

[Fix] Add ResponseWriteTimeout so a client that does not read its response cannot hold a connection (#66) - #85

Merged
samgerene merged 1 commit into
developmentfrom
fix/66-response-write-timeout
Oct 11, 2026
Merged

samgerene merged 1 commit into
developmentfrom
fix/66-response-write-timeout

Conversation

@samgerene

@samgerene samgerene commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #66

Problem

Writing a response had no timeout. The host's pipe is unbuffered, so a write only completes when the client reads it. A client that sent a request and never read the response held its connection task, pipe instance, MaxConcurrentRequests slot and MaxConcurrentConnections slot until the host stopped.

Changes

  • ArgusPipeHostOptions.ResponseWriteTimeout (default 30 seconds, Timeout.InfiniteTimeSpan disables it): the maximum time the host may take to send a complete response.
  • Host: all five response writes in the connection task (the regular response, the 503 for the concurrency limit, the 406, the 503 for a request timeout and the 400 for a rejected request) go through one WriteResponseAsync helper. It links the timeout to the shutdown drain token; on expiry it logs the warning "The response was not sent within ...; closing the connection." and the connection is closed.
  • Compressed streamed bodies (ArgusChunkedWriteStream, ArgusChunkedEncoding): a compression stream writes its last block when it is disposed, without a cancellation token. On the unbuffered pipe that write could not be cancelled, so a client that read the start of a compressed streamed response and then stopped was still held, and shutdown could not end it either. ArgusChunkedWriteStream now takes the operation's token and uses it for writes whose caller passes none. The same path is used by ArgusClient for compressed streamed requests.
  • CI: a Test (Windows) job in CodeQuality.yml. Some of the new tests only make sense on Windows (see below), and nothing in CI exercised Windows named pipes. The existing Build job, with Sonar and coverage, is unchanged.

Behavior to be aware of

The timeout is a budget for the whole response, like RequestReadTimeout, and not an idle timeout. For a streamed response (BodyStream) it includes the time the handler's stream takes to produce the data. A host that streams large or long-lived responses, which had no limit before, is cut off after 30 seconds by default and should raise or disable the timeout. This is documented on the option and in the wiki, and belongs in the release notes.

Tests

Cross-platform:

  • a client that never reads an 8 MB response is disconnected; with MaxConcurrentConnections = 1 and MaxConcurrentRequests = 1 another client is then served, so both slots are released
  • the same for a streamed (BodyStream) response
  • a client that stops reading sees the connection closed before the complete body
  • Timeout.InfiniteTimeSpan and a generous timeout deliver the complete response to a slow reader
  • a response streamed from a stalled source is cut off although the client reads (total budget)
  • a handler that runs longer than the timeout still gets its response written (the timer starts at the write)
  • a client that disconnects during the write is not logged as a write timeout
  • StopAsync cancels a blocked write within the drain timeout, without a write-timeout warning
  • ArgusChunkedWriteStream: the constructor's token is used for writes without a token, and with gzip every write to the destination carries the token

Windows only ([Platform("Win")]): the unread 400, 406 and both 503 responses, and the compressed streamed response that is read partially. On Linux and macOS a pipe is a Unix domain socket that buffers a small response, so the write completes without a reader and there is nothing to time out. They run in the new Test (Windows) job.

Issue check

Point in #66 Status
Add a ResponseWriteTimeout option, default 30 seconds, Timeout.InfiniteTimeSpan to disable Done
Apply a linked cancellation token to all response writes: regular, 503, 406, timeout and 400 Done, through WriteResponseAsync; one test per write (four of them Windows only)
On expiry, log a warning and close the connection Done
Add a test with a client that sends a request and never reads Done
Scenario: the blocked write holds the connection task, pipe instance, request slot and connection slot Covered by the test with both limits set to 1
Not in the issue: a compressed streamed response escaped the timeout Found in review, fixed here

Not in this PR

Verification

  • dotnet build ArgusTransfer.sln: 0 warnings
  • dotnet test ArgusTransfer.sln: 687 passed (Windows); the 23 timing-sensitive tests passed in five repeat runs
  • dotnet format ArgusTransfer.sln --verify-no-changes: clean
  • dotnet build ArgusTransfer.sln -warnaserror: succeeded
  • Not run locally: the tests on Linux, and the new Test (Windows) job; both run on this PR.

Wiki: Server-Configuration (option table and "Connection Limits and I/O Timeouts") and Troubleshooting.

🤖 Generated with Claude Code

@sonarqubecloud

Copy link
Copy Markdown

@samgerene
samgerene merged commit 611695c into development Oct 11, 2026
5 checks passed
@samgerene
samgerene deleted the fix/66-response-write-timeout branch October 11, 2026 14:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Response writes have no timeout, so a client that never reads holds a connection indefinitely

1 participant