Repository navigation
Conversation
6 tasks done
A consumer that buffers body parts on another thread decides there whether to resume reads, but resume() from that thread only takes effect later on the event loop. In between, the event loop can deliver another part that already meets the consumer's demand, and the resume then lets one more read through. With a highly compressible body, that read can inflate to many megabytes beyond demand. Only the caller knows which state its decision depends on, so AHC cannot re-check it. Add execute(Runnable), which runs a task on the event loop that delivers the response body, in sequence with the callbacks made there: before returning when called on that event loop, and queued to it otherwise. suspend(), resume() and cancel() called from the task take effect immediately, so a consumer can decide there on everything delivered so far. onThrowable can run on other threads, after a cancel or a timeout, so a task is not ordered with it. Unlike the other methods, the task also runs after completion; it does not run once the event loop rejects tasks. NettyResponseBodyControl already applied its own control calls this way; that method is now public. The default implementation runs the task on the calling thread, for implementations that apply control calls synchronously. Play WS needs this: it currently reaches the event loop through Netty's internal ThreadExecutorMap to avoid the extra read. Claude Code on behalf of Matthias Kurz Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
mkurz
force-pushed
the
feature/response-body-control-execute
branch
from
October 7, 2026 08:45
bcee2e0 to
0fd1488
Compare
11 of 15 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
ResponseBodyControl.execute(Runnable), which runs a task on the event loop that delivers the response body, in sequence with the callbacks AHC makes there, such asonBodyPartReceived. Calls tosuspend(),resume()andcancel()from the task take effect immediately.NettyResponseBodyControlalready applied its own control calls this way; that method is now public. The default implementation runs the task on the calling thread.Problem
A typical streaming consumer buffers body parts from
onBodyPartReceivedand hands them to a reader on another thread. When that reader has emptied the buffer and still wants more, it callsresume(). Called from that thread,resume()only takes effect later, on the event loop. In between, the event loop can still deliver parts from the current socket read, and those parts may already meet the reader's demand. The queuedresume()then lets one more socket read through that nobody asked for.With a highly compressible body, that one extra read can inflate to many megabytes beyond demand. For a gzipped stream with early demand from another thread, Play WS measured about 35 MB buffered beyond demand, against about 1.9 MB when the decision is re-checked on the event loop.
AHC cannot fix this by re-checking the decision itself, because only the consumer knows which state its decision depends on. Play WS currently works around it by reaching the event loop through Netty's internal
ThreadExecutorMap, and then making its resume decision there.Change
executereturns.suspend(),resume()andcancel()called from the task take effect immediately.onThrowablecan run on the thread that cancels the request, or on a timer thread when a timeout fires. A task submitted from there is queued like one from any other thread, and can run while that callback is still running. The Javadoc says so; the implementation does not try to change it.NettyResponseBodyControl.executeis the existing private method made public, with a null check. It works for HTTP/1.1 and for HTTP/2 stream channels.Alternatives considered
resumeIf(BooleanSupplier), a resume whose condition AHC evaluates on the event loop. It needs subtle ordering rules: a true condition counts as a resume at that moment and overrides an earlier suspend. A consumer whose own drain loop may run on either thread then has to fit its state machine to those rules, and we found that hard to get right. Withexecute, such a consumer can writecontrol.execute(() -> { if (cond) control.resume(); }).request(n)). HTTP/1.1 decodes one socket read into several parts regardless of demand, so AHC would need its own buffer. That's a much larger change.executeonly exposes what the control already does internally. Callbacks already run on the event loop under the same rule not to block.Compatibility
ResponseBodyControl. Revapi passed. Existing implementations keep compiling and behave as before, with two exceptions:execute(Runnable)no longer compiles;void execute(Runnable)now implements this method, with whatever semantics it has.AI disclosure
Claude Code on behalf of Matthias Kurz. The commit includes
Co-Authored-ByperAGENTS.md.Test plan
New tests:
ResponseBodyControlTest:executeruns inline in a callback, runs on the event loop when called from another thread, and runs after control calls queued before it;executesees the part delivered in the meantime and does not resume;executeafter the response has completed still runs the task on the event loop;Http2ResponseBodyControlTest: a task from another thread resumes a suspended HTTP/2 stream.NettyResponseBodyControlExecuteTest: once the event loop has shut down,executeneither runs the task nor throws.Broken variants, each run against these tests and the existing control tests (24 tests in all):
Earlier, at the first revision, deciding on the other thread without
executefailed "the task must see the part buffered after it was queued".Runs:
ResponseBodyControlTest,Http2ResponseBodyControlTestandNettyResponseBodyControlExecuteTeston JDK 11: 24 tests passed../mvnw -B -ntp -Dgpg.skip=true clean verifyon JDK 11: 1,797 tests, 0 failures, 0 errors, 22 skipped; Revapi passed. No test-skipping flags were used.The same 24 tests, compiled on JDK 11 and run on JDK 17, 21 and 25 (Maven Surefire's
-Djvm): 0 failures on each.Play WS, with its resume check moved from
ThreadExecutorMaptoexecute, against a local build of the first revision of this branch alone (Scala 2.13):ThreadExecutorMapworkaround;execute, the same test buffers 35,589,292 bytes and fails.This revision changes only Javadoc and tests.
All four open AHC changes merged together (raw
Cookieheaders on retries,ResponseBodyControl.execute, suspend/resume order, demand-bounded decompression): they merge without conflicts, together and in every pair, and./mvnw -B -ntp -Dgpg.skip=true clean installon JDK 11 passes with 1,853 tests, 0 failures, 0 errors, 22 skipped; Revapi passed.Play WS against that merged build, Scala 2.13 and 3.3, JDK 17: all tests pass.