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/README.md +854 -125
- package/index.js +5 -2
- package/package.json +2 -2
- package/src/agent.js +58 -0
- package/src/client.js +34 -5
- package/src/crypto.js +17 -0
- package/src/h3_client.js +9 -0
- package/src/quic_connection.js +187 -13
- package/src/quic_server.js +61 -3
- package/src/quic_socket.js +52 -1
- package/src/streams.js +15 -1
- package/src/tls_bridge.js +42 -2
- package/src/transport.js +93 -0
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
|
+
"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.
|
|
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)
|
|
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
|
-
//
|
|
227
|
-
if (
|
|
228
|
-
|
|
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);
|
package/src/quic_connection.js
CHANGED
|
@@ -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)
|
|
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
|
-
|
|
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][
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
|
package/src/quic_server.js
CHANGED
|
@@ -14,7 +14,11 @@
|
|
|
14
14
|
|
|
15
15
|
import dgram from 'node:dgram';
|
|
16
16
|
import { DEBUG, Emitter } from './utils.js';
|
|
17
|
-
import {
|
|
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
|
-
|
|
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
|
|