Skip to content

Stop the request body stream after an early >= 300 response - #944

Open
beshkenadze wants to merge 1 commit into
swift-server:mainfrom
beshkenadze:fix/early-response-request-body-stream
Open

beshkenadze wants to merge 1 commit into
swift-server:mainfrom
beshkenadze:fix/early-response-request-body-stream

Conversation

@beshkenadze

@beshkenadze beshkenadze commented Oct 22, 2026 •

Copy link
Copy Markdown

Closes #939

Motivation

After a complete >= 300 response, a paused body write stays suspended. It keeps the Transaction, the body's async iterator and its buffered data, even after HTTPClient.shutdown().

Modifications

  • Transaction.StateMachine keeps the response status in streamingBody.
  • If a >= 300 response ends during a paused upload, finish the transaction. Then resume the waiting body write with HTTPClientError.requestStreamCancelled. This follows HTTPRequestStateMachine, which sends no more body parts.
  • A < 300 response keeps the body stream open, as before.
New test in TransactionTests Case On main (commands in #939)
testRequestBodyStreamIsReleasedIfResponseAtLeast300EndsWhileUploadIsPaused A body write waits for demand. Fails with EventLoopFutureTimeoutError. AHC does not release the iterator in 5 s.
testRequestBodyStreamIsReleasedIfResponseAtLeast300EndsWhileWaitingForNextBodyPart The body task waits for the next part. The next write fails at once. Fails with EventLoopFutureTimeoutError. AHC does not release the iterator in 5 s.
testRequestBodyStreamResumesIfResponseBelow300EndsWhileUploadIsPaused An early 200 does not stop the upload. Passes. It also passes on this branch.

Result

After the write fails, the body task ends and releases the iterator. A task in next() ends when next() returns. Buffered body data stays until the caller releases the response and request. The response does not change.

Memory test: https://github.com/beshkenadze/async-http-client-early-response-leak (64 MiB PUT, 401 response). The program releases the response and request, then waits 30 s.

Malloc memory kept main and 1.36.2, HTTP/1.1 and HTTP/2 (8 runs for each body) This branch as afcf468, HTTP/1.1 and HTTP/2 (4 runs for each body)
eager body (64 chunks buffered) 45.5–62.3 MiB 0.3–0.5 MiB
lazy body 1.3–1.5 MiB 0.3–0.5 MiB

To repeat in the repro directory:

AHC_PATH=/path/to/this/branch ./run.sh

The README has the HTTP/1.1 and lazy body variants.

Raw data: results/upstream.jsonl, results/fix-verification.jsonl.

afcf468 ("ahcRef":"local@afcf468") is an earlier local version of this commit, not on GitHub. Its Sources/ tree (6b69592) matches this branch. Only TransactionTests.swift differs.

#945 fixes the same bug in HTTPClient.execute(request:delegate:) (RequestBag). The two PRs change different files and merge in any order.

Motivation:

A server can send a complete response with a status >= 300 while the
client still uploads a streamed request body. `HTTPRequestStateMachine`
pauses the request body stream when the head arrives. It moves to
`.finished` when the end arrives. The HTTP/1 and HTTP/2 channel
handlers then drop the request, so nothing asks the `Transaction` to
resume its request body stream again. A body write that waits for
demand in `Transaction.writeRequestBodyPart` stays suspended forever.

The suspended write keeps the request body pump task, the
`Transaction` and the body's async iterator in memory. The iterator
keeps all the data that it buffers. This occurs even after the user
drops the response. Only an explicit `Transaction.cancel()` releases
them, but `execute()` has already returned at that point. For example,
a client can upload a large blob without credentials first and get a
401 while the blob streams. That client keeps the complete buffered
blob in memory for every such upload.

Modifications:

- Keep the response status in the `streamingBody` response state of
  `Transaction.StateMachine`.
- When the response ends with a status >= 300 while the request body
  stream is paused, finish the transaction. Then resume the waiting
  body write with `HTTPClientError.requestStreamCancelled`. This follows
  the rule of `HTTPRequestStateMachine`, which does not send more
  request body parts after a >= 300 response. A response with a status
  < 300 keeps the request body stream open, as before.
- Add `TransactionTests` that check that the body's async iterator is
  released after an early 401. One test covers a body write that waits
  for demand. One test covers a pump that waits for the next body part.
  In this case, the next body write fails and does not wait. A third
  test checks that the request body stream still resumes after an early
  200.

Result:

After a complete >= 300 response, the request body stream ends and
releases its iterator and the buffered data without a cancellation. The
response and the behaviour for responses with a status < 300 do not
change.

This branch has not been deployed

No deployments
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.

Transaction keeps the request body after an early response with status >= 300

1 participant