From 41e31135dc4e15723883954d43153c228858c08d Mon Sep 17 00:00:00 2001 From: ch4r10t33r Date: Fri, 10 Jul 2026 13:33:30 +0100 Subject: [PATCH] =?UTF-8?q?transport:=20ACK=20Frequency=20extension=20?= =?UTF-8?q?=E2=80=94=20ACK=5FFREQUENCY=20+=20IMMEDIATE=5FACK=20(#187)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements draft-ietf-quic-ack-frequency so peers (quinn / quiche / msquic all ship it) can tune our ACK cadence: - src/frames/ack_frequency.zig (new): ACK_FREQUENCY (0xaf) parse/ serialize — sequence number, ack-eliciting threshold, requested max ack delay (µs), reordering threshold — and IMMEDIATE_ACK (0x1f). 0xaf > 0x3f so the type is a 2-byte varint on the wire; both frame loops already varint-decode the type. - quic_tls.zig: advertise + parse the min_ack_delay transport parameter (0xff04de1b, MICROSECONDS). Advertised by default at 1000 µs (1 ms — our drive-tick ACK granularity; well under the 25 ms max_ack_delay we advertise). Set TransportParamsOpts.min_ack_delay_us = 0 to disable. - io.zig frame loops (server + client): 0x1f sets ack_immediate; 0xaf is sequence-gated (stale frames ignored per draft §4), malformed bodies → FRAME_ENCODING_ERROR, requested delay below our advertised min_ack_delay → PROTOCOL_VIOLATION. - ACK cadence: with no ACK_FREQUENCY received the flush path is UNCHANGED (every drive tick). Once armed, flushAllConnAppAcks holds a conn's ACK until the eliciting-packet threshold is crossed, the requested max ack delay expires, a reorder event beyond the reordering threshold occurs, or IMMEDIATE_ACK arrives. Ack-eliciting counting piggybacks on noteFrameReceived (anything but PADDING/ACK/ CONNECTION_CLOSE). The interop client keeps its eager per-batch flush — acking more often than requested is explicitly permitted by the draft. - PTO probes (both sides) ride an IMMEDIATE_ACK alongside the PING when the peer advertised min_ack_delay, so probes are answered without waiting out the peer's (possibly relaxed) delayed-ack timer. - Peer TP validation: min_ack_delay > max_ack_delay is treated defensively as not-advertised (extension disabled for that peer) rather than tearing down the handshake from the void apply path. Tests: frame codec round-trip + truncation, TP round-trip + omit-when- zero, ConnState apply/stale/violation gating, threshold + delay-timer + IMMEDIATE_ACK flush gating, reorder trigger incl. threshold=0 disable. 296/297 pass (1 pre-existing skip). Local interop smoke: handshake + transfer green with both endpoints advertising the new TP. --- src/crypto/quic_tls.zig | 28 +++ src/frames/ack_frequency.zig | 144 +++++++++++++++ src/frames/frame.zig | 11 ++ src/transport/io.zig | 333 ++++++++++++++++++++++++++++++++++- 4 files changed, 514 insertions(+), 2 deletions(-) create mode 100644 src/frames/ack_frequency.zig diff --git a/src/crypto/quic_tls.zig b/src/crypto/quic_tls.zig index 6415b8d..0e9b9cd 100644 --- a/src/crypto/quic_tls.zig +++ b/src/crypto/quic_tls.zig @@ -173,6 +173,12 @@ pub const TransportParamsOpts = struct { grease_quic_bit: bool = false, /// RFC 9221 `max_datagram_frame_size` (0x20). Omitted when zero (disabled). max_datagram_frame_size: u64 = 0, + /// draft-ietf-quic-ack-frequency `min_ack_delay` (0xff04de1b), in + /// MICROSECONDS. Advertising it signals support for ACK_FREQUENCY / + /// IMMEDIATE_ACK frames and obligates us to honor them. Omitted when + /// zero. Default 1 ms — matches the drive-loop ACK granularity. Must + /// not exceed our advertised max_ack_delay (25 ms). + min_ack_delay_us: u64 = 1000, }; /// Preset transport-parameter profiles for common embedders. @@ -336,6 +342,10 @@ pub fn buildTransportParams(out: []u8, opts: TransportParamsOpts) (varint.Encode if (opts.max_datagram_frame_size > 0) { pos = try writeParamVarint(out, pos, 0x20, opts.max_datagram_frame_size); } + // draft-ietf-quic-ack-frequency: min_ack_delay (0xff04de1b), microseconds. + if (opts.min_ack_delay_us > 0) { + pos = try writeParamVarint(out, pos, 0xff04de1b, opts.min_ack_delay_us); + } return pos; } @@ -398,6 +408,9 @@ pub const PeerTransportParams = struct { grease_quic_bit: bool = false, /// 0x20 — RFC 9221 max DATAGRAM frame payload size. Zero when absent. max_datagram_frame_size: u64 = 0, + /// 0xff04de1b — draft-ietf-quic-ack-frequency min_ack_delay in + /// MICROSECONDS. Zero when absent (peer does not support the extension). + min_ack_delay_us: u64 = 0, }; /// On-wire layout of the preferred_address transport parameter (RFC 9000 @@ -494,6 +507,7 @@ pub fn parseTransportParams(bytes: []const u8) varint.DecodeError!PeerTransportP 0x0e => out.active_connection_id_limit = readVarintField(value) catch continue, 0x2ab2 => out.grease_quic_bit = (value_len == 0), 0x20 => out.max_datagram_frame_size = readVarintField(value) catch continue, + 0xff04de1b => out.min_ack_delay_us = readVarintField(value) catch continue, else => {}, // unknown / reserved / connection-id params are not surfaced here } } @@ -527,6 +541,20 @@ test "transport params: round-trip varint fields" { try testing.expectEqual(@as(u64, 4), parsed.active_connection_id_limit); // We don't emit `disable_active_migration`; check the default here. try testing.expectEqual(false, parsed.disable_active_migration); + // draft-ietf-quic-ack-frequency: advertised by default at 1 ms. + try testing.expectEqual(@as(u64, 1000), parsed.min_ack_delay_us); +} + +test "transport params: min_ack_delay omitted when zero" { + const testing = std.testing; + var buf: [256]u8 = undefined; + const cid = [_]u8{0xaa}; + const n = try buildTransportParams(&buf, .{ + .initial_source_cid = &cid, + .min_ack_delay_us = 0, + }); + const parsed = try parseTransportParams(buf[0..n]); + try testing.expectEqual(@as(u64, 0), parsed.min_ack_delay_us); } test "transport params: max_datagram_frame_size round-trip" { diff --git a/src/frames/ack_frequency.zig b/src/frames/ack_frequency.zig new file mode 100644 index 0000000..ce1d1e6 --- /dev/null +++ b/src/frames/ack_frequency.zig @@ -0,0 +1,144 @@ +//! QUIC ACK Frequency extension frames (draft-ietf-quic-ack-frequency). +//! +//! Two frames let a sender tune how often the peer acknowledges: +//! - ACK_FREQUENCY (0xaf): request an ack-eliciting threshold, a max ack +//! delay, and a reordering threshold. +//! - IMMEDIATE_ACK (0x1f): request an ACK right now (e.g. in PTO probes). +//! +//! Both are extension frames: an endpoint MUST NOT send them unless the peer +//! advertised the `min_ack_delay` transport parameter (0xff04de1b). Frame +//! type 0xaf is > 0x3f, so it occupies a 2-byte varint on the wire (0x40 0xaf); +//! 0x1f fits in one byte. + +const std = @import("std"); +const varint = @import("../varint.zig"); + +/// Frame type values (draft-ietf-quic-ack-frequency §4/§5 provisional codepoints). +pub const ack_frequency_frame_type: u64 = 0xaf; +pub const immediate_ack_frame_type: u64 = 0x1f; + +/// `min_ack_delay` transport parameter id (draft §3). Value is in +/// MICROSECONDS (unlike max_ack_delay, which is milliseconds). +pub const min_ack_delay_tp_id: u64 = 0xff04de1b; + +pub const AckFrequencyFrame = struct { + /// Monotonically increasing per sender; receiver processes a frame only + /// when its sequence number is strictly greater than any previously + /// processed one (out-of-order/duplicate frames are ignored, not errors). + sequence_number: u64, + /// Max ack-eliciting packets the recipient may receive without sending an + /// ACK. 0 = acknowledge every ack-eliciting packet; 1 = RFC 9000 default + /// (every second packet). + ack_eliciting_threshold: u64, + /// Requested max_ack_delay in MICROSECONDS. MUST be >= the receiving + /// endpoint's advertised min_ack_delay or the connection errors with + /// PROTOCOL_VIOLATION (draft §4). + request_max_ack_delay_us: u64, + /// Out-of-order tolerance before an immediate ACK: 0 = reordering never + /// elicits an immediate ACK; 1 = any reordering does (RFC 9000 default). + reordering_threshold: u64, + + pub const ParseResult = struct { + frame: AckFrequencyFrame, + /// Bytes consumed from `buf` (frame body only — the type varint has + /// already been consumed by the frame-loop dispatcher). + consumed: usize, + }; + + /// Parse the frame body (after the type varint). + pub fn parse(buf: []const u8) varint.DecodeError!ParseResult { + var pos: usize = 0; + const seq = try varint.decode(buf[pos..]); + pos += seq.len; + const thresh = try varint.decode(buf[pos..]); + pos += thresh.len; + const delay = try varint.decode(buf[pos..]); + pos += delay.len; + const reorder = try varint.decode(buf[pos..]); + pos += reorder.len; + return .{ + .frame = .{ + .sequence_number = seq.value, + .ack_eliciting_threshold = thresh.value, + .request_max_ack_delay_us = delay.value, + .reordering_threshold = reorder.value, + }, + .consumed = pos, + }; + } + + /// Serialize including the leading type varint. Returns bytes written. + pub fn serialize(self: *const AckFrequencyFrame, buf: []u8) (varint.EncodeError || varint.DecodeError)!usize { + var pos: usize = 0; + const fields = [_]u64{ + ack_frequency_frame_type, + self.sequence_number, + self.ack_eliciting_threshold, + self.request_max_ack_delay_us, + self.reordering_threshold, + }; + for (fields) |v| { + const enc = try varint.encode(buf[pos..], v); + pos += enc.len; + } + return pos; + } +}; + +/// Serialize an IMMEDIATE_ACK frame (type only, no body). +pub fn serializeImmediateAck(buf: []u8) (varint.EncodeError || varint.DecodeError)!usize { + const enc = try varint.encode(buf, immediate_ack_frame_type); + return enc.len; +} + +test "ack_frequency: serialize/parse round-trip" { + const testing = std.testing; + const f = AckFrequencyFrame{ + .sequence_number = 7, + .ack_eliciting_threshold = 10, + .request_max_ack_delay_us = 25_000, + .reordering_threshold = 1, + }; + var buf: [64]u8 = undefined; + const n = try f.serialize(&buf); + // Type 0xaf > 0x3f → 2-byte varint on the wire. + try testing.expectEqual(@as(u8, 0x40), buf[0]); + try testing.expectEqual(@as(u8, 0xaf), buf[1]); + + const ft = try varint.decode(buf[0..n]); + try testing.expectEqual(ack_frequency_frame_type, ft.value); + const r = try AckFrequencyFrame.parse(buf[ft.len..n]); + try testing.expectEqual(f.sequence_number, r.frame.sequence_number); + try testing.expectEqual(f.ack_eliciting_threshold, r.frame.ack_eliciting_threshold); + try testing.expectEqual(f.request_max_ack_delay_us, r.frame.request_max_ack_delay_us); + try testing.expectEqual(f.reordering_threshold, r.frame.reordering_threshold); + try testing.expectEqual(n - ft.len, r.consumed); +} + +test "ack_frequency: parse rejects truncated body" { + const testing = std.testing; + const f = AckFrequencyFrame{ + .sequence_number = 300, // 2-byte varint — truncation lands mid-field + .ack_eliciting_threshold = 2, + .request_max_ack_delay_us = 1000, + .reordering_threshold = 0, + }; + var buf: [64]u8 = undefined; + const n = try f.serialize(&buf); + const ft = try varint.decode(buf[0..n]); + var cut = ft.len; + while (cut < n) : (cut += 1) { + try testing.expectError(error.BufferTooShort, AckFrequencyFrame.parse(buf[ft.len..cut])); + } +} + +test "ack_frequency: immediate ack is a single byte 0x1f" { + const testing = std.testing; + var buf: [8]u8 = undefined; + const n = try serializeImmediateAck(&buf); + try testing.expectEqual(@as(usize, 1), n); + try testing.expectEqual(@as(u8, 0x1f), buf[0]); +} + +/// IMMEDIATE_ACK has no fields; empty struct for the frame registry union. +pub const ImmediateAck = struct {}; diff --git a/src/frames/frame.zig b/src/frames/frame.zig index 9c371c0..cef42c8 100644 --- a/src/frames/frame.zig +++ b/src/frames/frame.zig @@ -23,7 +23,9 @@ //! PATH_RESPONSE 0x1b //! CONNECTION_CLOSE 0x1c / 0x1d //! HANDSHAKE_DONE 0x1e +//! IMMEDIATE_ACK 0x1f (draft-ietf-quic-ack-frequency) //! DATAGRAM 0x30 / 0x31 (RFC 9221) +//! ACK_FREQUENCY 0xaf (draft-ietf-quic-ack-frequency) const std = @import("std"); const varint = @import("../varint.zig"); @@ -32,6 +34,7 @@ const types = @import("../types.zig"); pub const ack = @import("ack.zig"); pub const crypto_frame = @import("crypto_frame.zig"); pub const datagram_mod = @import("datagram.zig"); +pub const ack_frequency_mod = @import("ack_frequency.zig"); pub const stream = @import("stream.zig"); pub const transport = @import("transport.zig"); @@ -60,6 +63,7 @@ pub const FrameType = enum(u64) { connection_close_quic = 0x1c, connection_close_app = 0x1d, handshake_done = 0x1e, + immediate_ack = 0x1f, _, }; @@ -88,6 +92,8 @@ pub const Frame = union(enum) { connection_close: transport.ConnectionClose, handshake_done: transport.HandshakeDone, datagram: datagram_mod.DatagramFrame, + immediate_ack: ack_frequency_mod.ImmediateAck, + ack_frequency: ack_frequency_mod.AckFrequencyFrame, /// Returns true if this frame type is allowed in Initial packets. pub fn allowedInInitial(self: Frame) bool { @@ -213,10 +219,15 @@ pub fn parseOne(buf: []const u8) ParseError!struct { frame: Frame, consumed: usi return .{ .frame = .{ .connection_close = r.frame }, .consumed = pos + r.consumed }; }, 0x1e => return .{ .frame = .{ .handshake_done = .{} }, .consumed = pos }, + 0x1f => return .{ .frame = .{ .immediate_ack = .{} }, .consumed = pos }, 0x30, 0x31 => { const r = try datagram_mod.DatagramFrame.parse(buf[pos..], ft); return .{ .frame = .{ .datagram = r.frame }, .consumed = pos + r.consumed }; }, + ack_frequency_mod.ack_frequency_frame_type => { + const r = try ack_frequency_mod.AckFrequencyFrame.parse(buf[pos..]); + return .{ .frame = .{ .ack_frequency = r.frame }, .consumed = pos + r.consumed }; + }, else => return error.UnknownFrameType, } } diff --git a/src/transport/io.zig b/src/transport/io.zig index b842732..ef8e89e 100644 --- a/src/transport/io.zig +++ b/src/transport/io.zig @@ -34,6 +34,7 @@ const h3_frame = @import("../http3/frame.zig"); const h3_qpack = @import("../http3/qpack.zig"); const h3_connect = @import("../http3/connect.zig"); const datagram_mod = @import("../frames/datagram.zig"); +const ack_frequency_mod = @import("../frames/ack_frequency.zig"); const datagrams_mod = @import("datagrams.zig"); const qlog_writer = @import("../qlog/writer.zig"); const transport_frames = @import("../frames/transport.zig"); @@ -2189,6 +2190,40 @@ pub const ConnState = struct { /// Used by our PTO computation (RFC 9002 §6.2.1). Defaults to the spec /// default of 25 ms. peer_max_ack_delay_ms: u64 = 25, + + // ── ACK Frequency extension state (draft-ietf-quic-ack-frequency) ── + /// Our advertised `min_ack_delay` (µs); >0 means we advertised the TP and + /// MUST honor inbound ACK_FREQUENCY / IMMEDIATE_ACK frames. + local_min_ack_delay_us: u64 = 0, + /// Peer's advertised `min_ack_delay` (µs); >0 gates whether WE may send + /// ACK_FREQUENCY / IMMEDIATE_ACK frames toward the peer (draft §3). + peer_min_ack_delay_us: u64 = 0, + /// Largest ACK_FREQUENCY sequence number processed; stale/duplicate + /// frames (seq <= this) are ignored per draft §4. + ack_freq_seq: ?u64 = null, + /// Requested max ack delay from the peer's ACK_FREQUENCY frame, in ms + /// (rounded up from µs, min 1 ms). null = no ACK_FREQUENCY received → + /// default behavior (ACKs flush every drive tick, unchanged from before + /// this extension). + ack_freq_max_delay_ms: ?u64 = null, + /// Ack-eliciting packets we may accumulate before an ACK is due (only + /// consulted when `ack_freq_max_delay_ms != null`). + ack_freq_threshold: u64 = 0, + /// Reordering tolerance: 0 = reordering never forces an immediate ACK; + /// >=1 = a reorder event of at least this magnitude does (1 = RFC 9000 + /// default of ack-immediately on any reorder). + ack_freq_reorder_threshold: u64 = 1, + /// Ack-eliciting packets received since the last app-ACK flush. + ack_eliciting_since_flush: u64 = 0, + /// Wall-clock stamp of the first unflushed ack-eliciting packet (0 = + /// none pending). Drives the requested-max-ack-delay timer. + oldest_unacked_recv_ms: i64 = 0, + /// Force the next flush regardless of thresholds (IMMEDIATE_ACK frame, + /// reorder trigger). + ack_immediate: bool = false, + /// Scratch: set by `noteFrameReceived` when the current packet carried an + /// ack-eliciting frame; consumed by `noteAppAckPacketObserved`. + recvd_ack_eliciting_frame: bool = false, /// Server-advertised preferred address (RFC 9000 §9.6) captured from /// the peer's transport-parameters extension (0x0d). Used purely as a /// signal to trigger active migration on the client — we still send to @@ -2344,6 +2379,72 @@ pub const ConnState = struct { pub fn noteFrameReceived(self: *ConnState, ft: u64) void { stats_mod.noteFrameRx(&self.stats_acc.frames, ft); + // Ack-eliciting = anything but PADDING, ACK/ACK_ECN, CONNECTION_CLOSE + // (RFC 9000 §13.2.1). Feeds the ACK-frequency threshold counter. + switch (ft) { + 0x00, 0x02, 0x03, 0x1c, 0x1d => {}, + else => self.recvd_ack_eliciting_frame = true, + } + } + + /// Per received 1-RTT packet, after its frames were processed and before + /// `observe(pn)`: update ACK-frequency accounting (threshold counter, + /// delay-timer stamp, reorder trigger). No-op behavioral change until an + /// ACK_FREQUENCY frame arms `ack_freq_max_delay_ms`. + pub fn noteAppAckPacketObserved(self: *ConnState, pn: u64, now_ms: i64, tracker_largest: u64, tracker_has_ranges: bool) void { + if (self.recvd_ack_eliciting_frame) { + self.recvd_ack_eliciting_frame = false; + self.ack_eliciting_since_flush +|= 1; + if (self.oldest_unacked_recv_ms == 0) self.oldest_unacked_recv_ms = now_ms; + } + // Reorder trigger (draft §6.2): a packet that arrives late (pn below + // the tracked largest by >= threshold) or that opens a gap (skips + // ahead) should elicit an immediate ACK so the sender's loss detector + // keeps working despite the relaxed ack cadence. + if (self.ack_freq_max_delay_ms != null and self.ack_freq_reorder_threshold != 0 and tracker_has_ranges) { + const late = pn + self.ack_freq_reorder_threshold <= tracker_largest; + const gap = pn > tracker_largest + 1; + if (late or gap) self.ack_immediate = true; + } + } + + /// True when the accumulated app-space ACK ranges should be flushed now. + /// Default mode (no ACK_FREQUENCY received): always true — preserves the + /// pre-extension flush-every-drive-tick behavior exactly. + pub fn ackFlushDue(self: *const ConnState, now_ms: i64) bool { + const max_delay_ms = self.ack_freq_max_delay_ms orelse return true; + if (self.ack_immediate) return true; + if (self.ack_eliciting_since_flush > self.ack_freq_threshold) return true; + if (self.oldest_unacked_recv_ms != 0 and + now_ms - self.oldest_unacked_recv_ms >= @as(i64, @intCast(max_delay_ms))) return true; + return false; + } + + /// Reset ACK-frequency accounting after an app-ACK actually went out. + pub fn noteAckFlushed(self: *ConnState) void { + self.ack_eliciting_since_flush = 0; + self.oldest_unacked_recv_ms = 0; + self.ack_immediate = false; + } + + pub const AckFrequencyApply = enum { applied, stale, protocol_violation }; + + /// Apply an inbound ACK_FREQUENCY frame (draft §4): sequence-gated; + /// requested max ack delay below our advertised min_ack_delay is a + /// PROTOCOL_VIOLATION the caller must surface as a connection error. + pub fn applyAckFrequencyFrame(self: *ConnState, f: ack_frequency_mod.AckFrequencyFrame) AckFrequencyApply { + if (self.ack_freq_seq) |seen| { + if (f.sequence_number <= seen) return .stale; + } + if (self.local_min_ack_delay_us > 0 and f.request_max_ack_delay_us < self.local_min_ack_delay_us) { + return .protocol_violation; + } + self.ack_freq_seq = f.sequence_number; + self.ack_freq_threshold = f.ack_eliciting_threshold; + // µs → ms, rounded up, min 1 ms (our timers are ms-granular). + self.ack_freq_max_delay_ms = @max(1, (f.request_max_ack_delay_us + 999) / 1000); + self.ack_freq_reorder_threshold = f.reordering_threshold; + return .applied; } pub fn note1RttPayloadSent(self: *ConnState, payload: []const u8, pkt_len: usize) void { @@ -2505,6 +2606,16 @@ pub const ConnState = struct { } self.peer_grease_quic_bit = parsed.grease_quic_bit; self.peer_max_datagram_frame_size = parsed.max_datagram_frame_size; + // draft-ietf-quic-ack-frequency §3: min_ack_delay greater than the + // same peer's max_ack_delay is a TRANSPORT_PARAMETER_ERROR per the + // draft; we defensively treat the extension as not-advertised instead + // of tearing down the handshake from inside this void apply path. + if (parsed.min_ack_delay_us > parsed.max_ack_delay_ms *| 1000) { + dbg("io: peer min_ack_delay {}us > max_ack_delay {}ms — ignoring ack-frequency support\n", .{ parsed.min_ack_delay_us, parsed.max_ack_delay_ms }); + self.peer_min_ack_delay_us = 0; + } else { + self.peer_min_ack_delay_us = parsed.min_ack_delay_us; + } } /// True when both endpoints advertised a non-zero max_datagram_frame_size. @@ -4800,6 +4911,7 @@ pub const Server = struct { const datagram_tp = configMaxDatagramFrameSize(self.config.http3, self.config.max_datagram_frame_size); tp_opts.max_datagram_frame_size = datagram_tp; conn.local_max_datagram_frame_size = datagram_tp; + conn.local_min_ack_delay_us = tp_opts.min_ack_delay_us; // Optional per-server override of the advertised incoming-stream limits. // Also raise our own receive-side accounting so we actually accept the // number of peer-initiated streams we advertised. @@ -5530,6 +5642,12 @@ pub const Server = struct { conn.noteDatagramRecv(buf.len); self.processAppFrames(conn, plaintext[0..pt_len], src); + conn.noteAppAckPacketObserved( + srv_decrypted_pn, + compat.milliTimestamp(), + conn.app_recv_ack.largest, + conn.app_recv_ack.range_count > 0, + ); if (conn.app_recv_ack.observe(srv_decrypted_pn)) { self.flushConnAppAck(conn, src); _ = conn.app_recv_ack.observe(srv_decrypted_pn); @@ -5711,6 +5829,32 @@ pub const Server = struct { if (ft == 0x00) continue; // PADDING if (ft == 0x01) continue; // PING — no body + if (ft == ack_frequency_mod.immediate_ack_frame_type) { + // IMMEDIATE_ACK (draft-ietf-quic-ack-frequency §5): flush the + // pending app ACK at the end of this recv pass instead of + // waiting out any ACK_FREQUENCY-relaxed cadence. + conn.ack_immediate = true; + continue; + } + if (ft == ack_frequency_mod.ack_frequency_frame_type) { + // ACK_FREQUENCY (draft §4): peer tunes our ack cadence. + const afr = ack_frequency_mod.AckFrequencyFrame.parse(frames[pos..]) catch { + dbg("io: malformed ACK_FREQUENCY frame\n", .{}); + self.sendConnectionClose(conn, 0x07, "malformed ACK_FREQUENCY", src); + return; + }; + pos += afr.consumed; + switch (conn.applyAckFrequencyFrame(afr.frame)) { + .applied, .stale => {}, + .protocol_violation => { + // draft §4: requested max ack delay below our + // advertised min_ack_delay. + self.sendConnectionClose(conn, 0x0a, "ACK_FREQUENCY max_ack_delay < min_ack_delay", src); + return; + }, + } + continue; + } if (ft == 0x02 or ft == 0x03) { // ACK frame (RFC 9000 §19.3). // Parse Largest Acknowledged, ACK Delay, ACK Range Count, and @@ -6378,15 +6522,22 @@ pub const Server = struct { if (ack_len == 0) return; self.send1Rtt(conn, ack_buf[0..ack_len], dst); conn.app_recv_ack.reset(); + conn.noteAckFlushed(); } /// Flush deferred 1-RTT ACKs for all connections (after a recv batch). fn flushAllConnAppAcks(self: *Server) void { + const now_ms = compat.milliTimestamp(); for (&self.conns) |*cslot| { const conn = if (cslot.*) |c| c else continue; if (!conn.has_app_keys) continue; if (conn.phase != .connected and conn.phase != .waiting_finished) continue; if (conn.app_recv_ack.range_count == 0) continue; + // ACK-frequency gate (draft-ietf-quic-ack-frequency): once the + // peer sent an ACK_FREQUENCY frame, hold ACKs until the eliciting + // threshold / requested delay / immediate trigger fires. Default + // (no frame received): always due — unchanged behavior. + if (!conn.ackFlushDue(now_ms)) continue; self.flushConnAppAck(conn, conn.peer); } } @@ -6605,8 +6756,16 @@ pub const Server = struct { return switch (space) { .application => blk: { if (!conn.has_app_keys) break :blk false; - const ping_frame = [_]u8{0x01}; - self.send1Rtt(conn, &ping_frame, dst); + // When the peer supports the ACK-frequency extension, ride an + // IMMEDIATE_ACK on the probe so it answers without waiting out + // its (possibly ACK_FREQUENCY-relaxed) delayed-ack timer. + if (conn.peer_min_ack_delay_us > 0) { + const probe = [_]u8{ 0x01, 0x1f }; + self.send1Rtt(conn, &probe, dst); + } else { + const ping_frame = [_]u8{0x01}; + self.send1Rtt(conn, &ping_frame, dst); + } break :blk true; }, .handshake => self.sendHandshakePtoProbe(conn, dst), @@ -9623,6 +9782,13 @@ pub const Client = struct { /// congestion window. Returns false if packet build or `sendto` fails. /// Shared between PTO probe and idle keepalive (RFC 9000 §10.1.2). fn sendOnePingFrame(self: *Client) bool { + // When the peer supports the ACK-frequency extension, ride an + // IMMEDIATE_ACK (0x1f) on the PTO probe so the peer answers without + // waiting out its (possibly ACK_FREQUENCY-relaxed) delayed-ack timer. + if (self.conn.peer_min_ack_delay_us > 0) { + const probe = [_]u8{ 0x01, 0x1f }; + return self.sendClient1Rtt(&probe) != null; + } const ping_frame = [_]u8{0x01}; return self.sendClient1Rtt(&ping_frame) != null; } @@ -10064,6 +10230,9 @@ pub const Client = struct { var quic_tp_buf: [128]u8 = undefined; const datagram_tp = configMaxDatagramFrameSize(self.config.http3, self.config.max_datagram_frame_size); self.conn.local_max_datagram_frame_size = datagram_tp; + // buildEndpointTransportParams uses TransportParamsOpts defaults + // for min_ack_delay (advertised unless zeroed there). + self.conn.local_min_ack_delay_us = (quic_tls_mod.TransportParamsOpts{ .initial_source_cid = &.{} }).min_ack_delay_us; const quic_tp = try buildEndpointTransportParams( &quic_tp_buf, self.conn.local_cid.slice(), @@ -10829,6 +10998,28 @@ pub const Client = struct { if (ft == 0x00) continue; // PADDING if (ft == 0x01) continue; // PING — no body + if (ft == ack_frequency_mod.immediate_ack_frame_type) { + // IMMEDIATE_ACK (draft-ietf-quic-ack-frequency §5). + self.conn.ack_immediate = true; + continue; + } + if (ft == ack_frequency_mod.ack_frequency_frame_type) { + // ACK_FREQUENCY (draft §4): peer tunes our ack cadence. + const afr = ack_frequency_mod.AckFrequencyFrame.parse(plaintext[pos..pt_len]) catch { + dbg("io: client malformed ACK_FREQUENCY frame\n", .{}); + self.sendConnectionClose(0x07, "malformed ACK_FREQUENCY"); + return; + }; + pos += afr.consumed; + switch (self.conn.applyAckFrequencyFrame(afr.frame)) { + .applied, .stale => {}, + .protocol_violation => { + self.sendConnectionClose(0x0a, "ACK_FREQUENCY max_ack_delay < min_ack_delay"); + return; + }, + } + continue; + } if (ft == 0x02 or ft == 0x03) { // ACK frame (RFC 9000 §19.3). Parse the first range and run // it through the loss detector so server-sent ACKs can ack @@ -11223,6 +11414,12 @@ pub const Client = struct { } // Defer ACK until after the recv drain loop in downloadUrls. + self.conn.noteAppAckPacketObserved( + decompressed_pn, + compat.milliTimestamp(), + self.app_ack.largest, + self.app_ack.range_count > 0, + ); if (self.app_ack.observe(decompressed_pn)) { self.flushDeferredAck(); _ = self.app_ack.observe(decompressed_pn); @@ -11290,6 +11487,7 @@ pub const Client = struct { self.app_ack.largest, self.app_ack.range_count, }); self.app_ack.reset(); + self.conn.noteAckFlushed(); } fn handleAppCrypto(self: *Client, data: []const u8) void { @@ -14310,3 +14508,134 @@ test "resetRawAppStream: server resets a stream, client observes RESET_STREAM co } try std.testing.expectEqual(@as(?u64, 42), seen_code); } + +test "ack-frequency: default mode flushes every tick (no behavior change)" { + var conn = makeConnForStreamTest(); + // No ACK_FREQUENCY frame received → ack_freq_max_delay_ms is null → + // always due, regardless of counters. + try std.testing.expect(conn.ackFlushDue(1000)); + conn.ack_eliciting_since_flush = 0; + try std.testing.expect(conn.ackFlushDue(1000)); +} + +test "ack-frequency: applyAckFrequencyFrame applies, ignores stale, rejects below min_ack_delay" { + var conn = makeConnForStreamTest(); + conn.local_min_ack_delay_us = 1000; + + // Apply seq 3. + try std.testing.expectEqual(ConnState.AckFrequencyApply.applied, conn.applyAckFrequencyFrame(.{ + .sequence_number = 3, + .ack_eliciting_threshold = 9, + .request_max_ack_delay_us = 25_000, + .reordering_threshold = 0, + })); + try std.testing.expectEqual(@as(?u64, 25), conn.ack_freq_max_delay_ms); + try std.testing.expectEqual(@as(u64, 9), conn.ack_freq_threshold); + try std.testing.expectEqual(@as(u64, 0), conn.ack_freq_reorder_threshold); + + // Stale (seq <= 3) is ignored, state unchanged. + try std.testing.expectEqual(ConnState.AckFrequencyApply.stale, conn.applyAckFrequencyFrame(.{ + .sequence_number = 3, + .ack_eliciting_threshold = 1, + .request_max_ack_delay_us = 5_000, + .reordering_threshold = 1, + })); + try std.testing.expectEqual(@as(u64, 9), conn.ack_freq_threshold); + + // Requested delay below our advertised min_ack_delay → protocol violation + // (draft §4); state unchanged. + try std.testing.expectEqual(ConnState.AckFrequencyApply.protocol_violation, conn.applyAckFrequencyFrame(.{ + .sequence_number = 4, + .ack_eliciting_threshold = 1, + .request_max_ack_delay_us = 999, + .reordering_threshold = 1, + })); + try std.testing.expectEqual(@as(?u64, 25), conn.ack_freq_max_delay_ms); + + // µs → ms rounds up with a 1 ms floor. + try std.testing.expectEqual(ConnState.AckFrequencyApply.applied, conn.applyAckFrequencyFrame(.{ + .sequence_number = 5, + .ack_eliciting_threshold = 0, + .request_max_ack_delay_us = 1000, + .reordering_threshold = 1, + })); + try std.testing.expectEqual(@as(?u64, 1), conn.ack_freq_max_delay_ms); +} + +test "ack-frequency: threshold, delay timer, and IMMEDIATE_ACK gate the flush" { + var conn = makeConnForStreamTest(); + conn.local_min_ack_delay_us = 1000; + _ = conn.applyAckFrequencyFrame(.{ + .sequence_number = 1, + .ack_eliciting_threshold = 2, // up to 2 eliciting packets may wait + .request_max_ack_delay_us = 20_000, // 20 ms + .reordering_threshold = 1, + }); + + // Simulate two ack-eliciting packets at t=1000 — under threshold, within + // the delay window → not due. + conn.recvd_ack_eliciting_frame = true; + conn.noteAppAckPacketObserved(10, 1000, 0, false); + conn.recvd_ack_eliciting_frame = true; + conn.noteAppAckPacketObserved(11, 1005, 10, true); + try std.testing.expect(!conn.ackFlushDue(1010)); + + // Third eliciting packet exceeds the threshold → due. + conn.recvd_ack_eliciting_frame = true; + conn.noteAppAckPacketObserved(12, 1010, 11, true); + try std.testing.expect(conn.ackFlushDue(1010)); + + // Flush resets the accounting. + conn.noteAckFlushed(); + try std.testing.expect(!conn.ackFlushDue(1011)); + + // Delay timer: one eliciting packet, then 20 ms elapse → due. + conn.recvd_ack_eliciting_frame = true; + conn.noteAppAckPacketObserved(13, 2000, 12, true); + try std.testing.expect(!conn.ackFlushDue(2019)); + try std.testing.expect(conn.ackFlushDue(2020)); + + // IMMEDIATE_ACK forces due regardless of counters. + conn.noteAckFlushed(); + conn.ack_immediate = true; + try std.testing.expect(conn.ackFlushDue(2021)); +} + +test "ack-frequency: reorder trigger honors reordering_threshold" { + var conn = makeConnForStreamTest(); + conn.local_min_ack_delay_us = 1000; + _ = conn.applyAckFrequencyFrame(.{ + .sequence_number = 1, + .ack_eliciting_threshold = 100, + .request_max_ack_delay_us = 100_000, + .reordering_threshold = 1, + }); + + // In-order packet: no immediate. + conn.noteAppAckPacketObserved(6, 1000, 5, true); + try std.testing.expect(!conn.ack_immediate); + // Late packet (pn 3 while largest tracked is 6) → immediate. + conn.noteAppAckPacketObserved(3, 1001, 6, true); + try std.testing.expect(conn.ack_immediate); + + // reordering_threshold = 0 disables the trigger entirely. + conn.noteAckFlushed(); + _ = conn.applyAckFrequencyFrame(.{ + .sequence_number = 2, + .ack_eliciting_threshold = 100, + .request_max_ack_delay_us = 100_000, + .reordering_threshold = 0, + }); + conn.noteAppAckPacketObserved(2, 1002, 6, true); + try std.testing.expect(!conn.ack_immediate); + + // Gap (pn skips ahead past largest+1) also triggers when threshold >= 1. + _ = conn.applyAckFrequencyFrame(.{ + .sequence_number = 3, + .ack_eliciting_threshold = 100, + .request_max_ack_delay_us = 100_000, + .reordering_threshold = 1, + }); + conn.noteAppAckPacketObserved(20, 1003, 6, true); + try std.testing.expect(conn.ack_immediate); +}