servlet: fix write when not ready in AsyncServletOutputStreamWriter#12790
servlet: fix write when not ready in AsyncServletOutputStreamWriter#12790mgustimz wants to merge 4 commits intogrpc:masterfrom
Conversation
7f3e6d6 to
3f87103
Compare
There was a problem hiding this comment.
Pull request overview
This PR aims to address Tomcat's non-blocking write enforcement in the servlet transport by changing how AsyncServletOutputStreamWriter tracks readiness after direct application-thread writes.
Changes:
- Updates
runOrBuffer()so direct non-complete actions always clear thereadyAndDrainedstate. - Intended to route subsequent writes back through
onWritePossible()instead of continuing direct writes from the application thread. - Targets the servlet transport path implicated by
TomcatTransportTest.clientChecksInboundMetadataSize_header.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| boolean successful = | ||
| writeState.compareAndSet(curState, curState.withReadyAndDrained(false)); | ||
| LockSupport.unpark(parkingThread); | ||
| checkState(successful, "Bug: curState is unexpectedly changed by another thread"); | ||
| log.finest("the servlet output stream becomes not ready"); |
| boolean successful = | ||
| writeState.compareAndSet(curState, curState.withReadyAndDrained(false)); | ||
| LockSupport.unpark(parkingThread); | ||
| checkState(successful, "Bug: curState is unexpectedly changed by another thread"); | ||
| log.finest("the servlet output stream becomes not ready"); |
| boolean successful = | ||
| writeState.compareAndSet(curState, curState.withReadyAndDrained(false)); | ||
| LockSupport.unpark(parkingThread); | ||
| checkState(successful, "Bug: curState is unexpectedly changed by another thread"); | ||
| log.finest("the servlet output stream becomes not ready"); |
|
The Tomcat unit tests are timing out with this change. |
Apply the readiness fix only to writeBytes actions, not flush. Flush can still go direct when isReady is true since it is less latency-sensitive. This prevents potential stalling of responses when a follow-up flush would be queued behind onWritePossible. Also update WriteState docs to reflect the new behavior. Fixes grpc#12723
|
Thanks for the review @copilot-pull-request-reviewer[bot]. Here is our response to your comments: 1. Stalling risk for flush() — Fixed 2. Missing regression test — Acknowledged 3. Outdated documentation — Fixed
We would appreciate any further feedback on the narrowed approach. |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 1 out of 1 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| return; | ||
| } | ||
| if (!isReady.getAsBoolean()) { | ||
| if (actionItem == writeAction) { |
| if (actionItem == writeAction) { | ||
| // For writeBytes, always set readyAndDrained to false even when isReady() | ||
| // returns true. Tomcat requires onWritePossible() to fire between writes, | ||
| // even if isReady() is still true. For flush, keep the original behavior | ||
| // since flush is less latency-sensitive and can safely wait for | ||
| // onWritePossible. |
Use 'actionItem != flushAction' instead of 'actionItem == writeAction'. The latter was comparing incompatible types and would never evaluate to true. writeBytes passes writeAction.apply(...) (an ActionItem), not writeAction itself. Non-flush, non-complete actions are writeBytes. Fixes Copilot review comment on PR grpc#12790
|
Thanks for the updated review. Two issues raised: 1. Type comparison fix 2. Lincheck test model |
|
Unlike the complex Lincheck concurrency tests, we could just have a targeted JUnit test that uses a mock isReady supplier that always returns true. |
Targeted JUnit test that uses a mock isReady supplier always returning true. This specifically tests the Tomcat scenario where isReady() stays true but writes can still fail because the previous write hasn't completed internally. The test verifies that multiple consecutive writes do not stall when isReady always returns true, exercising the new readyAndDrained=false path added for writeBytes actions. Addresses Copilot review comment on PR grpc#12790
| public void writeBytes_alwaysReady_doesNotStall() throws IOException { | ||
| AtomicBoolean isReady = new AtomicBoolean(true); | ||
| List<byte[]> writtenData = new ArrayList<>(); | ||
| AtomicInteger onWritePossibleCount = new AtomicInteger(0); |
There was a problem hiding this comment.
The compiler flags AtomicInteger because you are using it for a variable that is technically only being used by a single thread in this specific test scope. Error Prone's [UnnecessaryAsync]
check suggests using a non-concurrent type to reduce verbosity and overhead.
However, since you are inside a JUnit test and need to increment a counter inside a lambda (which requires variables to be "effectively final"), a simple int won't work.
The idiomatic way to satisfy both Error Prone and the "effectively final" constraint in gRPC tests is to use a single-element primitive array (e.g., int[]) instead of an AtomicInteger.
Description
Even when
isReady()returnstrue, the servlet container may still be processing a previous write internally. Always setreadyAndDrainedtofalseafter a direct write inrunOrBuffer()to ensure subsequent writes go through the container thread viaonWritePossible(), matching the servlet spec requirement.Tomcat throws:
Testing
Validated by running servlet tests locally.
TomcatTransportTest.clientChecksInboundMetadataSize_headerpasses.Fixes
Fixes #12723