quico 0.3.0 → 0.4.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.
package/index.js CHANGED
@@ -19,6 +19,7 @@ import { IncomingMessage, ServerResponse, ClientRequest } from './src/streams.js
19
19
  import { createSecureContext } from './src/tls_bridge.js';
20
20
  import { WebTransport } from './src/webtransport.js';
21
21
  import { createQuicServer } from './src/quic_server.js';
22
+ import { createQuicClientSocket } from './src/quic_socket.js';
22
23
 
23
24
  export {
24
25
  createServer,
@@ -31,7 +32,8 @@ export {
31
32
  ClientRequest,
32
33
  createSecureContext,
33
34
  WebTransport,
34
- createQuicServer
35
+ createQuicServer,
36
+ createQuicClientSocket
35
37
  };
36
38
 
37
39
  export default {
@@ -45,5 +47,6 @@ export default {
45
47
  ClientRequest,
46
48
  createSecureContext,
47
49
  WebTransport,
48
- createQuicServer
50
+ createQuicServer,
51
+ createQuicClientSocket
49
52
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "quico",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "A pure JavaScript implementation of QUIC, HTTP/3, QPACK, and WebTransport for Node.js",
5
5
  "type": "module",
6
6
  "main": "index.cjs",
@@ -78,6 +78,6 @@
78
78
  ],
79
79
  "dependencies": {
80
80
  "flat-ranges": "^2.0.0",
81
- "lemon-tls": "^0.4.1"
81
+ "lemon-tls": "^0.6.0"
82
82
  }
83
83
  }
package/src/agent.js CHANGED
@@ -56,6 +56,17 @@ class Agent extends EventEmitter {
56
56
  // Allows multiplexing multiple requests over one QUIC connection
57
57
  this._h3Pool = {};
58
58
 
59
+ // Connections still being ESTABLISHED: "host:port" → [waiter callbacks].
60
+ // Without this third state the pool only knows "have one" / "don't", so N
61
+ // requests issued in the same tick each see an empty pool and open N
62
+ // separate QUIC connections to the same origin — N handshakes, N
63
+ // congestion controllers, zero multiplexing (the QUIC interop runner's
64
+ // http3 test flags exactly this: "Expected exactly 1 handshake. Got: 3").
65
+ // The first request to miss the pool becomes the lead and creates the
66
+ // entry; requests arriving before it finishes park a callback here and
67
+ // are replayed once the lead settles.
68
+ this._h3Pending = {};
69
+
59
70
  // Active HTTP/2 sessions: "host:port" → http2.ClientHttp2Session
60
71
  this._h2Pool = {};
61
72
 
@@ -165,6 +176,42 @@ class Agent extends EventEmitter {
165
176
  return conn;
166
177
  }
167
178
 
179
+ /**
180
+ * Coalesce concurrent connection attempts to one origin.
181
+ *
182
+ * Returns false if the caller is the LEAD: no connection exists and nobody
183
+ * else is establishing one. The lead MUST eventually call
184
+ * resolveH3Pending(host, port, connOrNull) — on success, failure, or
185
+ * abandonment — or every later request to this origin parks forever.
186
+ *
187
+ * Returns true if the caller was parked as a follower; `cb` fires (with no
188
+ * arguments) once the lead settles, and the caller should simply retry its
189
+ * pool lookup: on success the connection is already registered, and on
190
+ * failure the retry becomes the next lead — serial retry, no thundering
191
+ * herd.
192
+ */
193
+ joinH3Pending(host, port, cb) {
194
+ var key = host + ':' + (port || 443);
195
+ if (this._h3Pending[key]) {
196
+ this._h3Pending[key].push(cb);
197
+ return true;
198
+ }
199
+ this._h3Pending[key] = [];
200
+ return false;
201
+ }
202
+
203
+ /** Lead's settlement. Registers the connection (if any) BEFORE waking the
204
+ * waiters, so their retried pool lookups succeed synchronously. */
205
+ resolveH3Pending(host, port, conn) {
206
+ var key = host + ':' + (port || 443);
207
+ var waiters = this._h3Pending[key] || [];
208
+ delete this._h3Pending[key];
209
+ if (conn) this.setH3Connection(host, port, conn);
210
+ for (var i = 0; i < waiters.length; i++) {
211
+ try { waiters[i](); } catch (e) { /* a waiter's failure is its own */ }
212
+ }
213
+ }
214
+
168
215
  setH3Connection(host, port, conn) {
169
216
  var key = host + ':' + (port || 443);
170
217
  this._h3Pool[key] = conn;
@@ -262,6 +309,17 @@ class Agent extends EventEmitter {
262
309
  destroy() {
263
310
  if (this._sweepTimer) { clearInterval(this._sweepTimer); this._sweepTimer = null; }
264
311
 
312
+ // Wake anyone parked on an in-flight connection attempt; their retried
313
+ // pool lookup will find nothing and proceed (or fail) on its own.
314
+ for (var key in this._h3Pending) {
315
+ var waiters = this._h3Pending[key];
316
+ delete this._h3Pending[key];
317
+ for (var i = 0; i < waiters.length; i++) {
318
+ try { waiters[i](); } catch (e) {}
319
+ }
320
+ }
321
+ this._h3Pending = {};
322
+
265
323
  for (var key in this._h3Pool) {
266
324
  try { this._h3Pool[key].quic.close(0, 'agent destroy'); } catch (e) {}
267
325
  }
package/src/client.js CHANGED
@@ -206,7 +206,13 @@ function request(url, options, callback) {
206
206
  port: port,
207
207
  path: path,
208
208
  method: method,
209
- headers: Object.assign({}, headers)
209
+ headers: Object.assign({}, headers),
210
+ // The H2 and HTTPS fallbacks below honour this; dropping it here made
211
+ // H3 — alone of the three — unable to talk to self-signed or
212
+ // private-CA servers, and every such request silently fell back to
213
+ // TCP (or hung when the server was QUIC-only).
214
+ rejectUnauthorized: options.rejectUnauthorized,
215
+ ca: options.ca
210
216
  };
211
217
 
212
218
  // Check Agent for existing QUIC connection (multiplexing).
@@ -217,15 +223,36 @@ function request(url, options, callback) {
217
223
  if (agent) {
218
224
  h3Opts._managed = true;
219
225
  var existing = agent.getH3Connection(hostname, port);
220
- if (existing) h3Opts._connection = existing;
226
+ if (existing) {
227
+ h3Opts._connection = existing;
228
+ } else if (agent.joinH3Pending &&
229
+ agent.joinH3Pending(hostname, port, function () {
230
+ // The lead settled. Retry from the top: on success the
231
+ // pool lookup now hits and we multiplex on the lead's
232
+ // connection; on failure we become the next lead — a
233
+ // serial retry, never a thundering herd.
234
+ tryH3(onFail);
235
+ })) {
236
+ return; // parked as a follower until the lead settles
237
+ }
238
+ // else: we are the LEAD for this origin. _settleH3Pending below MUST
239
+ // run on every exit path, or followers park forever.
240
+ }
241
+
242
+ var _pendingLead = !!(agent && !h3Opts._connection);
243
+ function _settleH3Pending(conn) {
244
+ if (!_pendingLead) return;
245
+ _pendingLead = false;
246
+ if (agent.resolveH3Pending) agent.resolveH3Pending(hostname, port, conn);
247
+ else if (conn) agent.setH3Connection(hostname, port, conn);
221
248
  }
222
249
 
223
250
  var h3Req = h3Request(h3Opts, function (h3Res) {
224
251
  if (responded) return;
225
252
 
226
- // Store connection for future reuse
227
- if (agent && h3Req._connection && !h3Opts._connection) {
228
- agent.setH3Connection(hostname, port, h3Req._connection);
253
+ // Register the connection and wake coalesced followers.
254
+ if (h3Req._connection && !h3Opts._connection) {
255
+ _settleH3Pending(h3Req._connection);
229
256
  }
230
257
 
231
258
  var res = new IncomingMessage({
@@ -246,6 +273,7 @@ function request(url, options, callback) {
246
273
  });
247
274
 
248
275
  h3Req.on('error', function (err) {
276
+ _settleH3Pending(null); // release followers before failing over
249
277
  onFail(err);
250
278
  });
251
279
 
@@ -254,6 +282,7 @@ function request(url, options, callback) {
254
282
  h3Req.end();
255
283
 
256
284
  } catch (err) {
285
+ if (typeof _settleH3Pending === 'function') _settleH3Pending(null);
257
286
  onFail(err);
258
287
  }
259
288
  }
package/src/crypto.js CHANGED
@@ -373,6 +373,23 @@ function encrypt_quic_packet(packetType, encodedFrames, writeKey, writeIv, write
373
373
  function decrypt_quic_packet(array, readKey, readIv, readHp, ownCid, largestPn) {
374
374
  if (!(array instanceof Uint8Array)) throw new Error('Invalid input');
375
375
 
376
+ // Work on a private copy — this function must not mutate its input.
377
+ //
378
+ // remove_header_protection XORs the first byte and the packet-number bytes
379
+ // IN PLACE. That is fine for exactly one decryption attempt, but the key
380
+ // update path retries this function on the same packet with derived keys
381
+ // (RFC 9001 §6): the second call re-applies the (unchanged) HP mask to a
382
+ // header that is already unprotected, re-scrambling it — and reads pnLength
383
+ // from the scrambled byte, so it may even XOR the wrong number of bytes.
384
+ // The retry then fails even with the correct keys, every phase-flipped
385
+ // packet is dropped, and the connection goes permanently deaf in that
386
+ // direction. Measured against quic-go (which key-updates shortly after
387
+ // handshake confirmation): ACK processing died at t≈2s, BBR froze mid-
388
+ // startup, and the sender spent 58s retransmitting one 8 KB band 29 times.
389
+ //
390
+ // One ≤1500 B copy per decryption attempt is noise next to the AEAD itself.
391
+ array = array.slice();
392
+
376
393
  var firstByte = array[0];
377
394
  var isShort = (firstByte & 0x80) === 0;
378
395
 
package/src/h3_client.js CHANGED
@@ -34,6 +34,13 @@ function request(options, callback) {
34
34
  }
35
35
 
36
36
  var hostname = options.hostname || options.host || 'localhost';
37
+ // Certificate verification options, forwarded to the QUIC/TLS layer.
38
+ // Defaults to verifying (node:tls semantics); without this passthrough the
39
+ // option dies here and H3 connections can never opt out — which makes the
40
+ // one transport this library exists for the only one that can't reach a
41
+ // self-signed or private-CA server (H2/H1.1 fallbacks honour the option).
42
+ var rejectUnauthorized = options.rejectUnauthorized !== false;
43
+ var ca = options.ca || null;
37
44
  var port = options.port || 443;
38
45
  var path = options.path || '/';
39
46
  var method = (options.method || 'GET').toUpperCase();
@@ -144,6 +151,8 @@ function request(options, callback) {
144
151
  remoteIp: remoteIp,
145
152
  remotePort: remotePort,
146
153
  hostname: hostname,
154
+ rejectUnauthorized: rejectUnauthorized,
155
+ ca: ca,
147
156
 
148
157
  onError: function (err) {
149
158
  emit(reqListeners, 'error', err);
@@ -40,6 +40,14 @@ import {
40
40
 
41
41
  import { TLSBridge } from './tls_bridge.js';
42
42
 
43
+ // Congestion-control tracing on its own switch. QUICO_DEBUG enables per-packet
44
+ // logging, whose synchronous console.log volume is heavy enough to become the
45
+ // bottleneck it is trying to observe (a 2 MB transfer measured 33s with it on,
46
+ // 3s with it off). The [bbr] round line fires only once per RTT — cheap — so
47
+ // it gets a separate flag that leaves the per-packet spam off.
48
+ var DEBUG_BBR = DEBUG ||
49
+ (typeof process !== 'undefined' && process.env && !!process.env.QUICO_DEBUG_BBR);
50
+
43
51
 
44
52
  function QUICConnection(options) {
45
53
  if (!(this instanceof QUICConnection)) return new QUICConnection(options);
@@ -279,6 +287,21 @@ function QUICConnection(options) {
279
287
  latest_delivery_rate: null, // bytes/sec acked in the most recent ACK sample
280
288
  max_delivery_rate: null, // peak delivery rate ≈ BtlBw (a CC bandwidth input)
281
289
  lost_count: 0, // packets declared lost by expireInFlight (≈ loss)
290
+
291
+ // Expired-but-unacknowledged packets ("limbo"). When expireInFlight times a
292
+ // packet out, its stream spans are parked here instead of being forgotten.
293
+ // If the ACK then arrives late — which on a queued path it regularly does,
294
+ // since the expiry timer races the ACK's arrival — the spans are credited
295
+ // to `delivered` and marked acked, cancelling any not-yet-sent retransmit.
296
+ //
297
+ // Without this, a spuriously-expired packet's bytes vanish from delivery
298
+ // accounting forever: the ACK finds nothing to credit, BtlBw (computed
299
+ // from `delivered`) reads a collapsing rate, the pacer follows it down,
300
+ // and the sender relaxes the queue until ACKs beat the timer again — a
301
+ // relaxation oscillator, measured against quic-go through the interop
302
+ // simulator as a 114/4-packets-per-second sawtooth and ~21× duplication.
303
+ // pn → { t: expiry wall-time, spans: [[sid, from, to], ...] }
304
+ expired_unacked: {},
282
305
  reorder_in_count: 0, // app packets that arrived below the highest PN seen (network reordering, peer→us)
283
306
 
284
307
  // --- BBR-lite measurement (Phase 4b, step 1+2 — measure only, no control). ---
@@ -332,6 +355,10 @@ function QUICConnection(options) {
332
355
 
333
356
  SNICallback: options.SNICallback || null,
334
357
  hostname: options.hostname || null,
358
+ // Client-side peer certificate verification. Defaults to true (node:tls
359
+ // semantics); set false for self-signed certs, private CAs, or interop.
360
+ rejectUnauthorized: options.rejectUnauthorized !== false,
361
+ ca: options.ca || null,
335
362
 
336
363
  // ALPN protocol(s) to advertise in the TLS handshake.
337
364
  // Defaults to ['h3'] (HTTP/3). Other QUIC-based protocols set their own,
@@ -508,11 +535,19 @@ function QUICConnection(options) {
508
535
  originalDcid: context.original_dcid,
509
536
  localCid: context.my_cids.length > 0 ? context.my_cids[0] : new Uint8Array(0),
510
537
  hostname: context.hostname,
511
- alpn: context.alpn
538
+ alpn: context.alpn,
539
+ rejectUnauthorized: context.rejectUnauthorized,
540
+ ca: context.ca
512
541
  });
513
542
 
514
543
  tls.on('send', function (epoch, data) { cryptoWrite(epoch, data); });
515
544
 
545
+ // NSS key log lines (Wireshark / SSLKEYLOGFILE). Surfaced on the
546
+ // connection so an application can persist them without reaching into the
547
+ // TLS layer. Without this a QUIC capture is undecryptable, which makes
548
+ // every wire-level bug an order of magnitude harder to diagnose.
549
+ tls.on('keylog', function (line) { ev.emit('keylog', line); });
550
+
516
551
  // Peer's QUIC transport parameters (parsed from the TLS 0x39 extension). These
517
552
  // replace hardcoded defaults: the peer's flow-control limit, ACK-delay scaling,
518
553
  // and max ACK delay. Arrives once, during the handshake, before app data flows.
@@ -712,6 +747,20 @@ function QUICConnection(options) {
712
747
  context.app_read = { key: next.key, iv: next.iv, hp: readKeys.hp };
713
748
  context.app_read_secret = next.secret;
714
749
  context.read_key_phase = !context.read_key_phase;
750
+
751
+ // RFC 9001 §6.2: a key update is BIDIRECTIONAL. Having accepted the
752
+ // peer's new phase for reading, we MUST move our send keys to the same
753
+ // phase before acknowledging anything received under it. Updating only
754
+ // the read side means our very next ACK — which covers the peer's
755
+ // new-phase packets — goes out under the OLD keys, and a conforming
756
+ // peer treats that as KEY_UPDATE_ERROR and kills the connection.
757
+ // (Measured against quic-go: connection closed ~3s after its routine
758
+ // post-handshake key update, exactly when our stale-phase ACK landed.)
759
+ // set_context({key_phase}) drives the existing machinery: it derives
760
+ // next-generation write keys from app_write_secret and flips the KP
761
+ // bit used by the encrypt path, so every packet from here on — the
762
+ // ACK included — is protected under the new phase.
763
+ set_context({ key_phase: !context.key_phase });
715
764
  }
716
765
  }
717
766
 
@@ -863,6 +912,25 @@ function QUICConnection(options) {
863
912
 
864
913
  if (ackEliciting) {
865
914
  flat_ranges.add(context.pending_ack[space], [packetNumber, packetNumber + 1]);
915
+
916
+ // Cap the acknowledged ranges (RFC 9000 §13.2.4 explicitly allows
917
+ // acking a subset). Under loss, packet-number gaps never close — a lost
918
+ // PN is gone forever; its DATA returns under a new PN — so every loss
919
+ // event mints one more range that would otherwise live here for the
920
+ // rest of the connection. Unpruned, the ACK frame grows with the loss
921
+ // count until it crosses the MTU and the ACK cannot be sent AT ALL, at
922
+ // which point the peer (correctly) stalls and idle-times-out. Measured
923
+ // against a quiche server through the interop simulator: 485 losses in
924
+ // 7s of line-rate transfer inflated our ACK packets 325B→1017B second
925
+ // by second, ACKs died at t≈8s, and quiche collected the connection.
926
+ // Dropping the LOWEST ranges is safe: those packets were received and
927
+ // have been acknowledged in every ACK frame sent since; anything the
928
+ // peer still considers unacked it will retransmit as new data under a
929
+ // fresh, high PN that the surviving ranges cover.
930
+ var pa = context.pending_ack[space];
931
+ var MAX_ACK_RANGES = 32;
932
+ if (pa.length > MAX_ACK_RANGES * 2) pa.splice(0, pa.length - MAX_ACK_RANGES * 2);
933
+
866
934
  var ackFrame = ranges_to_ack_frame(context.pending_ack[space], null, 0);
867
935
  if (ackFrame) {
868
936
  sendFrames(space, [ackFrame]);
@@ -917,6 +985,7 @@ function QUICConnection(options) {
917
985
  var sid = frame.id;
918
986
  if (!(sid in context.recv_streams)) {
919
987
  context.recv_streams[sid] = { chunks: {}, ranges: [], total_size: 0, flushed_to: 0,
988
+ fin_emitted: false, // end-of-stream delivered to the app exactly once
920
989
  max_recv_offset: 0, // highest offset seen — the peer's FC usage on this stream
921
990
  local_max_stream_data: context.local_initial_max_stream_data };
922
991
  }
@@ -1000,11 +1069,30 @@ function QUICConnection(options) {
1000
1069
  parts.push(skip > 0 ? chunk.subarray(skip) : chunk);
1001
1070
  offset = chunkStart + chunk.byteLength;
1002
1071
  }
1003
- if (parts.length === 0) return;
1072
+ if (parts.length === 0) {
1073
+ // No bytes to deliver — but the stream may just have COMPLETED. A peer
1074
+ // whose final write ends exactly on a packet boundary closes the stream
1075
+ // with a zero-length FIN-only frame (quic-go does this for any body
1076
+ // that is a multiple of its write-buffer size — a 64 KiB file, say).
1077
+ // That frame stores total_size above and lands here with an empty chunk
1078
+ // map; returning without emitting means the FIN evaporates: no more
1079
+ // frames will ever arrive to re-trigger this flush (retransmits of the
1080
+ // empty frame take this same path), the H3 layer never sees http_end,
1081
+ // and the request above it waits forever. Emit the end-of-stream event
1082
+ // with an empty payload instead — every consumer (h3, hq-interop,
1083
+ // tls_bridge) already guards zero-length data.
1084
+ if (stream.total_size > 0 && offset >= stream.total_size && !stream.fin_emitted) {
1085
+ stream.fin_emitted = true;
1086
+ ev.emit('stream', Number(sid), new Uint8Array(0), true);
1087
+ setTimeout(function () { delete context.recv_streams[sid]; }, 100);
1088
+ }
1089
+ return;
1090
+ }
1004
1091
  var delivered = offset - stream.flushed_to; // in-order bytes handed to the app now
1005
1092
  stream.flushed_to = offset;
1006
1093
  var data = parts.length === 1 ? parts[0] : concatUint8Arrays(parts);
1007
1094
  var fin = (stream.total_size > 0 && offset >= stream.total_size);
1095
+ if (fin) stream.fin_emitted = true; // a late FIN-only retransmit must not fire a second end
1008
1096
  ev.emit('stream', Number(sid), data, fin);
1009
1097
  if (fin) setTimeout(function () { delete context.recv_streams[sid]; }, 100);
1010
1098
 
@@ -1135,7 +1223,19 @@ function QUICConnection(options) {
1135
1223
  // (RFC 9002 §5.1). sending_app_pn_in_flight holds data-carrying PNs —
1136
1224
  // the closest signal until sent_packets tracks ack_eliciting per packet
1137
1225
  // (so PING-only packets are not yet used as RTT samples).
1138
- if (context.sending_app_pn_in_flight.has(largest_pn)) {
1226
+ //
1227
+ // Expired-but-limboed packets ALSO qualify. QUIC packet numbers are
1228
+ // never reused, so a late ACK is unambiguous (no TCP retransmission
1229
+ // ambiguity), and limbo membership proves the packet carried data.
1230
+ // Excluding them creates a self-sealing failure: an expiry timer that
1231
+ // is too short kills packets before their ACKs arrive → the ACKs are
1232
+ // then barred from producing RTT samples → srtt/rttvar keep reflecting
1233
+ // the pre-queue path → the timer never widens → every packet keeps
1234
+ // expiring. The queue-inflated samples these late ACKs carry are
1235
+ // exactly the evidence the timer needs in order to grow past the real
1236
+ // arrival time; one such sample spikes rttvar and ends the spiral.
1237
+ if (context.sending_app_pn_in_flight.has(largest_pn) ||
1238
+ (largest_pn in context.expired_unacked)) {
1139
1239
  var now = Date.now();
1140
1240
  // ACK Delay is microseconds scaled by 2^ack_delay_exponent; use the peer's
1141
1241
  // value (parsed from its transport params; defaults to 3 until then).
@@ -1156,6 +1256,35 @@ function QUICConnection(options) {
1156
1256
  if (pn >= ackedRanges[ri] && pn <= ackedRanges[ri + 1]) { ackedPns.push(pn); break; }
1157
1257
  }
1158
1258
  }
1259
+ // Bytes of [from,to) already recorded as acked — the portion NOT to
1260
+ // credit again. Guards against double-counting when both the original
1261
+ // transmission and a retransmission of the same range end up ACKed
1262
+ // (routine once late ACKs for expired packets are honoured).
1263
+ function ackedOverlap(flat, from, to) {
1264
+ var ov = 0;
1265
+ for (var oi = 0; oi < flat.length; oi += 2) {
1266
+ if (flat[oi] >= to) break;
1267
+ var a = Math.max(from, flat[oi]), b = Math.min(to, flat[oi + 1]);
1268
+ if (b > a) ov += b - a;
1269
+ }
1270
+ return ov;
1271
+ }
1272
+
1273
+ function creditSpan(st, from, to) {
1274
+ var fresh = (to - from) - ackedOverlap(st.acked_ranges, from, to);
1275
+ flat_ranges.add(st.acked_ranges, [from, to]);
1276
+ return fresh > 0 ? fresh : 0;
1277
+ }
1278
+
1279
+ function retireStreamIfComplete(sid, st) {
1280
+ if (st.total_size > 0 && st.acked_ranges.length === 2 &&
1281
+ st.acked_ranges[0] === 0 && st.acked_ranges[1] >= st.total_size) {
1282
+ delete context.send_streams[sid];
1283
+ delete context.remote_max_stream_data_by_sid[sid];
1284
+ delete context.last_stream_blocked_sent[sid];
1285
+ }
1286
+ }
1287
+
1159
1288
  var newlyAckedBytes = 0; // goodput bytes confirmed by this ACK (for delivery_rate)
1160
1289
  var oldestAckedPn = null; // smallest newly-acked PN → longest delivery interval
1161
1290
  for (var ai = 0; ai < ackedPns.length; ai++) {
@@ -1165,19 +1294,36 @@ function QUICConnection(options) {
1165
1294
  for (var sid in context.send_streams) {
1166
1295
  var st = context.send_streams[sid];
1167
1296
  if (st.in_flight_ranges && apn in st.in_flight_ranges) {
1168
- newlyAckedBytes += st.in_flight_ranges[apn][1] - st.in_flight_ranges[apn][0];
1169
- flat_ranges.add(st.acked_ranges, st.in_flight_ranges[apn]);
1297
+ newlyAckedBytes += creditSpan(st, st.in_flight_ranges[apn][0], st.in_flight_ranges[apn][1]);
1170
1298
  delete st.in_flight_ranges[apn];
1171
- if (st.total_size > 0 && st.acked_ranges.length === 2 &&
1172
- st.acked_ranges[0] === 0 && st.acked_ranges[1] >= st.total_size) {
1173
- delete context.send_streams[sid];
1174
- delete context.remote_max_stream_data_by_sid[sid];
1175
- delete context.last_stream_blocked_sent[sid];
1176
- }
1299
+ retireStreamIfComplete(sid, st);
1177
1300
  }
1178
1301
  }
1179
1302
  }
1180
1303
 
1304
+ // Late ACKs for packets the expiry timer already gave up on. The data
1305
+ // DID arrive — the timer was simply wrong — so: credit the goodput
1306
+ // (repairing the BtlBw signal), mark the spans acked (cancelling any
1307
+ // retransmit that hasn't left yet), and take back the loss we counted.
1308
+ for (var lpn in context.expired_unacked) {
1309
+ var lnum = Number(lpn);
1310
+ var hit = false;
1311
+ for (var ri2 = 0; ri2 < ackedRanges.length; ri2 += 2) {
1312
+ if (lnum >= ackedRanges[ri2] && lnum <= ackedRanges[ri2 + 1]) { hit = true; break; }
1313
+ }
1314
+ if (!hit) continue;
1315
+ var rec = context.expired_unacked[lpn];
1316
+ for (var si2 = 0; si2 < rec.spans.length; si2++) {
1317
+ var sidL = rec.spans[si2][0];
1318
+ var stL = context.send_streams[sidL];
1319
+ if (!stL) continue; // stream already fully acked via a retransmit
1320
+ newlyAckedBytes += creditSpan(stL, rec.spans[si2][1], rec.spans[si2][2]);
1321
+ retireStreamIfComplete(sidL, stL);
1322
+ }
1323
+ delete context.expired_unacked[lpn];
1324
+ if (context.lost_count > 0) context.lost_count--; // it wasn't a loss after all
1325
+ }
1326
+
1181
1327
  // Cumulative delivered advances by this ACK's goodput; the delivery clock
1182
1328
  // is the wall-time of this update.
1183
1329
  context.delivered += newlyAckedBytes;
@@ -1295,7 +1441,7 @@ function QUICConnection(options) {
1295
1441
  Math.round(targetRate / context.current_limit_packet_payload),
1296
1442
  1, context.max_limit_packets_per_sec);
1297
1443
  }
1298
- if (DEBUG && context.bbr_bdp !== null) {
1444
+ if (DEBUG_BBR && context.bbr_bdp !== null) {
1299
1445
  console.log('[bbr] round=' + context.bbr_round_count + ' ' + context.bbr_state +
1300
1446
  ' BtlBw=' + (context.bbr_btlbw * 8 / 1e6).toFixed(2) + 'Mbps' +
1301
1447
  ' RTprop=' + context.bbr_min_rtt + 'ms' +
@@ -1661,7 +1807,21 @@ function QUICConnection(options) {
1661
1807
  // timeout while the peer isn't ACKing, so a dead path isn't flooded.
1662
1808
  function expireInFlight() {
1663
1809
  var now = Date.now();
1664
- var base = (context.srtt === null) ? 333 : context.srtt + Math.max(4 * context.rttvar, 1);
1810
+ // RFC 9002 §6.2.1: PTO = srtt + max(4·rttvar, granularity) + max_ack_delay.
1811
+ //
1812
+ // The max_ack_delay term is NOT optional. The peer is allowed to sit on an
1813
+ // ACK for up to that long (quic-go uses its full 25ms), so on a stable
1814
+ // 30ms-RTT path the ACK legitimately arrives up to ~55ms after the send —
1815
+ // while srtt + 4·rttvar alone converges to ~31ms as rttvar shrinks. Every
1816
+ // packet then "expires" moments before its ACK lands: the ACK finds the PN
1817
+ // already forgotten, the delivered counter never advances, BtlBw (computed
1818
+ // from delivered) collapses, the pacer follows it down, and the data is
1819
+ // resent anyway. Measured against quic-go through the interop simulator:
1820
+ // each byte crossed the wire ~21×, goodput 54 KB/s on a 10 Mbps link.
1821
+ // With the term restored the same path runs at line rate.
1822
+ var base = (context.srtt === null)
1823
+ ? 333
1824
+ : context.srtt + Math.max(4 * context.rttvar, 1) + context.max_ack_delay;
1665
1825
 
1666
1826
  // Global backoff: while no ACK has arrived, widen the timeout geometrically.
1667
1827
  // Resets the instant an ACK updates last_ack_time.
@@ -1687,6 +1847,13 @@ function QUICConnection(options) {
1687
1847
  expired = (now - context.sending_app_pn_history[idx][0] >= timeout);
1688
1848
  }
1689
1849
  if (expired) {
1850
+ // Park the span before forgetting it, so a late ACK can still be
1851
+ // honoured (see expired_unacked above). Keyed per-PN; a packet that
1852
+ // carried several streams accumulates one span per stream.
1853
+ var limbo = context.expired_unacked[pnum];
1854
+ if (!limbo) limbo = context.expired_unacked[pnum] = { t: now, spans: [] };
1855
+ limbo.spans.push([sid, st.in_flight_ranges[pn][0], st.in_flight_ranges[pn][1]]);
1856
+
1690
1857
  delete st.in_flight_ranges[pn]; // → becomes "missing" again, resent next pass
1691
1858
  // Count the loss once per packet: the Set holds each PN once, so the
1692
1859
  // first stream to expire it returns true; later same-PN deletes (a
@@ -1695,6 +1862,13 @@ function QUICConnection(options) {
1695
1862
  }
1696
1863
  }
1697
1864
  }
1865
+
1866
+ // Bound the limbo table: entries older than ~8 timeouts (min 10s) will
1867
+ // never see their ACK — the peer retires ACK ranges long before that.
1868
+ var limboCutoff = now - Math.max(10000, timeout * 8);
1869
+ for (var lpn in context.expired_unacked) {
1870
+ if (context.expired_unacked[lpn].t < limboCutoff) delete context.expired_unacked[lpn];
1871
+ }
1698
1872
  }
1699
1873
 
1700
1874
 
@@ -14,7 +14,11 @@
14
14
 
15
15
  import dgram from 'node:dgram';
16
16
  import { DEBUG, Emitter } from './utils.js';
17
- import { parse_quic_datagram } from './transport.js';
17
+ import {
18
+ parse_quic_datagram,
19
+ is_supported_version,
20
+ build_version_negotiation
21
+ } from './transport.js';
18
22
  import { QUICConnection } from './quic_connection.js';
19
23
  import { createSecureContext } from './tls_bridge.js';
20
24
 
@@ -116,7 +120,40 @@ function createQuicServer(options) {
116
120
  // routing decision covers them all — and the datagram is parsed only once
117
121
  // here, handing the parsed packets straight to the connection (previously
118
122
  // each packet's raw bytes were re-parsed inside feedDatagram).
119
- var connId = findConnectionId(packets[0], addressKey);
123
+ // ── Version Negotiation (RFC 9000 §6) ──
124
+ // Must happen before any connection is created. A long-header packet
125
+ // carrying a version we don't speak gets a Version Negotiation packet in
126
+ // reply — that is how a client discovers what we support, and how a prober
127
+ // checks we are alive without completing a handshake.
128
+ //
129
+ // Getting this wrong is silent and confusing: without it, findConnectionId
130
+ // below happily creates a connection for the unknown-version Initial, maps
131
+ // the address to it, and every subsequent probe is routed into a
132
+ // connection that cannot do anything with it. The peer sees no reply at
133
+ // all and concludes the server is down.
134
+ var firstPkt = packets[0];
135
+ if (firstPkt.form === 'long' &&
136
+ firstPkt.type !== 'version_negotiation' &&
137
+ !is_supported_version(firstPkt.version)) {
138
+
139
+ // Anti-amplification (RFC 9000 §14.1): only answer datagrams that are
140
+ // at least 1200 bytes, so we can't be used to reflect traffic at a
141
+ // spoofed source. A genuine Initial is always padded to that size.
142
+ if (data.length < 1200) {
143
+ if (DEBUG) console.log('[qserver] unsupported version 0x' +
144
+ firstPkt.version.toString(16) + ' in a short datagram (' +
145
+ data.length + 'B) — dropping without VN');
146
+ return;
147
+ }
148
+
149
+ var vn = build_version_negotiation(firstPkt.dcid, firstPkt.scid);
150
+ if (DEBUG) console.log('[qserver] version 0x' +
151
+ firstPkt.version.toString(16) + ' unsupported — sending Version Negotiation');
152
+ sendUdp(fromIp, fromPort, vn);
153
+ return;
154
+ }
155
+
156
+ var connId = findConnectionId(firstPkt, addressKey);
120
157
  if (connId === null) return; // stray/unmatched non-Initial packet — drop
121
158
 
122
159
  if (!(connId in connections)) {
@@ -205,28 +242,49 @@ function createQuicServer(options) {
205
242
  if (isExternal) {
206
243
  // External sockets already bound — wire up incoming messages.
207
244
  // Starting the sweep timer is safe here (idempotent check below).
245
+ // Externally-owned sockets are already bound, so enlarge them here.
246
+ // The owner may not have done it, and the cost of asking twice is nil.
208
247
  if (externalSocket4) {
209
248
  externalSocket4.on('message', onUdpMessage);
249
+ try { externalSocket4.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
250
+ try { externalSocket4.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
210
251
  }
211
252
  if (externalSocket6) {
212
253
  externalSocket6.on('message', onUdpMessage);
254
+ try { externalSocket6.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
255
+ try { externalSocket6.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
213
256
  }
214
257
  startSweepTimer();
215
258
  if (typeof callback === 'function') setImmediate(callback);
216
259
  return;
217
260
  }
218
261
 
262
+ // Grow the kernel socket buffers once the socket is bound.
263
+ //
264
+ // Node's defaults are small. A full send buffer drops the datagram
265
+ // locally and silently — indistinguishable from network loss, so loss
266
+ // recovery engages and throughput collapses. See LOSS_PTO_PLAN 7.4: this
267
+ // is what defeated the third pacer-calibration attempt.
268
+ //
269
+ // try/catch because some platforms cap the value and throw; not being
270
+ // able to enlarge the buffer must not stop the server from listening.
271
+ function growBuffers(sock) {
272
+ try { sock.setSendBufferSize(2 * 1024 * 1024); } catch (e) {}
273
+ try { sock.setRecvBufferSize(2 * 1024 * 1024); } catch (e) {}
274
+ }
275
+
219
276
  udp4 = dgram.createSocket('udp4');
220
277
  udp4.on('message', onUdpMessage);
221
278
  udp4.on('error', function (err) { ev.emit('error', err); });
222
279
  var host4 = host.indexOf('.') !== -1 ? host : '0.0.0.0';
223
- udp4.bind(port, host4);
280
+ udp4.bind(port, host4, function () { growBuffers(udp4); });
224
281
 
225
282
  udp6 = dgram.createSocket({ type: 'udp6', ipv6Only: true });
226
283
  udp6.on('message', onUdpMessage);
227
284
  udp6.on('error', function (err) { ev.emit('error', err); });
228
285
  var host6 = host.indexOf(':') !== -1 ? host : '::';
229
286
  udp6.bind(port, host6, function () {
287
+ growBuffers(udp6);
230
288
  if (typeof callback === 'function') callback();
231
289
  });
232
290