Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,12 @@
1.9.4 (2026-07-12)
==================

**Fixed**
- Congestion and loss algorithms against more brittle network connections

**Changed**
- Updated lsqpack to v2.6.5 via our ls-qpack-rs crate v0.3.2

1.9.3 (2026-07-08)
==================

Expand Down
34 changes: 17 additions & 17 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "qh3"
version = "1.9.3"
version = "1.9.4"
edition = "2021"
rust-version = "1.75"
license = "BSD-3-Clause"
Expand Down
2 changes: 1 addition & 1 deletion noxfile.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ def tests_impl(
session.install("-U", "pip", "maturin", silent=False)
session.install("-r", "dev-requirements.txt", silent=False)

session.run("maturin", "develop")
session.run("maturin", "develop", "--release")

# Show the pip version.
session.run("pip", "--version")
Expand Down
2 changes: 1 addition & 1 deletion qh3/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
from .quic.packet import QuicProtocolVersion
from .tls import CipherSuite, SessionTicket

__version__ = "1.9.3"
__version__ = "1.9.4"

__all__ = (
"connect",
Expand Down
3 changes: 3 additions & 0 deletions qh3/_hazmat.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,9 @@ def decode_packet_number(truncated: int, num_bits: int, expected: int) -> int:

class QuicPacketPacer:
def __init__(self, max_datagram_size: int) -> None: ...
def start_pacing(
self, now: float, congestion_window: int, smoothed_rtt: float
) -> None: ...
def next_send_time(self, now: float) -> float | None: ...
def update_after_send(self, now: float) -> None: ...
def update_bucket(self, now: float) -> None: ...
Expand Down
6 changes: 5 additions & 1 deletion qh3/asyncio/_transport.py
Original file line number Diff line number Diff line change
Expand Up @@ -382,7 +382,11 @@ def sendto_many(self, datagrams: list[bytes], addr: typing.Any = None) -> None:
target = addr if addr is not None else self._address
if target is not None:
try:
state.send(datagrams, str(target[0]), int(target[1]))
sent = state.send(datagrams, str(target[0]), int(target[1]))
if sent < len(datagrams):
self._register_writer()
for dgram in datagrams[sent:]:
self._queue_write(dgram, addr)
return
except BlockingIOError:
self._register_writer()
Expand Down
25 changes: 20 additions & 5 deletions qh3/quic/connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -554,7 +554,7 @@ def __init__(
self._datagrams_pending: deque[bytes] = deque()
self._handshake_done_pending = False
self._ping_pending: list[int] = []
self._probe_pending = False
self._probe_pending = 0
self._retire_connection_ids: list[int] = []
self._streams_blocked_pending = False

Expand Down Expand Up @@ -708,6 +708,7 @@ def connect(self, addr: NetworkAddress, now: float) -> None:
else:
self._version = self._configuration.supported_versions[0]
self._connect(now=now)
self._loss.start_packet_pacing(now)

def datagrams_to_send(self, now: float) -> list[tuple[bytes, NetworkAddress]]:
"""
Expand Down Expand Up @@ -3237,7 +3238,7 @@ def _push_crypto_data(self) -> None:
buf.seek(0)

def _send_probe(self) -> None:
self._probe_pending = True
self._probe_pending += 1

def _is_stateless_reset(self, datagram: bytes) -> bool:
"""
Expand Down Expand Up @@ -3813,7 +3814,7 @@ def _write_application(
# PING (probe)
if self._probe_pending:
self._write_ping_frame(builder, comment="probe")
self._probe_pending = False
self._probe_pending -= 1

# CRYPTO
if crypto_stream is not None and not crypto_stream.sender.buffer_is_empty:
Expand Down Expand Up @@ -3952,6 +3953,15 @@ def _write_handshake(
space = self._spaces[epoch]

while True:
# Handshake packets use the same path pacer as application data.
# ACKs and PTO probes bypass pacing to preserve recovery latency.
if (
space.ack_at is None or space.ack_at >= now
) and not self._probe_pending:
self._pacing_at = self._loss._pacer.next_send_time(now=now)
if self._pacing_at is not None:
break

if epoch == tls.Epoch.INITIAL:
packet_type = QuicPacketType.INITIAL
else:
Expand All @@ -3964,26 +3974,31 @@ def _write_handshake(
self._write_ack_frame(builder=builder, space=space, now=now)

# CRYPTO
crypto_written = False
if not crypto_stream.sender.buffer_is_empty:
if self._write_crypto_frame(
builder=builder, space=space, stream=crypto_stream
):
self._probe_pending = False
crypto_written = True
self._probe_pending = max(self._probe_pending - 1, 0)

# PING (probe)
if (
self._probe_pending
and not crypto_written
and not self._handshake_complete
and (
epoch == tls.Epoch.HANDSHAKE
or not self._cryptos[tls.Epoch.HANDSHAKE].send.is_valid()
)
):
self._write_ping_frame(builder, comment="probe")
self._probe_pending = False
self._probe_pending -= 1

if builder.packet_is_empty:
break
if builder._packet.in_flight:
self._loss._pacer.update_after_send(now=now)

def _write_ack_frame(
self, builder: QuicPacketBuilder, space: QuicPacketSpace, now: float
Expand Down
Loading
Loading