From dacbb904b1d035ee6a20810ded1aaa7bfe2ec24e Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 5 Nov 2025 15:45:53 +0100 Subject: [PATCH 01/29] Implement packet pacing --- .../net/http/quic/PacketSpaceManager.java | 19 +- .../http/quic/QuicCongestionController.java | 47 ++- .../net/http/quic/QuicConnectionImpl.java | 2 +- .../jdk/internal/net/http/quic/QuicPacer.java | 190 ++++++++++++ .../quic/QuicRenoCongestionController.java | 112 ++++++- .../java/net/httpclient/quic/PacerTest.java | 273 ++++++++++++++++++ .../quic/PacketSpaceManagerTest.java | 38 ++- 7 files changed, 667 insertions(+), 14 deletions(-) create mode 100644 src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java create mode 100644 test/jdk/java/net/httpclient/quic/PacerTest.java diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java index 494af85447e91..aed878f6f92c9 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java @@ -386,6 +386,7 @@ private void handleLoop0() throws IOException, QuicTransportException { // Handle is called from within the executor var nextDeadline = this.nextDeadline; Deadline now = now(); + congestionController.updatePacer(now); do { transmitNow = false; var closed = !isOpenForTransmission(); @@ -404,6 +405,7 @@ private void handleLoop0() throws IOException, QuicTransportException { boolean needBackoff = isPTO(now); int packetsSent = 0; boolean cwndAvailable; + long startTime = System.nanoTime(); while ((cwndAvailable = congestionController.canSendPacket()) || (needBackoff && packetsSent < 2)) { // if PTO, try to send 2 packets if (!isOpenForTransmission()) { @@ -442,6 +444,7 @@ private void handleLoop0() throws IOException, QuicTransportException { + qkue.getMessage()); } if (!sentNew) { + congestionController.appLimited(); break; } else { if (needBackoff && packetsSent == 0 && Log.quicRetransmit()) { @@ -451,7 +454,12 @@ private void handleLoop0() throws IOException, QuicTransportException { } packetsSent++; } - blockedByCC = !cwndAvailable; + if (packetsSent != 0 && Log.quicCC()) { + Log.logQuic("%s OUT: sent: %s packets in %s ns, cwnd limited: %s, pacer limited: %s".formatted( + packetEmitter.logTag(), packetsSent, System.nanoTime() - startTime, + congestionController.isCwndLimited(), congestionController.isPacerLimited())); + } + blockedByCC = !cwndAvailable && congestionController.isCwndLimited(); if (!cwndAvailable && isOpenForTransmission()) { if (debug.on()) debug.log("handle: blocked by CC"); // CC might be available already @@ -1389,6 +1397,15 @@ public Deadline computeNextDeadline(boolean verbose) { Deadline ackDeadline = (ack == null || ack.sent() != null) ? Deadline.MAX // if the ack frame has already been sent, getNextAck() returns null : ack.deadline(); + if (congestionController.isPacerLimited()) { + Deadline pacerDeadline = congestionController.pacerDeadline(); + if (verbose && Log.quicTimer()) { + Log.logQuic(String.format("%s: [%s] pacer deadline: %s, ackDeadline: %s, deadline in %s", + packetEmitter.logTag(), packetNumberSpace, pacerDeadline, ackDeadline, + Utils.debugDeadline(now(), min(ackDeadline, pacerDeadline)))); + } + return min(ackDeadline, pacerDeadline); + } Deadline lossDeadline = getLossTimer(); // TODO: consider removing the debug traces in this method when integrating // if both loss deadline and PTO timer are set, loss deadline is always earlier diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCongestionController.java index 4bfad2c5560c9..94dafaf16ebad 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCongestionController.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -72,4 +72,49 @@ public interface QuicCongestionController { */ void packetDiscarded(Collection discardedPackets); + /** + * {@return the current size of the congestion window in bytes} + */ + long congestionWindow(); + + /** + * {@return the initial window size in bytes} + */ + long initialWindow(); + + /** + * {@return maximum datagram size} + */ + long maxDatagramSize(); + + /** + * {@return true if the connection is in slow start phase} + */ + boolean isSlowStart(); + + /** + * Update the pacer with the current time + * @param now the current time + */ + void updatePacer(Deadline now); + + /** + * {@return true if sending is blocked by pacer} + */ + boolean isPacerLimited(); + + /** + * {@return true if sending is blocked by congestion window} + */ + boolean isCwndLimited(); + + /** + * {@return deadline when pacer will unblock sending} + */ + Deadline pacerDeadline(); + + /** + * Notify the congestion controller that sending is app-limited + */ + void appLimited(); } diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java index f05519d339b72..c18e74ba0de3f 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java @@ -334,7 +334,7 @@ protected QuicConnectionImpl(final QuicVersion firstFlightVersion, this.connectionId = this.endpoint.idFactory().newConnectionId(); this.logTag = logTagFormat.formatted(labelId); this.dbgTag = dbgTag(quicInstance, logTag); - this.congestionController = new QuicRenoCongestionController(dbgTag); + this.congestionController = new QuicRenoCongestionController(dbgTag, rttEstimator); this.originalVersion = this.quicVersion = firstFlightVersion == null ? QuicVersion.firstFlightVersion(quicInstance.getAvailableVersions()) : firstFlightVersion; diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java new file mode 100644 index 0000000000000..a1ab64eef251a --- /dev/null +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java @@ -0,0 +1,190 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.net.http.quic; + +import jdk.internal.net.http.common.Deadline; +import jdk.internal.net.http.common.Log; +import jdk.internal.net.http.common.Utils; +import jdk.internal.util.OperatingSystem; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Implementation of pacing. + * + * When the connection is sending at a rate lower than permitted + * by the congestion controller, pacer is responsible for spreading out + * the outgoing packets across the entire RTT. + * + * Technically the pacer provides two functions: + * - computes the number of packets that can be sent now + * - computes the time when another packet can be sent + * + * When a new flow starts, or when the flow is not pacer-limited, + * the pacer limits the window to: + * max(INITIAL_WINDOW, pacingRate / timerFreq) + * timerFreq is the best timer resolution we can get from the selector. + * pacingRate is N * congestionWindow / smoothedRTT + * where N = 2 when in slow start, N = 1.25 otherwise. + * + * After that, the window refills at pacingRate, up to two timer periods or 4 packets, + * whichever is higher. + * + * The time when another packet can be sent is computed + * as the time when the window will allow at least 2 packets. + * + * All methods are externally synchronized in congestion controller. + * + * Ideas taken from: + * https://www.rfc-editor.org/rfc/rfc9002.html#name-pacing + * https://www.ietf.org/archive/id/draft-welzl-iccrg-pacing-03.html + */ +public class QuicPacer { + + // usually 64 Hz on Windows, 1000 on Linux + private static final long DEFAULT_TIMER_FREQ_HZ = OperatingSystem.isWindows() ? 64 : 1000; + private static final long TIMER_FREQ_HZ = Math.clamp( + Utils.getLongProperty("jdk.httpclient.quic.timerFrequency", DEFAULT_TIMER_FREQ_HZ), + 1, 1000); + + private final QuicRttEstimator rttEstimator; + private final QuicCongestionController congestionController; + + private boolean appLimited; + private long quota; + private Deadline lastUpdate; + + /** + * Create a QUIC pacer for the given RTT estimator and congestion controller + * + * @param rttEstimator the RTT estimator + * @param congestionController the congestion controller + */ + public QuicPacer(QuicRttEstimator rttEstimator, + QuicCongestionController congestionController) { + this.rttEstimator = rttEstimator; + this.congestionController = congestionController; + this.appLimited = true; + } + + /** + * called to indicate that the flow is app-limited. + * Alters the behavior of the following updateQuota call. + */ + public void appLimited() { + appLimited = true; + } + + /** + * {@return true if pacer quota not hit yet, false otherwise} + */ + public boolean canSend() { + return quota >= congestionController.maxDatagramSize(); + } + + /** + * Update quota based on time since the last call to this method + * and whether appLimited() was called or not. + * + * @param now current time + */ + public void updateQuota(Deadline now) { + long rttMicros = rttEstimator.state().smoothedRttMicros(); + long cwnd = congestionController.congestionWindow(); + if (rttMicros * TIMER_FREQ_HZ < TimeUnit.SECONDS.toMicros(2)) { + // RTT less than two timer periods; don't pace + quota = cwnd; + lastUpdate = now; + return; + } + if (lastUpdate != null && !now.isAfter(lastUpdate)) { + // might happen when transmission tasks from different packet spaces + // race to update quota. Keep the most recent update only. + return; + } + long pacingRate = cwnd * (congestionController.isSlowStart() ? 2_000_000 : 1_250_000) / rttMicros; // bytes per second + long initialWindow = congestionController.initialWindow(); + long onePeriodWindow = pacingRate / TIMER_FREQ_HZ; + long maxQuota; + if (appLimited) { + maxQuota = Math.max(initialWindow, onePeriodWindow); + } else { + maxQuota = Math.max(2 * onePeriodWindow, 4 * congestionController.maxDatagramSize()); + } + if (lastUpdate == null) { + quota = Math.max(initialWindow, maxQuota); + } else { + long nanosSinceUpdate = Deadline.between(lastUpdate, now).toNanos(); + if (nanosSinceUpdate >= TimeUnit.MICROSECONDS.toNanos(rttMicros)) { + // don't bother computing the increment, it might overflow and will be capped to maxQuota anyway + quota = maxQuota; + if (Log.quicCC()) { + Log.logQuic("pacer cwnd: %s, rtt %s us, duration %s ns, quota: %s".formatted( + cwnd, rttMicros, nanosSinceUpdate, quota)); + } + } else { + long quotaIncrement = pacingRate * nanosSinceUpdate / 1_000_000_000; + quota += quotaIncrement; + quota = Math.min(quota, maxQuota); + if (Log.quicCC()) { + Log.logQuic("pacer cwnd: %s, rtt %s us, duration %s ns, increment %s, quota %s".formatted( + cwnd, rttMicros, nanosSinceUpdate, quotaIncrement, quota)); + } + } + } + lastUpdate = now; + appLimited = false; + } + + /** + * {@return the deadline when quota will increase to two packets} + */ + public Deadline twoPacketDeadline() { + long datagramSize = congestionController.maxDatagramSize(); + long quotaNeeded = datagramSize * 2 - quota; + if (quotaNeeded <= 0) { + assert canSend(); + return lastUpdate; + } + // Window increases at a rate of rtt / cwnd / N + long rttMicros = rttEstimator.state().smoothedRttMicros(); + long cwnd = congestionController.congestionWindow(); + return lastUpdate.plus(Duration.ofNanos(rttMicros + * (congestionController.isSlowStart() ? 500 : 800) /* 1000/N */ + * quotaNeeded / cwnd)); + } + + /** + * called to indicate that a packet was sent + * + * @param packetBytes packet size in bytes + */ + public void packetSent(int packetBytes) { + quota -= packetBytes; + } +} diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index fde253740d1cf..c8a333b345bbd 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -64,9 +64,12 @@ class QuicRenoCongestionController implements QuicCongestionController { private Deadline congestionRecoveryStartTime; private long ssThresh = Long.MAX_VALUE; - public QuicRenoCongestionController(String dbgTag) { + private final QuicPacer pacer; + + public QuicRenoCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { this.dbgTag = dbgTag; this.timeSource = TimeSource.source(); + this.pacer = new QuicPacer(rttEstimator, this); } private boolean inCongestionRecovery(Deadline sentTime) { @@ -83,7 +86,7 @@ private void onCongestionEvent(Deadline sentTime) { congestionWindow = Math.max(minimumWindow, ssThresh); maxBytesInFlight = 0; if (Log.quicCC()) { - Log.logQuic(dbgTag+ " Congestion: ssThresh: " + ssThresh + + Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + ", in flight: " + bytesInFlight + ", cwnd:" + congestionWindow); } @@ -103,8 +106,10 @@ public boolean canSendPacket() { if (bytesInFlight >= MAX_BYTES_IN_FLIGHT) { return false; } - var canSend = congestionWindow - bytesInFlight >= maxDatagramSize; - return canSend; + if (isCwndLimited() || isPacerLimited()) { + return false; + } + return true; } finally { lock.unlock(); } @@ -132,6 +137,7 @@ public void packetSent(int packetBytes) { if (bytesInFlight > maxBytesInFlight) { maxBytesInFlight = bytesInFlight; } + pacer.packetSent(packetBytes); } finally { lock.unlock(); } @@ -148,7 +154,7 @@ public void packetAcked(int packetBytes, Deadline sentTime) { // observed since the last congestion event if (inCongestionRecovery(sentTime)) { if (Log.quicCC()) { - Log.logQuic(dbgTag+ " Acked, in recovery: bytes: " + packetBytes + + Log.logQuic(dbgTag + " Acked, in recovery: bytes: " + packetBytes + ", in flight: " + bytesInFlight); } return; @@ -167,7 +173,7 @@ public void packetAcked(int packetBytes, Deadline sentTime) { } if (Log.quicCC()) { if (isAppLimited) { - Log.logQuic(dbgTag+ " Acked, not blocked: bytes: " + packetBytes + + Log.logQuic(dbgTag + " Acked, not blocked: bytes: " + packetBytes + ", in flight: " + bytesInFlight); } else { Log.logQuic(dbgTag + " Acked, increased: bytes: " + packetBytes + @@ -194,7 +200,7 @@ public void packetLost(Collection lostPackets, Deadline sentTime, bo congestionWindow = minimumWindow; congestionRecoveryStartTime = null; if (Log.quicCC()) { - Log.logQuic(dbgTag+ " Persistent congestion: ssThresh: " + ssThresh + + Log.logQuic(dbgTag + " Persistent congestion: ssThresh: " + ssThresh + ", in flight: " + bytesInFlight + ", cwnd:" + congestionWindow); } @@ -217,4 +223,94 @@ public void packetDiscarded(Collection discardedPackets) { lock.unlock(); } } + + @Override + public long congestionWindow() { + lock.lock(); + try { + return congestionWindow; + } finally { + lock.unlock(); + } + } + + @Override + public long initialWindow() { + lock.lock(); + try { + return Math.max(14720, 2 * maxDatagramSize); + } finally { + lock.unlock(); + } + } + + @Override + public long maxDatagramSize() { + lock.lock(); + try { + return maxDatagramSize; + } finally { + lock.unlock(); + } + } + + @Override + public boolean isSlowStart() { + lock.lock(); + try { + return congestionWindow < ssThresh; + } finally { + lock.unlock(); + } + } + + @Override + public void updatePacer(Deadline now) { + lock.lock(); + try { + pacer.updateQuota(now); + } finally { + lock.unlock(); + } + } + + @Override + public boolean isPacerLimited() { + lock.lock(); + try { + return !pacer.canSend(); + } finally { + lock.unlock(); + } + } + + @Override + public boolean isCwndLimited() { + lock.lock(); + try { + return congestionWindow - bytesInFlight < maxDatagramSize; + } finally { + lock.unlock(); + } + } + + @Override + public Deadline pacerDeadline() { + lock.lock(); + try { + return pacer.twoPacketDeadline(); + } finally { + lock.unlock(); + } + } + + @Override + public void appLimited() { + lock.lock(); + try { + pacer.appLimited(); + } finally { + lock.unlock(); + } + } } diff --git a/test/jdk/java/net/httpclient/quic/PacerTest.java b/test/jdk/java/net/httpclient/quic/PacerTest.java new file mode 100644 index 0000000000000..33dbbed034095 --- /dev/null +++ b/test/jdk/java/net/httpclient/quic/PacerTest.java @@ -0,0 +1,273 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import jdk.internal.net.http.common.Deadline; +import jdk.internal.net.http.quic.QuicCongestionController; +import jdk.internal.net.http.quic.QuicPacer; +import jdk.internal.net.http.quic.QuicRttEstimator; +import jdk.internal.net.http.quic.packets.QuicPacket; +import org.testng.annotations.DataProvider; +import org.testng.annotations.Test; + +import java.time.temporal.ChronoUnit; +import java.util.Collection; +import java.util.List; +import java.util.stream.Stream; + +import static org.testng.Assert.assertFalse; +import static org.testng.Assert.assertTrue; + +/* + * @test + * @run testng/othervm -Djdk.httpclient.quic.timerFrequency=1000 PacerTest + */ +public class PacerTest { + + private static class TestCongestionController implements QuicCongestionController { + private long cwnd; + private long iw; + private long maxDatagramSize; + private boolean isSlowStart; + + private TestCongestionController(long cwnd, long iw, long maxDatagramSize, boolean isSlowStart) { + this.cwnd = cwnd; + this.iw = iw; + this.maxDatagramSize = maxDatagramSize; + this.isSlowStart = isSlowStart; + } + + @Override + public boolean canSendPacket() { + throw new AssertionError("Should not come here"); + } + + @Override + public void updateMaxDatagramSize(int newSize) { + throw new AssertionError("Should not come here"); + } + + @Override + public void packetSent(int packetBytes) { + throw new AssertionError("Should not come here"); + } + + @Override + public void packetAcked(int packetBytes, Deadline sentTime) { + throw new AssertionError("Should not come here"); + } + + @Override + public void packetLost(Collection lostPackets, Deadline sentTime, boolean persistent) { + throw new AssertionError("Should not come here"); + } + + @Override + public void packetDiscarded(Collection discardedPackets) { + throw new AssertionError("Should not come here"); + } + + @Override + public long congestionWindow() { + return cwnd; + } + + @Override + public long initialWindow() { + return iw; + } + + @Override + public long maxDatagramSize() { + return maxDatagramSize; + } + + @Override + public boolean isSlowStart() { + return isSlowStart; + } + + @Override + public void updatePacer(Deadline now) { + throw new AssertionError("Should not come here"); + } + + @Override + public boolean isPacerLimited() { + throw new AssertionError("Should not come here"); + } + + @Override + public boolean isCwndLimited() { + throw new AssertionError("Should not come here"); + } + + @Override + public Deadline pacerDeadline() { + throw new AssertionError("Should not come here"); + } + + @Override + public void appLimited() { + throw new AssertionError("Should not come here"); + } + } + + public record TestCase(int maxDatagramSize, int packetsInIW, int packetsInCwnd, int millisInRtt, + int initialPermit, int periodicPermit, boolean slowStart) { + } + + @DataProvider + public Object[][] pacerFirstFlight() { + return List.of( + // Should permit initial window before blocking + new TestCase(1200, 10, 32, 16, 10, 4, true), + // Should permit 2*cwnd/rtt packets before blocking + new TestCase(1200, 10, 128, 16, 16, 16, true), + // Should permit 1.25*cwnd/rtt packets before blocking + new TestCase(1200, 10, 256, 16, 20, 20, false) + ).stream().map(Stream::of) + .map(Stream::toArray) + .toArray(Object[][]::new); + } + + @Test(dataProvider = "pacerFirstFlight") + public void testBasicPacing(TestCase test) { + int maxDatagramSize = test.maxDatagramSize; + int packetsInIW = test.packetsInIW; + int packetsInCwnd = test.packetsInCwnd; + int millisInRtt = test.millisInRtt; + int permit = test.initialPermit; + QuicCongestionController cc = new TestCongestionController(packetsInCwnd * maxDatagramSize, + maxDatagramSize * packetsInIW, maxDatagramSize, test.slowStart); + QuicRttEstimator rtt = new QuicRttEstimator(); + rtt.consumeRttSample(1000 * millisInRtt, 0, Deadline.MIN); + QuicPacer pacer = new QuicPacer(rtt, cc); + pacer.updateQuota(Deadline.MIN); + for (int i = 0; i < permit; i++) { + assertTrue(pacer.canSend(), "Pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "Pacer didn't block"); + Deadline next = pacer.twoPacketDeadline(); + pacer.updateQuota(next); + for (int i = 0; i < 2; i++) { + assertTrue(pacer.canSend(), "Two packet deadline: pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "Two packet deadline: pacer didn't block"); + next = next.plus(1, ChronoUnit.MILLIS); + pacer.updateQuota(next); + for (int i = 0; i < test.periodicPermit; i++) { + assertTrue(pacer.canSend(), "One millisecond: pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "One millisecond: pacer didn't block"); + next = next.plus(3, ChronoUnit.MILLIS); + pacer.updateQuota(next); + // Quota capped at two millisecond equivalent + for (int i = 0; i < 2 * test.periodicPermit; i++) { + assertTrue(pacer.canSend(), "Three milliseconds: pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "Three milliseconds: pacer didn't block"); + next = next.plus(3, ChronoUnit.MILLIS); + pacer.appLimited(); + pacer.updateQuota(next); + // App-limited: quota capped at initialPermit + for (int i = 0; i < test.initialPermit; i++) { + assertTrue(pacer.canSend(), "App limited: pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "App limited: pacer didn't block"); + } + + @Test + public void testPacingShortRtt() { + int maxDatagramSize = 1200; + int packetsInIW = 10; + int packetsInCwnd = 32; + QuicCongestionController cc = new TestCongestionController(packetsInCwnd * maxDatagramSize, + maxDatagramSize * packetsInIW, maxDatagramSize, true); + QuicRttEstimator rtt = new QuicRttEstimator(); + rtt.consumeRttSample(1000, 0, Deadline.MIN); + QuicPacer pacer = new QuicPacer(rtt, cc); + pacer.updateQuota(Deadline.MIN); + for (int i = 0; i < packetsInCwnd; i++) { + assertTrue(pacer.canSend(), "Pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "Pacer didn't block"); + // when RTT is short, permit cwnd on every update + Deadline next = pacer.twoPacketDeadline(); + pacer.updateQuota(next); + for (int i = 0; i < packetsInCwnd; i++) { + assertTrue(pacer.canSend(), "Two packet deadline: pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "Two packet deadline: pacer didn't block"); + } + + @Test + public void testPacingSmallCwnd() { + int maxDatagramSize = 1200; + int packetsInIW = 10; + int packetsInCwnd = 2; + int millisInRtt = 16; + QuicCongestionController cc = new TestCongestionController(packetsInCwnd * maxDatagramSize, + maxDatagramSize * packetsInIW, maxDatagramSize, true); + QuicRttEstimator rtt = new QuicRttEstimator(); + rtt.consumeRttSample(1000 * millisInRtt, 0, Deadline.MIN); + QuicPacer pacer = new QuicPacer(rtt, cc); + // first quota update is capped to IW + pacer.updateQuota(Deadline.MIN); + // update quota again. This time it's capped to 4 packets + pacer.updateQuota(Deadline.MIN.plusNanos(1)); + for (int i = 0; i < 4; i++) { + assertTrue(pacer.canSend(), "Pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "Pacer didn't block"); + Deadline next = pacer.twoPacketDeadline(); + pacer.updateQuota(next); + for (int i = 0; i < 2; i++) { + assertTrue(pacer.canSend(), "Two packet deadline: pacer blocked after " + i + " packets"); + pacer.packetSent(maxDatagramSize); + } + assertFalse(pacer.canSend(), "Two packet deadline: pacer didn't block"); + // pacing rate is 1 packet per 4 milliseconds + next = next.plus(4, ChronoUnit.MILLIS); + pacer.updateQuota(next); + assertTrue(pacer.canSend(), "Pacer blocked after 4 millis"); + pacer.packetSent(maxDatagramSize); + assertFalse(pacer.canSend(), "Pacer permitted 2 packets after 4 millis"); + + next = next.plus(2, ChronoUnit.MILLIS); + pacer.updateQuota(next); + assertFalse(pacer.canSend(), "Pacer permitted a packet after 2 millis"); + next = next.plus(2, ChronoUnit.MILLIS); + pacer.updateQuota(next); + assertTrue(pacer.canSend(), "Pacer blocked after 2x2 millis"); + pacer.packetSent(maxDatagramSize); + assertFalse(pacer.canSend(), "Pacer permitted 2 packets after 2x2 millis"); + } +} diff --git a/test/jdk/java/net/httpclient/quic/PacketSpaceManagerTest.java b/test/jdk/java/net/httpclient/quic/PacketSpaceManagerTest.java index 7cd2adfe7abbd..40497ec13da60 100644 --- a/test/jdk/java/net/httpclient/quic/PacketSpaceManagerTest.java +++ b/test/jdk/java/net/httpclient/quic/PacketSpaceManagerTest.java @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021, 2024, Oracle and/or its affiliates. All rights reserved. + * Copyright (c) 2021, 2025, Oracle and/or its affiliates. All rights reserved. * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. * * This code is free software; you can redistribute it and/or modify it @@ -45,10 +45,12 @@ import jdk.internal.net.http.common.Logger; import jdk.internal.net.http.common.TestLoggerUtil; import jdk.internal.net.http.common.TimeLine; +import jdk.internal.net.http.quic.CodingContext; import jdk.internal.net.http.quic.PacketEmitter; import jdk.internal.net.http.quic.PacketSpaceManager; import jdk.internal.net.http.quic.PeerConnectionId; import jdk.internal.net.http.quic.QuicCongestionController; +import jdk.internal.net.http.quic.QuicConnectionId; import jdk.internal.net.http.quic.QuicRttEstimator; import jdk.internal.net.http.quic.QuicTimerQueue; import jdk.internal.net.http.quic.frames.AckFrame; @@ -61,8 +63,6 @@ import jdk.internal.net.http.quic.packets.PacketSpace; import jdk.internal.net.http.quic.packets.QuicPacketDecoder; import jdk.internal.net.http.quic.packets.QuicPacketEncoder; -import jdk.internal.net.http.quic.CodingContext; -import jdk.internal.net.http.quic.QuicConnectionId; import jdk.internal.net.http.quic.packets.QuicPacket; import jdk.internal.net.http.quic.packets.QuicPacket.PacketNumberSpace; import jdk.internal.net.http.quic.packets.QuicPacket.PacketType; @@ -608,6 +608,38 @@ public void packetAcked(int packetBytes, Deadline sentTime) { } public void packetLost(Collection lostPackets, Deadline sentTime, boolean persistent) { } @Override public void packetDiscarded(Collection discardedPackets) { } + @Override + public long congestionWindow() { + return Integer.MAX_VALUE; + } + @Override + public long initialWindow() { + return Integer.MAX_VALUE; + } + @Override + public long maxDatagramSize() { + return 1200; + } + @Override + public boolean isSlowStart() { + return false; + } + @Override + public void updatePacer(Deadline now) { } + @Override + public boolean isPacerLimited() { + return false; + } + @Override + public boolean isCwndLimited() { + return false; + } + @Override + public Deadline pacerDeadline() { + return Deadline.MIN; + } + @Override + public void appLimited() { } }; manager = new PacketSpaceManager(space, this, timeSource, rttEstimator, congestionController, new DummyQuicTLSEngine(), From 9478fe6c11d4b3830c74e850b6e29b4e0f80c5f0 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 5 Nov 2025 15:46:38 +0100 Subject: [PATCH 02/29] Less verbose CC logging --- .../internal/net/http/quic/QuicRenoCongestionController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index c8a333b345bbd..ff51aafc131d7 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -153,7 +153,7 @@ public void packetAcked(int packetBytes, Deadline sentTime) { // Here we limit cwnd growth based on the maximum bytes in flight // observed since the last congestion event if (inCongestionRecovery(sentTime)) { - if (Log.quicCC()) { + if (Log.quicCC() && Log.trace()) { Log.logQuic(dbgTag + " Acked, in recovery: bytes: " + packetBytes + ", in flight: " + bytesInFlight); } @@ -171,7 +171,7 @@ public void packetAcked(int packetBytes, Deadline sentTime) { congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); } } - if (Log.quicCC()) { + if (Log.quicCC() && Log.trace()) { if (isAppLimited) { Log.logQuic(dbgTag + " Acked, not blocked: bytes: " + packetBytes + ", in flight: " + bytesInFlight); From f2393d0170954be55353313118ed9b1f7969436a Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 5 Nov 2025 17:31:10 +0100 Subject: [PATCH 03/29] Keep track of pacer-limited events --- .../jdk/internal/net/http/quic/PacketSpaceManager.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java index aed878f6f92c9..90031decdb4f6 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/PacketSpaceManager.java @@ -97,6 +97,7 @@ public sealed class PacketSpaceManager implements PacketSpace private final QuicCongestionController congestionController; private volatile boolean blockedByCC; + private volatile boolean blockedByPacer; // packet threshold for loss detection; RFC 9002 suggests 3 private static final long kPacketThreshold = 3; // Multiplier for persistent congestion; RFC 9002 suggests 3 @@ -460,11 +461,13 @@ private void handleLoop0() throws IOException, QuicTransportException { congestionController.isCwndLimited(), congestionController.isPacerLimited())); } blockedByCC = !cwndAvailable && congestionController.isCwndLimited(); + blockedByPacer = !cwndAvailable && congestionController.isPacerLimited(); if (!cwndAvailable && isOpenForTransmission()) { if (debug.on()) debug.log("handle: blocked by CC"); // CC might be available already if (congestionController.canSendPacket()) { if (debug.on()) debug.log("handle: unblocked immediately"); + blockedByCC = blockedByPacer = false; transmitNow = true; } } @@ -1397,7 +1400,7 @@ public Deadline computeNextDeadline(boolean verbose) { Deadline ackDeadline = (ack == null || ack.sent() != null) ? Deadline.MAX // if the ack frame has already been sent, getNextAck() returns null : ack.deadline(); - if (congestionController.isPacerLimited()) { + if (blockedByPacer) { Deadline pacerDeadline = congestionController.pacerDeadline(); if (verbose && Log.quicTimer()) { Log.logQuic(String.format("%s: [%s] pacer deadline: %s, ackDeadline: %s, deadline in %s", From be15a200b565357ef6a0f5f2b7907e91ee58a125 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 5 Nov 2025 19:21:19 +0100 Subject: [PATCH 04/29] Fix CRLF --- .../jdk/internal/net/http/quic/QuicPacer.java | 380 +++++++++--------- 1 file changed, 190 insertions(+), 190 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java index a1ab64eef251a..ddb2d57216fc0 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java @@ -1,190 +1,190 @@ -/* - * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. - * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. - * - * This code is free software; you can redistribute it and/or modify it - * under the terms of the GNU General Public License version 2 only, as - * published by the Free Software Foundation. Oracle designates this - * particular file as subject to the "Classpath" exception as provided - * by Oracle in the LICENSE file that accompanied this code. - * - * This code is distributed in the hope that it will be useful, but WITHOUT - * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License - * version 2 for more details (a copy is included in the LICENSE file that - * accompanied this code). - * - * You should have received a copy of the GNU General Public License version - * 2 along with this work; if not, write to the Free Software Foundation, - * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. - * - * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA - * or visit www.oracle.com if you need additional information or have any - * questions. - */ - -package jdk.internal.net.http.quic; - -import jdk.internal.net.http.common.Deadline; -import jdk.internal.net.http.common.Log; -import jdk.internal.net.http.common.Utils; -import jdk.internal.util.OperatingSystem; - -import java.time.Duration; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Implementation of pacing. - * - * When the connection is sending at a rate lower than permitted - * by the congestion controller, pacer is responsible for spreading out - * the outgoing packets across the entire RTT. - * - * Technically the pacer provides two functions: - * - computes the number of packets that can be sent now - * - computes the time when another packet can be sent - * - * When a new flow starts, or when the flow is not pacer-limited, - * the pacer limits the window to: - * max(INITIAL_WINDOW, pacingRate / timerFreq) - * timerFreq is the best timer resolution we can get from the selector. - * pacingRate is N * congestionWindow / smoothedRTT - * where N = 2 when in slow start, N = 1.25 otherwise. - * - * After that, the window refills at pacingRate, up to two timer periods or 4 packets, - * whichever is higher. - * - * The time when another packet can be sent is computed - * as the time when the window will allow at least 2 packets. - * - * All methods are externally synchronized in congestion controller. - * - * Ideas taken from: - * https://www.rfc-editor.org/rfc/rfc9002.html#name-pacing - * https://www.ietf.org/archive/id/draft-welzl-iccrg-pacing-03.html - */ -public class QuicPacer { - - // usually 64 Hz on Windows, 1000 on Linux - private static final long DEFAULT_TIMER_FREQ_HZ = OperatingSystem.isWindows() ? 64 : 1000; - private static final long TIMER_FREQ_HZ = Math.clamp( - Utils.getLongProperty("jdk.httpclient.quic.timerFrequency", DEFAULT_TIMER_FREQ_HZ), - 1, 1000); - - private final QuicRttEstimator rttEstimator; - private final QuicCongestionController congestionController; - - private boolean appLimited; - private long quota; - private Deadline lastUpdate; - - /** - * Create a QUIC pacer for the given RTT estimator and congestion controller - * - * @param rttEstimator the RTT estimator - * @param congestionController the congestion controller - */ - public QuicPacer(QuicRttEstimator rttEstimator, - QuicCongestionController congestionController) { - this.rttEstimator = rttEstimator; - this.congestionController = congestionController; - this.appLimited = true; - } - - /** - * called to indicate that the flow is app-limited. - * Alters the behavior of the following updateQuota call. - */ - public void appLimited() { - appLimited = true; - } - - /** - * {@return true if pacer quota not hit yet, false otherwise} - */ - public boolean canSend() { - return quota >= congestionController.maxDatagramSize(); - } - - /** - * Update quota based on time since the last call to this method - * and whether appLimited() was called or not. - * - * @param now current time - */ - public void updateQuota(Deadline now) { - long rttMicros = rttEstimator.state().smoothedRttMicros(); - long cwnd = congestionController.congestionWindow(); - if (rttMicros * TIMER_FREQ_HZ < TimeUnit.SECONDS.toMicros(2)) { - // RTT less than two timer periods; don't pace - quota = cwnd; - lastUpdate = now; - return; - } - if (lastUpdate != null && !now.isAfter(lastUpdate)) { - // might happen when transmission tasks from different packet spaces - // race to update quota. Keep the most recent update only. - return; - } - long pacingRate = cwnd * (congestionController.isSlowStart() ? 2_000_000 : 1_250_000) / rttMicros; // bytes per second - long initialWindow = congestionController.initialWindow(); - long onePeriodWindow = pacingRate / TIMER_FREQ_HZ; - long maxQuota; - if (appLimited) { - maxQuota = Math.max(initialWindow, onePeriodWindow); - } else { - maxQuota = Math.max(2 * onePeriodWindow, 4 * congestionController.maxDatagramSize()); - } - if (lastUpdate == null) { - quota = Math.max(initialWindow, maxQuota); - } else { - long nanosSinceUpdate = Deadline.between(lastUpdate, now).toNanos(); - if (nanosSinceUpdate >= TimeUnit.MICROSECONDS.toNanos(rttMicros)) { - // don't bother computing the increment, it might overflow and will be capped to maxQuota anyway - quota = maxQuota; - if (Log.quicCC()) { - Log.logQuic("pacer cwnd: %s, rtt %s us, duration %s ns, quota: %s".formatted( - cwnd, rttMicros, nanosSinceUpdate, quota)); - } - } else { - long quotaIncrement = pacingRate * nanosSinceUpdate / 1_000_000_000; - quota += quotaIncrement; - quota = Math.min(quota, maxQuota); - if (Log.quicCC()) { - Log.logQuic("pacer cwnd: %s, rtt %s us, duration %s ns, increment %s, quota %s".formatted( - cwnd, rttMicros, nanosSinceUpdate, quotaIncrement, quota)); - } - } - } - lastUpdate = now; - appLimited = false; - } - - /** - * {@return the deadline when quota will increase to two packets} - */ - public Deadline twoPacketDeadline() { - long datagramSize = congestionController.maxDatagramSize(); - long quotaNeeded = datagramSize * 2 - quota; - if (quotaNeeded <= 0) { - assert canSend(); - return lastUpdate; - } - // Window increases at a rate of rtt / cwnd / N - long rttMicros = rttEstimator.state().smoothedRttMicros(); - long cwnd = congestionController.congestionWindow(); - return lastUpdate.plus(Duration.ofNanos(rttMicros - * (congestionController.isSlowStart() ? 500 : 800) /* 1000/N */ - * quotaNeeded / cwnd)); - } - - /** - * called to indicate that a packet was sent - * - * @param packetBytes packet size in bytes - */ - public void packetSent(int packetBytes) { - quota -= packetBytes; - } -} +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.net.http.quic; + +import jdk.internal.net.http.common.Deadline; +import jdk.internal.net.http.common.Log; +import jdk.internal.net.http.common.Utils; +import jdk.internal.util.OperatingSystem; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Implementation of pacing. + * + * When the connection is sending at a rate lower than permitted + * by the congestion controller, pacer is responsible for spreading out + * the outgoing packets across the entire RTT. + * + * Technically the pacer provides two functions: + * - computes the number of packets that can be sent now + * - computes the time when another packet can be sent + * + * When a new flow starts, or when the flow is not pacer-limited, + * the pacer limits the window to: + * max(INITIAL_WINDOW, pacingRate / timerFreq) + * timerFreq is the best timer resolution we can get from the selector. + * pacingRate is N * congestionWindow / smoothedRTT + * where N = 2 when in slow start, N = 1.25 otherwise. + * + * After that, the window refills at pacingRate, up to two timer periods or 4 packets, + * whichever is higher. + * + * The time when another packet can be sent is computed + * as the time when the window will allow at least 2 packets. + * + * All methods are externally synchronized in congestion controller. + * + * Ideas taken from: + * https://www.rfc-editor.org/rfc/rfc9002.html#name-pacing + * https://www.ietf.org/archive/id/draft-welzl-iccrg-pacing-03.html + */ +public class QuicPacer { + + // usually 64 Hz on Windows, 1000 on Linux + private static final long DEFAULT_TIMER_FREQ_HZ = OperatingSystem.isWindows() ? 64 : 1000; + private static final long TIMER_FREQ_HZ = Math.clamp( + Utils.getLongProperty("jdk.httpclient.quic.timerFrequency", DEFAULT_TIMER_FREQ_HZ), + 1, 1000); + + private final QuicRttEstimator rttEstimator; + private final QuicCongestionController congestionController; + + private boolean appLimited; + private long quota; + private Deadline lastUpdate; + + /** + * Create a QUIC pacer for the given RTT estimator and congestion controller + * + * @param rttEstimator the RTT estimator + * @param congestionController the congestion controller + */ + public QuicPacer(QuicRttEstimator rttEstimator, + QuicCongestionController congestionController) { + this.rttEstimator = rttEstimator; + this.congestionController = congestionController; + this.appLimited = true; + } + + /** + * called to indicate that the flow is app-limited. + * Alters the behavior of the following updateQuota call. + */ + public void appLimited() { + appLimited = true; + } + + /** + * {@return true if pacer quota not hit yet, false otherwise} + */ + public boolean canSend() { + return quota >= congestionController.maxDatagramSize(); + } + + /** + * Update quota based on time since the last call to this method + * and whether appLimited() was called or not. + * + * @param now current time + */ + public void updateQuota(Deadline now) { + long rttMicros = rttEstimator.state().smoothedRttMicros(); + long cwnd = congestionController.congestionWindow(); + if (rttMicros * TIMER_FREQ_HZ < TimeUnit.SECONDS.toMicros(2)) { + // RTT less than two timer periods; don't pace + quota = cwnd; + lastUpdate = now; + return; + } + if (lastUpdate != null && !now.isAfter(lastUpdate)) { + // might happen when transmission tasks from different packet spaces + // race to update quota. Keep the most recent update only. + return; + } + long pacingRate = cwnd * (congestionController.isSlowStart() ? 2_000_000 : 1_250_000) / rttMicros; // bytes per second + long initialWindow = congestionController.initialWindow(); + long onePeriodWindow = pacingRate / TIMER_FREQ_HZ; + long maxQuota; + if (appLimited) { + maxQuota = Math.max(initialWindow, onePeriodWindow); + } else { + maxQuota = Math.max(2 * onePeriodWindow, 4 * congestionController.maxDatagramSize()); + } + if (lastUpdate == null) { + quota = Math.max(initialWindow, maxQuota); + } else { + long nanosSinceUpdate = Deadline.between(lastUpdate, now).toNanos(); + if (nanosSinceUpdate >= TimeUnit.MICROSECONDS.toNanos(rttMicros)) { + // don't bother computing the increment, it might overflow and will be capped to maxQuota anyway + quota = maxQuota; + if (Log.quicCC()) { + Log.logQuic("pacer cwnd: %s, rtt %s us, duration %s ns, quota: %s".formatted( + cwnd, rttMicros, nanosSinceUpdate, quota)); + } + } else { + long quotaIncrement = pacingRate * nanosSinceUpdate / 1_000_000_000; + quota += quotaIncrement; + quota = Math.min(quota, maxQuota); + if (Log.quicCC()) { + Log.logQuic("pacer cwnd: %s, rtt %s us, duration %s ns, increment %s, quota %s".formatted( + cwnd, rttMicros, nanosSinceUpdate, quotaIncrement, quota)); + } + } + } + lastUpdate = now; + appLimited = false; + } + + /** + * {@return the deadline when quota will increase to two packets} + */ + public Deadline twoPacketDeadline() { + long datagramSize = congestionController.maxDatagramSize(); + long quotaNeeded = datagramSize * 2 - quota; + if (quotaNeeded <= 0) { + assert canSend(); + return lastUpdate; + } + // Window increases at a rate of rtt / cwnd / N + long rttMicros = rttEstimator.state().smoothedRttMicros(); + long cwnd = congestionController.congestionWindow(); + return lastUpdate.plus(Duration.ofNanos(rttMicros + * (congestionController.isSlowStart() ? 500 : 800) /* 1000/N */ + * quotaNeeded / cwnd)); + } + + /** + * called to indicate that a packet was sent + * + * @param packetBytes packet size in bytes + */ + public void packetSent(int packetBytes) { + quota -= packetBytes; + } +} From dab80e581c3000b5e3c1f41943912b41362a749e Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Thu, 6 Nov 2025 08:28:31 +0100 Subject: [PATCH 05/29] Fix race in pacer updates, increase low-RTT quota --- .../jdk/internal/net/http/quic/QuicPacer.java | 12 ++++++------ test/jdk/java/net/httpclient/quic/PacerTest.java | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java index ddb2d57216fc0..dc247d4bec4b1 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicPacer.java @@ -114,19 +114,19 @@ public boolean canSend() { * @param now current time */ public void updateQuota(Deadline now) { + if (lastUpdate != null && !now.isAfter(lastUpdate)) { + // might happen when transmission tasks from different packet spaces + // race to update quota. Keep the most recent update only. + return; + } long rttMicros = rttEstimator.state().smoothedRttMicros(); long cwnd = congestionController.congestionWindow(); if (rttMicros * TIMER_FREQ_HZ < TimeUnit.SECONDS.toMicros(2)) { // RTT less than two timer periods; don't pace - quota = cwnd; + quota = 2 * cwnd; lastUpdate = now; return; } - if (lastUpdate != null && !now.isAfter(lastUpdate)) { - // might happen when transmission tasks from different packet spaces - // race to update quota. Keep the most recent update only. - return; - } long pacingRate = cwnd * (congestionController.isSlowStart() ? 2_000_000 : 1_250_000) / rttMicros; // bytes per second long initialWindow = congestionController.initialWindow(); long onePeriodWindow = pacingRate / TIMER_FREQ_HZ; diff --git a/test/jdk/java/net/httpclient/quic/PacerTest.java b/test/jdk/java/net/httpclient/quic/PacerTest.java index 33dbbed034095..d54f4125a460e 100644 --- a/test/jdk/java/net/httpclient/quic/PacerTest.java +++ b/test/jdk/java/net/httpclient/quic/PacerTest.java @@ -212,7 +212,7 @@ public void testPacingShortRtt() { rtt.consumeRttSample(1000, 0, Deadline.MIN); QuicPacer pacer = new QuicPacer(rtt, cc); pacer.updateQuota(Deadline.MIN); - for (int i = 0; i < packetsInCwnd; i++) { + for (int i = 0; i < 2 * packetsInCwnd; i++) { assertTrue(pacer.canSend(), "Pacer blocked after " + i + " packets"); pacer.packetSent(maxDatagramSize); } @@ -220,7 +220,7 @@ public void testPacingShortRtt() { // when RTT is short, permit cwnd on every update Deadline next = pacer.twoPacketDeadline(); pacer.updateQuota(next); - for (int i = 0; i < packetsInCwnd; i++) { + for (int i = 0; i < 2 * packetsInCwnd; i++) { assertTrue(pacer.canSend(), "Two packet deadline: pacer blocked after " + i + " packets"); pacer.packetSent(maxDatagramSize); } From 274c13a81d3986aa9ca457eb0c48c6139027d016 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Thu, 6 Nov 2025 12:03:52 +0100 Subject: [PATCH 06/29] Initial cubic commit --- .../quic/QuicBaseCongestionController.java | 316 ++++++++++++++++++ .../quic/QuicCubicCongestionController.java | 33 ++ .../quic/QuicRenoCongestionController.java | 287 +--------------- 3 files changed, 351 insertions(+), 285 deletions(-) create mode 100644 src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java create mode 100644 src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java new file mode 100644 index 0000000000000..9da9fe932bf3d --- /dev/null +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java @@ -0,0 +1,316 @@ +/* + * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.net.http.quic; + +import jdk.internal.net.http.common.Deadline; +import jdk.internal.net.http.common.Log; +import jdk.internal.net.http.common.TimeLine; +import jdk.internal.net.http.common.TimeSource; +import jdk.internal.net.http.common.Utils; +import jdk.internal.net.http.quic.frames.AckFrame; +import jdk.internal.net.http.quic.packets.QuicPacket; + +import java.util.Collection; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Implementation of QUIC congestion controller based on RFC 9002. + * This is a QUIC variant of New Reno algorithm. + * + * @spec https://www.rfc-editor.org/info/rfc9000 + * RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport + * @spec https://www.rfc-editor.org/info/rfc9002 + * RFC 9002: QUIC Loss Detection and Congestion Control + */ +abstract class QuicBaseCongestionController implements QuicCongestionController { + // higher of 14720 and 2*maxDatagramSize; we use fixed maxDatagramSize + private static final int INITIAL_WINDOW = Math.max(14720, 2 * QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE); + private static final int MAX_BYTES_IN_FLIGHT = Math.clamp( + Utils.getLongProperty("jdk.httpclient.quic.maxBytesInFlight", 1 << 24), + 1 << 14, 1 << 24); + private final TimeLine timeSource; + private final String dbgTag; + private final Lock lock = new ReentrantLock(); + private long congestionWindow = INITIAL_WINDOW; + private int maxDatagramSize = QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE; + private int minimumWindow = 2 * maxDatagramSize; + private long bytesInFlight; + // maximum bytes in flight seen since the last congestion event + private long maxBytesInFlight; + private Deadline congestionRecoveryStartTime; + private long ssThresh = Long.MAX_VALUE; + + private final QuicPacer pacer; + + public QuicBaseCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { + this.dbgTag = dbgTag; + this.timeSource = TimeSource.source(); + this.pacer = new QuicPacer(rttEstimator, this); + } + + private boolean inCongestionRecovery(Deadline sentTime) { + return (congestionRecoveryStartTime != null && + !sentTime.isAfter(congestionRecoveryStartTime)); + } + + private void onCongestionEvent(Deadline sentTime) { + if (inCongestionRecovery(sentTime)) { + return; + } + congestionRecoveryStartTime = timeSource.instant(); + ssThresh = congestionWindow / 2; + congestionWindow = Math.max(minimumWindow, ssThresh); + maxBytesInFlight = 0; + if (Log.quicCC()) { + Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + + ", in flight: " + bytesInFlight + + ", cwnd:" + congestionWindow); + } + } + + private static boolean inFlight(QuicPacket packet) { + // packet is in flight if it contains anything other than a single ACK frame + // specifically, a packet containing padding is considered to be in flight. + return packet.frames().size() != 1 || + !(packet.frames().get(0) instanceof AckFrame); + } + + @Override + public boolean canSendPacket() { + lock.lock(); + try { + if (bytesInFlight >= MAX_BYTES_IN_FLIGHT) { + return false; + } + if (isCwndLimited() || isPacerLimited()) { + return false; + } + return true; + } finally { + lock.unlock(); + } + } + + @Override + public void updateMaxDatagramSize(int newSize) { + lock.lock(); + try { + if (minimumWindow != newSize * 2) { + minimumWindow = newSize * 2; + maxDatagramSize = newSize; + congestionWindow = Math.max(congestionWindow, minimumWindow); + } + } finally { + lock.unlock(); + } + } + + @Override + public void packetSent(int packetBytes) { + lock.lock(); + try { + bytesInFlight += packetBytes; + if (bytesInFlight > maxBytesInFlight) { + maxBytesInFlight = bytesInFlight; + } + pacer.packetSent(packetBytes); + } finally { + lock.unlock(); + } + } + + @Override + public void packetAcked(int packetBytes, Deadline sentTime) { + lock.lock(); + try { + bytesInFlight -= packetBytes; + // RFC 9002 says we should not increase cwnd when application limited. + // The concept itself is poorly defined. + // Here we limit cwnd growth based on the maximum bytes in flight + // observed since the last congestion event + if (inCongestionRecovery(sentTime)) { + if (Log.quicCC() && Log.trace()) { + Log.logQuic(dbgTag + " Acked, in recovery: bytes: " + packetBytes + + ", in flight: " + bytesInFlight); + } + return; + } + boolean isAppLimited; + if (congestionWindow < ssThresh) { + isAppLimited = congestionWindow >= 2 * maxBytesInFlight; + if (!isAppLimited) { + congestionWindow += packetBytes; + } + } else { + isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; + if (!isAppLimited) { + congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); + } + } + if (Log.quicCC() && Log.trace()) { + if (isAppLimited) { + Log.logQuic(dbgTag + " Acked, not blocked: bytes: " + packetBytes + + ", in flight: " + bytesInFlight); + } else { + Log.logQuic(dbgTag + " Acked, increased: bytes: " + packetBytes + + ", in flight: " + bytesInFlight + + ", new cwnd:" + congestionWindow); + } + } + } finally { + lock.unlock(); + } + } + + @Override + public void packetLost(Collection lostPackets, Deadline sentTime, boolean persistent) { + lock.lock(); + try { + for (QuicPacket packet : lostPackets) { + if (inFlight(packet)) { + bytesInFlight -= packet.size(); + } + } + onCongestionEvent(sentTime); + if (persistent) { + congestionWindow = minimumWindow; + congestionRecoveryStartTime = null; + if (Log.quicCC()) { + Log.logQuic(dbgTag + " Persistent congestion: ssThresh: " + ssThresh + + ", in flight: " + bytesInFlight + + ", cwnd:" + congestionWindow); + } + } + } finally { + lock.unlock(); + } + } + + @Override + public void packetDiscarded(Collection discardedPackets) { + lock.lock(); + try { + for (QuicPacket packet : discardedPackets) { + if (inFlight(packet)) { + bytesInFlight -= packet.size(); + } + } + } finally { + lock.unlock(); + } + } + + @Override + public long congestionWindow() { + lock.lock(); + try { + return congestionWindow; + } finally { + lock.unlock(); + } + } + + @Override + public long initialWindow() { + lock.lock(); + try { + return Math.max(14720, 2 * maxDatagramSize); + } finally { + lock.unlock(); + } + } + + @Override + public long maxDatagramSize() { + lock.lock(); + try { + return maxDatagramSize; + } finally { + lock.unlock(); + } + } + + @Override + public boolean isSlowStart() { + lock.lock(); + try { + return congestionWindow < ssThresh; + } finally { + lock.unlock(); + } + } + + @Override + public void updatePacer(Deadline now) { + lock.lock(); + try { + pacer.updateQuota(now); + } finally { + lock.unlock(); + } + } + + @Override + public boolean isPacerLimited() { + lock.lock(); + try { + return !pacer.canSend(); + } finally { + lock.unlock(); + } + } + + @Override + public boolean isCwndLimited() { + lock.lock(); + try { + return congestionWindow - bytesInFlight < maxDatagramSize; + } finally { + lock.unlock(); + } + } + + @Override + public Deadline pacerDeadline() { + lock.lock(); + try { + return pacer.twoPacketDeadline(); + } finally { + lock.unlock(); + } + } + + @Override + public void appLimited() { + lock.lock(); + try { + pacer.appLimited(); + } finally { + lock.unlock(); + } + } +} diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java new file mode 100644 index 0000000000000..87872acc721e7 --- /dev/null +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022, 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. Oracle designates this + * particular file as subject to the "Classpath" exception as provided + * by Oracle in the LICENSE file that accompanied this code. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +package jdk.internal.net.http.quic; + +class QuicCubicCongestionController extends QuicBaseCongestionController { + public QuicCubicCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { + super(dbgTag, rttEstimator); + } + +} diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index ff51aafc131d7..08a76829cdb1f 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -25,292 +25,9 @@ package jdk.internal.net.http.quic; -import jdk.internal.net.http.common.Deadline; -import jdk.internal.net.http.common.Log; -import jdk.internal.net.http.common.TimeLine; -import jdk.internal.net.http.common.TimeSource; -import jdk.internal.net.http.common.Utils; -import jdk.internal.net.http.quic.frames.AckFrame; -import jdk.internal.net.http.quic.packets.QuicPacket; - -import java.util.Collection; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; - -/** - * Implementation of QUIC congestion controller based on RFC 9002. - * This is a QUIC variant of New Reno algorithm. - * - * @spec https://www.rfc-editor.org/info/rfc9000 - * RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport - * @spec https://www.rfc-editor.org/info/rfc9002 - * RFC 9002: QUIC Loss Detection and Congestion Control - */ -class QuicRenoCongestionController implements QuicCongestionController { - // higher of 14720 and 2*maxDatagramSize; we use fixed maxDatagramSize - private static final int INITIAL_WINDOW = Math.max(14720, 2 * QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE); - private static final int MAX_BYTES_IN_FLIGHT = Math.clamp( - Utils.getLongProperty("jdk.httpclient.quic.maxBytesInFlight", 1 << 24), - 1 << 14, 1 << 24); - private final TimeLine timeSource; - private final String dbgTag; - private final Lock lock = new ReentrantLock(); - private long congestionWindow = INITIAL_WINDOW; - private int maxDatagramSize = QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE; - private int minimumWindow = 2 * maxDatagramSize; - private long bytesInFlight; - // maximum bytes in flight seen since the last congestion event - private long maxBytesInFlight; - private Deadline congestionRecoveryStartTime; - private long ssThresh = Long.MAX_VALUE; - - private final QuicPacer pacer; - +class QuicRenoCongestionController extends QuicBaseCongestionController { public QuicRenoCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { - this.dbgTag = dbgTag; - this.timeSource = TimeSource.source(); - this.pacer = new QuicPacer(rttEstimator, this); + super(dbgTag, rttEstimator); } - private boolean inCongestionRecovery(Deadline sentTime) { - return (congestionRecoveryStartTime != null && - !sentTime.isAfter(congestionRecoveryStartTime)); - } - - private void onCongestionEvent(Deadline sentTime) { - if (inCongestionRecovery(sentTime)) { - return; - } - congestionRecoveryStartTime = timeSource.instant(); - ssThresh = congestionWindow / 2; - congestionWindow = Math.max(minimumWindow, ssThresh); - maxBytesInFlight = 0; - if (Log.quicCC()) { - Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + - ", in flight: " + bytesInFlight + - ", cwnd:" + congestionWindow); - } - } - - private static boolean inFlight(QuicPacket packet) { - // packet is in flight if it contains anything other than a single ACK frame - // specifically, a packet containing padding is considered to be in flight. - return packet.frames().size() != 1 || - !(packet.frames().get(0) instanceof AckFrame); - } - - @Override - public boolean canSendPacket() { - lock.lock(); - try { - if (bytesInFlight >= MAX_BYTES_IN_FLIGHT) { - return false; - } - if (isCwndLimited() || isPacerLimited()) { - return false; - } - return true; - } finally { - lock.unlock(); - } - } - - @Override - public void updateMaxDatagramSize(int newSize) { - lock.lock(); - try { - if (minimumWindow != newSize * 2) { - minimumWindow = newSize * 2; - maxDatagramSize = newSize; - congestionWindow = Math.max(congestionWindow, minimumWindow); - } - } finally { - lock.unlock(); - } - } - - @Override - public void packetSent(int packetBytes) { - lock.lock(); - try { - bytesInFlight += packetBytes; - if (bytesInFlight > maxBytesInFlight) { - maxBytesInFlight = bytesInFlight; - } - pacer.packetSent(packetBytes); - } finally { - lock.unlock(); - } - } - - @Override - public void packetAcked(int packetBytes, Deadline sentTime) { - lock.lock(); - try { - bytesInFlight -= packetBytes; - // RFC 9002 says we should not increase cwnd when application limited. - // The concept itself is poorly defined. - // Here we limit cwnd growth based on the maximum bytes in flight - // observed since the last congestion event - if (inCongestionRecovery(sentTime)) { - if (Log.quicCC() && Log.trace()) { - Log.logQuic(dbgTag + " Acked, in recovery: bytes: " + packetBytes + - ", in flight: " + bytesInFlight); - } - return; - } - boolean isAppLimited; - if (congestionWindow < ssThresh) { - isAppLimited = congestionWindow >= 2 * maxBytesInFlight; - if (!isAppLimited) { - congestionWindow += packetBytes; - } - } else { - isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; - if (!isAppLimited) { - congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); - } - } - if (Log.quicCC() && Log.trace()) { - if (isAppLimited) { - Log.logQuic(dbgTag + " Acked, not blocked: bytes: " + packetBytes + - ", in flight: " + bytesInFlight); - } else { - Log.logQuic(dbgTag + " Acked, increased: bytes: " + packetBytes + - ", in flight: " + bytesInFlight + - ", new cwnd:" + congestionWindow); - } - } - } finally { - lock.unlock(); - } - } - - @Override - public void packetLost(Collection lostPackets, Deadline sentTime, boolean persistent) { - lock.lock(); - try { - for (QuicPacket packet : lostPackets) { - if (inFlight(packet)) { - bytesInFlight -= packet.size(); - } - } - onCongestionEvent(sentTime); - if (persistent) { - congestionWindow = minimumWindow; - congestionRecoveryStartTime = null; - if (Log.quicCC()) { - Log.logQuic(dbgTag + " Persistent congestion: ssThresh: " + ssThresh + - ", in flight: " + bytesInFlight + - ", cwnd:" + congestionWindow); - } - } - } finally { - lock.unlock(); - } - } - - @Override - public void packetDiscarded(Collection discardedPackets) { - lock.lock(); - try { - for (QuicPacket packet : discardedPackets) { - if (inFlight(packet)) { - bytesInFlight -= packet.size(); - } - } - } finally { - lock.unlock(); - } - } - - @Override - public long congestionWindow() { - lock.lock(); - try { - return congestionWindow; - } finally { - lock.unlock(); - } - } - - @Override - public long initialWindow() { - lock.lock(); - try { - return Math.max(14720, 2 * maxDatagramSize); - } finally { - lock.unlock(); - } - } - - @Override - public long maxDatagramSize() { - lock.lock(); - try { - return maxDatagramSize; - } finally { - lock.unlock(); - } - } - - @Override - public boolean isSlowStart() { - lock.lock(); - try { - return congestionWindow < ssThresh; - } finally { - lock.unlock(); - } - } - - @Override - public void updatePacer(Deadline now) { - lock.lock(); - try { - pacer.updateQuota(now); - } finally { - lock.unlock(); - } - } - - @Override - public boolean isPacerLimited() { - lock.lock(); - try { - return !pacer.canSend(); - } finally { - lock.unlock(); - } - } - - @Override - public boolean isCwndLimited() { - lock.lock(); - try { - return congestionWindow - bytesInFlight < maxDatagramSize; - } finally { - lock.unlock(); - } - } - - @Override - public Deadline pacerDeadline() { - lock.lock(); - try { - return pacer.twoPacketDeadline(); - } finally { - lock.unlock(); - } - } - - @Override - public void appLimited() { - lock.lock(); - try { - pacer.appLimited(); - } finally { - lock.unlock(); - } - } } From 8e8515b43cef4e9f3d3eeb6dd6438fd798f9a23d Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Thu, 6 Nov 2025 15:34:16 +0100 Subject: [PATCH 07/29] Implement cubic --- .../quic/QuicBaseCongestionController.java | 44 +++----- .../net/http/quic/QuicConnectionImpl.java | 2 +- .../quic/QuicCubicCongestionController.java | 102 ++++++++++++++++++ .../quic/QuicRenoCongestionController.java | 26 +++++ 4 files changed, 144 insertions(+), 30 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java index 9da9fe932bf3d..71252ca356f9d 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java @@ -52,17 +52,17 @@ abstract class QuicBaseCongestionController implements QuicCongestionController private static final int MAX_BYTES_IN_FLIGHT = Math.clamp( Utils.getLongProperty("jdk.httpclient.quic.maxBytesInFlight", 1 << 24), 1 << 14, 1 << 24); - private final TimeLine timeSource; - private final String dbgTag; - private final Lock lock = new ReentrantLock(); - private long congestionWindow = INITIAL_WINDOW; - private int maxDatagramSize = QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE; - private int minimumWindow = 2 * maxDatagramSize; - private long bytesInFlight; + protected final TimeLine timeSource; + protected final String dbgTag; + protected final Lock lock = new ReentrantLock(); + protected long congestionWindow = INITIAL_WINDOW; + protected int maxDatagramSize = QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE; + protected int minimumWindow = 2 * maxDatagramSize; + protected long bytesInFlight; // maximum bytes in flight seen since the last congestion event - private long maxBytesInFlight; - private Deadline congestionRecoveryStartTime; - private long ssThresh = Long.MAX_VALUE; + protected long maxBytesInFlight; + protected Deadline congestionRecoveryStartTime; + protected long ssThresh = Long.MAX_VALUE; private final QuicPacer pacer; @@ -72,25 +72,12 @@ public QuicBaseCongestionController(String dbgTag, QuicRttEstimator rttEstimator this.pacer = new QuicPacer(rttEstimator, this); } - private boolean inCongestionRecovery(Deadline sentTime) { + protected boolean inCongestionRecovery(Deadline sentTime) { return (congestionRecoveryStartTime != null && !sentTime.isAfter(congestionRecoveryStartTime)); } - private void onCongestionEvent(Deadline sentTime) { - if (inCongestionRecovery(sentTime)) { - return; - } - congestionRecoveryStartTime = timeSource.instant(); - ssThresh = congestionWindow / 2; - congestionWindow = Math.max(minimumWindow, ssThresh); - maxBytesInFlight = 0; - if (Log.quicCC()) { - Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + - ", in flight: " + bytesInFlight + - ", cwnd:" + congestionWindow); - } - } + protected abstract void onCongestionEvent(Deadline sentTime); private static boolean inFlight(QuicPacket packet) { // packet is in flight if it contains anything other than a single ACK frame @@ -166,10 +153,7 @@ public void packetAcked(int packetBytes, Deadline sentTime) { congestionWindow += packetBytes; } } else { - isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; - if (!isAppLimited) { - congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); - } + isAppLimited = congestionAvoidanceAcked(packetBytes, sentTime); } if (Log.quicCC() && Log.trace()) { if (isAppLimited) { @@ -186,6 +170,8 @@ public void packetAcked(int packetBytes, Deadline sentTime) { } } + protected abstract boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime); + @Override public void packetLost(Collection lostPackets, Deadline sentTime, boolean persistent) { lock.lock(); diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java index c18e74ba0de3f..5af90c69e53b3 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java @@ -334,7 +334,7 @@ protected QuicConnectionImpl(final QuicVersion firstFlightVersion, this.connectionId = this.endpoint.idFactory().newConnectionId(); this.logTag = logTagFormat.formatted(labelId); this.dbgTag = dbgTag(quicInstance, logTag); - this.congestionController = new QuicRenoCongestionController(dbgTag, rttEstimator); + this.congestionController = new QuicCubicCongestionController(dbgTag, rttEstimator); this.originalVersion = this.quicVersion = firstFlightVersion == null ? QuicVersion.firstFlightVersion(quicInstance.getAvailableVersions()) : firstFlightVersion; diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 87872acc721e7..a8e96b19cbca8 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -25,9 +25,111 @@ package jdk.internal.net.http.quic; +import jdk.internal.net.http.common.Deadline; +import jdk.internal.net.http.common.Log; + +import java.util.concurrent.TimeUnit; + +/** + * Implementation of the CUBIC congestion controller + * based on RFC 9438. + * + * @spec https://www.rfc-editor.org/rfc/rfc9438.html + * RFC 9438: CUBIC for Fast and Long-Distance Networks + */ class QuicCubicCongestionController extends QuicBaseCongestionController { + + private static final double BETA = 0.7; + private static final double ALPHA = 3 * (1 - BETA) / (1 + BETA); + private static final double C = 0.4; + private final QuicRttEstimator rttEstimator; + // Cubic curve inflection point, in bytes + private long wMaxBytes; + // cwnd before the most recent congestion event + private long cwndPriorBytes; + // "t" from RFC 9438 + private long timeNanos; + // "K" from RFC 9438 + private long kNanos; + // estimate for the Reno-friendly congestion window + private long wEstBytes; + // the most recent time when the congestion window was filled + private Deadline lastFullWindow; + public QuicCubicCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { super(dbgTag, rttEstimator); + this.rttEstimator = rttEstimator; } + @Override + public void packetSent(int packetBytes) { + lock.lock(); + try { + super.packetSent(packetBytes); + if (isCwndLimited()) { + Deadline now = timeSource.instant(); + if (lastFullWindow == null) { + lastFullWindow = now; + } else { + long timePassedNanos = Deadline.between(lastFullWindow, now).toNanos(); + if (timePassedNanos > 0) { + long rttNanos = TimeUnit.MICROSECONDS.toNanos(rttEstimator.state().smoothedRttMicros()); + timeNanos += Math.min(timePassedNanos, rttNanos); + lastFullWindow = now; + } + } + } + } finally { + lock.unlock(); + } + } + + + protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { + boolean isAppLimited; + isAppLimited = sentTime.isAfter(lastFullWindow); + if (!isAppLimited) { + // C * (t-K [seconds])^3 + Wmax (segments) + if (wEstBytes < cwndPriorBytes) { + wEstBytes += Math.max((long) (ALPHA * packetBytes / congestionWindow), 1); + } else { + wEstBytes += Math.max(packetBytes / congestionWindow, 1); + } + long targetBytes = (long)(C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; + if (targetBytes > 1.5 * congestionWindow) { + targetBytes = (long) (1.5 * congestionWindow); + } + if (targetBytes > congestionWindow) { + congestionWindow += Math.max((targetBytes - congestionWindow) * packetBytes / congestionWindow, 1L); + } + if (wEstBytes > congestionWindow) { + congestionWindow = wEstBytes; + } + } + return isAppLimited; + } + + protected void onCongestionEvent(Deadline sentTime) { + if (inCongestionRecovery(sentTime)) { + return; + } + // TODO implement fast convergence (RFC 9438 section 4.7) + wMaxBytes = congestionWindow; + cwndPriorBytes = congestionWindow; + congestionRecoveryStartTime = timeSource.instant(); + ssThresh = (long)(congestionWindow * BETA); + wEstBytes = congestionWindow = Math.max(minimumWindow, ssThresh); + maxBytesInFlight = 0; + timeNanos = 0; + // set lastFullWindow to prevent rapid timeNanos growth + lastFullWindow = congestionRecoveryStartTime; + // ((wmax_segments - cwnd_segments) / C) ^ (1/3) seconds + kNanos = (long)(Math.cbrt((wMaxBytes - congestionWindow) / C / maxDatagramSize) * 1_000_000_000); + if (Log.quicCC()) { + Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + + ", in flight: " + bytesInFlight + + ", cwnd:" + congestionWindow + + ", k: " + kNanos + " ns"); + } + } } diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index 08a76829cdb1f..2076a18f74e5c 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -25,9 +25,35 @@ package jdk.internal.net.http.quic; +import jdk.internal.net.http.common.Deadline; +import jdk.internal.net.http.common.Log; + class QuicRenoCongestionController extends QuicBaseCongestionController { public QuicRenoCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { super(dbgTag, rttEstimator); } + protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { + boolean isAppLimited; + isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; + if (!isAppLimited) { + congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); + } + return isAppLimited; + } + + protected void onCongestionEvent(Deadline sentTime) { + if (inCongestionRecovery(sentTime)) { + return; + } + congestionRecoveryStartTime = timeSource.instant(); + ssThresh = congestionWindow / 2; + congestionWindow = Math.max(minimumWindow, ssThresh); + maxBytesInFlight = 0; + if (Log.quicCC()) { + Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + + ", in flight: " + bytesInFlight + + ", cwnd:" + congestionWindow); + } + } } From 859b7abc4d7e33cbb45176f26f45702b144f58fb Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Thu, 6 Nov 2025 17:46:03 +0100 Subject: [PATCH 08/29] Log K in milliseconds --- .../internal/net/http/quic/QuicCubicCongestionController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index a8e96b19cbca8..348eec69b6e4a 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -129,7 +129,7 @@ protected void onCongestionEvent(Deadline sentTime) { Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + ", in flight: " + bytesInFlight + ", cwnd:" + congestionWindow + - ", k: " + kNanos + " ns"); + ", K: " + TimeUnit.NANOSECONDS.toMillis(kNanos) + " ms"); } } } From de7ebe946ff1e08f0b9df7c79895372c2448822e Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Thu, 6 Nov 2025 21:14:44 +0100 Subject: [PATCH 09/29] Cubic tests, more aggressive Reno window increase --- .../quic/QuicCubicCongestionController.java | 12 +- .../quic/QuicRenoCongestionController.java | 2 +- .../java/net/httpclient/quic/CubicTest.java | 175 ++++++++++++++++++ 3 files changed, 182 insertions(+), 7 deletions(-) create mode 100644 test/jdk/java/net/httpclient/quic/CubicTest.java diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 348eec69b6e4a..1783ee4d7dce7 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -37,10 +37,10 @@ * @spec https://www.rfc-editor.org/rfc/rfc9438.html * RFC 9438: CUBIC for Fast and Long-Distance Networks */ -class QuicCubicCongestionController extends QuicBaseCongestionController { +public class QuicCubicCongestionController extends QuicBaseCongestionController { - private static final double BETA = 0.7; - private static final double ALPHA = 3 * (1 - BETA) / (1 + BETA); + public static final double BETA = 0.7; + public static final double ALPHA = 3 * (1 - BETA) / (1 + BETA); private static final double C = 0.4; private final QuicRttEstimator rttEstimator; // Cubic curve inflection point, in bytes @@ -91,16 +91,16 @@ protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { if (!isAppLimited) { // C * (t-K [seconds])^3 + Wmax (segments) if (wEstBytes < cwndPriorBytes) { - wEstBytes += Math.max((long) (ALPHA * packetBytes / congestionWindow), 1); + wEstBytes += Math.max((long) (ALPHA * maxDatagramSize * packetBytes / maxBytesInFlight), 1); } else { - wEstBytes += Math.max(packetBytes / congestionWindow, 1); + wEstBytes += Math.max((long)maxDatagramSize * packetBytes / maxBytesInFlight, 1); } long targetBytes = (long)(C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; if (targetBytes > 1.5 * congestionWindow) { targetBytes = (long) (1.5 * congestionWindow); } if (targetBytes > congestionWindow) { - congestionWindow += Math.max((targetBytes - congestionWindow) * packetBytes / congestionWindow, 1L); + congestionWindow += Math.max((targetBytes - congestionWindow) * packetBytes / maxBytesInFlight, 1L); } if (wEstBytes > congestionWindow) { congestionWindow = wEstBytes; diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index 2076a18f74e5c..b4410937a16c7 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -37,7 +37,7 @@ protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { boolean isAppLimited; isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; if (!isAppLimited) { - congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); + congestionWindow += Math.max((long) maxDatagramSize * packetBytes / maxBytesInFlight, 1L); } return isAppLimited; } diff --git a/test/jdk/java/net/httpclient/quic/CubicTest.java b/test/jdk/java/net/httpclient/quic/CubicTest.java new file mode 100644 index 0000000000000..d824138c3319f --- /dev/null +++ b/test/jdk/java/net/httpclient/quic/CubicTest.java @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2025, Oracle and/or its affiliates. All rights reserved. + * DO NOT ALTER OR REMOVE COPYRIGHT NOTICES OR THIS FILE HEADER. + * + * This code is free software; you can redistribute it and/or modify it + * under the terms of the GNU General Public License version 2 only, as + * published by the Free Software Foundation. + * + * This code is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License + * version 2 for more details (a copy is included in the LICENSE file that + * accompanied this code). + * + * You should have received a copy of the GNU General Public License version + * 2 along with this work; if not, write to the Free Software Foundation, + * Inc., 51 Franklin St, Fifth Floor, Boston, MA 02110-1301 USA. + * + * Please contact Oracle, 500 Oracle Parkway, Redwood Shores, CA 94065 USA + * or visit www.oracle.com if you need additional information or have any + * questions. + */ + +import jdk.internal.net.http.common.Deadline; +import jdk.internal.net.http.common.TimeSource; +import jdk.internal.net.http.quic.*; +import jdk.internal.net.http.quic.frames.PaddingFrame; +import jdk.internal.net.http.quic.frames.QuicFrame; +import jdk.internal.net.http.quic.packets.QuicPacket; +import org.testng.annotations.Test; + +import java.time.temporal.ChronoUnit; +import java.util.List; + +import static jdk.internal.net.http.quic.QuicCubicCongestionController.ALPHA; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertTrue; + +/* + * @test + * @run testng/othervm -Djdk.httpclient.HttpClient.log=trace,quic:cc CubicTest + */ +public class CubicTest { + private TimeSource timeSource = TimeSource.source(); + + private class TestQuicPacket implements QuicPacket { + private final int size; + + public TestQuicPacket(int size) { + this.size = size; + } + + @Override + public List frames() { + // fool congestion controller that this packet is in flight + return List.of(new PaddingFrame(1)); + } + + @Override + public QuicConnectionId destinationId() { + throw new AssertionError("Should not come here"); + } + + @Override + public PacketNumberSpace numberSpace() { + throw new AssertionError("Should not come here"); + } + + @Override + public int size() { + return size; + } + + @Override + public HeadersType headersType() { + throw new AssertionError("Should not come here"); + } + + @Override + public PacketType packetType() { + throw new AssertionError("Should not come here"); + } + } + + @Test + public void testReduction() { + QuicRttEstimator rtt = new QuicRttEstimator(); + rtt.consumeRttSample(1, 0, Deadline.MIN); + QuicCongestionController cc = new QuicCubicCongestionController("TEST", rtt); + int packetSize = (int) cc.maxDatagramSize(); + assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); + do { + cc.packetSent(packetSize); + // reduce to 70% of the last value, but not below 2*SMSS + long newCongestionWindow = Math.max((long) (QuicCubicCongestionController.BETA * cc.congestionWindow()), 2 * packetSize); + cc.packetLost(List.of(new TestQuicPacket(packetSize)), Deadline.MAX, false); + assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + } while (cc.congestionWindow() > 2 * packetSize); + } + + @Test + public void testAppLimited() { + QuicRttEstimator rtt = new QuicRttEstimator(); + rtt.consumeRttSample(1, 0, Deadline.MIN); + QuicCongestionController cc = new QuicCubicCongestionController("TEST", rtt); + int packetSize = (int) cc.maxDatagramSize(); + assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); + cc.packetSent(packetSize); + long newCongestionWindow = (long) (QuicCubicCongestionController.BETA * cc.congestionWindow()); + // lose packet to exit slow start + cc.packetLost(List.of(new TestQuicPacket(packetSize)), Deadline.MAX, false); + assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + Deadline sentTime = timeSource.instant().plus(1, ChronoUnit.NANOS); + // congestion window should not increase when sender is app-limited + cc.packetSent(packetSize); + cc.packetAcked(packetSize, sentTime); + assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected congestion window change"); + } + + @Test + public void testRenoFriendly() { + QuicRttEstimator rtt = new QuicRttEstimator(); + rtt.consumeRttSample(1, 0, Deadline.MIN); + QuicCongestionController cc = new QuicCubicCongestionController("TEST", rtt); + int packetSize = (int) cc.maxDatagramSize(); + assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); + cc.packetSent(packetSize); + long newCongestionWindow = (long) (QuicCubicCongestionController.BETA * cc.congestionWindow()); + // lose packet to exit slow start + cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); + assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + // enter cwnd-limited state to start increasing cwnd + Deadline sentTime = timeSource.instant().plus(1, ChronoUnit.NANOS); + int numPackets = 0; + while (!cc.isCwndLimited()) { + cc.packetSent(packetSize); + numPackets++; + } + // test that the window increases roughly by ALPHA * maxDatagramSize every RTT + long startingCwnd = cc.congestionWindow(); + for (int i = 0; i < numPackets; i++) { + cc.packetAcked(packetSize, sentTime); + } + long expectedCwnd = (long) (startingCwnd + ALPHA * packetSize); + long actualCwnd = cc.congestionWindow(); + assertTrue(actualCwnd > expectedCwnd - numPackets && actualCwnd < expectedCwnd + numPackets, + "actual cwnd %s not within the expected range (%s, %s)".formatted( + actualCwnd, expectedCwnd - numPackets, expectedCwnd + numPackets + )); + numPackets = 0; + do { + while (!cc.isCwndLimited()) { + cc.packetSent(packetSize); + numPackets++; + } + cc.packetAcked(packetSize, sentTime); + numPackets--; + } while (cc.congestionWindow() < cc.initialWindow()); + while (!cc.isCwndLimited()) { + cc.packetSent(packetSize); + numPackets++; + } + // test that the window increases roughly by maxDatagramSize every RTT after passing cwndPrior + startingCwnd = cc.congestionWindow(); + for (int i = 0; i < numPackets; i++) { + cc.packetAcked(packetSize, sentTime); + } + expectedCwnd = startingCwnd + packetSize; + actualCwnd = cc.congestionWindow(); + assertTrue(actualCwnd > expectedCwnd - numPackets && actualCwnd < expectedCwnd + numPackets, + "actual cwnd %s not within the expected range (%s, %s)".formatted( + actualCwnd, expectedCwnd - numPackets, expectedCwnd + numPackets + )); + } +} From 98e26c3145cbb1e19ef645eec35849b1e00d2050 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 09:47:45 +0100 Subject: [PATCH 10/29] Documentation updates --- .../http/quic/QuicBaseCongestionController.java | 12 ++++++++---- .../http/quic/QuicCubicCongestionController.java | 15 +++++++++++++++ .../http/quic/QuicRenoCongestionController.java | 9 +++++++++ 3 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java index 71252ca356f9d..c90bf1f45e74f 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java @@ -38,11 +38,15 @@ import java.util.concurrent.locks.ReentrantLock; /** - * Implementation of QUIC congestion controller based on RFC 9002. - * This is a QUIC variant of New Reno algorithm. + * Implementation of the common parts of a QUIC congestion controller based on RFC 9002. + * + * This class implements the common parts of a congestion controller: + * - slow start + * - loss recovery + * - cooperation with pacer + * + * Subclasses implement congestion window growth in congestion avoidance phase. * - * @spec https://www.rfc-editor.org/info/rfc9000 - * RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport * @spec https://www.rfc-editor.org/info/rfc9002 * RFC 9002: QUIC Loss Detection and Congestion Control */ diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 1783ee4d7dce7..3528036f40719 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -73,6 +73,21 @@ public void packetSent(int packetBytes) { } else { long timePassedNanos = Deadline.between(lastFullWindow, now).toNanos(); if (timePassedNanos > 0) { + /* "The elapsed time MUST NOT include periods during which cwnd + has not been updated due to application-limited behavior" + "A flow is application limited if it is currently sending less + than what is allowed by the congestion window." + + We are sending asynchronously; one thread is sending data, + a separate thread is processing the acknowledgements. + We can't rely on cwnd being fully utilized when we process an ack, because + most of the time it won't be. + + Instead, we assume that if we filled the cwnd, we were not application-limited + in the last RTT (which is a pretty good approximation because of pacing), + and acknowledgements for all packets sent prior to filling the cwnd + count towards cwnd increase. + */ long rttNanos = TimeUnit.MICROSECONDS.toNanos(rttEstimator.state().smoothedRttMicros()); timeNanos += Math.min(timePassedNanos, rttNanos); lastFullWindow = now; diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index b4410937a16c7..a4f50984a758c 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -28,6 +28,15 @@ import jdk.internal.net.http.common.Deadline; import jdk.internal.net.http.common.Log; +/** + * Implementation of QUIC congestion controller based on RFC 9002. + * This is a QUIC variant of New Reno algorithm. + * + * @spec https://www.rfc-editor.org/info/rfc9000 + * RFC 9000: QUIC: A UDP-Based Multiplexed and Secure Transport + * @spec https://www.rfc-editor.org/info/rfc9002 + * RFC 9002: QUIC Loss Detection and Congestion Control + */ class QuicRenoCongestionController extends QuicBaseCongestionController { public QuicRenoCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { super(dbgTag, rttEstimator); From fcd9a71901a0d5bb8670d7e0b43bac7823993b27 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 10:25:04 +0100 Subject: [PATCH 11/29] Use custom timeline for testing --- .../quic/QuicBaseCongestionController.java | 9 +++++- .../quic/QuicCubicCongestionController.java | 7 +++++ .../java/net/httpclient/quic/CubicTest.java | 28 +++++++++++++++---- 3 files changed, 37 insertions(+), 7 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java index c90bf1f45e74f..f6cda2a65c314 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java @@ -70,12 +70,19 @@ abstract class QuicBaseCongestionController implements QuicCongestionController private final QuicPacer pacer; - public QuicBaseCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { + protected QuicBaseCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { this.dbgTag = dbgTag; this.timeSource = TimeSource.source(); this.pacer = new QuicPacer(rttEstimator, this); } + // for testing + protected QuicBaseCongestionController(TimeLine source, QuicRttEstimator rttEstimator) { + this.dbgTag = "TEST"; + this.timeSource = source; + this.pacer = new QuicPacer(rttEstimator, this); + } + protected boolean inCongestionRecovery(Deadline sentTime) { return (congestionRecoveryStartTime != null && !sentTime.isAfter(congestionRecoveryStartTime)); diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 3528036f40719..4f3cc54452169 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -27,6 +27,7 @@ import jdk.internal.net.http.common.Deadline; import jdk.internal.net.http.common.Log; +import jdk.internal.net.http.common.TimeLine; import java.util.concurrent.TimeUnit; @@ -61,6 +62,12 @@ public QuicCubicCongestionController(String dbgTag, QuicRttEstimator rttEstimato this.rttEstimator = rttEstimator; } + // for testing + public QuicCubicCongestionController(TimeLine source, QuicRttEstimator rttEstimator) { + super(source, rttEstimator); + this.rttEstimator = rttEstimator; + } + @Override public void packetSent(int packetBytes) { lock.lock(); diff --git a/test/jdk/java/net/httpclient/quic/CubicTest.java b/test/jdk/java/net/httpclient/quic/CubicTest.java index d824138c3319f..237bf16a60afb 100644 --- a/test/jdk/java/net/httpclient/quic/CubicTest.java +++ b/test/jdk/java/net/httpclient/quic/CubicTest.java @@ -22,7 +22,7 @@ */ import jdk.internal.net.http.common.Deadline; -import jdk.internal.net.http.common.TimeSource; +import jdk.internal.net.http.common.TimeLine; import jdk.internal.net.http.quic.*; import jdk.internal.net.http.quic.frames.PaddingFrame; import jdk.internal.net.http.quic.frames.QuicFrame; @@ -30,6 +30,7 @@ import org.testng.annotations.Test; import java.time.temporal.ChronoUnit; +import java.time.temporal.TemporalUnit; import java.util.List; import static jdk.internal.net.http.quic.QuicCubicCongestionController.ALPHA; @@ -41,7 +42,22 @@ * @run testng/othervm -Djdk.httpclient.HttpClient.log=trace,quic:cc CubicTest */ public class CubicTest { - private TimeSource timeSource = TimeSource.source(); + static class TimeSource implements TimeLine { + final Deadline first = jdk.internal.net.http.common.TimeSource.now(); + volatile Deadline current = first; + public synchronized Deadline advance(long duration, TemporalUnit unit) { + return current = current.plus(duration, unit); + } + public Deadline advanceMillis(long millis) { + return advance(millis, ChronoUnit.MILLIS); + } + @Override + public Deadline instant() { + return current; + } + } + + private final TimeSource timeSource = new TimeSource(); private class TestQuicPacket implements QuicPacket { private final int size; @@ -86,7 +102,7 @@ public PacketType packetType() { public void testReduction() { QuicRttEstimator rtt = new QuicRttEstimator(); rtt.consumeRttSample(1, 0, Deadline.MIN); - QuicCongestionController cc = new QuicCubicCongestionController("TEST", rtt); + QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); do { @@ -102,7 +118,7 @@ public void testReduction() { public void testAppLimited() { QuicRttEstimator rtt = new QuicRttEstimator(); rtt.consumeRttSample(1, 0, Deadline.MIN); - QuicCongestionController cc = new QuicCubicCongestionController("TEST", rtt); + QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); cc.packetSent(packetSize); @@ -121,7 +137,7 @@ public void testAppLimited() { public void testRenoFriendly() { QuicRttEstimator rtt = new QuicRttEstimator(); rtt.consumeRttSample(1, 0, Deadline.MIN); - QuicCongestionController cc = new QuicCubicCongestionController("TEST", rtt); + QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); cc.packetSent(packetSize); @@ -130,7 +146,7 @@ public void testRenoFriendly() { cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); // enter cwnd-limited state to start increasing cwnd - Deadline sentTime = timeSource.instant().plus(1, ChronoUnit.NANOS); + Deadline sentTime = timeSource.advanceMillis(1); int numPackets = 0; while (!cc.isCwndLimited()) { cc.packetSent(packetSize); From 2d82033bf0fc60bee2d38cecd3e33db7d2f29492 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 11:28:16 +0100 Subject: [PATCH 12/29] Test the cubic curve --- .../java/net/httpclient/quic/CubicTest.java | 38 +++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/test/jdk/java/net/httpclient/quic/CubicTest.java b/test/jdk/java/net/httpclient/quic/CubicTest.java index 237bf16a60afb..c23988bedc337 100644 --- a/test/jdk/java/net/httpclient/quic/CubicTest.java +++ b/test/jdk/java/net/httpclient/quic/CubicTest.java @@ -188,4 +188,42 @@ public void testRenoFriendly() { actualCwnd, expectedCwnd - numPackets, expectedCwnd + numPackets )); } + + @Test + public void testCubic() { + /* + Manually created test vector: + - ramp up the congestion window to 36 packets + - trigger congestion; window will be reduced to 25.2 packets, K=3 seconds + - to make things easier, set RTT = 3+ seconds, advance "t" to 3 seconds, + send and acknowledge a whole cwnd of data + - cwnd should be back to 36 packets, give or take a few bytes. + */ + QuicRttEstimator rtt = new QuicRttEstimator(); + rtt.consumeRttSample(4_000_000, 0, Deadline.MIN); + QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); + int packetSize = (int) cc.maxDatagramSize(); + long cwnd = cc.congestionWindow(); + // ramp up the congestion window to 36 packets + int tmp = (int) (36 * packetSize - cwnd); + cc.packetSent(tmp + packetSize); + cc.packetAcked(tmp, timeSource.instant()); + assertEquals(cc.congestionWindow(), 36*packetSize, "Unexpected congestion window"); + long newCongestionWindow = (long) (QuicCubicCongestionController.BETA * cc.congestionWindow()); + // trigger congestion; window will be reduced to 25.2 packets, K=3 seconds + cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); + assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + // advance "t" to 3 seconds, + Deadline sentTime = timeSource.advanceMillis(3000); + // send and acknowledge a whole cwnd of data + tmp = (int) cc.congestionWindow(); + cc.packetSent(tmp); + cc.packetAcked(tmp, sentTime); + long expectedCwnd = 36 * packetSize; + long actualCwnd = cc.congestionWindow(); + assertTrue(actualCwnd >= expectedCwnd - 1 && actualCwnd <= expectedCwnd + 1, + "actual cwnd %s not within the expected range (%s, %s)".formatted( + actualCwnd, expectedCwnd - 1, expectedCwnd + 1 + )); + } } From 8ecbf0ddb174ff23fbd8b1ba9ca1a351552ec91d Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 11:29:59 +0100 Subject: [PATCH 13/29] Revert: more aggressive window increases --- .../net/http/quic/QuicCubicCongestionController.java | 6 +++--- .../net/http/quic/QuicRenoCongestionController.java | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 4f3cc54452169..391d5c73a4b77 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -113,16 +113,16 @@ protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { if (!isAppLimited) { // C * (t-K [seconds])^3 + Wmax (segments) if (wEstBytes < cwndPriorBytes) { - wEstBytes += Math.max((long) (ALPHA * maxDatagramSize * packetBytes / maxBytesInFlight), 1); + wEstBytes += Math.max((long) (ALPHA * maxDatagramSize * packetBytes / congestionWindow), 1); } else { - wEstBytes += Math.max((long)maxDatagramSize * packetBytes / maxBytesInFlight, 1); + wEstBytes += Math.max((long)maxDatagramSize * packetBytes / congestionWindow, 1); } long targetBytes = (long)(C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; if (targetBytes > 1.5 * congestionWindow) { targetBytes = (long) (1.5 * congestionWindow); } if (targetBytes > congestionWindow) { - congestionWindow += Math.max((targetBytes - congestionWindow) * packetBytes / maxBytesInFlight, 1L); + congestionWindow += Math.max((targetBytes - congestionWindow) * packetBytes / congestionWindow, 1L); } if (wEstBytes > congestionWindow) { congestionWindow = wEstBytes; diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index a4f50984a758c..aae47337ac281 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -46,7 +46,7 @@ protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { boolean isAppLimited; isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; if (!isAppLimited) { - congestionWindow += Math.max((long) maxDatagramSize * packetBytes / maxBytesInFlight, 1L); + congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); } return isAppLimited; } From 060678cc03b61d579f772cc4c652935dbd809003 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 12:16:06 +0100 Subject: [PATCH 14/29] Update test --- .../java/net/httpclient/quic/CubicTest.java | 67 ++++++++----------- 1 file changed, 29 insertions(+), 38 deletions(-) diff --git a/test/jdk/java/net/httpclient/quic/CubicTest.java b/test/jdk/java/net/httpclient/quic/CubicTest.java index c23988bedc337..c378ef4fd234e 100644 --- a/test/jdk/java/net/httpclient/quic/CubicTest.java +++ b/test/jdk/java/net/httpclient/quic/CubicTest.java @@ -100,6 +100,7 @@ public PacketType packetType() { @Test public void testReduction() { + System.err.println("***** testReduction *****"); QuicRttEstimator rtt = new QuicRttEstimator(); rtt.consumeRttSample(1, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); @@ -116,6 +117,7 @@ public void testReduction() { @Test public void testAppLimited() { + System.err.println("***** testAppLimited *****"); QuicRttEstimator rtt = new QuicRttEstimator(); rtt.consumeRttSample(1, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); @@ -135,57 +137,43 @@ public void testAppLimited() { @Test public void testRenoFriendly() { + System.err.println("***** testRenoFriendly *****"); QuicRttEstimator rtt = new QuicRttEstimator(); rtt.consumeRttSample(1, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); + int startingWindow = (int) cc.congestionWindow(); + // lose packet to exit slow start cc.packetSent(packetSize); long newCongestionWindow = (long) (QuicCubicCongestionController.BETA * cc.congestionWindow()); - // lose packet to exit slow start cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); - // enter cwnd-limited state to start increasing cwnd + // exit loss recovery to start increasing cwnd Deadline sentTime = timeSource.advanceMillis(1); - int numPackets = 0; - while (!cc.isCwndLimited()) { - cc.packetSent(packetSize); - numPackets++; - } - // test that the window increases roughly by ALPHA * maxDatagramSize every RTT - long startingCwnd = cc.congestionWindow(); - for (int i = 0; i < numPackets; i++) { - cc.packetAcked(packetSize, sentTime); - } - long expectedCwnd = (long) (startingCwnd + ALPHA * packetSize); - long actualCwnd = cc.congestionWindow(); - assertTrue(actualCwnd > expectedCwnd - numPackets && actualCwnd < expectedCwnd + numPackets, - "actual cwnd %s not within the expected range (%s, %s)".formatted( - actualCwnd, expectedCwnd - numPackets, expectedCwnd + numPackets - )); - numPackets = 0; do { - while (!cc.isCwndLimited()) { - cc.packetSent(packetSize); - numPackets++; - } - cc.packetAcked(packetSize, sentTime); - numPackets--; - } while (cc.congestionWindow() < cc.initialWindow()); - while (!cc.isCwndLimited()) { - cc.packetSent(packetSize); - numPackets++; - } + // test that the window increases roughly by ALPHA * maxDatagramSize every RTT + int startingCwnd = (int) cc.congestionWindow(); + cc.packetSent(startingCwnd); + // we ack the entire window in one call; in practice the increase will be slower + // because cwnd increases (and increase rate reduces) after every call to packetAcked + cc.packetAcked(startingCwnd, sentTime); + long expectedCwnd = (long) (startingCwnd + ALPHA * packetSize); + long actualCwnd = cc.congestionWindow(); + assertTrue(actualCwnd >= expectedCwnd - 1 && actualCwnd <= expectedCwnd + 1, + "actual cwnd %s not within the expected range (%s, %s)".formatted( + actualCwnd, expectedCwnd - 1, expectedCwnd + 1 + )); + } while (cc.congestionWindow() < startingWindow); // test that the window increases roughly by maxDatagramSize every RTT after passing cwndPrior - startingCwnd = cc.congestionWindow(); - for (int i = 0; i < numPackets; i++) { - cc.packetAcked(packetSize, sentTime); - } - expectedCwnd = startingCwnd + packetSize; - actualCwnd = cc.congestionWindow(); - assertTrue(actualCwnd > expectedCwnd - numPackets && actualCwnd < expectedCwnd + numPackets, + int startingCwnd = (int) cc.congestionWindow(); + cc.packetSent(startingCwnd); + cc.packetAcked(startingCwnd, sentTime); + int expectedCwnd = startingCwnd + packetSize; + long actualCwnd = cc.congestionWindow(); + assertTrue(actualCwnd >= expectedCwnd - 1 && actualCwnd <= expectedCwnd + 1, "actual cwnd %s not within the expected range (%s, %s)".formatted( - actualCwnd, expectedCwnd - numPackets, expectedCwnd + numPackets + actualCwnd, expectedCwnd - 1, expectedCwnd + 1 )); } @@ -199,6 +187,7 @@ public void testCubic() { send and acknowledge a whole cwnd of data - cwnd should be back to 36 packets, give or take a few bytes. */ + System.err.println("***** testCubic *****"); QuicRttEstimator rtt = new QuicRttEstimator(); rtt.consumeRttSample(4_000_000, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); @@ -218,6 +207,8 @@ public void testCubic() { // send and acknowledge a whole cwnd of data tmp = (int) cc.congestionWindow(); cc.packetSent(tmp); + // we ack the entire window in one call; in practice the increase will be slower + // because cwnd increases (and increase rate reduces) after every call to packetAcked cc.packetAcked(tmp, sentTime); long expectedCwnd = 36 * packetSize; long actualCwnd = cc.congestionWindow(); From f1fdc21e84dd5532d2d817357a2d53e0f0fb460c Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 12:45:03 +0100 Subject: [PATCH 15/29] Add comments --- .../http/quic/QuicCubicCongestionController.java | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 391d5c73a4b77..f939d7ed1a62f 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -111,15 +111,22 @@ protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { boolean isAppLimited; isAppLimited = sentTime.isAfter(lastFullWindow); if (!isAppLimited) { - // C * (t-K [seconds])^3 + Wmax (segments) if (wEstBytes < cwndPriorBytes) { wEstBytes += Math.max((long) (ALPHA * maxDatagramSize * packetBytes / congestionWindow), 1); } else { wEstBytes += Math.max((long)maxDatagramSize * packetBytes / congestionWindow, 1); } - long targetBytes = (long)(C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; - if (targetBytes > 1.5 * congestionWindow) { + // Wcubic(t) = C * (t-K [seconds])^3 + Wmax (segments) + // target = Wcubic(t) + // this is less aggressive than RFC 9438, which uses target=Wcubic(t+RTT), + // but seems to work well enough + double dblTargetBytes = (C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; + long targetBytes; + // not sure if dblTarget can overflow a long, but 1.5 congestionWindow can not. + if (dblTargetBytes > 1.5 * congestionWindow) { targetBytes = (long) (1.5 * congestionWindow); + } else { + targetBytes = (long)dblTargetBytes; } if (targetBytes > congestionWindow) { congestionWindow += Math.max((targetBytes - congestionWindow) * packetBytes / congestionWindow, 1L); From eda81c76e738f763fe108919883cdf49b0575e55 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 12:47:52 +0100 Subject: [PATCH 16/29] Implement fast convergence --- .../net/http/quic/QuicCubicCongestionController.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index f939d7ed1a62f..9eebc0b6cfb61 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -142,8 +142,12 @@ protected void onCongestionEvent(Deadline sentTime) { if (inCongestionRecovery(sentTime)) { return; } - // TODO implement fast convergence (RFC 9438 section 4.7) - wMaxBytes = congestionWindow; + if (congestionWindow < wMaxBytes) { + // fast convergence + wMaxBytes = (long) ((1 + BETA) * congestionWindow / 2); + } else { + wMaxBytes = congestionWindow; + } cwndPriorBytes = congestionWindow; congestionRecoveryStartTime = timeSource.instant(); ssThresh = (long)(congestionWindow * BETA); From b95950f2cd70b34565f73642675ec9b6a800bbe9 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 7 Nov 2025 14:06:17 +0100 Subject: [PATCH 17/29] Add a system property to select congestion controller --- .../internal/net/http/quic/QuicConnectionImpl.java | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java index 5af90c69e53b3..33e9f5ef6566a 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java @@ -334,7 +334,7 @@ protected QuicConnectionImpl(final QuicVersion firstFlightVersion, this.connectionId = this.endpoint.idFactory().newConnectionId(); this.logTag = logTagFormat.formatted(labelId); this.dbgTag = dbgTag(quicInstance, logTag); - this.congestionController = new QuicCubicCongestionController(dbgTag, rttEstimator); + this.congestionController = createCongestionController(dbgTag, rttEstimator); this.originalVersion = this.quicVersion = firstFlightVersion == null ? QuicVersion.firstFlightVersion(quicInstance.getAvailableVersions()) : firstFlightVersion; @@ -366,6 +366,16 @@ protected QuicConnectionImpl(final QuicVersion firstFlightVersion, if (debug.on()) debug.log("Quic Connection Created"); } + private static QuicCongestionController createCongestionController + (String dbgTag, QuicRttEstimator rttEstimator) { + String algo = System.getProperty("jdk.httpclient.quic.congestionController", "cubic"); + if (algo.equalsIgnoreCase("reno")) { + return new QuicRenoCongestionController(dbgTag, rttEstimator); + } else { + return new QuicCubicCongestionController(dbgTag, rttEstimator); + } + } + @Override public final long uniqueId() { return labelId; From 894a3944c5e48cf178be54ed0f13ef72e2d4bce9 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 12 Nov 2025 12:32:59 +0100 Subject: [PATCH 18/29] Rename system property to internal --- .../classes/jdk/internal/net/http/quic/QuicConnectionImpl.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java index 33e9f5ef6566a..ad477d1637fa0 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicConnectionImpl.java @@ -368,7 +368,7 @@ protected QuicConnectionImpl(final QuicVersion firstFlightVersion, private static QuicCongestionController createCongestionController (String dbgTag, QuicRttEstimator rttEstimator) { - String algo = System.getProperty("jdk.httpclient.quic.congestionController", "cubic"); + String algo = System.getProperty("jdk.internal.httpclient.quic.congestionController", "cubic"); if (algo.equalsIgnoreCase("reno")) { return new QuicRenoCongestionController(dbgTag, rttEstimator); } else { From fde4d8658ca843d2e8185b07b0ed6eb67def3e94 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 12 Nov 2025 12:43:58 +0100 Subject: [PATCH 19/29] Make classes final --- .../internal/net/http/quic/QuicCubicCongestionController.java | 2 +- .../internal/net/http/quic/QuicRenoCongestionController.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 9eebc0b6cfb61..527feab7d93e7 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -38,7 +38,7 @@ * @spec https://www.rfc-editor.org/rfc/rfc9438.html * RFC 9438: CUBIC for Fast and Long-Distance Networks */ -public class QuicCubicCongestionController extends QuicBaseCongestionController { +public final class QuicCubicCongestionController extends QuicBaseCongestionController { public static final double BETA = 0.7; public static final double ALPHA = 3 * (1 - BETA) / (1 + BETA); diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index aae47337ac281..0a619020114c6 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -37,7 +37,7 @@ * @spec https://www.rfc-editor.org/info/rfc9002 * RFC 9002: QUIC Loss Detection and Congestion Control */ -class QuicRenoCongestionController extends QuicBaseCongestionController { +final class QuicRenoCongestionController extends QuicBaseCongestionController { public QuicRenoCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { super(dbgTag, rttEstimator); } From 74b80ebb672eb09f5b5bca929fd691131ec17bd2 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 12 Nov 2025 15:06:35 +0100 Subject: [PATCH 20/29] More aggressive target growth --- .../http/quic/QuicCubicCongestionController.java | 16 +++++++++------- test/jdk/java/net/httpclient/quic/CubicTest.java | 4 ++-- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 527feab7d93e7..cd218b6fdcede 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -108,19 +108,16 @@ in the last RTT (which is a pretty good approximation because of pacing), protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { - boolean isAppLimited; - isAppLimited = sentTime.isAfter(lastFullWindow); + boolean isAppLimited = sentTime.isAfter(lastFullWindow); if (!isAppLimited) { if (wEstBytes < cwndPriorBytes) { wEstBytes += Math.max((long) (ALPHA * maxDatagramSize * packetBytes / congestionWindow), 1); } else { wEstBytes += Math.max((long)maxDatagramSize * packetBytes / congestionWindow, 1); } - // Wcubic(t) = C * (t-K [seconds])^3 + Wmax (segments) - // target = Wcubic(t) - // this is less aggressive than RFC 9438, which uses target=Wcubic(t+RTT), - // but seems to work well enough - double dblTargetBytes = (C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; + // target = Wcubic(t + RTT) + long rttNanos = TimeUnit.MICROSECONDS.toNanos(rttEstimator.state().smoothedRttMicros()); + double dblTargetBytes = wCubicBytes(timeNanos + rttNanos); long targetBytes; // not sure if dblTarget can overflow a long, but 1.5 congestionWindow can not. if (dblTargetBytes > 1.5 * congestionWindow) { @@ -138,6 +135,11 @@ protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { return isAppLimited; } + // Wcubic(t) = C * (t-K [seconds])^3 + Wmax (segments) + private double wCubicBytes(long timeNanos) { + return (C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; + } + protected void onCongestionEvent(Deadline sentTime) { if (inCongestionRecovery(sentTime)) { return; diff --git a/test/jdk/java/net/httpclient/quic/CubicTest.java b/test/jdk/java/net/httpclient/quic/CubicTest.java index c378ef4fd234e..fe58a4ce11d66 100644 --- a/test/jdk/java/net/httpclient/quic/CubicTest.java +++ b/test/jdk/java/net/httpclient/quic/CubicTest.java @@ -189,7 +189,7 @@ public void testCubic() { */ System.err.println("***** testCubic *****"); QuicRttEstimator rtt = new QuicRttEstimator(); - rtt.consumeRttSample(4_000_000, 0, Deadline.MIN); + rtt.consumeRttSample(1_500_000, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); long cwnd = cc.congestionWindow(); @@ -203,7 +203,7 @@ public void testCubic() { cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); // advance "t" to 3 seconds, - Deadline sentTime = timeSource.advanceMillis(3000); + Deadline sentTime = timeSource.advanceMillis(1500); // send and acknowledge a whole cwnd of data tmp = (int) cc.congestionWindow(); cc.packetSent(tmp); From d4e3e60de4593c09c068e41632b78d2a29b0c766 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 12 Nov 2025 15:19:43 +0100 Subject: [PATCH 21/29] Merge declaration and assignment --- .../internal/net/http/quic/QuicRenoCongestionController.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index 0a619020114c6..56a63635d380a 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -43,8 +43,7 @@ public QuicRenoCongestionController(String dbgTag, QuicRttEstimator rttEstimator } protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { - boolean isAppLimited; - isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; + boolean isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; if (!isAppLimited) { congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); } From ff7ecf6b1a1d2e124e23c52743b2d64c6a05ab04 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 12 Nov 2025 15:36:42 +0100 Subject: [PATCH 22/29] Convert CubicTest to JUnit --- .../java/net/httpclient/quic/CubicTest.java | 43 ++++++++----------- 1 file changed, 18 insertions(+), 25 deletions(-) diff --git a/test/jdk/java/net/httpclient/quic/CubicTest.java b/test/jdk/java/net/httpclient/quic/CubicTest.java index fe58a4ce11d66..8012bc619c4a1 100644 --- a/test/jdk/java/net/httpclient/quic/CubicTest.java +++ b/test/jdk/java/net/httpclient/quic/CubicTest.java @@ -27,19 +27,18 @@ import jdk.internal.net.http.quic.frames.PaddingFrame; import jdk.internal.net.http.quic.frames.QuicFrame; import jdk.internal.net.http.quic.packets.QuicPacket; -import org.testng.annotations.Test; +import org.junit.jupiter.api.Test; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalUnit; import java.util.List; import static jdk.internal.net.http.quic.QuicCubicCongestionController.ALPHA; -import static org.testng.Assert.assertEquals; -import static org.testng.Assert.assertTrue; +import static org.junit.jupiter.api.Assertions.assertEquals; /* * @test - * @run testng/othervm -Djdk.httpclient.HttpClient.log=trace,quic:cc CubicTest + * @run junit/othervm -Djdk.httpclient.HttpClient.log=trace,quic:cc CubicTest */ public class CubicTest { static class TimeSource implements TimeLine { @@ -105,13 +104,13 @@ public void testReduction() { rtt.consumeRttSample(1, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); - assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); + assertEquals(cc.initialWindow(), cc.congestionWindow(), "Unexpected starting congestion window"); do { cc.packetSent(packetSize); // reduce to 70% of the last value, but not below 2*SMSS long newCongestionWindow = Math.max((long) (QuicCubicCongestionController.BETA * cc.congestionWindow()), 2 * packetSize); cc.packetLost(List.of(new TestQuicPacket(packetSize)), Deadline.MAX, false); - assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + assertEquals(newCongestionWindow, cc.congestionWindow(), "Unexpected reduced congestion window"); } while (cc.congestionWindow() > 2 * packetSize); } @@ -122,17 +121,17 @@ public void testAppLimited() { rtt.consumeRttSample(1, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); - assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); + assertEquals(cc.initialWindow(), cc.congestionWindow(), "Unexpected starting congestion window"); cc.packetSent(packetSize); long newCongestionWindow = (long) (QuicCubicCongestionController.BETA * cc.congestionWindow()); // lose packet to exit slow start cc.packetLost(List.of(new TestQuicPacket(packetSize)), Deadline.MAX, false); - assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + assertEquals(newCongestionWindow, cc.congestionWindow(), "Unexpected reduced congestion window"); Deadline sentTime = timeSource.instant().plus(1, ChronoUnit.NANOS); // congestion window should not increase when sender is app-limited cc.packetSent(packetSize); cc.packetAcked(packetSize, sentTime); - assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected congestion window change"); + assertEquals(newCongestionWindow, cc.congestionWindow(), "Unexpected congestion window change"); } @Test @@ -142,13 +141,13 @@ public void testRenoFriendly() { rtt.consumeRttSample(1, 0, Deadline.MIN); QuicCongestionController cc = new QuicCubicCongestionController(timeSource, rtt); int packetSize = (int) cc.maxDatagramSize(); - assertEquals(cc.congestionWindow(), cc.initialWindow(), "Unexpected starting congestion window"); + assertEquals(cc.initialWindow(), cc.congestionWindow(), "Unexpected starting congestion window"); int startingWindow = (int) cc.congestionWindow(); // lose packet to exit slow start cc.packetSent(packetSize); long newCongestionWindow = (long) (QuicCubicCongestionController.BETA * cc.congestionWindow()); cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); - assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + assertEquals(newCongestionWindow, cc.congestionWindow(), "Unexpected reduced congestion window"); // exit loss recovery to start increasing cwnd Deadline sentTime = timeSource.advanceMillis(1); do { @@ -160,10 +159,8 @@ public void testRenoFriendly() { cc.packetAcked(startingCwnd, sentTime); long expectedCwnd = (long) (startingCwnd + ALPHA * packetSize); long actualCwnd = cc.congestionWindow(); - assertTrue(actualCwnd >= expectedCwnd - 1 && actualCwnd <= expectedCwnd + 1, - "actual cwnd %s not within the expected range (%s, %s)".formatted( - actualCwnd, expectedCwnd - 1, expectedCwnd + 1 - )); + assertEquals(expectedCwnd, actualCwnd, 1.0, + "actual cwnd not within the expected range"); } while (cc.congestionWindow() < startingWindow); // test that the window increases roughly by maxDatagramSize every RTT after passing cwndPrior int startingCwnd = (int) cc.congestionWindow(); @@ -171,10 +168,8 @@ public void testRenoFriendly() { cc.packetAcked(startingCwnd, sentTime); int expectedCwnd = startingCwnd + packetSize; long actualCwnd = cc.congestionWindow(); - assertTrue(actualCwnd >= expectedCwnd - 1 && actualCwnd <= expectedCwnd + 1, - "actual cwnd %s not within the expected range (%s, %s)".formatted( - actualCwnd, expectedCwnd - 1, expectedCwnd + 1 - )); + assertEquals(expectedCwnd, actualCwnd, 1.0, + "actual cwnd not within the expected range"); } @Test @@ -197,11 +192,11 @@ public void testCubic() { int tmp = (int) (36 * packetSize - cwnd); cc.packetSent(tmp + packetSize); cc.packetAcked(tmp, timeSource.instant()); - assertEquals(cc.congestionWindow(), 36*packetSize, "Unexpected congestion window"); + assertEquals(36*packetSize, cc.congestionWindow(), "Unexpected congestion window"); long newCongestionWindow = (long) (QuicCubicCongestionController.BETA * cc.congestionWindow()); // trigger congestion; window will be reduced to 25.2 packets, K=3 seconds cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); - assertEquals(cc.congestionWindow(), newCongestionWindow, "Unexpected reduced congestion window"); + assertEquals(newCongestionWindow, cc.congestionWindow(), "Unexpected reduced congestion window"); // advance "t" to 3 seconds, Deadline sentTime = timeSource.advanceMillis(1500); // send and acknowledge a whole cwnd of data @@ -212,9 +207,7 @@ public void testCubic() { cc.packetAcked(tmp, sentTime); long expectedCwnd = 36 * packetSize; long actualCwnd = cc.congestionWindow(); - assertTrue(actualCwnd >= expectedCwnd - 1 && actualCwnd <= expectedCwnd + 1, - "actual cwnd %s not within the expected range (%s, %s)".formatted( - actualCwnd, expectedCwnd - 1, expectedCwnd + 1 - )); + assertEquals(expectedCwnd, actualCwnd, 1.0, + "actual cwnd not within the expected range"); } } From 195b0f894ce825e9063996f04e055424bdf6e7a0 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Wed, 12 Nov 2025 16:01:28 +0100 Subject: [PATCH 23/29] Update test comments --- test/jdk/java/net/httpclient/quic/CubicTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/jdk/java/net/httpclient/quic/CubicTest.java b/test/jdk/java/net/httpclient/quic/CubicTest.java index 8012bc619c4a1..f4b08e6d9b7eb 100644 --- a/test/jdk/java/net/httpclient/quic/CubicTest.java +++ b/test/jdk/java/net/httpclient/quic/CubicTest.java @@ -178,7 +178,7 @@ public void testCubic() { Manually created test vector: - ramp up the congestion window to 36 packets - trigger congestion; window will be reduced to 25.2 packets, K=3 seconds - - to make things easier, set RTT = 3+ seconds, advance "t" to 3 seconds, + - set RTT = 1.5 seconds, advance "t" to 1.5 seconds, send and acknowledge a whole cwnd of data - cwnd should be back to 36 packets, give or take a few bytes. */ @@ -197,7 +197,7 @@ public void testCubic() { // trigger congestion; window will be reduced to 25.2 packets, K=3 seconds cc.packetLost(List.of(new TestQuicPacket(packetSize)), timeSource.instant(), false); assertEquals(newCongestionWindow, cc.congestionWindow(), "Unexpected reduced congestion window"); - // advance "t" to 3 seconds, + // advance "t" to 1.5 seconds, Deadline sentTime = timeSource.advanceMillis(1500); // send and acknowledge a whole cwnd of data tmp = (int) cc.congestionWindow(); From 2a1b973cb5372f39b64ef105d79465a27c658715 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Mon, 17 Nov 2025 11:34:00 +0100 Subject: [PATCH 24/29] remove useless protected keyword --- .../quic/QuicBaseCongestionController.java | 30 +++++++++---------- .../quic/QuicCubicCongestionController.java | 4 +-- .../quic/QuicRenoCongestionController.java | 4 +-- 3 files changed, 19 insertions(+), 19 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java index f6cda2a65c314..82b56a92b0dec 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java @@ -56,39 +56,39 @@ abstract class QuicBaseCongestionController implements QuicCongestionController private static final int MAX_BYTES_IN_FLIGHT = Math.clamp( Utils.getLongProperty("jdk.httpclient.quic.maxBytesInFlight", 1 << 24), 1 << 14, 1 << 24); - protected final TimeLine timeSource; - protected final String dbgTag; - protected final Lock lock = new ReentrantLock(); - protected long congestionWindow = INITIAL_WINDOW; - protected int maxDatagramSize = QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE; - protected int minimumWindow = 2 * maxDatagramSize; - protected long bytesInFlight; + final TimeLine timeSource; + final String dbgTag; + final Lock lock = new ReentrantLock(); + long congestionWindow = INITIAL_WINDOW; + int maxDatagramSize = QuicConnectionImpl.DEFAULT_DATAGRAM_SIZE; + int minimumWindow = 2 * maxDatagramSize; + long bytesInFlight; // maximum bytes in flight seen since the last congestion event - protected long maxBytesInFlight; - protected Deadline congestionRecoveryStartTime; - protected long ssThresh = Long.MAX_VALUE; + long maxBytesInFlight; + Deadline congestionRecoveryStartTime; + long ssThresh = Long.MAX_VALUE; private final QuicPacer pacer; - protected QuicBaseCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { + QuicBaseCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { this.dbgTag = dbgTag; this.timeSource = TimeSource.source(); this.pacer = new QuicPacer(rttEstimator, this); } // for testing - protected QuicBaseCongestionController(TimeLine source, QuicRttEstimator rttEstimator) { + QuicBaseCongestionController(TimeLine source, QuicRttEstimator rttEstimator) { this.dbgTag = "TEST"; this.timeSource = source; this.pacer = new QuicPacer(rttEstimator, this); } - protected boolean inCongestionRecovery(Deadline sentTime) { + boolean inCongestionRecovery(Deadline sentTime) { return (congestionRecoveryStartTime != null && !sentTime.isAfter(congestionRecoveryStartTime)); } - protected abstract void onCongestionEvent(Deadline sentTime); + abstract void onCongestionEvent(Deadline sentTime); private static boolean inFlight(QuicPacket packet) { // packet is in flight if it contains anything other than a single ACK frame @@ -181,7 +181,7 @@ public void packetAcked(int packetBytes, Deadline sentTime) { } } - protected abstract boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime); + abstract boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime); @Override public void packetLost(Collection lostPackets, Deadline sentTime, boolean persistent) { diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index cd218b6fdcede..100c216479265 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -107,7 +107,7 @@ in the last RTT (which is a pretty good approximation because of pacing), } - protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { + boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { boolean isAppLimited = sentTime.isAfter(lastFullWindow); if (!isAppLimited) { if (wEstBytes < cwndPriorBytes) { @@ -140,7 +140,7 @@ private double wCubicBytes(long timeNanos) { return (C * maxDatagramSize * Math.pow((timeNanos - kNanos) / 1e9, 3)) + wMaxBytes; } - protected void onCongestionEvent(Deadline sentTime) { + void onCongestionEvent(Deadline sentTime) { if (inCongestionRecovery(sentTime)) { return; } diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java index 56a63635d380a..2594c00055f14 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicRenoCongestionController.java @@ -42,7 +42,7 @@ public QuicRenoCongestionController(String dbgTag, QuicRttEstimator rttEstimator super(dbgTag, rttEstimator); } - protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { + boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { boolean isAppLimited = congestionWindow > maxBytesInFlight + 2L * maxDatagramSize; if (!isAppLimited) { congestionWindow += Math.max((long) maxDatagramSize * packetBytes / congestionWindow, 1L); @@ -50,7 +50,7 @@ protected boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { return isAppLimited; } - protected void onCongestionEvent(Deadline sentTime) { + void onCongestionEvent(Deadline sentTime) { if (inCongestionRecovery(sentTime)) { return; } From 56db9507f807bd9f0758a83323d903115d2f2500 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Mon, 17 Nov 2025 13:16:08 +0100 Subject: [PATCH 25/29] Refactor QuicBaseCC constructor --- .../net/http/quic/QuicBaseCongestionController.java | 10 ++++------ .../net/http/quic/QuicCubicCongestionController.java | 2 +- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java index 82b56a92b0dec..b56db9fca71c4 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java @@ -71,14 +71,12 @@ abstract class QuicBaseCongestionController implements QuicCongestionController private final QuicPacer pacer; QuicBaseCongestionController(String dbgTag, QuicRttEstimator rttEstimator) { - this.dbgTag = dbgTag; - this.timeSource = TimeSource.source(); - this.pacer = new QuicPacer(rttEstimator, this); + this(dbgTag, TimeSource.source(), rttEstimator); } - // for testing - QuicBaseCongestionController(TimeLine source, QuicRttEstimator rttEstimator) { - this.dbgTag = "TEST"; + // Allows to pass a custom timeline for testing + QuicBaseCongestionController(String dbgTag, TimeLine source, QuicRttEstimator rttEstimator) { + this.dbgTag = dbgTag; this.timeSource = source; this.pacer = new QuicPacer(rttEstimator, this); } diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 100c216479265..8b3c741b8d97c 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -64,7 +64,7 @@ public QuicCubicCongestionController(String dbgTag, QuicRttEstimator rttEstimato // for testing public QuicCubicCongestionController(TimeLine source, QuicRttEstimator rttEstimator) { - super(source, rttEstimator); + super("TEST", source, rttEstimator); this.rttEstimator = rttEstimator; } From 58708ad8c2a938dfd5b8bed15d1da3c5ec4a9db1 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Mon, 17 Nov 2025 15:42:20 +0100 Subject: [PATCH 26/29] Refactor target calculations --- .../net/http/quic/QuicCubicCongestionController.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index 8b3c741b8d97c..de5bc7437dcec 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -118,15 +118,13 @@ boolean congestionAvoidanceAcked(int packetBytes, Deadline sentTime) { // target = Wcubic(t + RTT) long rttNanos = TimeUnit.MICROSECONDS.toNanos(rttEstimator.state().smoothedRttMicros()); double dblTargetBytes = wCubicBytes(timeNanos + rttNanos); - long targetBytes; - // not sure if dblTarget can overflow a long, but 1.5 congestionWindow can not. - if (dblTargetBytes > 1.5 * congestionWindow) { - targetBytes = (long) (1.5 * congestionWindow); - } else { - targetBytes = (long)dblTargetBytes; - } + assert dblTargetBytes > 0 : "Unexpected negative target bytes"; + long targetBytes = (long) Math.min(dblTargetBytes, 1.5 * congestionWindow); if (targetBytes > congestionWindow) { + long oldWindow = congestionWindow; congestionWindow += Math.max((targetBytes - congestionWindow) * packetBytes / congestionWindow, 1L); + assert congestionWindow > oldWindow : + "Window size decreased: %s to %s".formatted(oldWindow, congestionWindow); } if (wEstBytes > congestionWindow) { congestionWindow = wEstBytes; From 32160ef91088020a9b03a1964ab2f499193f3e68 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Mon, 17 Nov 2025 16:57:55 +0100 Subject: [PATCH 27/29] Add test coverage for Reno --- .../H3MultipleConnectionsToSameHost.java | 25 +++++++++++++++++++ .../net/httpclient/http3/H3SimpleGet.java | 12 +++++++++ .../net/httpclient/http3/H3SimpleTest.java | 7 +++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/test/jdk/java/net/httpclient/http3/H3MultipleConnectionsToSameHost.java b/test/jdk/java/net/httpclient/http3/H3MultipleConnectionsToSameHost.java index 45024c58e1f57..34d5163760f01 100644 --- a/test/jdk/java/net/httpclient/http3/H3MultipleConnectionsToSameHost.java +++ b/test/jdk/java/net/httpclient/http3/H3MultipleConnectionsToSameHost.java @@ -94,6 +94,31 @@ * limit is artificially low, in order to cause concurrent connections * to the same host to be created, with non-blocking IO and selector */ +/* + * @test id=reno-cc + * @bug 8087112 + * @library /test/lib /test/jdk/java/net/httpclient/lib + * @build jdk.test.lib.net.SimpleSSLContext + * jdk.httpclient.test.lib.http2.Http2TestServer + * @run testng/othervm/timeout=360 -XX:+CrashOnOutOfMemoryError + * -Djdk.httpclient.quic.idleTimeout=120 + * -Djdk.httpclient.keepalive.timeout.h3=120 + * -Djdk.test.server.quic.idleTimeout=90 + * -Djdk.httpclient.quic.minPtoBackoffTime=60 + * -Djdk.httpclient.quic.maxPtoBackoffTime=120 + * -Djdk.httpclient.quic.maxPtoBackoff=9 + * -Djdk.httpclient.http3.maxStreamLimitTimeout=0 + * -Djdk.httpclient.quic.maxEndpoints=1 + * -Djdk.httpclient.quic.maxBidiStreams=2 + * -Djdk.httpclient.retryOnStreamlimit=50 + * -Djdk.httpclient.HttpClient.log=errors,http3,quic:hs:retransmit + * -Dsimpleget.requests=100 + * -Djdk.internal.httpclient.quic.congestionController=reno + * H3MultipleConnectionsToSameHost + * @summary Send 100 large concurrent requests, with connections whose max stream + * limit is artificially low, in order to cause concurrent connections + * to the same host to be created, with Reno congestion controller + */ // Interesting additional settings for debugging and manual testing: // ----------------------------------------------------------------- diff --git a/test/jdk/java/net/httpclient/http3/H3SimpleGet.java b/test/jdk/java/net/httpclient/http3/H3SimpleGet.java index e75ad04263a55..fe0217e64b600 100644 --- a/test/jdk/java/net/httpclient/http3/H3SimpleGet.java +++ b/test/jdk/java/net/httpclient/http3/H3SimpleGet.java @@ -148,6 +148,18 @@ * H3SimpleGet */ +/* + * @test id=reno-cc + * @bug 8087112 + * @library /test/lib /test/jdk/java/net/httpclient/lib + * @build jdk.test.lib.net.SimpleSSLContext jdk.httpclient.test.lib.common.TestUtil + * jdk.httpclient.test.lib.http2.Http2TestServer + * @run testng/othervm/timeout=480 -Djdk.internal.httpclient.quic.congestionController=reno + * H3SimpleGet + * @summary send multiple GET requests using Reno congestion controller + */ + + // Interesting additional settings for debugging and manual testing: // ----------------------------------------------------------------- // -Djdk.httpclient.HttpClient.log=requests,errors,quic:retransmit:control,http3 diff --git a/test/jdk/java/net/httpclient/http3/H3SimpleTest.java b/test/jdk/java/net/httpclient/http3/H3SimpleTest.java index 73c766f1eab25..8531f10bea7dd 100644 --- a/test/jdk/java/net/httpclient/http3/H3SimpleTest.java +++ b/test/jdk/java/net/httpclient/http3/H3SimpleTest.java @@ -46,7 +46,7 @@ * @test * @summary Basic test to verify that simple GET/POST/HEAD * requests work as expected with HTTP/3, using IPv4 - * or IPv6 + * or IPv6, using CUBIC or Reno * @library /test/lib /test/jdk/java/net/httpclient/lib * @build jdk.test.lib.net.SimpleSSLContext * jdk.httpclient.test.lib.common.HttpServerAdapters @@ -64,6 +64,11 @@ * -Djdk.httpclient.HttpClient.log=requests,responses,errors * -Djava.net.preferIPv4Stack=true * H3SimpleTest + * @run testng/othervm + * -Djdk.internal.httpclient.debug=true + * -Djdk.httpclient.HttpClient.log=requests,responses,errors + * -Djdk.internal.httpclient.quic.congestionController=reno + * H3SimpleTest */ // -Djava.security.debug=all public class H3SimpleTest implements HttpServerAdapters { From 2b47732ec11dd0b27948df5e8d7a79d8df0e1afb Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 21 Nov 2025 11:03:45 +0100 Subject: [PATCH 28/29] Add more assertions --- .../net/http/quic/QuicBaseCongestionController.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java index b56db9fca71c4..7dd3276f0d3e9 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicBaseCongestionController.java @@ -143,6 +143,9 @@ public void packetSent(int packetBytes) { public void packetAcked(int packetBytes, Deadline sentTime) { lock.lock(); try { + long oldWindow = congestionWindow; + assert oldWindow >= minimumWindow : + "Congestion window lower than minimum: %s < %s".formatted(oldWindow, minimumWindow); bytesInFlight -= packetBytes; // RFC 9002 says we should not increase cwnd when application limited. // The concept itself is poorly defined. @@ -174,6 +177,8 @@ public void packetAcked(int packetBytes, Deadline sentTime) { ", new cwnd:" + congestionWindow); } } + assert congestionWindow >= oldWindow : + "Window size decreased on ACK: %s to %s".formatted(oldWindow, congestionWindow); } finally { lock.unlock(); } @@ -200,6 +205,8 @@ public void packetLost(Collection lostPackets, Deadline sentTime, bo ", cwnd:" + congestionWindow); } } + assert congestionWindow >= minimumWindow : + "Congestion window lower than minimum: %s < %s".formatted(congestionWindow, minimumWindow); } finally { lock.unlock(); } From 606809622384b93be22ad70d61632339f1c944e6 Mon Sep 17 00:00:00 2001 From: Daniel Jelinski Date: Fri, 21 Nov 2025 11:12:35 +0100 Subject: [PATCH 29/29] Add comment for negative K --- .../internal/net/http/quic/QuicCubicCongestionController.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java index de5bc7437dcec..a7a1cd0c0bcdf 100644 --- a/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java +++ b/src/java.net.http/share/classes/jdk/internal/net/http/quic/QuicCubicCongestionController.java @@ -158,6 +158,8 @@ void onCongestionEvent(Deadline sentTime) { lastFullWindow = congestionRecoveryStartTime; // ((wmax_segments - cwnd_segments) / C) ^ (1/3) seconds kNanos = (long)(Math.cbrt((wMaxBytes - congestionWindow) / C / maxDatagramSize) * 1_000_000_000); + // kNanos may be negative if we reduced the window below minimum, + // and fast convergence was used. This is acceptable. if (Log.quicCC()) { Log.logQuic(dbgTag + " Congestion: ssThresh: " + ssThresh + ", in flight: " + bytesInFlight +