quico 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -299,6 +299,7 @@ function QUICConnection(options) {
299
299
  // complete"), issues no connection IDs and sends no HANDSHAKE_DONE.
300
300
  peer_finished: false,
301
301
  handshake_done_sent: false,
302
+ new_token_for: null, // the address:port the last NEW_TOKEN was for (issueNewToken)
302
303
 
303
304
  // Connection close (RFC 9000 §10.2)
304
305
  close_frame: null, // the CONNECTION_CLOSE frame to (re)send while closing
@@ -613,6 +614,13 @@ function QUICConnection(options) {
613
614
  stats_bytes_sent: 0,
614
615
  stats_packets_received: 0,
615
616
  stats_bytes_received: 0,
617
+ // The application's bytes in them, for WebTransport's getStats() (W3C
618
+ // WebTransportConnectionStats): DATAGRAM payloads sent (new STREAM bytes
619
+ // are max_data_sent), the payload of the packets acknowledged, and the
620
+ // size of the packets declared lost.
621
+ stats_datagram_bytes_sent: 0,
622
+ stats_payload_acked: 0,
623
+ stats_bytes_lost: 0,
616
624
  // Streams that hit the peer's flow-control limit since the last check —
617
625
  // for STREAM_DATA_BLOCKED (maybeSendBlockedFrames), instead of a walk of
618
626
  // every stream on every send pass.
@@ -696,13 +704,18 @@ function QUICConnection(options) {
696
704
 
697
705
  max_packets_per_burst: 20, // fixed cap — not CC-controlled, no current_/init_
698
706
 
699
- // packet payload (MTU). current_ is the size actually used; max_ is the ceiling
700
- // DPLPMTUD must not probe past; init_/floor is QUIC's guaranteed 1200-byte minimum.
707
+ // packet size (MTU). Datagram sizes are WHOLE UDP payloads: max_ is the
708
+ // ceiling DPLPMTUD must not probe past; init_ is QUIC's 1200 — RFC 9000
709
+ // §14: no datagram larger until a path has shown it carries one; current_
710
+ // is the size in use. current_limit_packet_payload is what a packet has
711
+ // for FRAMES: that datagram, within the peer's max_udp_payload_size, less
712
+ // the packet's header and AEAD tag (syncPacketPayload).
701
713
  max_limit_packet_payload: connOpts.maxUdpPayloadSize, // ceiling, maxUdpPayloadSize: 1452 (IPv6-safe Ethernet: 1500 − 48 hdr) —
702
714
  // a whole UDP datagram: what DPLPMTUD probes up to
703
- init_limit_packet_payload: 1200, // QUIC guaranteed floor — where we start
704
- current_limit_packet_payload: 1200, // = init_; DPLPMTUD raises it: the frames a packet
705
- // carries, the datagram it confirmed less its header
715
+ init_limit_datagram: 1200, // QUIC's guaranteed floor — where we start
716
+ current_limit_datagram: 1200, // = init_; DPLPMTUD raises it
717
+ current_limit_packet_payload: 1200 - 69, // frames; set by syncPacketPayload — until then,
718
+ // 1200 less the largest header (a 0-RTT packet, 20-byte CIDs)
706
719
  pmtu: null, // DPLPMTUD's state (pmtuReset)
707
720
  peer_max_udp_payload: 65527, // its max_udp_payload_size (RFC 9000 §18.2)
708
721
 
@@ -731,7 +744,7 @@ function QUICConnection(options) {
731
744
  current_limit_packets_per_sec: 12000, // = init_; BBR rewrites at round-end
732
745
  max_limit_bytes_per_sec: 14000000, // ceiling ~14 MB/s — coherent ↑
733
746
  min_limit_bytes_per_sec: 12000, // floor ~96 kbps — avoid 0/deadlock
734
- init_limit_bytes_per_sec: 14000000,
747
+ init_limit_bytes_per_sec: 14000000, // until the first RTT sample (bbrInitPacingRate)
735
748
  current_limit_bytes_per_sec: 14000000, // = init_; BBR rewrites at round-end
736
749
 
737
750
  burst_timer: null,
@@ -888,6 +901,7 @@ function QUICConnection(options) {
888
901
  // pn → { t: expiry wall-time, spans: [[sid, ranges, finLost], ...] }
889
902
  // (ranges: the packet's flat range list of that stream)
890
903
  expired_unacked: {},
904
+ limbo_n: 0, // entries in expired_unacked (kept, not counted)
891
905
  reorder_in_count: 0, // app packets that arrived below the highest PN seen (network reordering, peer→us)
892
906
 
893
907
  // --- BBR-lite measurement (Phase 4b, step 1+2 — measure only, no control). ---
@@ -917,6 +931,10 @@ function QUICConnection(options) {
917
931
  // a round says how much the APPLICATION produced, not what the path can
918
932
  // carry — see the sample filter at round end.
919
933
  bbr_round_app_limited: false,
934
+ // The most bytes in flight in this round and in the last — whether the
935
+ // window is in use, for Startup's growth (growStartupWindow).
936
+ bbr_peak_inflight: 0,
937
+ bbr_prev_peak_inflight: 0,
920
938
  bbr_full_bw: 0, // highest BtlBw seen — Startup plateau detector
921
939
  bbr_full_bw_count: 0, // consecutive rounds without ≥25% BtlBw growth
922
940
  // ACK aggregation (BBR's extra_acked): how far ACKs ran ahead of what
@@ -1122,6 +1140,18 @@ function QUICConnection(options) {
1122
1140
  if (changed.state && (context.state === 'draining' || context.state === 'closing' || context.state === 'closed')) handshakeEnded(false);
1123
1141
 
1124
1142
  if (changed.state && (context.state === 'draining' || context.state === 'closing')) {
1143
+ // 'closing': the connection is over for the application now — the
1144
+ // peer's CONNECTION_CLOSE came (draining), or ours went (closing).
1145
+ // 'close' follows when the closing or draining period ends (RFC 9000
1146
+ // §10.2: up to three PTOs, here at most 3 s), which is packet
1147
+ // housekeeping, not news: a session above that waited for 'close'
1148
+ // learned the peer was gone seconds late. With the peer's code and
1149
+ // reason (context.peer_close) when it was the peer; null when we
1150
+ // closed. Once.
1151
+ if (!context.closing_emitted) {
1152
+ context.closing_emitted = true;
1153
+ ev.emit('closing', context.state === 'draining' ? (context.peer_close || null) : null);
1154
+ }
1125
1155
  failPermitWaiters();
1126
1156
  clearIdleTimer();
1127
1157
  // A draining/closing endpoint must stop retransmitting CRYPTO — the
@@ -1441,7 +1471,7 @@ function QUICConnection(options) {
1441
1471
  // the search may go there.
1442
1472
  function setMaxUdpPayload(v) {
1443
1473
  context.max_limit_packet_payload = v;
1444
- var now = context.current_limit_packet_payload + shortHeaderOverhead();
1474
+ var now = context.current_limit_datagram;
1445
1475
  var m = context.pmtu;
1446
1476
  if (now > v) pmtuApply(v);
1447
1477
  if (m) {
@@ -1660,6 +1690,7 @@ function QUICConnection(options) {
1660
1690
  context.sending_app_pn_in_flight.clear();
1661
1691
  context.pn_sids.clear();
1662
1692
  context.expired_unacked = {};
1693
+ context.limbo_n = 0;
1663
1694
  context.no_resend_in_flight = {};
1664
1695
  context.control_in_flight = {};
1665
1696
  for (var sid in context.send_streams) {
@@ -1777,6 +1808,8 @@ function QUICConnection(options) {
1777
1808
  // as a CRYPTO_ERROR (0x100 + alert code, RFC 9001 §4.8) — the peer learns
1778
1809
  // why, and this side reports the close now rather than at a timeout.
1779
1810
  tls.on('alert', function (code) { close(0x100 + code, 'TLS alert ' + code); });
1811
+ // The peer's quic_transport_parameters that cannot be read at all.
1812
+ tls.on('transportParamsError', function (why) { close(0x08, why); });
1780
1813
  // The reason for that alert. During the handshake it is the connection's
1781
1814
  // failure, reported like the handshake timeout: 'error', then 'close'.
1782
1815
  // The alert has already closed the connection, so nothing else to do.
@@ -1902,6 +1935,7 @@ function QUICConnection(options) {
1902
1935
  // a bound on what DPLPMTUD probes. "Values below 1200 are invalid."
1903
1936
  if (typeof p.max_udp_payload_size === 'number') {
1904
1937
  context.peer_max_udp_payload = p.max_udp_payload_size;
1938
+ syncPacketPayload();
1905
1939
  }
1906
1940
  // Stream-count limits the peer grants us (localStreamPermitted holds
1907
1941
  // back a stream past them). Seeded directly, not through set_context:
@@ -2042,10 +2076,7 @@ function QUICConnection(options) {
2042
2076
  queueControl('hd'); // retransmitted until acknowledged (§13.3)
2043
2077
  // A token for the client's next connection (NEW_TOKEN, §8.1.3):
2044
2078
  // it will need no proof of address then.
2045
- if (typeof options.newToken === 'function') {
2046
- var nt = options.newToken(peerPath().address, context.version);
2047
- if (nt && nt.byteLength > 0) queueControl('nt', { type: 'new_token', token: nt });
2048
- }
2079
+ issueNewToken(peerPath());
2049
2080
  }
2050
2081
  discardSpace('handshake');
2051
2082
  crStart();
@@ -2060,9 +2091,10 @@ function QUICConnection(options) {
2060
2091
  }
2061
2092
  });
2062
2093
 
2063
- // Note: QUIC handles key updates via key_phase bit, not TLS KeyUpdate message.
2064
- // RFC 9001 §6: "endpoints MUST treat the receipt of a TLS KeyUpdate message
2065
- // as a connection error of type 0x010a" — but we silently ignore it.
2094
+ // QUIC updates keys with the Key Phase bit, never a TLS KeyUpdate (RFC
2095
+ // 9001 §6): one received, in any space, closes the connection with 0x010a
2096
+ // (TLS_RECEIVABLE, in processCryptoFrame); one TLS would send is not sent
2097
+ // (tls_bridge).
2066
2098
  }
2067
2099
 
2068
2100
 
@@ -2344,13 +2376,22 @@ function QUICConnection(options) {
2344
2376
  );
2345
2377
  }
2346
2378
 
2347
- if (!decrypted || !decrypted.plaintext || decrypted.plaintext.byteLength === 0) {
2379
+ if (!decrypted || !decrypted.plaintext) {
2348
2380
  if (DEBUG) console.log('[quic] decrypt failed: ' + space + ' raw_len=' + pkt.raw.byteLength + ' first20=' + Array.from(pkt.raw.slice(0, 20)).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' ') + ' recv_cid_len=' + (recvCid ? recvCid.byteLength : 'null') + ' has_keys=' + !!readKeys + ' largest_pn=' + context.recv_pn_largest[space]);
2349
2381
  return;
2350
2382
  }
2351
2383
 
2352
2384
  if (DEBUG) console.log('[quic] decrypted ' + space + ' pn=' + decrypted.packet_number + ' len=' + decrypted.plaintext.byteLength);
2353
2385
 
2386
+ // The reserved bits, now that the packet is authenticated: set, it is a
2387
+ // PROTOCOL_VIOLATION (RFC 9000 §17.2: 0x0c of a long header; §17.3.1:
2388
+ // 0x18 of a short one). An empty payload goes on to the frames, where a
2389
+ // packet with none is one too (§12.4).
2390
+ if (decrypted.first_byte & ((decrypted.first_byte & 0x80) ? 0x0c : 0x18)) {
2391
+ close(0x0a, 'reserved header bits set in a ' + space + ' packet');
2392
+ return;
2393
+ }
2394
+
2354
2395
  // RFC 9000 §8.1: "A server might wish to validate the client address
2355
2396
  // before starting the cryptographic handshake. [...] the server MAY
2356
2397
  // consider the peer address validated if the client uses a connection ID
@@ -2657,10 +2698,15 @@ function QUICConnection(options) {
2657
2698
  } else if (frame.type === 'streams_blocked_bidi' || frame.type === 'streams_blocked_uni') {
2658
2699
  // The peer has used every stream we granted. Ack-eliciting like any
2659
2700
  // frame but ACK/PADDING/CONNECTION_CLOSE (it used to fall through
2660
- // unacknowledged); answered with our current limit, which also
2661
- // recovers a lost MAX_STREAMS, as DATA_BLOCKED does for MAX_DATA.
2701
+ // unacknowledged). Blocked below our current limit, it missed a
2702
+ // MAX_STREAMS: sent again — as DATA_BLOCKED recovers MAX_DATA. At it,
2703
+ // the peer has our limit already, and a MAX_STREAMS for each such
2704
+ // frame would be one frame of ours for every one of its.
2662
2705
  ackEliciting = true;
2663
- queueControl(frame.type === 'streams_blocked_bidi' ? 'ms_bidi' : 'ms_uni');
2706
+ var sbBidi = frame.type === 'streams_blocked_bidi';
2707
+ if (frame.limit < (sbBidi ? context.local_max_streams_bidi : context.local_max_streams_uni)) {
2708
+ queueControl(sbBidi ? 'ms_bidi' : 'ms_uni');
2709
+ }
2664
2710
  } else if (frame.type === 'data_blocked') {
2665
2711
  // The peer is stalled on our connection window. Our MAX_DATA may have
2666
2712
  // been lost (window updates are sent once, unrecovered) — re-send the
@@ -2930,6 +2976,21 @@ function QUICConnection(options) {
2930
2976
  // which rides in the ticket, never came back to it.
2931
2977
  var NEW_SESSION_TICKET = 0x04;
2932
2978
 
2979
+ // Which TLS messages each side may receive, in which space (RFC 9001 §4,
2980
+ // Table 1; RFC 8446 §4). TLS itself does not know the spaces — they are
2981
+ // QUIC's encryption levels — so they are held to here, for every message,
2982
+ // before TLS sees it. Never in any space: KeyUpdate (§6 — QUIC updates its
2983
+ // keys itself), EndOfEarlyData (§8.3), anything a client sends to a client
2984
+ // or a server to a server. Any other: unexpected_message (§4.8, §6).
2985
+ // 1 ClientHello · 2 ServerHello (a HelloRetryRequest included) ·
2986
+ // 4 NewSessionTicket · 8 EncryptedExtensions · 11 Certificate ·
2987
+ // 13 CertificateRequest · 15 CertificateVerify · 20 Finished ·
2988
+ // 25 CompressedCertificate (RFC 8879)
2989
+ var TLS_RECEIVABLE = {
2990
+ server: { initial: [1], handshake: [11, 15, 20, 25], app: [] },
2991
+ client: { initial: [2], handshake: [8, 11, 13, 15, 20, 25], app: [NEW_SESSION_TICKET] }
2992
+ };
2993
+
2933
2994
  // Reassembly, per space. In order: `q`, the bytes received and not yet
2934
2995
  // read as TLS messages (pieces, `qlen` of them in all), up to `end`.
2935
2996
  // Ahead of a gap: `ahead`, pieces sorted by offset that never overlap
@@ -3040,15 +3101,21 @@ function QUICConnection(options) {
3040
3101
  if (messages.length === 0) return;
3041
3102
  if (!tls) { if (DEBUG) console.log('[quic] initializing TLS bridge'); initTLS(); }
3042
3103
 
3043
- for (var mi = 0; mi < messages.length; mi++) {
3044
- var msg = messages[mi];
3045
- var msgType = msg[0];
3046
- if (space === 'app' && (context.isServer || msgType !== NEW_SESSION_TICKET)) {
3104
+ // Every message of the batch checked before TLS sees any: a Finished
3105
+ // followed by a KeyUpdate closes the connection without the Finished
3106
+ // answered first (a ticket, HANDSHAKE_DONE) — not after it.
3107
+ var receivable = TLS_RECEIVABLE[context.isServer ? 'server' : 'client'][space];
3108
+ for (var ci = 0; ci < messages.length; ci++) {
3109
+ if (receivable.indexOf(messages[ci][0]) < 0) {
3047
3110
  // TLS's unexpected_message as a CRYPTO_ERROR (RFC 9001 §4.8); a
3048
3111
  // KeyUpdate is named as one (§6).
3049
- close(0x100 + 10, 'TLS message ' + msgType + ' after the handshake');
3112
+ close(0x100 + 10, 'TLS message ' + messages[ci][0] + ' in the ' + space + ' space');
3050
3113
  return;
3051
3114
  }
3115
+ }
3116
+ for (var mi = 0; mi < messages.length; mi++) {
3117
+ var msg = messages[mi];
3118
+ var msgType = msg[0];
3052
3119
  // Closed by what an earlier message brought: the rest is not read.
3053
3120
  if (context.state === 'closing' || context.state === 'draining' || context.state === 'closed') return;
3054
3121
  if (DEBUG) console.log('[quic] → TLS msg #' + mi + ' type=0x' + msgType.toString(16) + ' len=' + msg.length);
@@ -4181,6 +4248,7 @@ function QUICConnection(options) {
4181
4248
  if (context.srtt === null) {
4182
4249
  context.srtt = adjusted;
4183
4250
  context.rttvar = adjusted / 2;
4251
+ bbrInitPacingRate();
4184
4252
  } else {
4185
4253
  context.rttvar = 0.75 * context.rttvar + 0.25 * Math.abs(context.srtt - adjusted);
4186
4254
  context.srtt = 0.875 * context.srtt + 0.125 * adjusted;
@@ -4297,10 +4365,23 @@ function QUICConnection(options) {
4297
4365
  if (extra > context.bbr_ea_round_max) context.bbr_ea_round_max = extra;
4298
4366
  }
4299
4367
 
4368
+ // Whether a window of w bytes is in use: in flight reached half of it,
4369
+ // this round or the last — slow start's test for being limited by the
4370
+ // window (Linux tcp_is_cwnd_limited; RFC 9002 §7.8).
4371
+ function windowInUse(w) {
4372
+ return 2 * Math.max(context.bbr_peak_inflight, context.bbr_prev_peak_inflight) >= w;
4373
+ }
4374
+
4300
4375
  function growStartupWindow(newlyAckedBytes) {
4301
4376
  if (context.bbr_state !== 'startup' || !(newlyAckedBytes > 0)) return;
4302
4377
  var cap = startupCap(), cur = context.current_limit_bytes_in_flight;
4303
4378
  if (cur >= cap) return;
4379
+ // Only a window in use grows (windowInUse). A sender with less to send
4380
+ // than the window — media below the path's rate — grew it by every byte
4381
+ // acknowledged, without end (1.2 Mbit/s for 3 s: 25 KB → 496 KB), a
4382
+ // window nothing had measured, and the next burst (a keyframe) went out
4383
+ // in it at once, into the bottleneck's queue.
4384
+ if (!windowInUse(cur)) return;
4304
4385
  context.current_limit_bytes_in_flight = Math.min(context.max_limit_bytes_in_flight, cap,
4305
4386
  cur + newlyAckedBytes);
4306
4387
  context.current_limit_packets_in_flight = Math.min(context.max_limit_packets_in_flight,
@@ -4361,6 +4442,24 @@ function QUICConnection(options) {
4361
4442
  Math.max(context.min_limit_packets_in_flight,
4362
4443
  Math.round(context.current_limit_bytes_in_flight / context.current_limit_packet_payload)));
4363
4444
  }
4445
+ // Startup's first pacing rate, from the first RTT sample: the initial
4446
+ // window over the round trip, at Startup's gain (BBR's BBRInitPacingRate,
4447
+ // §5.6.2; quiche the same). It was the ceiling, 14 MB/s — and Startup only
4448
+ // raises the rate, so it stayed there: Startup was not paced at all, the
4449
+ // window left at once at ~112 Mbit/s, into the queue of any slower path.
4450
+ // A media sender rarely leaves Startup, and every keyframe went so (a
4451
+ // 90 KB one, a 20 Mbit/s link with a 64 KB queue: ~20 drops a keyframe);
4452
+ // on a long path the first rounds' bursts were dropped, Startup ended on
4453
+ // those losses with ~14 of 20 Mbit/s measured, and a 4 MB transfer took
4454
+ // 3.7-3.9 s instead of 2.6 (150 ms). The cost: the initial window takes
4455
+ // RTT/2.89 to leave — a first page's body ~27 ms later at 150 ms, within
4456
+ // noise at 40. A new path starts here again (resetPathEstimates: the rate
4457
+ // back to init_, srtt null until its first sample).
4458
+ function bbrInitPacingRate() {
4459
+ if (context.bbr_state !== 'startup') return;
4460
+ setPacingRate(BBR_STARTUP_GAIN * context.init_limit_bytes_in_flight / (Math.max(context.srtt, 1) / 1000));
4461
+ }
4462
+
4364
4463
  function setPacingRate(bps) {
4365
4464
  context.current_limit_bytes_per_sec = Math.max(context.min_limit_bytes_per_sec,
4366
4465
  Math.min(context.max_limit_bytes_per_sec, bps));
@@ -4522,6 +4621,16 @@ function QUICConnection(options) {
4522
4621
  }
4523
4622
  }
4524
4623
 
4624
+ // A 1-RTT packet carrying stream data or datagrams declared lost: counted
4625
+ // (getStats' packetsLost, and bytesLost by its size on the wire), and
4626
+ // Careful Resume told.
4627
+ function countLostPacket(pnum) {
4628
+ context.lost_count++;
4629
+ var h = context.sending_app_pn_history[pnum - (context.send_pn.app - context.sending_app_pn_history.length)];
4630
+ if (h) context.stats_bytes_lost += h[1];
4631
+ crOnLoss();
4632
+ }
4633
+
4525
4634
  // A packet given up on — expired, or its control frames deemed lost: out
4526
4635
  // of the bytes in flight. Returns its size (0: not in flight any more).
4527
4636
  function forgetPacket(pn) {
@@ -4532,6 +4641,58 @@ function QUICConnection(options) {
4532
4641
  return pk.bytes;
4533
4642
  }
4534
4643
 
4644
+ // How many of the values in `r` (flat [from, to) pairs) `a` already
4645
+ // covers — flat_ranges.length(flat_ranges.intersect(a, r)), found by binary
4646
+ // search in `a` for each pair of `r` instead of a walk of both. Both are
4647
+ // the library's sorted, merged lists; `r` not so (never seen) is answered
4648
+ // the original way.
4649
+ function coveredLength(a, r) {
4650
+ var na = a.length, total = 0, prevTo = -Infinity;
4651
+ if (na === 0 || r.length === 0) return 0;
4652
+ for (var i = 0; i < r.length; i += 2) {
4653
+ var f = r[i], t = r[i + 1];
4654
+ if (f < prevTo) return flat_ranges.length(flat_ranges.intersect(a, r));
4655
+ prevTo = t;
4656
+ if (f >= t) continue;
4657
+ // first pair of a whose `to` > f
4658
+ var lo = 0, hi = na >> 1;
4659
+ while (lo < hi) {
4660
+ var mid = (lo + hi) >> 1;
4661
+ if (a[(mid << 1) + 1] <= f) lo = mid + 1; else hi = mid;
4662
+ }
4663
+ for (var k = lo << 1; k < na && a[k] < t; k += 2) {
4664
+ var from = a[k] > f ? a[k] : f;
4665
+ var to = a[k + 1] < t ? a[k + 1] : t;
4666
+ if (to > from) total += to - from;
4667
+ }
4668
+ }
4669
+ return total;
4670
+ }
4671
+
4672
+ // Would flat_ranges.remove(list, r) change `list`? Only if a pair of `r`
4673
+ // overlaps one of it (half-open; flat_ranges.overlaps, a binary search).
4674
+ function overlapsAny(list, r) {
4675
+ if (list.length === 0) return false;
4676
+ for (var i = 0; i < r.length; i += 2) {
4677
+ if (flat_ranges.overlaps(list, r[i], r[i + 1])) return true;
4678
+ }
4679
+ return false;
4680
+ }
4681
+
4682
+ // How many packet numbers a flat [from, to, from, to, ...] list of
4683
+ // inclusive ranges covers.
4684
+ function rangesSpan(r) {
4685
+ var n = 0;
4686
+ for (var i = 0; i < r.length; i += 2) n += r[i + 1] - r[i] + 1;
4687
+ return n;
4688
+ }
4689
+
4690
+ // Entries in the limbo table (kept as it changes — counting them meant
4691
+ // walking it).
4692
+ function limboCount() {
4693
+ return context.limbo_n > 0 ? context.limbo_n : 0;
4694
+ }
4695
+
4535
4696
  function processAckFrame(space, frame) {
4536
4697
  // RFC 9000 §13.1: "An endpoint SHOULD treat receipt of an acknowledgment
4537
4698
  // for a packet it did not send as a connection error of type
@@ -4665,9 +4826,28 @@ function QUICConnection(options) {
4665
4826
  // Mark in-flight as acked. Collect the acked PNs first, then mutate —
4666
4827
  // deleting from a Set while iterating it with for-of is fragile.
4667
4828
  var ackedPns = [];
4668
- for (var pn of context.sending_app_pn_in_flight) {
4829
+ // Walked from whichever side is smaller: the packets in flight, or the
4830
+ // packet numbers this ACK covers. Every in-flight packet was tested
4831
+ // against every range on every ACK — at a high RTT the window holds
4832
+ // thousands, and a page's worth of ACKs cost that many times over.
4833
+ // Ascending either way (the set is in send order), as before.
4834
+ if (rangesSpan(ackedRanges) < context.sending_app_pn_in_flight.size) {
4669
4835
  for (var ri = 0; ri < ackedRanges.length; ri += 2) {
4670
- if (pn >= ackedRanges[ri] && pn <= ackedRanges[ri + 1]) { ackedPns.push(pn); break; }
4836
+ for (var rpn0 = ackedRanges[ri]; rpn0 <= ackedRanges[ri + 1]; rpn0++) {
4837
+ if (context.sending_app_pn_in_flight.has(rpn0)) ackedPns.push(rpn0);
4838
+ }
4839
+ }
4840
+ } else {
4841
+ // Both in ascending order (ack_frame_to_ranges sorts and merges the
4842
+ // ranges; the set is in packet-number order): one pointer into the
4843
+ // ranges, and the walk ends past the largest acknowledged — what is
4844
+ // in flight above it cannot be in this ACK. Each packet was tested
4845
+ // against every range, and the walk went on to the newest packet.
4846
+ var rp1 = 0, rTop1 = ackedRanges[ackedRanges.length - 1];
4847
+ for (var pn of context.sending_app_pn_in_flight) {
4848
+ if (pn > rTop1) break;
4849
+ while (ackedRanges[rp1 + 1] < pn) rp1 += 2;
4850
+ if (pn >= ackedRanges[rp1]) ackedPns.push(pn);
4671
4851
  }
4672
4852
  }
4673
4853
  // A packet's ranges of a stream, acknowledged. Returns the bytes newly
@@ -4675,12 +4855,19 @@ function QUICConnection(options) {
4675
4855
  // transmission and a retransmission of the same range ending up ACKed
4676
4856
  // (routine once late ACKs for expired packets are honoured) count once.
4677
4857
  function creditRanges(st, ranges) {
4678
- var fresh = flat_ranges.length(ranges) - flat_ranges.length(flat_ranges.intersect(st.acked_ranges, ranges));
4858
+ // The bytes of `ranges` not acknowledged before — what
4859
+ // length(intersect(acked, ranges)) gave, without walking all of
4860
+ // acked_ranges and building the intersection for every packet. After
4861
+ // a loss acked_ranges holds hundreds of pieces until the hole is
4862
+ // filled, and this ran once per acknowledged packet (coveredLength).
4863
+ var fresh = flat_ranges.length(ranges) - coveredLength(st.acked_ranges, ranges);
4679
4864
  flat_ranges.add(st.acked_ranges, ranges);
4680
4865
  // Out of missing, whatever it was: an ACK is proof — a late one too,
4681
4866
  // for bytes already given up on and not yet resent (add_have would
4682
4867
  // leave those missing: it defers to what not_have says).
4683
- flat_ranges.remove(st.missing_ranges, ranges);
4868
+ // remove() walks and rebuilds the whole list; it is called only when
4869
+ // it would change something — when the ranges touch a missing one.
4870
+ if (overlapsAny(st.missing_ranges, ranges)) flat_ranges.remove(st.missing_ranges, ranges);
4684
4871
  if (st.acked_ranges[0] === 0) sendQueueRelease(st, st.acked_ranges[1]);
4685
4872
  return fresh > 0 ? fresh : 0;
4686
4873
  }
@@ -4714,15 +4901,17 @@ function QUICConnection(options) {
4714
4901
  var largestAckedPn = -1;
4715
4902
  for (var lri = 0; lri < ackedRanges.length; lri += 2) if (ackedRanges[lri + 1] > largestAckedPn) largestAckedPn = ackedRanges[lri + 1];
4716
4903
  var ackedPkts = [];
4904
+ var rp2 = 0; // pointer into the ranges, as for ackedPns above
4717
4905
  for (var pke of context.pkt_in_flight) {
4718
4906
  var ppn = pke[0];
4719
4907
  if (ppn > largestAckedPn) break;
4720
- for (var pri = 0; pri < ackedRanges.length; pri += 2) {
4721
- if (ppn >= ackedRanges[pri] && ppn <= ackedRanges[pri + 1]) { ackedPkts.push(ppn); break; }
4722
- }
4908
+ while (ackedRanges[rp2 + 1] < ppn) rp2 += 2;
4909
+ if (ppn >= ackedRanges[rp2]) ackedPkts.push(ppn);
4723
4910
  }
4724
4911
  var ackedSizes = [];
4725
4912
  for (var api = 0; api < ackedPkts.length; api++) {
4913
+ var apk = context.pkt_in_flight.get(ackedPkts[api]);
4914
+ if (apk && apk.payload) context.stats_payload_acked += apk.payload;
4726
4915
  var asz = forgetPacket(ackedPkts[api]);
4727
4916
  ackedSizes.push(asz);
4728
4917
  ackedPacketBytes += asz;
@@ -4756,14 +4945,33 @@ function QUICConnection(options) {
4756
4945
  // DID arrive — the timer was simply wrong — so: credit the goodput
4757
4946
  // (repairing the BtlBw signal), mark the spans acked (cancelling any
4758
4947
  // retransmit that hasn't left yet), and take back the loss we counted.
4759
- for (var lpn in context.expired_unacked) {
4760
- var lnum = Number(lpn);
4761
- var hit = false;
4762
- for (var ri2 = 0; ri2 < ackedRanges.length; ri2 += 2) {
4763
- if (lnum >= ackedRanges[ri2] && lnum <= ackedRanges[ri2 + 1]) { hit = true; break; }
4948
+ // The limbo table holds up to ~10 s of expired packets: walking all of
4949
+ // it for each ACK (for-in, a string key made per entry) was the larger
4950
+ // part of an ACK's cost on a lossy, distant path. The covered numbers
4951
+ // are looked up instead when they are fewer — ascending, as for-in
4952
+ // visits integer keys.
4953
+ var lateHits = [];
4954
+ if (rangesSpan(ackedRanges) < limboCount()) {
4955
+ for (var lr = 0; lr < ackedRanges.length; lr += 2) {
4956
+ for (var lq = ackedRanges[lr]; lq <= ackedRanges[lr + 1]; lq++) {
4957
+ if (lq in context.expired_unacked) lateHits.push(String(lq));
4958
+ }
4764
4959
  }
4765
- if (!hit) continue;
4960
+ } else {
4961
+ // Ascending (integer keys), so: one pointer into the ranges, and
4962
+ // done past the largest acknowledged.
4963
+ var rp3 = 0, rTop3 = ackedRanges[ackedRanges.length - 1];
4964
+ for (var lpn0 in context.expired_unacked) {
4965
+ var lnum = Number(lpn0);
4966
+ if (lnum > rTop3) break;
4967
+ while (ackedRanges[rp3 + 1] < lnum) rp3 += 2;
4968
+ if (lnum >= ackedRanges[rp3]) lateHits.push(lpn0);
4969
+ }
4970
+ }
4971
+ for (var lhi = 0; lhi < lateHits.length; lhi++) {
4972
+ var lpn = lateHits[lhi];
4766
4973
  var rec = context.expired_unacked[lpn];
4974
+ if (!rec) continue;
4767
4975
  for (var si2 = 0; si2 < rec.spans.length; si2++) {
4768
4976
  var sidL = rec.spans[si2][0];
4769
4977
  var stL = context.send_streams[sidL];
@@ -4776,6 +4984,7 @@ function QUICConnection(options) {
4776
4984
  ackedPacketBytes += rec.bytes || 0;
4777
4985
  if (rec.early) context.stats_spurious_early++;
4778
4986
  delete context.expired_unacked[lpn];
4987
+ context.limbo_n--;
4779
4988
  if (context.lost_count > 0) context.lost_count--; // it wasn't a loss after all
4780
4989
  }
4781
4990
 
@@ -4837,7 +5046,10 @@ function QUICConnection(options) {
4837
5046
  var roundDelivered = context.delivered - context.bbr_round_start_delivered;
4838
5047
  // An app-limited sample is kept only if it RAISES the estimate
4839
5048
  // (BBR delivery-rate sampling: such a sample can show the path is at
4840
- // least that fast, never that it is slower).
5049
+ // least that fast, never that it is slower). With no estimate yet,
5050
+ // any sample raises it — BBR's max_bw starts at 0 (§5.2.1): a
5051
+ // connection that never filled the pipe (media below the path's
5052
+ // rate) had no estimate at all, estimatedSendRate null throughout.
4841
5053
  //
4842
5054
  // Without this, the first round of every connection — which carries
4843
5055
  // only a small HTML response and some ACK-only packets — measured a
@@ -4854,7 +5066,7 @@ function QUICConnection(options) {
4854
5066
  var crRetreat = !!(context.cr && context.cr.phase === 'safe_retreat');
4855
5067
  if (!crRetreat && roundDur >= 1 && roundDelivered > 0) {
4856
5068
  var roundRate = (roundDelivered * 1000) / roundDur;
4857
- if (!roundAppLimited || (context.bbr_btlbw !== null && roundRate > context.bbr_btlbw)) {
5069
+ if (!roundAppLimited || roundRate > (context.bbr_btlbw || 0)) {
4858
5070
  context.bbr_btlbw_samples.push({ round: context.bbr_round_count, rate: roundRate });
4859
5071
  }
4860
5072
  }
@@ -4873,6 +5085,8 @@ function QUICConnection(options) {
4873
5085
  context.bbr_round_start_pn = context.send_pn.app; // next PN must be acked for the next round
4874
5086
  context.bbr_round_start_delivered = context.delivered;
4875
5087
  context.bbr_round_start_time = ackNow;
5088
+ context.bbr_prev_peak_inflight = context.bbr_peak_inflight;
5089
+ context.bbr_peak_inflight = context.app_bytes_in_flight;
4876
5090
 
4877
5091
  var BBR_BTLBW_WINDOW = 10; // rounds
4878
5092
  var cutoff = context.bbr_round_count - BBR_BTLBW_WINDOW;
@@ -4885,7 +5099,11 @@ function QUICConnection(options) {
4885
5099
  }
4886
5100
  }
4887
5101
  context.bbr_btlbw_samples = kept;
4888
- context.bbr_btlbw = maxRate;
5102
+ // No sample left in the window — every round since app-limited and
5103
+ // below the estimate: the estimate stands (BBR's max filter changes
5104
+ // only on a sample it takes). It went back to null after ten such
5105
+ // rounds, and the state machine below with it, mid-connection.
5106
+ if (kept.length > 0) context.bbr_btlbw = maxRate;
4889
5107
 
4890
5108
  // Derived in-flight target. Uses bbr_min_rtt (RTprop, windowed) in seconds.
4891
5109
  if (!crRetreat && context.bbr_btlbw !== null && context.bbr_min_rtt !== null) {
@@ -4966,6 +5184,9 @@ function QUICConnection(options) {
4966
5184
  // ── Write the controls, clamped to [min,max] (the hard safety net). ──
4967
5185
  var clamp = function (v, lo, hi) { return Math.max(lo, Math.min(hi, v)); };
4968
5186
 
5187
+ // The window as the ACKs left it — Startup's floor, below; read
5188
+ // before it is written.
5189
+ var ackGrown = context.current_limit_bytes_in_flight;
4969
5190
  var targetInflight = clamp(cwnd_gain * context.bbr_bdp,
4970
5191
  context.min_limit_bytes_in_flight, context.max_limit_bytes_in_flight);
4971
5192
  context.current_limit_bytes_in_flight = targetInflight;
@@ -4984,8 +5205,18 @@ function QUICConnection(options) {
4984
5205
  if (context.bbr_state === 'startup') {
4985
5206
  // …within startupCap, which may now be lower than what ACKs grew
4986
5207
  // the window to (the BDP of this round is the measured one).
5208
+ // The window before the write above: compared with what was
5209
+ // just written, the max was of a value with itself, and every
5210
+ // round end set the window to max(IW, 2·BDP) — what the ACKs
5211
+ // had grown it by, gone. Kept while it is in use; a window the
5212
+ // last rounds did not fill — a burst (a keyframe) grew it, then
5213
+ // the sender went quiet — goes back to the BDP's: the next burst
5214
+ // left in all of it at Startup's rate, into the bottleneck's
5215
+ // queue (RFC 7661: a window not in use is not validated). Not
5216
+ // while Careful Resume's jump is out: that window is its own.
5217
+ var floor = crJumpOut() || windowInUse(ackGrown) ? ackGrown : 0;
4987
5218
  targetInflight = Math.max(context.init_limit_bytes_in_flight,
4988
- Math.min(Math.max(targetInflight, context.current_limit_bytes_in_flight), startupCap()));
5219
+ Math.min(Math.max(targetInflight, floor), startupCap()));
4989
5220
  context.current_limit_bytes_in_flight = targetInflight;
4990
5221
  context.current_limit_packets_in_flight = clamp(
4991
5222
  Math.round(targetInflight / context.current_limit_packet_payload),
@@ -5245,7 +5476,8 @@ function QUICConnection(options) {
5245
5476
  var sent = { initial: 0, handshake: 0 };
5246
5477
  if (context.state === 'draining' || context.state === 'closed' || context.state === 'closing') return sent;
5247
5478
  var path = context.path;
5248
- var cap = Math.max(1200, context.current_limit_packet_payload);
5479
+ // A whole datagram: the size in use, within what the peer takes.
5480
+ var cap = Math.min(context.current_limit_datagram, context.peer_max_udp_payload);
5249
5481
  var plan = [], planBytes = 0;
5250
5482
  var nextPn = { initial: context.send_pn.initial, handshake: context.send_pn.handshake };
5251
5483
  var planned = { initial: [], handshake: [] };
@@ -5682,10 +5914,27 @@ function QUICConnection(options) {
5682
5914
  return (prev && prev.validated) ? prev : context.path;
5683
5915
  }
5684
5916
 
5917
+ // Server: a NEW_TOKEN for the address the client is at (§8.1.3) — after
5918
+ // the handshake, and again each time the client turns up at a new address
5919
+ // and proves it (§9.3: a migration, a NAT rebinding), since the client's
5920
+ // next connection comes from there. The newest replaces one not yet sent.
5921
+ function issueNewToken(path) {
5922
+ if (!context.isServer || typeof options.newToken !== 'function' || !context.handshake_done_sent) return;
5923
+ var at = path.address + ':' + path.port;
5924
+ if (context.new_token_for === at) return;
5925
+ var nt = options.newToken(path.address, context.version);
5926
+ if (!nt || nt.byteLength === 0) return;
5927
+ context.new_token_for = at;
5928
+ queueControl('nt', { type: 'new_token', token: nt });
5929
+ }
5930
+
5685
5931
  function markValidated(path) {
5686
5932
  if (path.validated) return;
5687
5933
  path.validated = true;
5688
- if (path === context.path) ev.emit('path_validated', path.address, path.port);
5934
+ if (path === context.path) {
5935
+ ev.emit('path_validated', path.address, path.port);
5936
+ issueNewToken(path);
5937
+ }
5689
5938
  if (DEBUG) console.log('[quic] path ' + path.address + ':' + path.port + ' validated (received ' + path.received + 'B, sent ' + path.sent + 'B before)');
5690
5939
  if (path.queue.length > 0) releaseAmpQueue(path);
5691
5940
  }
@@ -5693,8 +5942,8 @@ function QUICConnection(options) {
5693
5942
  // ============================================================
5694
5943
  // Path MTU discovery — DPLPMTUD (RFC 8899; RFC 9000 §14.3)
5695
5944
  //
5696
- // Packets start at what every QUIC path carries (current_limit_packet_
5697
- // payload, 1200 bytes of frames). Most paths carry more — ~1450 on
5945
+ // Packets start at what every QUIC path carries (current_limit_datagram:
5946
+ // datagrams of 1200 bytes, header and all). Most paths carry more — ~1450 on
5698
5947
  // Ethernet — and every byte more in a packet is a packet less: an
5699
5948
  // encryption, a system call, the per-packet bookkeeping, which is where a
5700
5949
  // JavaScript sender spends its time. So once the handshake is confirmed,
@@ -5754,14 +6003,30 @@ function QUICConnection(options) {
5754
6003
  function shortHeaderOverhead() {
5755
6004
  return 1 + destinationCid('1rtt', context.path).byteLength + 4 + 16;
5756
6005
  }
5757
- // The datagram every packet up to the floor's frames fits in.
5758
- function pmtuFloor() { return context.init_limit_packet_payload + shortHeaderOverhead(); }
6006
+ // The header and AEAD tag of the packets app data goes in now: 0-RTT's
6007
+ // long header until 1-RTT keys, a short header after.
6008
+ function appPacketOverhead() {
6009
+ if (!context.app_write && zeroRttSending()) {
6010
+ return 1 + 4 + 1 + destinationCid('0rtt', context.path).byteLength + 1 +
6011
+ (context.my_cids[0] ? context.my_cids[0].byteLength : 0) + 2 + 4 + 16;
6012
+ }
6013
+ return shortHeaderOverhead();
6014
+ }
6015
+ // The frames a packet takes: the datagram in use — never past the peer's
6016
+ // max_udp_payload_size (§18.2) — less its header. Again whenever either, or
6017
+ // the connection ID, may have changed.
6018
+ function syncPacketPayload() {
6019
+ var d = Math.min(context.current_limit_datagram, context.peer_max_udp_payload);
6020
+ context.current_limit_packet_payload = Math.max(64, d - appPacketOverhead());
6021
+ }
6022
+ // The floor: a datagram of 1200 bytes, header included.
6023
+ function pmtuFloor() { return context.init_limit_datagram; }
5759
6024
  function pmtuCeiling() { return Math.min(context.max_limit_packet_payload, context.peer_max_udp_payload); }
5760
6025
 
5761
- // Packets carry the frames a `size` datagram has room for — never fewer
5762
- // than the floor's.
6026
+ // Packets fill `size`-byte datagrams — never smaller than the floor.
5763
6027
  function pmtuApply(size) {
5764
- context.current_limit_packet_payload = Math.max(context.init_limit_packet_payload, size - shortHeaderOverhead());
6028
+ context.current_limit_datagram = Math.max(context.init_limit_datagram, size);
6029
+ syncPacketPayload();
5765
6030
  if (DEBUG) console.log('[quic] PMTU ' + size + ' — packets of ' + context.current_limit_packet_payload + ' bytes of frames');
5766
6031
  }
5767
6032
 
@@ -5850,7 +6115,7 @@ function QUICConnection(options) {
5850
6115
  m.confirming = false;
5851
6116
  m.lo = 0; m.fail = p.size; m.size = 0; m.tries = 0;
5852
6117
  m.bigLost = 0;
5853
- context.current_limit_packet_payload = context.init_limit_packet_payload;
6118
+ pmtuApply(context.init_limit_datagram);
5854
6119
  m.nextAt = Date.now() + PMTU_RAISE_MS;
5855
6120
  return;
5856
6121
  }
@@ -5982,8 +6247,9 @@ function QUICConnection(options) {
5982
6247
  if (path !== context.path) return;
5983
6248
 
5984
6249
  // A path already validated, failing only the expanded re-check (§8.2.3).
5985
- // Every packet quico sends fits in 1200 bytes, so a path that has carried
5986
- // them is carrying what this connection needs; it goes on.
6250
+ // On a new path packets are back at the floor — datagrams of 1200 bytes
6251
+ // at most (pmtuApply) — so a path that has carried them is carrying what
6252
+ // this connection needs; it goes on.
5987
6253
  if (path.validated) return;
5988
6254
 
5989
6255
  // §9.3.2: "an endpoint MUST revert to using the last validated peer
@@ -6006,6 +6272,7 @@ function QUICConnection(options) {
6006
6272
  if (context.candidate_path === path) context.candidate_path = null;
6007
6273
  if (DEBUG) console.log('[quic] now sending to ' + path.address + ':' + path.port + (path.validated ? '' : ' (unvalidated)'));
6008
6274
  ev.emit('path', path.address, path.port);
6275
+ if (path.validated) issueNewToken(path);
6009
6276
  }
6010
6277
 
6011
6278
  // ── Connection IDs: issuing ours, keeping the peer's (RFC 9000 §5.1) ──
@@ -6210,7 +6477,7 @@ function QUICConnection(options) {
6210
6477
  context.current_limit_bytes_in_flight = context.init_limit_bytes_in_flight;
6211
6478
  context.current_limit_packets_per_sec = context.init_limit_packets_per_sec;
6212
6479
  context.current_limit_bytes_per_sec = context.init_limit_bytes_per_sec;
6213
- context.current_limit_packet_payload = context.init_limit_packet_payload;
6480
+ pmtuApply(context.init_limit_datagram);
6214
6481
  context.srtt = null;
6215
6482
  context.rttvar = null;
6216
6483
  context.min_rtt = null;
@@ -6229,6 +6496,7 @@ function QUICConnection(options) {
6229
6496
  context.bbr_bdp = null;
6230
6497
  context.bbr_state = 'startup';
6231
6498
  context.bbr_round_app_limited = false;
6499
+ context.bbr_peak_inflight = 0; context.bbr_prev_peak_inflight = 0;
6232
6500
  context.bbr_full_bw = 0;
6233
6501
  context.bbr_full_bw_count = 0;
6234
6502
  context.bbr_ea_start = 0; context.bbr_ea_delivered = 0; context.bbr_ea_round_max = 0;
@@ -6447,6 +6715,7 @@ function QUICConnection(options) {
6447
6715
  context.pkt_in_flight.set(pn, { bytes: encrypted.length, t: Date.now() });
6448
6716
  if (context.tlp_at === 0 || context.tlp_timer === null) tlpRearm(); else context.tlp_at = Date.now() + tlpDelay();
6449
6717
  context.app_bytes_in_flight += encrypted.length;
6718
+ if (context.app_bytes_in_flight > context.bbr_peak_inflight) context.bbr_peak_inflight = context.app_bytes_in_flight;
6450
6719
  }
6451
6720
  // In flight = carrying STREAM data, which expireInFlight finds and
6452
6721
  // retires through its stream. 1-RTT CRYPTO is the control queue's to
@@ -6454,11 +6723,14 @@ function QUICConnection(options) {
6454
6723
  // found by anything and held a slot of the packet budget forever.
6455
6724
  // DATAGRAM frames are in flight too, with nothing to resend
6456
6725
  // (no_resend_in_flight, see sendDatagram).
6457
- var has_data = false, datagramBytes = 0;
6726
+ var has_data = false, datagramBytes = 0, payloadBytes = 0;
6458
6727
  for (var i = 0; i < frameList.length; i++) {
6459
- if (frameList[i].type === 'stream') has_data = true;
6728
+ if (frameList[i].type === 'stream') { has_data = true; payloadBytes += frameList[i].data ? frameList[i].data.byteLength : 0; }
6460
6729
  else if (frameList[i].type === 'datagram') datagramBytes += frameList[i].data.byteLength;
6461
6730
  }
6731
+ context.stats_datagram_bytes_sent += datagramBytes;
6732
+ var inFlightPk = context.pkt_in_flight.get(pn);
6733
+ if (inFlightPk) inFlightPk.payload = payloadBytes + datagramBytes; // getStats' bytesAcknowledged
6462
6734
  if (has_data || datagramBytes > 0) context.sending_app_pn_in_flight.add(pn);
6463
6735
  if (datagramBytes > 0) {
6464
6736
  context.no_resend_in_flight[pn] = (context.no_resend_in_flight[pn] || 0) + datagramBytes;
@@ -6702,6 +6974,19 @@ function QUICConnection(options) {
6702
6974
  for (var i = 0; i < w.length; i++) w[i].cb(new Error('the connection is closing'));
6703
6975
  }
6704
6976
 
6977
+ function streamSendWindow(streamId) {
6978
+ var st = context.send_streams[Number(streamId)];
6979
+ if (!st) return 0;
6980
+ var queued = 0;
6981
+ for (var k in context.send_streams) {
6982
+ var o = context.send_streams[k];
6983
+ if (o.write_offset > o.max_sent_offset) queued += o.write_offset - o.max_sent_offset;
6984
+ }
6985
+ var streamRoom = st.remote_max_stream_data - st.write_offset;
6986
+ var connRoom = context.remote_max_data - context.max_data_sent - queued;
6987
+ return Math.max(0, Math.min(streamRoom, connRoom));
6988
+ }
6989
+
6705
6990
  // The connection's figures, as WebTransport's getStats() reports them
6706
6991
  // (W3C WebTransportConnectionStats), and what QUICO's congestion control
6707
6992
  // knows besides. Times in ms; estimatedSendRate in bits per second (BBR's
@@ -6709,9 +6994,21 @@ function QUICConnection(options) {
6709
6994
  // An exception of ours, caught: in the connection (internal_errors, the
6710
6995
  // last one's stack) and the server's counters. The packet being processed
6711
6996
  // is dropped (above), or HTTP/3 closes with H3_INTERNAL_ERROR (h3.js).
6997
+ // Reading e.stack formats the whole trace — a few hundred microseconds,
6998
+ // paid for every bad packet: a peer (or a scanner) sending many of them
6999
+ // cost the thread more in traces than in packets. The message always; the
7000
+ // stack, and a log line, once a minute per connection — the bug it points
7001
+ // at is the same one.
6712
7002
  function countInternalError(e) {
6713
7003
  context.internal_errors = (context.internal_errors || 0) + 1;
6714
- context.last_internal_error = (e && e.stack) || String(e);
7004
+ var now = Date.now();
7005
+ if (now - (context.internal_error_logged_at || 0) >= 60000) {
7006
+ context.internal_error_logged_at = now;
7007
+ context.last_internal_error = (e && e.stack) || String(e);
7008
+ console.warn('[quic] packet dropped on an internal error (' + context.internal_errors + ' on this connection so far): ' + context.last_internal_error);
7009
+ } else {
7010
+ context.last_internal_error = (e && e.message) || String(e);
7011
+ }
6715
7012
  if (serverStats) serverStats.internalErrors++;
6716
7013
  }
6717
7014
 
@@ -6720,6 +7017,11 @@ function QUICConnection(options) {
6720
7017
  bytesSent: context.stats_bytes_sent,
6721
7018
  packetsSent: context.stats_packets_sent,
6722
7019
  packetsLost: context.lost_count,
7020
+ bytesLost: context.stats_bytes_lost,
7021
+ // The application's bytes: new STREAM data and DATAGRAM payloads sent
7022
+ // (no retransmissions, no framing), and the payload acknowledged.
7023
+ payloadBytesSent: context.max_data_sent + context.stats_datagram_bytes_sent,
7024
+ payloadBytesAcknowledged: context.stats_payload_acked,
6723
7025
  bytesReceived: context.stats_bytes_received,
6724
7026
  packetsReceived: context.stats_packets_received,
6725
7027
  smoothedRtt: context.srtt,
@@ -7063,7 +7365,7 @@ function QUICConnection(options) {
7063
7365
  // honoured (see expired_unacked above). Keyed per-PN; a packet that
7064
7366
  // carried several streams accumulates one entry per stream.
7065
7367
  var limbo = context.expired_unacked[pnum];
7066
- if (!limbo) limbo = context.expired_unacked[pnum] = { t: now, spans: [] };
7368
+ if (!limbo) { limbo = context.expired_unacked[pnum] = { t: now, spans: [] }; context.limbo_n++; }
7067
7369
  var finLost = !!(st.fin_pns && st.fin_pns[pnum]);
7068
7370
  var lostRanges = st.in_flight_ranges[pn];
7069
7371
  limbo.spans.push([sid, lostRanges, finLost]);
@@ -7081,7 +7383,7 @@ function QUICConnection(options) {
7081
7383
  // Count the loss once per packet: the Set holds each PN once, so the
7082
7384
  // first stream to expire it returns true; later same-PN deletes (a
7083
7385
  // multi-stream packet) return false and don't double-count.
7084
- if (context.sending_app_pn_in_flight.delete(pnum)) { context.lost_count++; crOnLoss(); }
7386
+ if (context.sending_app_pn_in_flight.delete(pnum)) countLostPacket(pnum);
7085
7387
  }
7086
7388
  }
7087
7389
 
@@ -7098,7 +7400,7 @@ function QUICConnection(options) {
7098
7400
  if (context.expired_unacked[pnum]) context.expired_unacked[pnum].early = true;
7099
7401
  if (pnum in context.no_resend_in_flight) {
7100
7402
  delete context.no_resend_in_flight[pnum];
7101
- if (context.sending_app_pn_in_flight.delete(pnum)) { context.lost_count++; crOnLoss(); }
7403
+ if (context.sending_app_pn_in_flight.delete(pnum)) countLostPacket(pnum);
7102
7404
  }
7103
7405
  if (context.pkt_in_flight.has(pnum)) {
7104
7406
  var pBytes = forgetPacket(pnum);
@@ -7290,7 +7592,7 @@ function QUICConnection(options) {
7290
7592
  (now - context.sending_app_pn_history[ridx][0] >= timeout);
7291
7593
  if (!rexpired) continue;
7292
7594
  delete context.no_resend_in_flight[rpn];
7293
- if (context.sending_app_pn_in_flight.delete(rnum)) { context.lost_count++; crOnLoss(); }
7595
+ if (context.sending_app_pn_in_flight.delete(rnum)) countLostPacket(rnum);
7294
7596
  }
7295
7597
 
7296
7598
  // And every packet past the same timeout leaves the bytes in flight —
@@ -7306,9 +7608,15 @@ function QUICConnection(options) {
7306
7608
 
7307
7609
  // Bound the limbo table: entries older than ~8 timeouts (min 10s) will
7308
7610
  // never see their ACK — the peer retires ACK ranges long before that.
7309
- var limboCutoff = now - Math.max(10000, timeout * 8);
7310
- for (var lpn in context.expired_unacked) {
7311
- if (context.expired_unacked[lpn].t < limboCutoff) delete context.expired_unacked[lpn];
7611
+ // At most once a second: it walks the whole table, and this runs on
7612
+ // every send burst. An entry kept a second longer only means a late ACK
7613
+ // for it is still honoured.
7614
+ if (now - (context.limbo_pruned_at || 0) >= 1000) {
7615
+ context.limbo_pruned_at = now;
7616
+ var limboCutoff = now - Math.max(10000, timeout * 8);
7617
+ for (var lpn in context.expired_unacked) {
7618
+ if (context.expired_unacked[lpn].t < limboCutoff) { delete context.expired_unacked[lpn]; context.limbo_n--; }
7619
+ }
7312
7620
  }
7313
7621
  }
7314
7622
 
@@ -7371,6 +7679,7 @@ function QUICConnection(options) {
7371
7679
 
7372
7680
  function plan_quic_burst() {
7373
7681
  context.send_scheduled = false; // this pass takes what writes left waiting
7682
+ syncPacketPayload(); // 0-RTT → 1-RTT, a new connection ID
7374
7683
  if (!zeroRttSending()) {
7375
7684
  if (!context.app_write) return;
7376
7685
  if (context.state !== 'connected') return;
@@ -8284,6 +8593,13 @@ function QUICConnection(options) {
8284
8593
  getJA4: function (opts) { return (tls && tls.getJA4) ? tls.getJA4(opts) : null; },
8285
8594
  /** The peer's certificate chain, leaf first, as DER — or null. */
8286
8595
  getPeerCertificateChain: function () { return (tls && tls.getPeerCertificateChain) ? tls.getPeerCertificateChain() : null; },
8596
+ // TLS keying material (RFC 8446 §7.5), as node:tls's: WebTransport's
8597
+ // exportKeyingMaterial builds on it. null before the handshake has it.
8598
+ exportKeyingMaterial: function (length, label, context) { return (tls && tls.exportKeyingMaterial) ? tls.exportKeyingMaterial(length, label, context) : null; },
8599
+ // How many new bytes stream `streamId` could send now under flow control
8600
+ // (RFC 9000 §4.1): its own window, and the connection's less what every
8601
+ // stream has written and not yet sent. WebTransport's atomicWrite asks.
8602
+ streamSendWindow: streamSendWindow,
8287
8603
  // The client's certificate, node:tls-shaped, and whether it was verified
8288
8604
  // (requestCert): what req.socket answers over HTTP/3.
8289
8605
  getPeerCertificate: function () { return (tls && tls.getPeerCertificate) ? tls.getPeerCertificate() : {}; },