Skip to content

Commit 4f855bb

Browse files
Run HTTP/2 stream openers outside lock (#2274)
## Summary - reserve HTTP/2 stream slots and dequeue pending openers while holding `pendingLock` - invoke immediate and queued stream-opening callbacks after releasing the lock - keep the empty pending-queue release path allocation-free - add deterministic concurrency tests for both opener paths ## Motivation `Http2ConnectionState` invoked stream openers while holding `pendingLock`. Opening a stream can reach `AsyncHandler.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` - 49 tests passed - `./mvnw -pl client -Dtest='org.asynchttpclient.netty.channel.Http2ConnectionStateTest,org.asynchttpclient.Http2StreamOrphanRegressionTest,org.asynchttpclient.Http2MultiplexBugRegressionTest,org.asynchttpclient.Http2ResidualFixesRegressionTest' test` - 68 tests passed The local environment only provides JDK 21. The JDK 11 `./mvnw clean verify` gate was not run locally; the repository CI matrix provides JDK 11 coverage. ## Attribution Codex on behalf of Pavel Ptashyts --------- Co-authored-by: Codex <codex@openai.com>
1 parent 27e0039 commit 4f855bb

2 files changed

Lines changed: 134 additions & 13 deletions

File tree

client/src/main/java/org/asynchttpclient/netty/channel/Http2ConnectionState.java

Lines changed: 22 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -130,42 +130,51 @@ public boolean offerPendingOpener(Runnable opener) {
130130
* Race-free against {@link #failPendingOpeners}: that method sets {@code closed} and drains the queue under
131131
* {@code pendingLock}. An opener enqueued before the drain runs is caught by the drain; an enqueue attempt
132132
* sequenced after it observes {@code closed} here (the lock provides the happens-before) and is rejected.
133-
* Either way no opener is left stranded.
133+
* Either way no opener is left stranded. Opener callbacks always run after releasing {@code pendingLock},
134+
* because opening a stream can invoke user code and must not serialize other request submissions.
134135
*
135136
* @return {@code true} if the opener was run inline or queued; {@code false} if rejected because the
136137
* connection is draining/closed or the pending queue is full (caller must fail the request)
137138
*/
138139
public boolean offerPendingOpener(NettyResponseFuture<?> future, Runnable opener) {
140+
boolean runOpener = false;
139141
synchronized (pendingLock) {
140142
if (draining.get() || closed.get()) {
141143
return false;
142144
}
143145
if (tryAcquireStream()) {
144-
opener.run();
146+
runOpener = true;
145147
} else {
146148
if (pendingCount >= MAX_PENDING_OPENERS) {
147149
return false;
148150
}
149151
pendingOpeners.add(new PendingOpener(future, opener));
150152
pendingCount++;
151153
}
152-
return true;
153154
}
155+
if (runOpener) {
156+
opener.run();
157+
}
158+
return true;
154159
}
155160

156161
private void drainPendingOpeners() {
157-
synchronized (pendingLock) {
158-
// Open as many queued requests as there are now-free stream slots. A single stream completion
159-
// frees exactly one slot (so this usually runs one opener), but a SETTINGS frame that RAISES
160-
// SETTINGS_MAX_CONCURRENT_STREAMS frees several at once — drain them all here rather than waking
161-
// only one and stalling the rest until the next completion (a missed-wakeup; the Issue #2160
162-
// silent-timeout class). tryAcquireStream() enforces the cap and the draining/closed gate, so
163-
// this never over-opens; every poll is under pendingLock, so a non-empty queue always yields a
164-
// non-null opener.
165-
while (!pendingOpeners.isEmpty() && tryAcquireStream()) {
162+
// Drain every slot exposed by a SETTINGS increase; stopping after one can strand requests until another
163+
// stream completes (Issue #2160). Reserve and dequeue one opener per iteration under pendingLock:
164+
// tryAcquireStream() enforces capacity and draining/closed gates, the lock makes poll() non-null after
165+
// the emptiness check, and a throwing opener cannot strand a pre-reserved batch.
166+
while (true) {
167+
PendingOpener pending;
168+
synchronized (pendingLock) {
169+
if (pendingOpeners.isEmpty() || !tryAcquireStream()) {
170+
return;
171+
}
166172
pendingCount--;
167-
pendingOpeners.poll().opener.run();
173+
pending = pendingOpeners.poll();
168174
}
175+
// Opening a stream can invoke user callbacks. Run without pendingLock so a slow callback does not
176+
// serialize unrelated submissions to this connection.
177+
pending.opener.run();
169178
}
170179
}
171180

client/src/test/java/org/asynchttpclient/netty/channel/Http2ConnectionStateTest.java

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
import java.util.concurrent.CyclicBarrier;
2525
import java.util.concurrent.ExecutorService;
2626
import java.util.concurrent.Executors;
27+
import java.util.concurrent.Future;
2728
import java.util.concurrent.TimeUnit;
2829
import java.util.concurrent.atomic.AtomicInteger;
2930

@@ -270,6 +271,103 @@ public void pendingOpenerRunsOnRelease() {
270271
assertEquals(1, executionCount.get(), "Pending opener should have been executed on release");
271272
}
272273

274+
@Test
275+
public void immediateOpenerRunsOutsidePendingLock() throws Exception {
276+
Http2ConnectionState state = new Http2ConnectionState();
277+
state.updateMaxConcurrentStreams(2);
278+
CountDownLatch openerStarted = new CountDownLatch(1);
279+
CountDownLatch releaseOpener = new CountDownLatch(1);
280+
ExecutorService executor = Executors.newFixedThreadPool(2);
281+
282+
try {
283+
Future<Boolean> blockingOffer = executor.submit(() ->
284+
state.offerPendingOpener(blockingOpener(openerStarted, releaseOpener)));
285+
assertTrue(openerStarted.await(5, TimeUnit.SECONDS), "first opener should start");
286+
287+
Future<Boolean> competingOffer = executor.submit(() -> state.offerPendingOpener(() -> { }));
288+
assertTrue(competingOffer.get(5, TimeUnit.SECONDS),
289+
"a running opener must not hold pendingLock");
290+
291+
releaseOpener.countDown();
292+
assertTrue(blockingOffer.get(5, TimeUnit.SECONDS));
293+
state.releaseStream();
294+
state.releaseStream();
295+
} finally {
296+
releaseOpener.countDown();
297+
executor.shutdownNow();
298+
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
299+
}
300+
}
301+
302+
@Test
303+
public void queuedOpenerRunsOutsidePendingLock() throws Exception {
304+
Http2ConnectionState state = new Http2ConnectionState();
305+
state.updateMaxConcurrentStreams(1);
306+
assertTrue(state.tryAcquireStream());
307+
CountDownLatch openerStarted = new CountDownLatch(1);
308+
CountDownLatch releaseOpener = new CountDownLatch(1);
309+
state.addPendingOpener(blockingOpener(openerStarted, releaseOpener));
310+
ExecutorService executor = Executors.newFixedThreadPool(2);
311+
312+
try {
313+
Future<?> drain = executor.submit(state::releaseStream);
314+
assertTrue(openerStarted.await(5, TimeUnit.SECONDS), "queued opener should start");
315+
316+
Future<Boolean> competingOffer = executor.submit(() -> state.offerPendingOpener(() -> { }));
317+
assertTrue(competingOffer.get(5, TimeUnit.SECONDS),
318+
"a drained opener must not hold pendingLock");
319+
320+
releaseOpener.countDown();
321+
drain.get(5, TimeUnit.SECONDS);
322+
state.releaseStream();
323+
state.releaseStream();
324+
} finally {
325+
releaseOpener.countDown();
326+
executor.shutdownNow();
327+
assertTrue(executor.awaitTermination(5, TimeUnit.SECONDS));
328+
}
329+
}
330+
331+
@Test
332+
public void raisedLimitDrainsMultiplePendingOpenersInOrder() {
333+
Http2ConnectionState state = new Http2ConnectionState();
334+
state.updateMaxConcurrentStreams(1);
335+
assertTrue(state.tryAcquireStream());
336+
List<Integer> executionOrder = new ArrayList<>();
337+
state.addPendingOpener(() -> executionOrder.add(1));
338+
state.addPendingOpener(() -> executionOrder.add(2));
339+
state.addPendingOpener(() -> executionOrder.add(3));
340+
341+
state.updateMaxConcurrentStreams(4);
342+
343+
assertEquals(List.of(1, 2, 3), executionOrder);
344+
assertEquals(4, state.getActiveStreams());
345+
}
346+
347+
@Test
348+
public void throwingBatchOpenerLeavesRemainingQueueDrainable() {
349+
Http2ConnectionState state = new Http2ConnectionState();
350+
state.updateMaxConcurrentStreams(1);
351+
assertTrue(state.tryAcquireStream());
352+
List<Integer> executionOrder = new ArrayList<>();
353+
state.addPendingOpener(() -> {
354+
executionOrder.add(1);
355+
throw new IllegalStateException("boom");
356+
});
357+
state.addPendingOpener(() -> executionOrder.add(2));
358+
state.addPendingOpener(() -> executionOrder.add(3));
359+
360+
assertThrows(IllegalStateException.class, () -> state.updateMaxConcurrentStreams(4));
361+
assertEquals(List.of(1), executionOrder);
362+
// A throwing public Runnable leaks its reserved slot, matching the behavior before this change.
363+
assertEquals(2, state.getActiveStreams());
364+
365+
state.releaseStream();
366+
367+
assertEquals(List.of(1, 2, 3), executionOrder);
368+
assertEquals(3, state.getActiveStreams());
369+
}
370+
273371
@Test
274372
public void multiplePendingOpenersExecuteInOrder() {
275373
Http2ConnectionState state = new Http2ConnectionState();
@@ -897,4 +995,18 @@ public void releasePermitOnceIsAtomicUnderConcurrency() throws InterruptedExcept
897995
}
898996
assertEquals(rounds, totalReleases.get(), "exactly one release per round");
899997
}
998+
999+
private static Runnable blockingOpener(CountDownLatch started, CountDownLatch release) {
1000+
return () -> {
1001+
started.countDown();
1002+
try {
1003+
if (!release.await(10, TimeUnit.SECONDS)) {
1004+
throw new AssertionError("timed out waiting to release opener");
1005+
}
1006+
} catch (InterruptedException e) {
1007+
Thread.currentThread().interrupt();
1008+
throw new AssertionError("interrupted while waiting to release opener", e);
1009+
}
1010+
};
1011+
}
9001012
}

0 commit comments

Comments
 (0)