Skip to content

servlet: fix write when not ready in AsyncServletOutputStreamWriter#12790

Open
mgustimz wants to merge 4 commits intogrpc:masterfrom
mgustimz:fix/12723-servlet-clean
Open

servlet: fix write when not ready in AsyncServletOutputStreamWriter#12790
mgustimz wants to merge 4 commits intogrpc:masterfrom
mgustimz:fix/12723-servlet-clean

Conversation

@mgustimz
Copy link
Copy Markdown

@mgustimz mgustimz commented May 2, 2026

Description

Even when isReady() returns true, the servlet container may still be processing a previous write internally. Always set readyAndDrained to false after a direct write in runOrBuffer() to ensure subsequent writes go through the container thread via onWritePossible(), matching the servlet spec requirement.

Tomcat throws:

java.lang.IllegalStateException: In non-blocking mode you may not write to the ServletOutputStream until the previous write has completed and isReady() returns true

Testing

Validated by running servlet tests locally. TomcatTransportTest.clientChecksInboundMetadataSize_header passes.

Fixes

Fixes #12723

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 the readyAndDrained state.
  • 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.

Comment on lines +225 to +229
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");
Comment on lines +225 to +229
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");
Comment on lines +225 to +229
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");
@kannanjgithub
Copy link
Copy Markdown
Contributor

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
@mgustimz
Copy link
Copy Markdown
Author

mgustimz commented May 5, 2026

Thanks for the review @copilot-pull-request-reviewer[bot]. Here is our response to your comments:

1. Stalling risk for flush() — Fixed
We narrowed the fix to apply only to writeBytes actions, not flush. Flush still follows the original behavior (only sets readyAndDrained=false when isReady() returns false). This prevents the stall scenario you described while still fixing the Tomcat write issue.

2. Missing regression test — Acknowledged
The existing AsyncServletOutputStreamWriterConcurrencyTest models the servlet container behavior differently from Tomcat. We agree a targeted test for back-to-back writes that stay ready would be valuable. We are considering how to add such a test without overcomplicating the test model.

3. Outdated documentation — Fixed
The WriteState class javadoc has been updated to reflect the new behavior:

  • For writeBytes actions, readyAndDrained always transitions to false after a direct write
  • For flush actions, readyAndDrained transitions to false only when isReady() returns false

We would appreciate any further feedback on the narrowed approach.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) {
Comment on lines +225 to +230
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
@mgustimz
Copy link
Copy Markdown
Author

mgustimz commented May 6, 2026

Thanks for the updated review. Two issues raised:

1. Type comparison fix
You were right — actionItem == writeAction was comparing incompatible types (ActionItem vs BiFunction<byte[], Integer, ActionItem>). Since writeBytes() passes writeAction.apply(...) (the resulting ActionItem), not writeAction itself, this comparison could never be true. Fixed to actionItem != flushAction instead — any non-flush, non-complete action is a write action. Pushed at 44ee627.

2. Lincheck test model
You are correct that the existing AsyncServletOutputStreamWriterConcurrencyTest only triggers onWritePossible() after isReady() returns false, which no longer matches our intended contract. The test would need updating to model Tomcat behavior where onWritePossible() fires between consecutive writes even when isReady() stays true. We acknowledge this is a gap — we can address it in a follow-up if needed.

@kannanjgithub
Copy link
Copy Markdown
Contributor

Unlike the complex Lincheck concurrency tests, we could just have a targeted JUnit test that uses a mock isReady supplier that always returns true.
AsyncServletOutputStreamWriterTest.java

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);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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.

servlet: TomcatTransportTest detects write when not ready

3 participants