Run HTTP/2 stream openers outside lock#2274
Conversation
Reserve HTTP/2 stream slots and dequeue pending openers while holding pendingLock, then invoke opening callbacks after releasing it. This prevents a slow opener or onRequestSend callback from serializing other request submissions while preserving queue and slot accounting. Add deterministic concurrency tests for immediate and queued openers. Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex <codex@openai.com>
| if (ready == null) { | ||
| ready = new ArrayList<>(); | ||
| } | ||
| ready.add(pendingOpeners.poll()); |
There was a problem hiding this comment.
Nice catch on the contention, and keeping tryAcquireStream inside the lock was the right call. One thing on this loop though: every opener is polled out of the queue and pendingCount decremented before any of them run. If the first opener throws, the rest are already gone from pendingOpeners, so they never run, never get re-queued, and their reserved stream slots are never released. Before this change a throwing opener left the queue intact and the next releaseStream drained it.
I queued three openers with the first one throwing, then raised SETTINGS_MAX_CONCURRENT_STREAMS so all three drained in one pass:
before: ran=[1, 2, 3] active=3
after: ran=[1] active=3
Two requests stranded and two slots leaked, which is the silent-timeout class this file is built to prevent. It is not reachable through openHttp2Stream today because Netty swallows throwables in open() and in DefaultPromise.notifyListener0, but addPendingOpener(Runnable) and offerPendingOpener(Runnable) are public and take arbitrary Runnables.
Poll one and run one is smaller than what is here, keeps the openers outside the lock, drops the per drain ArrayList, and restores the old throw behaviour:
private void drainPendingOpeners() {
while (true) {
PendingOpener pending;
synchronized (pendingLock) {
if (pendingOpeners.isEmpty() || !tryAcquireStream()) {
return;
}
pendingCount--;
pending = pendingOpeners.poll();
}
pending.opener.run();
}
}
The ArrayList and List imports stay as they are, failPendingOpeners still needs them. I ran this locally: all 49 tests in Http2ConnectionStateTest pass including both new ones, and the probe above goes back to ran=[1, 2, 3].
There was a problem hiding this comment.
Agreed. I replaced the ready-list batch with a poll-one/run-one loop. Each stream slot and opener are now reserved atomically under pendingLock, then that opener runs after the lock is released. If it throws, later openers remain queued and no slots are pre-reserved for callbacks that did not run.
| executor.shutdownNow(); | ||
| assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS)); | ||
| } | ||
| } |
There was a problem hiding this comment.
These two cover the lock release nicely. One gap alongside them: neither drains more than a single opener, and multiplePendingOpenersExecuteInOrder just below releases one slot at a time, so the ready list never holds more than one entry. The batch drain this PR introduces has no coverage. Could you add a case that queues three openers and then calls updateMaxConcurrentStreams with a higher value so all three drain in one pass, asserting execution order and the final activeStreams count?
There was a problem hiding this comment.
Added coverage for both multi-opener paths. One test raises the limit from one to four and verifies all three queued openers run in order with four active streams. A second test makes the first opener throw and verifies the remaining two stay queued and drain on the next release.
| * {@code pendingLock}. An opener enqueued before the drain runs is caught by the drain; an enqueue attempt | ||
| * sequenced after it observes {@code closed} here (the lock provides the happens-before) and is rejected. | ||
| * Either way no opener is left stranded. | ||
| * Either way no opener is left stranded. Opener callbacks always run after releasing {@code pendingLock}, |
There was a problem hiding this comment.
Small doc point. "Either way no opener is left stranded" now has a third state it does not cover: an opener already dequeued into the ready list but not yet run, which failPendingOpeners can no longer see. Unreachable in practice given the event loop confinement, but the reasoning block down at failPendingOpeners no longer tells the whole story. The poll one run one form suggested above removes that state entirely, so this sentence stays accurate as written.
There was a problem hiding this comment.
The ready-list intermediate state is gone. The drain now removes only the opener it is about to run, so the existing no-stranding reasoning remains accurate.
Preserve queued openers when one callback fails during a SETTINGS-driven drain. Dequeueing and reserving the whole batch up front could strand the remaining requests and leak their stream slots. Keep each callback outside pendingLock while reserving only one opener at a time, and cover successful and exceptional multi-opener drains. Refs AsyncHttpClient#2160 Codex on behalf of Pavel Ptashyts Co-Authored-By: Codex <codex@openai.com>
Summary
pendingLockMotivation
Http2ConnectionStateinvoked stream openers while holdingpendingLock. Opening a stream can reachAsyncHandler.onRequestSend, so one slow callback serialized concurrent submissions to the same HTTP/2 connection.The change preserves the Issue #2160 close/enqueue happens-before relationship and atomic stream-slot accounting. Only callback execution moves outside the monitor.
Validation
./mvnw -pl client -Dtest=org.asynchttpclient.netty.channel.Http2ConnectionStateTest test./mvnw -pl client -Dtest='org.asynchttpclient.netty.channel.Http2ConnectionStateTest,org.asynchttpclient.Http2StreamOrphanRegressionTest,org.asynchttpclient.Http2MultiplexBugRegressionTest,org.asynchttpclient.Http2ResidualFixesRegressionTest' testThe local environment only provides JDK 21. The JDK 11
./mvnw clean verifygate was not run locally; the repository CI matrix provides JDK 11 coverage.Attribution
Codex on behalf of Pavel Ptashyts