quico 0.7.0 → 0.8.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.
@@ -613,6 +613,13 @@ function QUICConnection(options) {
613
613
  stats_bytes_sent: 0,
614
614
  stats_packets_received: 0,
615
615
  stats_bytes_received: 0,
616
+ // The application's bytes in them, for WebTransport's getStats() (W3C
617
+ // WebTransportConnectionStats): DATAGRAM payloads sent (new STREAM bytes
618
+ // are max_data_sent), the payload of the packets acknowledged, and the
619
+ // size of the packets declared lost.
620
+ stats_datagram_bytes_sent: 0,
621
+ stats_payload_acked: 0,
622
+ stats_bytes_lost: 0,
616
623
  // Streams that hit the peer's flow-control limit since the last check —
617
624
  // for STREAM_DATA_BLOCKED (maybeSendBlockedFrames), instead of a walk of
618
625
  // every stream on every send pass.
@@ -888,6 +895,7 @@ function QUICConnection(options) {
888
895
  // pn → { t: expiry wall-time, spans: [[sid, ranges, finLost], ...] }
889
896
  // (ranges: the packet's flat range list of that stream)
890
897
  expired_unacked: {},
898
+ limbo_n: 0, // entries in expired_unacked (kept, not counted)
891
899
  reorder_in_count: 0, // app packets that arrived below the highest PN seen (network reordering, peer→us)
892
900
 
893
901
  // --- BBR-lite measurement (Phase 4b, step 1+2 — measure only, no control). ---
@@ -1122,6 +1130,18 @@ function QUICConnection(options) {
1122
1130
  if (changed.state && (context.state === 'draining' || context.state === 'closing' || context.state === 'closed')) handshakeEnded(false);
1123
1131
 
1124
1132
  if (changed.state && (context.state === 'draining' || context.state === 'closing')) {
1133
+ // 'closing': the connection is over for the application now — the
1134
+ // peer's CONNECTION_CLOSE came (draining), or ours went (closing).
1135
+ // 'close' follows when the closing or draining period ends (RFC 9000
1136
+ // §10.2: up to three PTOs, here at most 3 s), which is packet
1137
+ // housekeeping, not news: a session above that waited for 'close'
1138
+ // learned the peer was gone seconds late. With the peer's code and
1139
+ // reason (context.peer_close) when it was the peer; null when we
1140
+ // closed. Once.
1141
+ if (!context.closing_emitted) {
1142
+ context.closing_emitted = true;
1143
+ ev.emit('closing', context.state === 'draining' ? (context.peer_close || null) : null);
1144
+ }
1125
1145
  failPermitWaiters();
1126
1146
  clearIdleTimer();
1127
1147
  // A draining/closing endpoint must stop retransmitting CRYPTO — the
@@ -1660,6 +1680,7 @@ function QUICConnection(options) {
1660
1680
  context.sending_app_pn_in_flight.clear();
1661
1681
  context.pn_sids.clear();
1662
1682
  context.expired_unacked = {};
1683
+ context.limbo_n = 0;
1663
1684
  context.no_resend_in_flight = {};
1664
1685
  context.control_in_flight = {};
1665
1686
  for (var sid in context.send_streams) {
@@ -4522,6 +4543,16 @@ function QUICConnection(options) {
4522
4543
  }
4523
4544
  }
4524
4545
 
4546
+ // A 1-RTT packet carrying stream data or datagrams declared lost: counted
4547
+ // (getStats' packetsLost, and bytesLost by its size on the wire), and
4548
+ // Careful Resume told.
4549
+ function countLostPacket(pnum) {
4550
+ context.lost_count++;
4551
+ var h = context.sending_app_pn_history[pnum - (context.send_pn.app - context.sending_app_pn_history.length)];
4552
+ if (h) context.stats_bytes_lost += h[1];
4553
+ crOnLoss();
4554
+ }
4555
+
4525
4556
  // A packet given up on — expired, or its control frames deemed lost: out
4526
4557
  // of the bytes in flight. Returns its size (0: not in flight any more).
4527
4558
  function forgetPacket(pn) {
@@ -4532,6 +4563,58 @@ function QUICConnection(options) {
4532
4563
  return pk.bytes;
4533
4564
  }
4534
4565
 
4566
+ // How many of the values in `r` (flat [from, to) pairs) `a` already
4567
+ // covers — flat_ranges.length(flat_ranges.intersect(a, r)), found by binary
4568
+ // search in `a` for each pair of `r` instead of a walk of both. Both are
4569
+ // the library's sorted, merged lists; `r` not so (never seen) is answered
4570
+ // the original way.
4571
+ function coveredLength(a, r) {
4572
+ var na = a.length, total = 0, prevTo = -Infinity;
4573
+ if (na === 0 || r.length === 0) return 0;
4574
+ for (var i = 0; i < r.length; i += 2) {
4575
+ var f = r[i], t = r[i + 1];
4576
+ if (f < prevTo) return flat_ranges.length(flat_ranges.intersect(a, r));
4577
+ prevTo = t;
4578
+ if (f >= t) continue;
4579
+ // first pair of a whose `to` > f
4580
+ var lo = 0, hi = na >> 1;
4581
+ while (lo < hi) {
4582
+ var mid = (lo + hi) >> 1;
4583
+ if (a[(mid << 1) + 1] <= f) lo = mid + 1; else hi = mid;
4584
+ }
4585
+ for (var k = lo << 1; k < na && a[k] < t; k += 2) {
4586
+ var from = a[k] > f ? a[k] : f;
4587
+ var to = a[k + 1] < t ? a[k + 1] : t;
4588
+ if (to > from) total += to - from;
4589
+ }
4590
+ }
4591
+ return total;
4592
+ }
4593
+
4594
+ // Would flat_ranges.remove(list, r) change `list`? Only if a pair of `r`
4595
+ // overlaps one of it (half-open; flat_ranges.overlaps, a binary search).
4596
+ function overlapsAny(list, r) {
4597
+ if (list.length === 0) return false;
4598
+ for (var i = 0; i < r.length; i += 2) {
4599
+ if (flat_ranges.overlaps(list, r[i], r[i + 1])) return true;
4600
+ }
4601
+ return false;
4602
+ }
4603
+
4604
+ // How many packet numbers a flat [from, to, from, to, ...] list of
4605
+ // inclusive ranges covers.
4606
+ function rangesSpan(r) {
4607
+ var n = 0;
4608
+ for (var i = 0; i < r.length; i += 2) n += r[i + 1] - r[i] + 1;
4609
+ return n;
4610
+ }
4611
+
4612
+ // Entries in the limbo table (kept as it changes — counting them meant
4613
+ // walking it).
4614
+ function limboCount() {
4615
+ return context.limbo_n > 0 ? context.limbo_n : 0;
4616
+ }
4617
+
4535
4618
  function processAckFrame(space, frame) {
4536
4619
  // RFC 9000 §13.1: "An endpoint SHOULD treat receipt of an acknowledgment
4537
4620
  // for a packet it did not send as a connection error of type
@@ -4665,9 +4748,28 @@ function QUICConnection(options) {
4665
4748
  // Mark in-flight as acked. Collect the acked PNs first, then mutate —
4666
4749
  // deleting from a Set while iterating it with for-of is fragile.
4667
4750
  var ackedPns = [];
4668
- for (var pn of context.sending_app_pn_in_flight) {
4751
+ // Walked from whichever side is smaller: the packets in flight, or the
4752
+ // packet numbers this ACK covers. Every in-flight packet was tested
4753
+ // against every range on every ACK — at a high RTT the window holds
4754
+ // thousands, and a page's worth of ACKs cost that many times over.
4755
+ // Ascending either way (the set is in send order), as before.
4756
+ if (rangesSpan(ackedRanges) < context.sending_app_pn_in_flight.size) {
4669
4757
  for (var ri = 0; ri < ackedRanges.length; ri += 2) {
4670
- if (pn >= ackedRanges[ri] && pn <= ackedRanges[ri + 1]) { ackedPns.push(pn); break; }
4758
+ for (var rpn0 = ackedRanges[ri]; rpn0 <= ackedRanges[ri + 1]; rpn0++) {
4759
+ if (context.sending_app_pn_in_flight.has(rpn0)) ackedPns.push(rpn0);
4760
+ }
4761
+ }
4762
+ } else {
4763
+ // Both in ascending order (ack_frame_to_ranges sorts and merges the
4764
+ // ranges; the set is in packet-number order): one pointer into the
4765
+ // ranges, and the walk ends past the largest acknowledged — what is
4766
+ // in flight above it cannot be in this ACK. Each packet was tested
4767
+ // against every range, and the walk went on to the newest packet.
4768
+ var rp1 = 0, rTop1 = ackedRanges[ackedRanges.length - 1];
4769
+ for (var pn of context.sending_app_pn_in_flight) {
4770
+ if (pn > rTop1) break;
4771
+ while (ackedRanges[rp1 + 1] < pn) rp1 += 2;
4772
+ if (pn >= ackedRanges[rp1]) ackedPns.push(pn);
4671
4773
  }
4672
4774
  }
4673
4775
  // A packet's ranges of a stream, acknowledged. Returns the bytes newly
@@ -4675,12 +4777,19 @@ function QUICConnection(options) {
4675
4777
  // transmission and a retransmission of the same range ending up ACKed
4676
4778
  // (routine once late ACKs for expired packets are honoured) count once.
4677
4779
  function creditRanges(st, ranges) {
4678
- var fresh = flat_ranges.length(ranges) - flat_ranges.length(flat_ranges.intersect(st.acked_ranges, ranges));
4780
+ // The bytes of `ranges` not acknowledged before — what
4781
+ // length(intersect(acked, ranges)) gave, without walking all of
4782
+ // acked_ranges and building the intersection for every packet. After
4783
+ // a loss acked_ranges holds hundreds of pieces until the hole is
4784
+ // filled, and this ran once per acknowledged packet (coveredLength).
4785
+ var fresh = flat_ranges.length(ranges) - coveredLength(st.acked_ranges, ranges);
4679
4786
  flat_ranges.add(st.acked_ranges, ranges);
4680
4787
  // Out of missing, whatever it was: an ACK is proof — a late one too,
4681
4788
  // for bytes already given up on and not yet resent (add_have would
4682
4789
  // leave those missing: it defers to what not_have says).
4683
- flat_ranges.remove(st.missing_ranges, ranges);
4790
+ // remove() walks and rebuilds the whole list; it is called only when
4791
+ // it would change something — when the ranges touch a missing one.
4792
+ if (overlapsAny(st.missing_ranges, ranges)) flat_ranges.remove(st.missing_ranges, ranges);
4684
4793
  if (st.acked_ranges[0] === 0) sendQueueRelease(st, st.acked_ranges[1]);
4685
4794
  return fresh > 0 ? fresh : 0;
4686
4795
  }
@@ -4714,15 +4823,17 @@ function QUICConnection(options) {
4714
4823
  var largestAckedPn = -1;
4715
4824
  for (var lri = 0; lri < ackedRanges.length; lri += 2) if (ackedRanges[lri + 1] > largestAckedPn) largestAckedPn = ackedRanges[lri + 1];
4716
4825
  var ackedPkts = [];
4826
+ var rp2 = 0; // pointer into the ranges, as for ackedPns above
4717
4827
  for (var pke of context.pkt_in_flight) {
4718
4828
  var ppn = pke[0];
4719
4829
  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
- }
4830
+ while (ackedRanges[rp2 + 1] < ppn) rp2 += 2;
4831
+ if (ppn >= ackedRanges[rp2]) ackedPkts.push(ppn);
4723
4832
  }
4724
4833
  var ackedSizes = [];
4725
4834
  for (var api = 0; api < ackedPkts.length; api++) {
4835
+ var apk = context.pkt_in_flight.get(ackedPkts[api]);
4836
+ if (apk && apk.payload) context.stats_payload_acked += apk.payload;
4726
4837
  var asz = forgetPacket(ackedPkts[api]);
4727
4838
  ackedSizes.push(asz);
4728
4839
  ackedPacketBytes += asz;
@@ -4756,14 +4867,33 @@ function QUICConnection(options) {
4756
4867
  // DID arrive — the timer was simply wrong — so: credit the goodput
4757
4868
  // (repairing the BtlBw signal), mark the spans acked (cancelling any
4758
4869
  // 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; }
4870
+ // The limbo table holds up to ~10 s of expired packets: walking all of
4871
+ // it for each ACK (for-in, a string key made per entry) was the larger
4872
+ // part of an ACK's cost on a lossy, distant path. The covered numbers
4873
+ // are looked up instead when they are fewer — ascending, as for-in
4874
+ // visits integer keys.
4875
+ var lateHits = [];
4876
+ if (rangesSpan(ackedRanges) < limboCount()) {
4877
+ for (var lr = 0; lr < ackedRanges.length; lr += 2) {
4878
+ for (var lq = ackedRanges[lr]; lq <= ackedRanges[lr + 1]; lq++) {
4879
+ if (lq in context.expired_unacked) lateHits.push(String(lq));
4880
+ }
4881
+ }
4882
+ } else {
4883
+ // Ascending (integer keys), so: one pointer into the ranges, and
4884
+ // done past the largest acknowledged.
4885
+ var rp3 = 0, rTop3 = ackedRanges[ackedRanges.length - 1];
4886
+ for (var lpn0 in context.expired_unacked) {
4887
+ var lnum = Number(lpn0);
4888
+ if (lnum > rTop3) break;
4889
+ while (ackedRanges[rp3 + 1] < lnum) rp3 += 2;
4890
+ if (lnum >= ackedRanges[rp3]) lateHits.push(lpn0);
4764
4891
  }
4765
- if (!hit) continue;
4892
+ }
4893
+ for (var lhi = 0; lhi < lateHits.length; lhi++) {
4894
+ var lpn = lateHits[lhi];
4766
4895
  var rec = context.expired_unacked[lpn];
4896
+ if (!rec) continue;
4767
4897
  for (var si2 = 0; si2 < rec.spans.length; si2++) {
4768
4898
  var sidL = rec.spans[si2][0];
4769
4899
  var stL = context.send_streams[sidL];
@@ -4776,6 +4906,7 @@ function QUICConnection(options) {
4776
4906
  ackedPacketBytes += rec.bytes || 0;
4777
4907
  if (rec.early) context.stats_spurious_early++;
4778
4908
  delete context.expired_unacked[lpn];
4909
+ context.limbo_n--;
4779
4910
  if (context.lost_count > 0) context.lost_count--; // it wasn't a loss after all
4780
4911
  }
4781
4912
 
@@ -6454,11 +6585,14 @@ function QUICConnection(options) {
6454
6585
  // found by anything and held a slot of the packet budget forever.
6455
6586
  // DATAGRAM frames are in flight too, with nothing to resend
6456
6587
  // (no_resend_in_flight, see sendDatagram).
6457
- var has_data = false, datagramBytes = 0;
6588
+ var has_data = false, datagramBytes = 0, payloadBytes = 0;
6458
6589
  for (var i = 0; i < frameList.length; i++) {
6459
- if (frameList[i].type === 'stream') has_data = true;
6590
+ if (frameList[i].type === 'stream') { has_data = true; payloadBytes += frameList[i].data ? frameList[i].data.byteLength : 0; }
6460
6591
  else if (frameList[i].type === 'datagram') datagramBytes += frameList[i].data.byteLength;
6461
6592
  }
6593
+ context.stats_datagram_bytes_sent += datagramBytes;
6594
+ var inFlightPk = context.pkt_in_flight.get(pn);
6595
+ if (inFlightPk) inFlightPk.payload = payloadBytes + datagramBytes; // getStats' bytesAcknowledged
6462
6596
  if (has_data || datagramBytes > 0) context.sending_app_pn_in_flight.add(pn);
6463
6597
  if (datagramBytes > 0) {
6464
6598
  context.no_resend_in_flight[pn] = (context.no_resend_in_flight[pn] || 0) + datagramBytes;
@@ -6702,6 +6836,19 @@ function QUICConnection(options) {
6702
6836
  for (var i = 0; i < w.length; i++) w[i].cb(new Error('the connection is closing'));
6703
6837
  }
6704
6838
 
6839
+ function streamSendWindow(streamId) {
6840
+ var st = context.send_streams[Number(streamId)];
6841
+ if (!st) return 0;
6842
+ var queued = 0;
6843
+ for (var k in context.send_streams) {
6844
+ var o = context.send_streams[k];
6845
+ if (o.write_offset > o.max_sent_offset) queued += o.write_offset - o.max_sent_offset;
6846
+ }
6847
+ var streamRoom = st.remote_max_stream_data - st.write_offset;
6848
+ var connRoom = context.remote_max_data - context.max_data_sent - queued;
6849
+ return Math.max(0, Math.min(streamRoom, connRoom));
6850
+ }
6851
+
6705
6852
  // The connection's figures, as WebTransport's getStats() reports them
6706
6853
  // (W3C WebTransportConnectionStats), and what QUICO's congestion control
6707
6854
  // knows besides. Times in ms; estimatedSendRate in bits per second (BBR's
@@ -6709,9 +6856,21 @@ function QUICConnection(options) {
6709
6856
  // An exception of ours, caught: in the connection (internal_errors, the
6710
6857
  // last one's stack) and the server's counters. The packet being processed
6711
6858
  // is dropped (above), or HTTP/3 closes with H3_INTERNAL_ERROR (h3.js).
6859
+ // Reading e.stack formats the whole trace — a few hundred microseconds,
6860
+ // paid for every bad packet: a peer (or a scanner) sending many of them
6861
+ // cost the thread more in traces than in packets. The message always; the
6862
+ // stack, and a log line, once a minute per connection — the bug it points
6863
+ // at is the same one.
6712
6864
  function countInternalError(e) {
6713
6865
  context.internal_errors = (context.internal_errors || 0) + 1;
6714
- context.last_internal_error = (e && e.stack) || String(e);
6866
+ var now = Date.now();
6867
+ if (now - (context.internal_error_logged_at || 0) >= 60000) {
6868
+ context.internal_error_logged_at = now;
6869
+ context.last_internal_error = (e && e.stack) || String(e);
6870
+ console.warn('[quic] packet dropped on an internal error (' + context.internal_errors + ' on this connection so far): ' + context.last_internal_error);
6871
+ } else {
6872
+ context.last_internal_error = (e && e.message) || String(e);
6873
+ }
6715
6874
  if (serverStats) serverStats.internalErrors++;
6716
6875
  }
6717
6876
 
@@ -6720,6 +6879,11 @@ function QUICConnection(options) {
6720
6879
  bytesSent: context.stats_bytes_sent,
6721
6880
  packetsSent: context.stats_packets_sent,
6722
6881
  packetsLost: context.lost_count,
6882
+ bytesLost: context.stats_bytes_lost,
6883
+ // The application's bytes: new STREAM data and DATAGRAM payloads sent
6884
+ // (no retransmissions, no framing), and the payload acknowledged.
6885
+ payloadBytesSent: context.max_data_sent + context.stats_datagram_bytes_sent,
6886
+ payloadBytesAcknowledged: context.stats_payload_acked,
6723
6887
  bytesReceived: context.stats_bytes_received,
6724
6888
  packetsReceived: context.stats_packets_received,
6725
6889
  smoothedRtt: context.srtt,
@@ -7063,7 +7227,7 @@ function QUICConnection(options) {
7063
7227
  // honoured (see expired_unacked above). Keyed per-PN; a packet that
7064
7228
  // carried several streams accumulates one entry per stream.
7065
7229
  var limbo = context.expired_unacked[pnum];
7066
- if (!limbo) limbo = context.expired_unacked[pnum] = { t: now, spans: [] };
7230
+ if (!limbo) { limbo = context.expired_unacked[pnum] = { t: now, spans: [] }; context.limbo_n++; }
7067
7231
  var finLost = !!(st.fin_pns && st.fin_pns[pnum]);
7068
7232
  var lostRanges = st.in_flight_ranges[pn];
7069
7233
  limbo.spans.push([sid, lostRanges, finLost]);
@@ -7081,7 +7245,7 @@ function QUICConnection(options) {
7081
7245
  // Count the loss once per packet: the Set holds each PN once, so the
7082
7246
  // first stream to expire it returns true; later same-PN deletes (a
7083
7247
  // multi-stream packet) return false and don't double-count.
7084
- if (context.sending_app_pn_in_flight.delete(pnum)) { context.lost_count++; crOnLoss(); }
7248
+ if (context.sending_app_pn_in_flight.delete(pnum)) countLostPacket(pnum);
7085
7249
  }
7086
7250
  }
7087
7251
 
@@ -7098,7 +7262,7 @@ function QUICConnection(options) {
7098
7262
  if (context.expired_unacked[pnum]) context.expired_unacked[pnum].early = true;
7099
7263
  if (pnum in context.no_resend_in_flight) {
7100
7264
  delete context.no_resend_in_flight[pnum];
7101
- if (context.sending_app_pn_in_flight.delete(pnum)) { context.lost_count++; crOnLoss(); }
7265
+ if (context.sending_app_pn_in_flight.delete(pnum)) countLostPacket(pnum);
7102
7266
  }
7103
7267
  if (context.pkt_in_flight.has(pnum)) {
7104
7268
  var pBytes = forgetPacket(pnum);
@@ -7290,7 +7454,7 @@ function QUICConnection(options) {
7290
7454
  (now - context.sending_app_pn_history[ridx][0] >= timeout);
7291
7455
  if (!rexpired) continue;
7292
7456
  delete context.no_resend_in_flight[rpn];
7293
- if (context.sending_app_pn_in_flight.delete(rnum)) { context.lost_count++; crOnLoss(); }
7457
+ if (context.sending_app_pn_in_flight.delete(rnum)) countLostPacket(rnum);
7294
7458
  }
7295
7459
 
7296
7460
  // And every packet past the same timeout leaves the bytes in flight —
@@ -7306,9 +7470,15 @@ function QUICConnection(options) {
7306
7470
 
7307
7471
  // Bound the limbo table: entries older than ~8 timeouts (min 10s) will
7308
7472
  // 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];
7473
+ // At most once a second: it walks the whole table, and this runs on
7474
+ // every send burst. An entry kept a second longer only means a late ACK
7475
+ // for it is still honoured.
7476
+ if (now - (context.limbo_pruned_at || 0) >= 1000) {
7477
+ context.limbo_pruned_at = now;
7478
+ var limboCutoff = now - Math.max(10000, timeout * 8);
7479
+ for (var lpn in context.expired_unacked) {
7480
+ if (context.expired_unacked[lpn].t < limboCutoff) { delete context.expired_unacked[lpn]; context.limbo_n--; }
7481
+ }
7312
7482
  }
7313
7483
  }
7314
7484
 
@@ -8284,6 +8454,13 @@ function QUICConnection(options) {
8284
8454
  getJA4: function (opts) { return (tls && tls.getJA4) ? tls.getJA4(opts) : null; },
8285
8455
  /** The peer's certificate chain, leaf first, as DER — or null. */
8286
8456
  getPeerCertificateChain: function () { return (tls && tls.getPeerCertificateChain) ? tls.getPeerCertificateChain() : null; },
8457
+ // TLS keying material (RFC 8446 §7.5), as node:tls's: WebTransport's
8458
+ // exportKeyingMaterial builds on it. null before the handshake has it.
8459
+ exportKeyingMaterial: function (length, label, context) { return (tls && tls.exportKeyingMaterial) ? tls.exportKeyingMaterial(length, label, context) : null; },
8460
+ // How many new bytes stream `streamId` could send now under flow control
8461
+ // (RFC 9000 §4.1): its own window, and the connection's less what every
8462
+ // stream has written and not yet sent. WebTransport's atomicWrite asks.
8463
+ streamSendWindow: streamSendWindow,
8287
8464
  // The client's certificate, node:tls-shaped, and whether it was verified
8288
8465
  // (requestCert): what req.socket answers over HTTP/3.
8289
8466
  getPeerCertificate: function () { return (tls && tls.getPeerCertificate) ? tls.getPeerCertificate() : {}; },
@@ -201,8 +201,28 @@ function createQuicServer(options) {
201
201
  // gave it, with the time, sealed (AES-256-GCM) to the client's IP address
202
202
  // under a key of this server's own. It lives seconds, so a key drawn at
203
203
  // start is enough: a restart costs only the Retries in flight.
204
+ //
205
+ // tokenSecret (node:quic's name, at least 16 bytes) gives the key instead:
206
+ // servers that pass the same one open each other's tokens. That is what
207
+ // several servers sharing a port need (one per thread, SO_REUSEPORT): a
208
+ // client back with a NEW_TOKEN from another port is hashed to another of
209
+ // them, and without the key its token does not open — no harm, only the
210
+ // shortcut lost. The AES key is derived from it (SHA-256), so a 16-byte
211
+ // secret serves as well as a 32-byte one.
204
212
  var validateAddress = options.validateAddress === true;
205
- var retryTokenKey = crypto.randomBytes(32);
213
+ var tokenSecret = checkTokenSecret(options.tokenSecret) || crypto.randomBytes(32);
214
+ var retryTokenKey = tokenKeyOf(tokenSecret);
215
+
216
+ function checkTokenSecret(secret) {
217
+ if (secret === undefined || secret === null) return null;
218
+ if (!(secret instanceof Uint8Array) || secret.byteLength < 16) {
219
+ throw new TypeError('tokenSecret must be a Buffer or Uint8Array of at least 16 bytes');
220
+ }
221
+ return secret;
222
+ }
223
+ function tokenKeyOf(secret) {
224
+ return crypto.createHash('sha256').update('quico address-validation token key').update(secret).digest();
225
+ }
206
226
  var RETRY_TOKEN_LIFETIME = 10000; // ms
207
227
  // Tokens of two kinds, sealed alike (RFC 9000 §8.1.3): a Retry's, good for
208
228
  // seconds and one connection attempt; and a NEW_TOKEN's, given to a client
@@ -478,6 +498,7 @@ function createQuicServer(options) {
478
498
  // in length". Shorter ones would also be easy to guess and collide.
479
499
  if (firstPkt.dcid.byteLength < 8) return;
480
500
  if (connectionCount >= maxConnections) return;
501
+ if (!accepting) return;
481
502
 
482
503
  var retry = null, knownAddress = false;
483
504
  if (firstPkt.token && firstPkt.token.byteLength > 0) {
@@ -723,8 +744,8 @@ function createQuicServer(options) {
723
744
  var SERVER_RUNTIME = ['validateAddress', 'handshakeLoadThreshold', 'maxHandshakesPerAddress', 'earlyData', 'earlyDataMaxAge'];
724
745
  var ELSEWHERE = {
725
746
  key: 'setSecureContext()', cert: 'setSecureContext()', ca: 'setSecureContext()', pfx: 'setSecureContext()',
726
- passphrase: 'setSecureContext()', SNICallback: 'setSecureContext()',
727
- ticketKeys: 'setTicketKeys()', ech: 'setEchKeys()', resetTokenSecret: 'setResetTokenSecret()',
747
+ passphrase: 'setSecureContext()', SNICallback: 'addContext() or setSecureContext()',
748
+ ticketKeys: 'setTicketKeys()', ech: 'setEchKeys()', resetTokenSecret: 'setResetTokenSecret()', tokenSecret: 'setTokenSecret()',
728
749
  transportParams: 'connectionOptions, per connection'
729
750
  };
730
751
  function optErr(Ctor, code, msg) { var e = new Ctor(msg); e.code = code; return e; }
@@ -826,6 +847,11 @@ function createQuicServer(options) {
826
847
  // timeout. close() waits for them — not long (SEND_DRAIN_MAX).
827
848
  var sendsPending = 0;
828
849
  var shutDown = false;
850
+ // stopAccepting(): no new connection (an Initial for an unknown one is
851
+ // dropped); those open go on — a graceful close, the layer above deciding
852
+ // when they end.
853
+ var accepting = true;
854
+ var refed = true;
829
855
  var closing = null;
830
856
  var SEND_DRAIN_MAX = 200;
831
857
 
@@ -836,6 +862,7 @@ function createQuicServer(options) {
836
862
 
837
863
  function listen(listenPort, host, callback) {
838
864
  shutDown = false;
865
+ accepting = true;
839
866
  if (typeof host === 'function') { callback = host; host = null; }
840
867
  // `|| 443` turned port 0 into 443 — and port 0 is not "no port": it is how
841
868
  // a caller asks the OS for a free one (server.js has the same fix).
@@ -919,6 +946,7 @@ function createQuicServer(options) {
919
946
  ev.emit('error', err);
920
947
  });
921
948
  var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
949
+ if (!refed) udp4.unref();
922
950
  udp4.bind(port, host4, function () { growBuffers(udp4); settle(null); });
923
951
 
924
952
  udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
@@ -931,6 +959,7 @@ function createQuicServer(options) {
931
959
  settle(err);
932
960
  });
933
961
  var host6 = host.indexOf(':') !== -1 ? host : '::';
962
+ if (!refed) udp6.unref();
934
963
  udp6.bind(port, host6, function () { growBuffers(udp6); settle(null); });
935
964
 
936
965
  startSweepTimer();
@@ -1004,13 +1033,47 @@ function createQuicServer(options) {
1004
1033
  // External packet API (for shared UDP port scenarios)
1005
1034
  // ============================================================
1006
1035
 
1007
- // Query whether the given 5-tuple matches an active QUIC connection.
1008
- // Used by the demuxer to disambiguate byte 0 in range 64-79 between
1009
- // QUIC short header and TURN ChannelData (RFC 9443). Fresh connections
1010
- // must be classified by byte 0 range (80-127 or 192+ = QUIC).
1011
- function hasConnection(rinfo) {
1012
- var key = rinfo.address + ':' + rinfo.port;
1013
- return (key in addressMap) && (addressMap[key] in connections);
1036
+ // Query whether a datagram belongs to an active QUIC connection.
1037
+ //
1038
+ // hasConnection(rinfo) — by the address it came from. Used by the demuxer
1039
+ // to disambiguate byte 0 in range 64-79 between QUIC short header and TURN
1040
+ // ChannelData (RFC 9443). Fresh connections must be classified by byte 0
1041
+ // range (80-127 or 192+ = QUIC).
1042
+ //
1043
+ // hasConnection(rinfo, msg) — by the connection ID the datagram is
1044
+ // addressed to, from wherever it came: the answer to "is this one mine"
1045
+ // when several servers share the port (one per thread, SO_REUSEPORT) and a
1046
+ // datagram can reach one that does not hold its connection — a client
1047
+ // whose address changed is hashed to another socket. Asked before
1048
+ // handlePacket: given a short-header packet for an ID it does not know,
1049
+ // handlePacket answers with a Stateless Reset, and that would end a
1050
+ // connection another server holds. The address does not count here: an ID
1051
+ // that leads nowhere is not ours, whatever address sent it.
1052
+ function hasConnection(rinfo, msg) {
1053
+ if (msg === undefined || msg === null) {
1054
+ var key = rinfo.address + ':' + rinfo.port;
1055
+ return (key in addressMap) && (addressMap[key] in connections);
1056
+ }
1057
+ var dcid = datagramDcid(msg);
1058
+ if (!dcid) return false;
1059
+ var k = cidKey(dcid);
1060
+ return (k in cidMap) && (cidMap[k] in connections);
1061
+ }
1062
+
1063
+ // The Destination Connection ID of a datagram's first packet, without
1064
+ // parsing the rest (RFC 9000 §17.2, §17.3; RFC 8999 for any version): a
1065
+ // long header carries its length, a short header ours (SERVER_CID_LEN).
1066
+ // null for one too short to hold it.
1067
+ function datagramDcid(msg) {
1068
+ if (!(msg instanceof Uint8Array) || msg.length < 1) return null;
1069
+ if (msg[0] & 0x80) {
1070
+ if (msg.length < 6) return null;
1071
+ var len = msg[5];
1072
+ if (msg.length < 6 + len) return null;
1073
+ return msg.subarray(6, 6 + len);
1074
+ }
1075
+ if (msg.length < 1 + SERVER_CID_LEN) return null;
1076
+ return msg.subarray(1, 1 + SERVER_CID_LEN);
1014
1077
  }
1015
1078
 
1016
1079
 
@@ -1021,14 +1084,21 @@ function createQuicServer(options) {
1021
1084
  return {
1022
1085
  listen: listen,
1023
1086
  close: close,
1087
+ stopAccepting: function () { accepting = false; },
1088
+ get connectionCount() { return connectionCount; },
1089
+ // node:dgram's: whether the process may exit while this listens.
1090
+ ref: function () { refed = true; if (udp4) udp4.ref(); if (udp6) udp6.ref(); },
1091
+ unref: function () { refed = false; if (udp4) udp4.unref(); if (udp6) udp6.unref(); },
1024
1092
  on: function (name, fn) { ev.on(name, fn); },
1025
1093
 
1026
1094
  /** Feed an incoming UDP packet when running in external-socket mode.
1027
1095
  * rinfo must be in Node's dgram format: { address, port, family, size }. */
1028
1096
  handlePacket: onUdpMessage,
1029
1097
 
1030
- /** Returns true if the 5-tuple matches an active QUIC connection.
1031
- * Used by demuxers for routing decisions on shared UDP ports. */
1098
+ /** hasConnection(rinfo): the address matches an active QUIC connection.
1099
+ * hasConnection(rinfo, msg): the datagram is addressed to one (by its
1100
+ * connection ID). Used by demuxers for routing decisions on shared UDP
1101
+ * ports — the second when several servers share it (see above). */
1032
1102
  hasConnection: hasConnection,
1033
1103
 
1034
1104
  /** The session ticket keys — tls.Server's names. A new key applies to
@@ -1059,7 +1129,18 @@ function createQuicServer(options) {
1059
1129
  setOptions: setOptions,
1060
1130
 
1061
1131
  getResetTokenSecret: function () { return resetTokenSecret; },
1062
- setResetTokenSecret: function (secret) { resetTokenSecret = checkResetTokenSecret(secret) || resetTokenSecret; }
1132
+ setResetTokenSecret: function (secret) { resetTokenSecret = checkResetTokenSecret(secret) || resetTokenSecret; },
1133
+
1134
+ /** The address-validation token secret (Retry, NEW_TOKEN — see
1135
+ * tokenSecret above). A new one applies from now on; tokens sealed
1136
+ * under the one before no longer open: a client back with a NEW_TOKEN
1137
+ * loses its shortcut, and one mid-Retry its attempt (it accepts no
1138
+ * second Retry). */
1139
+ getTokenSecret: function () { return tokenSecret; },
1140
+ setTokenSecret: function (secret) {
1141
+ var s = checkTokenSecret(secret);
1142
+ if (s) { tokenSecret = s; retryTokenKey = tokenKeyOf(s); }
1143
+ }
1063
1144
  };
1064
1145
  }
1065
1146