quico 0.2.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.
@@ -15,6 +15,7 @@
15
15
  import flat_ranges from 'flat-ranges';
16
16
 
17
17
  import {
18
+ DEBUG,
18
19
  Emitter,
19
20
  concatUint8Arrays,
20
21
  uint8Equal,
@@ -39,6 +40,14 @@ import {
39
40
 
40
41
  import { TLSBridge } from './tls_bridge.js';
41
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
+
42
51
 
43
52
  function QUICConnection(options) {
44
53
  if (!(this instanceof QUICConnection)) return new QUICConnection(options);
@@ -57,6 +66,10 @@ function QUICConnection(options) {
57
66
  handshake_done: false,
58
67
  handshake_done_sent: false,
59
68
 
69
+ // Connection close (RFC 9000 §10.2)
70
+ close_frame: null, // the CONNECTION_CLOSE frame to (re)send while closing
71
+ last_close_echo: 0, // timestamp throttle for closing-state CC echoes
72
+
60
73
  version: 1,
61
74
  original_dcid: null,
62
75
  my_cids: [],
@@ -85,10 +98,25 @@ function QUICConnection(options) {
85
98
  recv_pn_ranges: { initial: [], handshake: [], app: [] },
86
99
 
87
100
  // CRYPTO
88
- crypto_chunks: { initial: {}, handshake: {} },
101
+ crypto_chunks: { initial: {}, handshake: {} }, // RECEIVE-side reassembly
89
102
  crypto_offset: { initial: 0, handshake: 0 },
90
103
  crypto_send_offset: { initial: 0, handshake: 0, app: 0 },
91
104
 
105
+ // CRYPTO send-side loss recovery (Initial/Handshake spaces).
106
+ // The app stream path retransmits "for free" — stream bytes stay in
107
+ // send_streams and the round-robin re-scans them. CRYPTO is otherwise
108
+ // fire-and-forget (cryptoWrite discards the bytes after one send), and the
109
+ // handshake spaces have no expireInFlight. So we mirror the stream model on
110
+ // a tiny per-space structure (same flat-ranges semantics, no stream id, no
111
+ // FIN): retain sent bytes, track which PN carried which byte-range, expire
112
+ // by time, and re-send missing ranges. A CRYPTO frame is just a STREAM frame
113
+ // of a single id-less, fin-less stream — so the bookkeeping is identical.
114
+ crypto_sent: {
115
+ initial: { buf: [], in_flight: {}, acked: [], backoff: 0 },
116
+ handshake: { buf: [], in_flight: {}, acked: [], backoff: 0 },
117
+ },
118
+ crypto_timer: null,
119
+
92
120
  // ACK
93
121
  pending_ack: { initial: [], handshake: [], app: [] },
94
122
 
@@ -100,42 +128,245 @@ function QUICConnection(options) {
100
128
 
101
129
  // In-flight tracking (Phase 1)
102
130
  sending_app_pn_in_flight: new Set(),
103
- sending_app_pn_history: [],
104
-
105
- // Burst scheduler config (Phase 2)
106
- max_packets_per_burst: 20,
107
- max_packets_per_sec: 5000,
108
- max_bytes_per_sec: 10000000, // 10MB/s
109
- max_packet_payload: 1100,
110
- max_packets_in_flight: 100,
111
- max_bytes_in_flight: 5000000, // 5MB
131
+ sending_app_pn_history: [], // [time_sent, encoded_len, delivered_at_send, delivered_time_at_send]
132
+ delivered: 0, // cumulative app bytes acked — for BBR rate samples
133
+ delivered_time: Date.now(), // wall-clock of the last delivered update (BBR delivery clock)
134
+
135
+ // ── Burst / congestion-control knobs ───────────────────────────────────
136
+ // Three layers (+ a floor):
137
+ // max_* HARD CEILING — the programmer sets these; the CC NEVER exceeds
138
+ // them, whatever the network measurements say. Safety net against
139
+ // a CC bug, a pathological link, or memory blow-up.
140
+ // min_* FLOOR — the CC never shrinks below this. Also prevents the
141
+ // deadlock where a cap < one packet rounds down to 0.
142
+ // init_* STARTING point of the current_* values at each new connection.
143
+ // current_* RUNTIME values the CC rewrites (≈ every RTT). Always clamped to
144
+ // [min_, max_]. The send loop reads min(current_, max_), so the
145
+ // moment Phase 4b starts writing current_* it takes effect with no
146
+ // further wiring.
147
+ //
148
+ // Naming: <when>_limit_<noun>. The qualifiers (when=max/min/init/current, and the
149
+ // word `limit`) all sit up front; the noun (bytes_in_flight / packets_per_sec /
150
+ // packet_payload) stays whole at the end. This keeps `current_limit_bytes_in_flight`
151
+ // (the CC's CEILING) clearly distinct from the live `bytesInFlight` it gates against.
152
+ //
153
+ // What each algorithm owns:
154
+ // current_limit_*_in_flight (cwnd) ← Phase 4b (BBR-lite: ≈ 2·BtlBw·min_rtt)
155
+ // current_limit_*_per_sec (pacing rate) ← Phase 4b (BBR-lite: ≈ BtlBw)
156
+ // current_limit_packet_payload (MTU) ← DPLPMTUD (separate, later)
157
+ // Until those algorithms run, init_* = max_* so behavior is the static default.
158
+ // When 4b lands, lower the in-flight init_* toward IW10 (~10 pkts / ~14 KB)
159
+ // and let the algorithm climb from there.
160
+
161
+ max_packets_per_burst: 20, // fixed cap — not CC-controlled, no current_/init_
162
+
163
+ // packet payload (MTU). current_ is the size actually used; max_ is the ceiling
164
+ // DPLPMTUD must not probe past; init_/floor is QUIC's guaranteed 1200-byte minimum.
165
+ max_limit_packet_payload: 1452, // ceiling (IPv6-safe Ethernet: 1500 − 48 hdr)
166
+ init_limit_packet_payload: 1200, // QUIC guaranteed floor — where we start
167
+ current_limit_packet_payload: 1200, // = init_; DPLPMTUD raises toward max_
168
+
169
+ // in-flight window (cwnd) — bytes is the primary signal; packets kept coherent.
170
+ // init_/current_ start at IW10 (RFC 9002 initial window, ~10 pkts) and BBR-lite
171
+ // climbs from there toward 2·BDP; max_ is the hard ceiling, min_ the floor.
172
+ max_limit_packets_in_flight: 256, // ceiling ≈ 300 KB at 1200 B/pkt
173
+ min_limit_packets_in_flight: 2, // floor (RFC 9002 min cwnd)
174
+ init_limit_packets_in_flight: 10, // IW10
175
+ current_limit_packets_in_flight: 10, // = init_; BBR rewrites at round-end
176
+ max_limit_bytes_in_flight: 300000, // ceiling ~300 KB — coherent with packets ↑
177
+ min_limit_bytes_in_flight: 2400, // floor ~2 packets
178
+ init_limit_bytes_in_flight: 12000, // IW10 (10 × 1200 B)
179
+ current_limit_bytes_in_flight: 12000, // = init_; BBR rewrites at round-end
180
+
181
+ // pacing rate
182
+ max_limit_packets_per_sec: 12000, // ceiling ≈ 14 MB/s
183
+ min_limit_packets_per_sec: 10, // floor — avoid 0/deadlock
184
+ init_limit_packets_per_sec: 12000,
185
+ current_limit_packets_per_sec: 12000, // = init_; BBR rewrites at round-end
186
+ max_limit_bytes_per_sec: 14000000, // ceiling ~14 MB/s — coherent ↑
187
+ min_limit_bytes_per_sec: 12000, // floor ~96 kbps — avoid 0/deadlock
188
+ init_limit_bytes_per_sec: 14000000,
189
+ current_limit_bytes_per_sec: 14000000, // = init_; BBR rewrites at round-end
190
+
112
191
  burst_timer: null,
192
+ pacing_tokens: 0, // token-bucket pacer: accumulated send credit (bytes)
193
+ pacing_last_refill: Date.now(),
113
194
 
114
195
  // Pending app packets
115
196
  pending_app_packets: [],
116
197
 
117
- // Flow control — connection level
118
- bytes_sent: 0, // total STREAM bytes sent
119
- bytes_received: 0, // total STREAM bytes received
198
+ // Flow control — connection level (RFC 9000 §4.1)
199
+ bytes_sent: 0, // total STREAM bytes sent (raw stat, incl. retransmits)
200
+ // max_data_sent: connection-level FC USAGE — the sum of per-stream
201
+ // high-water marks (highest offset sent on each stream). RFC 9000 §4.1
202
+ // counts usage by highest offset, NOT bytes on the wire: retransmissions
203
+ // re-send offsets that were already inside the allowance, so they must not
204
+ // advance this counter. bytes_sent above (which does include retransmits)
205
+ // previously doubled as the FC counter — under loss that inflated usage
206
+ // until sending stalled with budget the peer had actually granted.
207
+ max_data_sent: 0,
208
+ bytes_received: 0, // total STREAM bytes *received off the wire* (incl. out-of-order)
120
209
  remote_max_data: 1048576, // peer's limit on what we can send (default 1MB until parsed)
121
- local_max_data: 1048576, // our limit on what peer can send (matches transport params)
122
- local_max_data_consumed: 0, // how much of local_max_data has been used
123
- local_max_data_threshold: 0.5, // send MAX_DATA when consumed > threshold × local_max_data
210
+
211
+ // ── Flow control, receive side (RFC 9000 §4.1) — consumption-based
212
+ // sliding window (the ngtcp2/quiche scheme; replaces the old unbounded
213
+ // ×2 doubling, which never actually limited the peer and grew forever).
214
+ //
215
+ // Three numbers per level:
216
+ // window (W) — fixed size, = the value in our transport params.
217
+ // consumed — bytes DELIVERED IN-ORDER to the application (advances in
218
+ // flushStream), NOT bytes received off the wire. Data can
219
+ // arrive out of order and park in the buffer unconsumed —
220
+ // exactly what the window must bound (memory).
221
+ // advertised — the limit last sent to the peer (local_max_data below /
222
+ // per-stream local_max_stream_data). Slides forward by
223
+ // `advertised = consumed + W` once consumed passes half a
224
+ // window since the last update (hysteresis).
225
+ //
226
+ // USAGE (what the peer is measured against) counts by HIGHEST OFFSET per
227
+ // stream (fc_recv_usage = Σ max_recv_offset), not bytes-on-the-wire —
228
+ // retransmissions must not advance it. Exceeding `advertised` on either
229
+ // level is a protocol violation → CONNECTION_CLOSE FLOW_CONTROL_ERROR
230
+ // (0x03). A peer stalled because we haven't consumed sends DATA_BLOCKED /
231
+ // STREAM_DATA_BLOCKED, answered by re-sending the current advertised —
232
+ // that pairing (see the frame handlers) is what makes a lost window
233
+ // update recoverable.
234
+ local_max_data: 1048576, // ADVERTISED conn limit (starts = window, from transport params)
235
+ local_max_data_window: 1048576, // W — fixed; matches initial_max_data we advertise
236
+ local_max_data_consumed: 0, // in-order bytes delivered to the app (all streams)
237
+ fc_recv_usage: 0, // Σ per-stream max_recv_offset — the peer's usage
238
+
239
+ //
240
+ // Flow control — stream level (RFC 9000 §4.1)
241
+ local_initial_max_stream_data: 262144, // matches transport params; doubled on MAX_STREAM_DATA updates
242
+
124
243
  remote_max_streams_bidi: 100,
125
244
  remote_max_streams_uni: 3,
126
245
 
127
- // RTT
128
- rtt_history: [],
246
+ // ── Per-stream send-side flow control (RFC 9000 §4.1) — the peer's limits
247
+ // on what WE may send per stream. Seeded from the peer's transport params
248
+ // (initial_max_stream_data_*, mapped by stream direction/initiator in
249
+ // initialStreamSendLimit), then raised by MAX_STREAM_DATA frames. The RFC
250
+ // default for an omitted param is 0 — a peer that doesn't grant, grants
251
+ // nothing (in practice params always arrive during the handshake, before
252
+ // any app data can flow).
253
+ peer_initial_max_stream_data_bidi_local: 0,
254
+ peer_initial_max_stream_data_bidi_remote: 0,
255
+ peer_initial_max_stream_data_uni: 0,
256
+ // MAX_STREAM_DATA values keyed by stream id (monotonic). Lives outside the
257
+ // stream object so a frame that arrives BEFORE the stream is created is
258
+ // still honored at creation time. Entries are dropped when the stream is
259
+ // fully acked / stopped.
260
+ remote_max_stream_data_by_sid: {},
261
+
262
+ // FC-blocked signaling (DATA_BLOCKED / STREAM_DATA_BLOCKED, RFC 9000 §4.1).
263
+ // Set by get_stream_chunks when a window clamp actually binds; consumed by
264
+ // maybeSendBlockedFrames after each burst pass. This doubles as our loss
265
+ // recovery for window updates: MAX_DATA / MAX_STREAM_DATA are sent once
266
+ // and never retransmitted, so a lost update would stall the sender forever
267
+ // — the repeated (rate-limited) BLOCKED frame prompts the peer to re-send
268
+ // its current limit.
269
+ fc_blocked_conn: false, // conn-level budget bound this pass
270
+ last_data_blocked_sent: 0, // rate-limit timestamp (connection)
271
+ last_stream_blocked_sent: {}, // sid → rate-limit timestamp (per stream)
272
+
273
+ // RTT estimate (RFC 9002 §5), updated incrementally on every new sample.
274
+ // null = no sample yet. Replaces the old raw rtt_history log: the EWMA below
275
+ // keeps the needed history implicitly, so per-sample storage isn't needed.
276
+ srtt: null,
277
+ rttvar: null,
278
+ min_rtt: null,
279
+ latest_rtt: null,
280
+ max_ack_delay: 25, // ms; seeded from the peer's transport params (max_ack_delay) once parsed
281
+ peer_ack_delay_exponent: 3, // seeded from the peer's transport params; ACK Delay = field × 2^this µs
282
+
283
+ // --- Raw network observations (collected for the future congestion
284
+ // controller; NOT acted on here). Flat on context, like the RTT fields.
285
+ // Counters are cumulative; rates/extents are derived later over a window. ---
286
+ max_rtt: null, // paired with min_rtt — the gap reveals bufferbloat
287
+ latest_delivery_rate: null, // bytes/sec acked in the most recent ACK sample
288
+ max_delivery_rate: null, // peak delivery rate ≈ BtlBw (a CC bandwidth input)
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: {},
305
+ reorder_in_count: 0, // app packets that arrived below the highest PN seen (network reordering, peer→us)
306
+
307
+ // --- BBR-lite measurement (Phase 4b, step 1+2 — measure only, no control). ---
308
+ // BBR models the path from two measured quantities and derives the BDP:
309
+ // BDP = BtlBw × RTprop. BtlBw = windowed-MAX delivery rate (≈ bottleneck
310
+ // bandwidth); RTprop = windowed-MIN RTT (≈ propagation, queue-free). Both use
311
+ // windows so a stale peak/min decays: a forever-max would never drop when the
312
+ // link slows, a forever-min would never rise as the true RTT changes.
313
+ bbr_round_count: 0, // round-trip counter; one round ≈ one RTT elapsed
314
+ bbr_round_start_pn: 0, // round completes when an ACK's largest >= this
315
+ bbr_round_start_delivered: 0, // context.delivered at round start (for per-round rate)
316
+ bbr_round_start_time: Date.now(), // wall-clock at round start (for per-round rate)
317
+ bbr_btlbw_samples: [], // [{round, rate}] — per-ACK delivery-rate samples
318
+ bbr_btlbw: null, // windowed-max delivery rate over the last N rounds (≈ BtlBw)
319
+ bbr_min_rtt: null, // windowed-min RTT over ~10s (≈ RTprop)
320
+ bbr_min_rtt_stamp: 0, // when bbr_min_rtt was last (re)set
321
+ bbr_bdp: null, // derived BtlBw × min_rtt (bytes) — the in-flight target
322
+
323
+ // BBR-lite state machine. Startup ramps exponentially (high gain) to discover
324
+ // BtlBw; once it plateaus (no ≥25% growth for 3 rounds) the pipe is full, so
325
+ // Drain removes the queue Startup built, then ProbeBW holds steady at the
326
+ // bottleneck rate. Without Startup, writing cwnd from an under-saturated
327
+ // measurement death-spirals to the floor (the link never gets filled).
328
+ bbr_state: 'startup', // 'startup' | 'drain' | 'probe_bw'
329
+ bbr_full_bw: 0, // highest BtlBw seen — Startup plateau detector
330
+ bbr_full_bw_count: 0, // consecutive rounds without ≥25% BtlBw growth
331
+ bbr_cycle_idx: 0, // ProbeBW pacing-gain cycle position (0..7)
129
332
 
130
333
  // Timers
131
334
  idle_timeout: options.idleTimeout || 30000,
132
335
  handshake_timeout: options.handshakeTimeout || 10000,
133
336
  last_activity: Date.now(),
337
+ // Time of the most recent ACK received from the peer (set only in
338
+ // processAckFrame). Drives the in-flight-expiry backoff: while the peer
339
+ // isn't ACKing us, the retransmit timeout widens so we don't flood a dead
340
+ // path. Distinct from last_activity (which also bumps on send/receive).
341
+ last_ack_time: Date.now(),
134
342
  idle_timer: null,
135
343
  handshake_timer: null,
136
344
 
345
+ // Keep-alive: when > 0, send a PING after this many ms of inactivity to
346
+ // keep the connection from idling out (RFC 9000 §10.1.2). Should be < the
347
+ // idle timeout. options.keepAlive: true → idle_timeout/2; a number → ms.
348
+ keep_alive_interval: (function () {
349
+ var k = options.keepAlive;
350
+ if (k === true) return Math.max(1000, Math.floor((options.idleTimeout || 30000) / 2));
351
+ if (typeof k === 'number' && k > 0) return k;
352
+ return 0;
353
+ })(),
354
+ keep_alive_timer: null,
355
+
137
356
  SNICallback: options.SNICallback || null,
138
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,
362
+
363
+ // ALPN protocol(s) to advertise in the TLS handshake.
364
+ // Defaults to ['h3'] (HTTP/3). Other QUIC-based protocols set their own,
365
+ // e.g. 'doq' for DNS-over-QUIC (RFC 9250). Accepts a string or array.
366
+ alpn: (function () {
367
+ var a = options.alpn || ['h3'];
368
+ return Array.isArray(a) ? a : [a];
369
+ })(),
139
370
  };
140
371
 
141
372
 
@@ -195,7 +426,7 @@ function QUICConnection(options) {
195
426
  }
196
427
 
197
428
  if (changed.handshake_done && context.handshake_done === true) {
198
- console.log('[quic] handshake done — flushing ' + context.pending_app_packets.length + ' pending');
429
+ if (DEBUG) console.log('[quic] handshake done — flushing ' + context.pending_app_packets.length + ' pending');
199
430
  if (context.pending_app_packets.length > 0) {
200
431
  var pending = context.pending_app_packets;
201
432
  context.pending_app_packets = [];
@@ -204,6 +435,7 @@ function QUICConnection(options) {
204
435
  }
205
436
  }
206
437
  startIdleTimer();
438
+ startKeepAliveTimer();
207
439
  }
208
440
 
209
441
  if (changed.state && context.state === 'connected') {
@@ -211,10 +443,15 @@ function QUICConnection(options) {
211
443
  // Server emits on appSecrets, Client emits on secureConnect
212
444
  }
213
445
 
214
- if (changed.state && context.state === 'draining') {
446
+ if (changed.state && (context.state === 'draining' || context.state === 'closing')) {
215
447
  clearIdleTimer();
448
+ // A draining/closing endpoint must stop retransmitting CRYPTO — the
449
+ // ticker would otherwise keep resending the handshake flight at a peer
450
+ // that already told us the connection is dead (seen live: three Initial
451
+ // retransmits fired after a CONNECTION_CLOSE was processed).
452
+ stopCryptoRetx();
216
453
  var _drainTimer = setTimeout(function () {
217
- if (context.state === 'draining') { context.state = 'closed'; ev.emit('close'); }
454
+ if (context.state === 'draining' || context.state === 'closing') { context.state = 'closed'; ev.emit('close'); }
218
455
  }, Math.min(3000, context.idle_timeout / 3));
219
456
  if (_drainTimer.unref) _drainTimer.unref();
220
457
  }
@@ -225,7 +462,7 @@ function QUICConnection(options) {
225
462
  // We initiated key update — derive new write keys
226
463
  if (context.app_write_secret) {
227
464
  var next = quic_derive_key_update(context.app_write_secret, context.cipher_hash, context.cipher_suite);
228
- console.log('[quic] key update initiated — new write keys');
465
+ if (DEBUG) console.log('[quic] key update initiated — new write keys');
229
466
  context.app_write = { key: next.key, iv: next.iv, hp: context.app_write.hp };
230
467
  context.app_write_secret = next.secret;
231
468
  }
@@ -244,15 +481,34 @@ function QUICConnection(options) {
244
481
  if (context.idle_timeout <= 0) return;
245
482
  context.idle_timer = setInterval(function () {
246
483
  if (Date.now() - context.last_activity >= context.idle_timeout) {
247
- console.log('[quic] idle timeout');
484
+ if (DEBUG) console.log('[quic] idle timeout');
248
485
  close(0, 'idle timeout');
249
486
  }
250
487
  }, Math.max(1000, Math.floor(context.idle_timeout / 4)));
251
488
  if (context.idle_timer.unref) context.idle_timer.unref();
252
489
  }
253
490
 
491
+ function startKeepAliveTimer() {
492
+ if (context.keep_alive_interval <= 0) return;
493
+ if (context.keep_alive_timer !== null) return; // idempotent
494
+ context.keep_alive_timer = setInterval(function () {
495
+ if (context.state !== 'connected') return;
496
+ // Only ping when the connection has actually been idle for the interval.
497
+ // Real traffic (sent or received) calls touchActivity(), which resets
498
+ // last_activity and suppresses unnecessary keep-alive PINGs.
499
+ if (Date.now() - context.last_activity >= context.keep_alive_interval) {
500
+ if (DEBUG) console.log('[quic] keep-alive PING');
501
+ sendFrames('app', [{ type: 'ping' }]);
502
+ }
503
+ }, context.keep_alive_interval);
504
+ if (context.keep_alive_timer.unref) context.keep_alive_timer.unref();
505
+ }
506
+
254
507
  function clearIdleTimer() {
255
508
  if (context.idle_timer !== null) { clearInterval(context.idle_timer); context.idle_timer = null; }
509
+ // Keep-alive shares the connection's liveness lifecycle: stop it wherever
510
+ // the idle timer is stopped (handshake (re)start, draining/closing, closed).
511
+ if (context.keep_alive_timer !== null) { clearInterval(context.keep_alive_timer); context.keep_alive_timer = null; }
256
512
  }
257
513
 
258
514
 
@@ -264,8 +520,8 @@ function QUICConnection(options) {
264
520
  // Start handshake timeout — if TLS doesn't complete in time, close
265
521
  if (context.handshake_timeout > 0 && !context.handshake_timer) {
266
522
  context.handshake_timer = setTimeout(function () {
267
- if (context.state !== 'connected' && context.state !== 'closed' && context.state !== 'draining') {
268
- console.log('[quic] handshake timeout (' + context.handshake_timeout + 'ms)');
523
+ if (context.state !== 'connected' && context.state !== 'closed' && context.state !== 'draining' && context.state !== 'closing') {
524
+ if (DEBUG) console.log('[quic] handshake timeout (' + context.handshake_timeout + 'ms)');
269
525
  ev.emit('error', new Error('QUIC handshake timeout'));
270
526
  close(0x100, 'handshake timeout'); // 0x100 = CRYPTO_ERROR
271
527
  }
@@ -278,11 +534,74 @@ function QUICConnection(options) {
278
534
  SNICallback: context.SNICallback,
279
535
  originalDcid: context.original_dcid,
280
536
  localCid: context.my_cids.length > 0 ? context.my_cids[0] : new Uint8Array(0),
281
- hostname: context.hostname
537
+ hostname: context.hostname,
538
+ alpn: context.alpn,
539
+ rejectUnauthorized: context.rejectUnauthorized,
540
+ ca: context.ca
282
541
  });
283
542
 
284
543
  tls.on('send', function (epoch, data) { cryptoWrite(epoch, data); });
285
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
+
551
+ // Peer's QUIC transport parameters (parsed from the TLS 0x39 extension). These
552
+ // replace hardcoded defaults: the peer's flow-control limit, ACK-delay scaling,
553
+ // and max ACK delay. Arrives once, during the handshake, before app data flows.
554
+ tls.on('peerTransportParams', function (p) {
555
+ // ack_delay_exponent: clamp to RFC 9000 range [0,20] (guards Math.pow blowup).
556
+ if (typeof p.ack_delay_exponent === 'number') {
557
+ context.peer_ack_delay_exponent = Math.max(0, Math.min(20, p.ack_delay_exponent));
558
+ }
559
+ // max_ack_delay (ms): the peer's stated max delay before it sends an ACK.
560
+ if (typeof p.max_ack_delay === 'number' && p.max_ack_delay >= 0) {
561
+ context.max_ack_delay = Math.min(p.max_ack_delay, 16384); // RFC cap 2^14 ms
562
+ }
563
+ // initial_max_data: the AUTHORITATIVE initial flow-control limit. Set directly
564
+ // (not via the monotonic MAX_DATA path, which only increases) so a peer that
565
+ // grants less than our 1MB default is honored. MAX_DATA frames raise it later.
566
+ if (typeof p.initial_max_data === 'number') {
567
+ context.remote_max_data = p.initial_max_data;
568
+ }
569
+ // Per-stream send limits (RFC 9000 §18.2). The params are named from the
570
+ // PEER's perspective; initialStreamSendLimit() maps them onto our stream
571
+ // ids by direction/initiator when a send-stream is created.
572
+ if (typeof p.initial_max_stream_data_bidi_local === 'number') {
573
+ context.peer_initial_max_stream_data_bidi_local = p.initial_max_stream_data_bidi_local;
574
+ }
575
+ if (typeof p.initial_max_stream_data_bidi_remote === 'number') {
576
+ context.peer_initial_max_stream_data_bidi_remote = p.initial_max_stream_data_bidi_remote;
577
+ }
578
+ if (typeof p.initial_max_stream_data_uni === 'number') {
579
+ context.peer_initial_max_stream_data_uni = p.initial_max_stream_data_uni;
580
+ }
581
+ // Stream-count limits the peer grants us. Enforcement (blocking opens
582
+ // past the cap) is future work, but seed the real values so the guessed
583
+ // defaults (100/3) don't masquerade as negotiated ones.
584
+ if (typeof p.initial_max_streams_bidi === 'number') {
585
+ context.remote_max_streams_bidi = p.initial_max_streams_bidi;
586
+ }
587
+ if (typeof p.initial_max_streams_uni === 'number') {
588
+ context.remote_max_streams_uni = p.initial_max_streams_uni;
589
+ }
590
+ // Defensive: raise the limit of any stream opened before the params
591
+ // arrived (normally none — params land during the handshake).
592
+ for (var rsid in context.send_streams) {
593
+ var rst = context.send_streams[rsid];
594
+ var seeded = initialStreamSendLimit(Number(rsid));
595
+ if (seeded > rst.remote_max_stream_data) rst.remote_max_stream_data = seeded;
596
+ }
597
+ plan_quic_burst(); // budgets changed
598
+ if (DEBUG) console.log('[quic] peer params: ack_delay_exp=' + context.peer_ack_delay_exponent +
599
+ ' max_ack_delay=' + context.max_ack_delay + ' remote_max_data=' + context.remote_max_data +
600
+ ' msd_bidi_local=' + context.peer_initial_max_stream_data_bidi_local +
601
+ ' msd_bidi_remote=' + context.peer_initial_max_stream_data_bidi_remote +
602
+ ' msd_uni=' + context.peer_initial_max_stream_data_uni);
603
+ });
604
+
286
605
  tls.on('handshakeSecrets', function (secrets) {
287
606
  set_context({
288
607
  handshake_read: quic_derive_from_tls_secrets(secrets.remote, secrets.hash, secrets.cipher),
@@ -310,6 +629,12 @@ function QUICConnection(options) {
310
629
  handshake_done: true
311
630
  });
312
631
 
632
+ // NOTE: do NOT stop crypto recovery here. Reaching appSecrets means OUR
633
+ // side finished, but the peer hasn't necessarily received our flight yet
634
+ // (the server hits this the moment it sends Finished, before the client
635
+ // confirms). The crypto ticker self-terminates once every CRYPTO byte is
636
+ // acked (cryptoHasInFlight() === false); close() force-stops it otherwise.
637
+
313
638
  // Server: emit connect now (Finished already sent)
314
639
  // Client: wait for secureConnect (after client Finished is generated)
315
640
  if (context.isServer) {
@@ -337,11 +662,35 @@ function QUICConnection(options) {
337
662
  // ============================================================
338
663
 
339
664
  function feedDatagram(from_ip, from_port, data) {
340
- console.log('[quic] datagram from ' + from_ip + ':' + from_port + ' len=' + data.length);
665
+ if (DEBUG) console.log('[quic] datagram from ' + from_ip + ':' + from_port + ' len=' + data.length);
666
+ feedPackets(from_ip, from_port, parse_quic_datagram(data));
667
+ }
668
+
669
+ // Feed already-parsed packets. Used by the server, which parses each UDP
670
+ // datagram once (to route it to a connection) and then hands the parsed
671
+ // packets here directly, avoiding a redundant second parse.
672
+ function feedPackets(from_ip, from_port, packets) {
673
+ if (context.state === 'closed' || context.state === 'draining') return;
674
+ if (context.state === 'closing') {
675
+ // RFC 9000 §10.2.1: while closing, don't process packets normally; instead
676
+ // re-send CONNECTION_CLOSE in response (rate-limited) so the peer learns
677
+ // we've closed even if the first CC was lost.
678
+ if (Date.now() - context.last_close_echo >= 200) sendConnectionClose();
679
+ return;
680
+ }
341
681
  touchActivity();
342
- var packets = parse_quic_datagram(data);
343
682
  for (var i = 0; i < packets.length; i++) {
344
- if (packets[i] !== null) feedPacket(packets[i]);
683
+ if (packets[i] !== null) {
684
+ // Safety net: a single malformed packet must never crash the process or
685
+ // tear down the connection. The parsers are bounds-checked, but this
686
+ // also guards decrypt / frame-handler edge cases. Drop the bad packet
687
+ // (logged under DEBUG) and keep processing the rest of the datagram.
688
+ try {
689
+ feedPacket(packets[i]);
690
+ } catch (e) {
691
+ if (DEBUG) console.log('[quic] dropped packet — processing error: ' + (e && e.message));
692
+ }
693
+ }
345
694
  }
346
695
  }
347
696
 
@@ -368,9 +717,19 @@ function QUICConnection(options) {
368
717
  : space === 'handshake' ? context.handshake_read : context.app_read;
369
718
  if (!readKeys) return;
370
719
 
720
+ // Connection ID by which *we* are addressed. Its length is needed to locate
721
+ // the packet number in short (1-RTT) headers, which carry no DCID-length
722
+ // field — the receiver must already know its own CID length. For a server
723
+ // this is the SCID it chose (which it adopts as original_dcid); for a client
724
+ // it's its own SCID (my_cids[0]). Long headers carry the DCID length on the
725
+ // wire, so decrypt ignores this value for Initial/Handshake packets.
726
+ var recvCid = context.isServer
727
+ ? context.original_dcid
728
+ : (context.my_cids.length > 0 ? context.my_cids[0] : context.original_dcid);
729
+
371
730
  var decrypted = decrypt_quic_packet(
372
731
  pkt.raw, readKeys.key, readKeys.iv, readKeys.hp,
373
- context.original_dcid, context.recv_pn_largest[space]
732
+ recvCid, context.recv_pn_largest[space]
374
733
  );
375
734
 
376
735
  // Key Update: if app decrypt fails, try with derived next keys
@@ -378,16 +737,30 @@ function QUICConnection(options) {
378
737
  var next = quic_derive_key_update(context.app_read_secret, context.cipher_hash, context.cipher_suite);
379
738
  decrypted = decrypt_quic_packet(
380
739
  pkt.raw, next.key, next.iv, readKeys.hp, // HP doesn't change
381
- context.original_dcid, context.recv_pn_largest[space]
740
+ recvCid, context.recv_pn_largest[space]
382
741
  );
383
742
 
384
743
  if (decrypted && decrypted.plaintext && decrypted.plaintext.byteLength > 0) {
385
744
  // Key update confirmed — install new read keys
386
- console.log('[quic] key update detected — installing new read keys');
745
+ if (DEBUG) console.log('[quic] key update detected — installing new read keys');
387
746
  context.app_prev_read = context.app_read;
388
747
  context.app_read = { key: next.key, iv: next.iv, hp: readKeys.hp };
389
748
  context.app_read_secret = next.secret;
390
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 });
391
764
  }
392
765
  }
393
766
 
@@ -395,16 +768,16 @@ function QUICConnection(options) {
395
768
  if ((!decrypted || !decrypted.plaintext) && space === 'app' && context.app_prev_read) {
396
769
  decrypted = decrypt_quic_packet(
397
770
  pkt.raw, context.app_prev_read.key, context.app_prev_read.iv, context.app_prev_read.hp,
398
- context.original_dcid, context.recv_pn_largest[space]
771
+ recvCid, context.recv_pn_largest[space]
399
772
  );
400
773
  }
401
774
 
402
775
  if (!decrypted || !decrypted.plaintext || decrypted.plaintext.byteLength === 0) {
403
- console.log('[quic] decrypt failed: ' + space + ' raw_len=' + pkt.raw.byteLength + ' first20=' + Array.from(pkt.raw.slice(0, 20)).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' ') + ' dcid_len=' + (context.original_dcid ? context.original_dcid.byteLength : 'null') + ' has_keys=' + !!readKeys + ' largest_pn=' + context.recv_pn_largest[space]);
776
+ if (DEBUG) console.log('[quic] decrypt failed: ' + space + ' raw_len=' + pkt.raw.byteLength + ' first20=' + Array.from(pkt.raw.slice(0, 20)).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(' ') + ' recv_cid_len=' + (recvCid ? recvCid.byteLength : 'null') + ' has_keys=' + !!readKeys + ' largest_pn=' + context.recv_pn_largest[space]);
404
777
  return;
405
778
  }
406
779
 
407
- console.log('[quic] decrypted ' + space + ' pn=' + decrypted.packet_number + ' len=' + decrypted.plaintext.byteLength);
780
+ if (DEBUG) console.log('[quic] decrypted ' + space + ' pn=' + decrypted.packet_number + ' len=' + decrypted.plaintext.byteLength);
408
781
 
409
782
  var pn = decrypted.packet_number;
410
783
  var ranges = context.recv_pn_ranges[space];
@@ -414,10 +787,16 @@ function QUICConnection(options) {
414
787
  }
415
788
  if (isNew) {
416
789
  flat_ranges.add(ranges, [pn, pn + 1]);
417
- if (pn > context.recv_pn_largest[space]) context.recv_pn_largest[space] = pn;
790
+ if (pn > context.recv_pn_largest[space]) {
791
+ context.recv_pn_largest[space] = pn;
792
+ } else if (space === 'app') {
793
+ // New packet, but below the highest PN already seen → it arrived out of
794
+ // order. Pure network reordering (the peer sends PNs strictly increasing).
795
+ context.reorder_in_count++;
796
+ }
418
797
  }
419
798
 
420
- console.log('[quic] pn=' + pn + ' isNew=' + isNew);
799
+ if (DEBUG) console.log('[quic] pn=' + pn + ' isNew=' + isNew);
421
800
  if (!isNew) return;
422
801
 
423
802
  if (space === 'app' && !context.handshake_done) {
@@ -435,7 +814,7 @@ function QUICConnection(options) {
435
814
 
436
815
  function processDecryptedPacket(space, packetNumber, plaintext) {
437
816
  var frames = parse_quic_frames(plaintext);
438
- console.log('[quic] frames: ' + space + ' pn=' + packetNumber + ' [' + frames.map(function(f){ return f.type; }).join(',') + ']');
817
+ if (DEBUG) console.log('[quic] frames: ' + space + ' pn=' + packetNumber + ' [' + frames.map(function(f){ return f.type; }).join(',') + ']');
439
818
  var ackEliciting = false;
440
819
 
441
820
  if (context.isServer && space === 'app' && !context.handshake_done_sent) {
@@ -464,21 +843,23 @@ function QUICConnection(options) {
464
843
  } else if (frame.type === 'new_connection_id') {
465
844
  ackEliciting = true;
466
845
  } else if (frame.type === 'connection_close') {
467
- console.log('[quic] CONNECTION_CLOSE error=0x' + (frame.error || 0).toString(16) + ' frame_type=0x' + (frame.frameType || 0).toString(16) + ' reason="' + (frame.reason || '') + '"');
846
+ if (DEBUG) console.log('[quic] CONNECTION_CLOSE error=0x' + (frame.error || 0).toString(16) + ' frame_type=0x' + (frame.frameType || 0).toString(16) + ' reason="' + (frame.reason || '') + '"');
468
847
  set_context({ state: 'draining' });
469
848
  return;
470
849
  } else if (frame.type === 'stop_sending') {
471
850
  // Peer says: stop sending on this stream
472
851
  ackEliciting = true;
473
852
  if (frame.id in context.send_streams) {
474
- console.log('[quic] STOP_SENDING stream=' + frame.id + ' error=' + frame.error);
853
+ if (DEBUG) console.log('[quic] STOP_SENDING stream=' + frame.id + ' error=' + frame.error);
475
854
  delete context.send_streams[frame.id];
855
+ delete context.remote_max_stream_data_by_sid[frame.id];
856
+ delete context.last_stream_blocked_sent[frame.id];
476
857
  }
477
858
  } else if (frame.type === 'reset_stream') {
478
859
  // Peer cancelled their stream
479
860
  ackEliciting = true;
480
861
  if (frame.id in context.recv_streams) {
481
- console.log('[quic] RESET_STREAM stream=' + frame.id + ' error=' + frame.error);
862
+ if (DEBUG) console.log('[quic] RESET_STREAM stream=' + frame.id + ' error=' + frame.error);
482
863
  delete context.recv_streams[frame.id];
483
864
  }
484
865
  } else if (frame.type === 'max_data') {
@@ -492,13 +873,64 @@ function QUICConnection(options) {
492
873
  set_context({ remote_max_streams_uni: frame.max });
493
874
  } else if (frame.type === 'max_stream_data') {
494
875
  ackEliciting = true;
876
+ // The peer raised our send allowance on one stream (previously the
877
+ // value was dropped on the floor). Track it monotonically in the
878
+ // sid-keyed map (so a frame arriving before the stream exists is
879
+ // honored at creation) and on the live stream, then reschedule — this
880
+ // may have just unblocked a sender stalled on the old limit.
881
+ var msdPrev = context.remote_max_stream_data_by_sid[frame.id] || 0;
882
+ if (frame.max > msdPrev) context.remote_max_stream_data_by_sid[frame.id] = frame.max;
883
+ var msdStream = context.send_streams[frame.id];
884
+ if (msdStream && frame.max > msdStream.remote_max_stream_data) {
885
+ msdStream.remote_max_stream_data = frame.max;
886
+ plan_quic_burst();
887
+ }
888
+ } else if (frame.type === 'data_blocked') {
889
+ // The peer is stalled on our connection window. Our MAX_DATA may have
890
+ // been lost (window updates are sent once, unrecovered) — re-send the
891
+ // current limit. Harmless if it wasn't lost: the peer's limit is
892
+ // monotonic, a duplicate is ignored.
893
+ ackEliciting = true;
894
+ sendFrames('app', [{ type: 'max_data', max: context.local_max_data }]);
895
+ } else if (frame.type === 'stream_data_blocked') {
896
+ // Same, per stream. If we never received data on this stream (all of
897
+ // it lost), no recv_stream exists yet — answer with the initial limit
898
+ // we advertised in our transport params.
899
+ ackEliciting = true;
900
+ var sdbStream = context.recv_streams[frame.id];
901
+ var sdbLimit = sdbStream ? sdbStream.local_max_stream_data
902
+ : context.local_initial_max_stream_data;
903
+ sendFrames('app', [{ type: 'max_stream_data', id: Number(frame.id), max: sdbLimit }]);
495
904
  } else if (frame.type === 'datagram') {
496
- ev.emit('datagram', frame.contextId, frame.data);
905
+ // DATAGRAM frames are ack-eliciting (RFC 9221 §5.2). Without this, a
906
+ // peer sending only datagrams (a classic WebTransport flow) would get
907
+ // no ACKs from us and read the silence as total loss.
908
+ ackEliciting = true;
909
+ ev.emit('datagram', frame.data);
497
910
  }
498
911
  }
499
912
 
500
913
  if (ackEliciting) {
501
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
+
502
934
  var ackFrame = ranges_to_ack_frame(context.pending_ack[space], null, 0);
503
935
  if (ackFrame) {
504
936
  sendFrames(space, [ackFrame]);
@@ -514,30 +946,30 @@ function QUICConnection(options) {
514
946
 
515
947
  function processCryptoFrame(space, offset, data) {
516
948
  if (space !== 'initial' && space !== 'handshake') return;
517
- console.log('[quic] CRYPTO: space=' + space + ' offset=' + offset + ' len=' + data.length);
949
+ if (DEBUG) console.log('[quic] CRYPTO: space=' + space + ' offset=' + offset + ' len=' + data.length);
518
950
 
519
951
  var chunks = context.crypto_chunks[space];
520
952
  var fromOffset = context.crypto_offset[space];
521
953
  if (!(offset in chunks) || chunks[offset].byteLength < data.byteLength) chunks[offset] = data;
522
954
 
523
955
  var result = extract_tls_messages_from_chunks(chunks, fromOffset);
524
- console.log('[quic] TLS messages: ' + (result ? result.tls_messages.length : 'none'));
956
+ if (DEBUG) console.log('[quic] TLS messages: ' + (result ? result.tls_messages.length : 'none'));
525
957
  if (!result) return;
526
958
 
527
959
  context.crypto_offset[space] = result.new_from_offset;
528
- if (!tls) { console.log('[quic] initializing TLS bridge'); initTLS(); }
960
+ if (!tls) { if (DEBUG) console.log('[quic] initializing TLS bridge'); initTLS(); }
529
961
 
530
962
  for (var i = 0; i < result.tls_messages.length; i++) {
531
963
  var msg = result.tls_messages[i];
532
964
  var msgType = msg[0];
533
- console.log('[quic] → TLS msg #' + i + ' type=0x' + msgType.toString(16) + ' len=' + msg.length);
965
+ if (DEBUG) console.log('[quic] → TLS msg #' + i + ' type=0x' + msgType.toString(16) + ' len=' + msg.length);
534
966
 
535
967
  // Detect HelloRetryRequest (type 0x02 with special random)
536
968
  if (msgType === 0x02 && msg.length >= 38) {
537
969
  var hrrRandom = [0xCF,0x21,0xAD,0x74,0xE5,0x9A,0x61,0x11,0xBE,0x1D,0x8C,0x02,0x1E,0x65,0xB8,0x91,0xC2,0xA2,0x11,0x16,0x7A,0xBB,0x8C,0x5E,0x07,0x9E,0x09,0xE2,0xC8,0xA8,0x33,0x9C];
538
970
  var isHRR = true;
539
971
  for (var j = 0; j < 32; j++) { if (msg[6 + j] !== hrrRandom[j]) { isHRR = false; break; } }
540
- if (isHRR) console.log('[quic] ⚠️ HelloRetryRequest detected! LemonTLS needs HRR support.');
972
+ if (isHRR && DEBUG) console.log('[quic] ⚠️ HelloRetryRequest detected! LemonTLS needs HRR support.');
541
973
  }
542
974
 
543
975
  tls.feedMessage(msg);
@@ -552,10 +984,36 @@ function QUICConnection(options) {
552
984
  function processStreamFrame(frame) {
553
985
  var sid = frame.id;
554
986
  if (!(sid in context.recv_streams)) {
555
- context.recv_streams[sid] = { chunks: {}, ranges: [], total_size: 0, flushed_to: 0 };
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
989
+ max_recv_offset: 0, // highest offset seen — the peer's FC usage on this stream
990
+ local_max_stream_data: context.local_initial_max_stream_data };
556
991
  }
557
992
  var stream = context.recv_streams[sid];
558
993
 
994
+ // ── FC usage + enforcement (RFC 9000 §4.1) ──────────────────────────────
995
+ // Usage advances only when the HIGHEST offset advances — a retransmission
996
+ // re-delivers offsets already counted and must not be charged (the old
997
+ // bytes_received counter charged every arriving byte, so under loss we
998
+ // advertised new windows early). Exceeding what we advertised, on either
999
+ // level, is the peer's protocol violation → FLOW_CONTROL_ERROR (0x03).
1000
+ var newHigh = frame.offset + frame.data.length;
1001
+ if (newHigh > stream.max_recv_offset) {
1002
+ if (newHigh > stream.local_max_stream_data) {
1003
+ if (DEBUG) console.log('[quic] FC violation: stream ' + sid + ' offset ' + newHigh + ' > advertised ' + stream.local_max_stream_data);
1004
+ close(0x03, 'stream flow control exceeded');
1005
+ return;
1006
+ }
1007
+ var fcDelta = newHigh - stream.max_recv_offset;
1008
+ if (context.fc_recv_usage + fcDelta > context.local_max_data) {
1009
+ if (DEBUG) console.log('[quic] FC violation: connection usage ' + (context.fc_recv_usage + fcDelta) + ' > advertised ' + context.local_max_data);
1010
+ close(0x03, 'connection flow control exceeded');
1011
+ return;
1012
+ }
1013
+ stream.max_recv_offset = newHigh;
1014
+ context.fc_recv_usage += fcDelta;
1015
+ }
1016
+
559
1017
  var alreadyHave = false;
560
1018
  for (var ri = 0; ri < stream.ranges.length; ri += 2) {
561
1019
  if (frame.offset >= stream.ranges[ri] && frame.offset + frame.data.length <= stream.ranges[ri + 1]) {
@@ -567,43 +1025,148 @@ function QUICConnection(options) {
567
1025
  if (!(frame.offset in stream.chunks) || stream.chunks[frame.offset].byteLength < frame.data.byteLength) {
568
1026
  stream.chunks[frame.offset] = frame.data;
569
1027
  }
570
- // Track connection-level bytes received
1028
+ // Raw stat only — FC usage is max_recv_offset above, not this.
571
1029
  context.bytes_received += frame.data.byteLength;
572
- checkLocalFlowControl();
573
1030
  }
574
1031
  if (frame.fin && stream.total_size === 0) stream.total_size = frame.offset + frame.data.length;
575
1032
  flushStream(sid);
576
1033
  }
577
1034
 
578
- /**
579
- * Check if we need to send MAX_DATA to give the peer more send capacity.
580
- * When consumed > threshold × window, double the window and notify peer.
581
- */
582
- function checkLocalFlowControl() {
583
- if (context.bytes_received > context.local_max_data * context.local_max_data_threshold) {
584
- var newMax = context.local_max_data * 2;
585
- context.local_max_data = newMax;
586
- sendFrames('app', [{ type: 'max_data', max: newMax }]);
587
- }
588
- }
1035
+ // (checkLocalFlowControl — the old unbounded ×2 doubling — was replaced by
1036
+ // the consumption-based sliding window inside flushStream above.)
589
1037
 
590
1038
  function flushStream(sid) {
591
1039
  var stream = context.recv_streams[sid];
592
1040
  if (!stream) return;
593
1041
  var parts = [];
594
1042
  var offset = stream.flushed_to;
595
- while (offset in stream.chunks) {
1043
+ // Walk the in-order prefix. Chunks are keyed by their start offset, but
1044
+ // retransmission re-slices byte ranges with boundaries that need not match
1045
+ // the original transmission's (the per-burst budget differs between
1046
+ // passes). So a chunk can START BEFORE the cursor and extend past it —
1047
+ // an exact-key walk (`offset in chunks`) stalls forever on such a chunk
1048
+ // even though every byte is present (observed live: ranges said
1049
+ // [0,262144] complete while flushed_to sat at 31846, mid-chunk of the
1050
+ // entry keyed 31747). When the exact key misses, scan for a covering
1051
+ // chunk and consume its tail; drop fully-stale chunks (entirely behind
1052
+ // the cursor — overlap leftovers) along the way.
1053
+ while (true) {
596
1054
  var chunk = stream.chunks[offset];
597
- delete stream.chunks[offset];
598
- parts.push(chunk);
599
- offset += chunk.byteLength;
1055
+ var chunkStart = offset;
1056
+ if (!chunk) {
1057
+ var keys = Object.keys(stream.chunks);
1058
+ for (var ki = 0; ki < keys.length; ki++) {
1059
+ var ks = Number(keys[ki]);
1060
+ var c = stream.chunks[ks];
1061
+ var kEnd = ks + c.byteLength;
1062
+ if (kEnd <= offset) { delete stream.chunks[ks]; continue; } // stale overlap leftover
1063
+ if (ks < offset && kEnd > offset) { chunk = c; chunkStart = ks; break; }
1064
+ }
1065
+ if (!chunk) break; // genuine gap — wait for more data
1066
+ }
1067
+ delete stream.chunks[chunkStart];
1068
+ var skip = offset - chunkStart; // 0 on the exact-key path
1069
+ parts.push(skip > 0 ? chunk.subarray(skip) : chunk);
1070
+ offset = chunkStart + chunk.byteLength;
1071
+ }
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;
600
1090
  }
601
- if (parts.length === 0) return;
1091
+ var delivered = offset - stream.flushed_to; // in-order bytes handed to the app now
602
1092
  stream.flushed_to = offset;
603
1093
  var data = parts.length === 1 ? parts[0] : concatUint8Arrays(parts);
604
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
605
1096
  ev.emit('stream', Number(sid), data, fin);
606
1097
  if (fin) setTimeout(function () { delete context.recv_streams[sid]; }, 100);
1098
+
1099
+ // ── Sliding window updates (consumption-based; see the context header) ──
1100
+ // Both levels use the same rule: once consumption has advanced at least
1101
+ // half a window past the last advertisement, advertise consumed + W.
1102
+ // Monotonic by construction; a duplicate/lower advert is ignored by the
1103
+ // peer, and re-sends on DATA_BLOCKED reuse these same fields.
1104
+
1105
+ // Connection level: consumed = in-order bytes delivered across all streams.
1106
+ context.local_max_data_consumed += delivered;
1107
+ var W = context.local_max_data_window;
1108
+ if (context.local_max_data_consumed + W - context.local_max_data >= W / 2) {
1109
+ context.local_max_data = context.local_max_data_consumed + W;
1110
+ sendFrames('app', [{ type: 'max_data', max: context.local_max_data }]);
1111
+ }
1112
+
1113
+ // Stream level: consumed = flushed_to. No update after FIN — the stream
1114
+ // is done, its final usage stays counted at the connection level.
1115
+ if (!fin) {
1116
+ var Ws = context.local_initial_max_stream_data;
1117
+ if (stream.flushed_to + Ws - stream.local_max_stream_data >= Ws / 2) {
1118
+ stream.local_max_stream_data = stream.flushed_to + Ws;
1119
+ sendFrames('app', [{ type: 'max_stream_data', id: Number(sid), max: stream.local_max_stream_data }]);
1120
+ }
1121
+ }
1122
+ }
1123
+
1124
+
1125
+ // Update the smoothed RTT estimate from a new sample (RFC 9002 §5.3).
1126
+ // latest_rtt = now − time_sent(largest_acked); ack_delay_ms is the peer's
1127
+ // reported ACK delay. min_rtt uses the raw sample; srtt/rttvar use the
1128
+ // ack_delay-adjusted sample. Recursive EWMA: each sample folds into
1129
+ // srtt/rttvar immediately, so no per-sample history is retained.
1130
+ function updateRtt(latest_rtt, ack_delay_ms) {
1131
+ context.latest_rtt = latest_rtt;
1132
+
1133
+ // min_rtt tracks the raw minimum (before any ack_delay adjustment).
1134
+ context.min_rtt = (context.min_rtt === null)
1135
+ ? latest_rtt
1136
+ : Math.min(context.min_rtt, latest_rtt);
1137
+
1138
+ // max_rtt tracks the raw maximum; max_rtt − min_rtt ≈ queueing/bufferbloat.
1139
+ context.max_rtt = (context.max_rtt === null)
1140
+ ? latest_rtt
1141
+ : Math.max(context.max_rtt, latest_rtt);
1142
+
1143
+ // BBR RTprop: windowed-min RTT over ~10s. Unlike the forever-min above, it
1144
+ // expires so it can rise if the true path RTT changes. It is refreshed by a
1145
+ // lower sample, or replaced when the 10s window lapses (ProbeRTT later forces
1146
+ // a queue-free sample so this stays honest under a persistent standing queue).
1147
+ var BBR_MIN_RTT_WINDOW = 10000; // ms
1148
+ var now_rtt = Date.now();
1149
+ if (context.bbr_min_rtt === null ||
1150
+ latest_rtt <= context.bbr_min_rtt ||
1151
+ now_rtt - context.bbr_min_rtt_stamp > BBR_MIN_RTT_WINDOW) {
1152
+ context.bbr_min_rtt = latest_rtt;
1153
+ context.bbr_min_rtt_stamp = now_rtt;
1154
+ }
1155
+
1156
+ // Subtract ACK delay, but cap it at max_ack_delay and never let it pull the
1157
+ // sample below min_rtt (RFC 9002 §5.3).
1158
+ var ack_delay = Math.min(ack_delay_ms, context.max_ack_delay);
1159
+ var adjusted = latest_rtt;
1160
+ if (latest_rtt >= context.min_rtt + ack_delay) adjusted = latest_rtt - ack_delay;
1161
+
1162
+ if (context.srtt === null) {
1163
+ context.srtt = adjusted;
1164
+ context.rttvar = adjusted / 2;
1165
+ } else {
1166
+ context.rttvar = 0.75 * context.rttvar + 0.25 * Math.abs(context.srtt - adjusted);
1167
+ context.srtt = 0.875 * context.srtt + 0.125 * adjusted;
1168
+ }
1169
+ if (DEBUG) console.log('[quic] rtt: sample=' + latest_rtt + 'ms srtt=' + Math.round(context.srtt) + ' rttvar=' + Math.round(context.rttvar) + ' min=' + context.min_rtt);
607
1170
  }
608
1171
 
609
1172
 
@@ -611,46 +1174,282 @@ function QUICConnection(options) {
611
1174
  // ACK processing (Phase 1)
612
1175
  // ============================================================
613
1176
 
1177
+ // Total app stream bytes currently in flight (unacked). Used by BBR's Drain
1178
+ // state to know when the queue built during Startup has been emptied.
1179
+ function appBytesInFlight() {
1180
+ var total = 0;
1181
+ for (var sid in context.send_streams) {
1182
+ var st = context.send_streams[sid];
1183
+ if (!st.in_flight_ranges) continue;
1184
+ for (var pn in st.in_flight_ranges) {
1185
+ if (pn === '_burst') continue;
1186
+ total += st.in_flight_ranges[pn][1] - st.in_flight_ranges[pn][0];
1187
+ }
1188
+ }
1189
+ return total;
1190
+ }
1191
+
614
1192
  function processAckFrame(space, frame) {
615
1193
  var ackedRanges = ack_frame_to_ranges(frame);
616
1194
  if (!ackedRanges || ackedRanges.length === 0) return;
617
1195
 
1196
+ // The peer acknowledged something → it's responsive in the return
1197
+ // direction. Resets the in-flight-expiry backoff (any space counts).
1198
+ // Capture the previous ACK time first — it's the interval for delivery_rate.
1199
+ var ackNow = Date.now();
1200
+ var prevAckTime = context.last_ack_time;
1201
+ context.last_ack_time = ackNow;
1202
+
1203
+ // Initial/Handshake: an ACK confirms crypto byte-ranges. Move them in_flight
1204
+ // → acked and reset that space's resend backoff (the peer is responding).
1205
+ if (space === 'initial' || space === 'handshake') {
1206
+ var cs = context.crypto_sent[space];
1207
+ for (var cpn in cs.in_flight) {
1208
+ var p = Number(cpn), hit = false;
1209
+ for (var cri = 0; cri < ackedRanges.length; cri += 2) {
1210
+ if (p >= ackedRanges[cri] && p <= ackedRanges[cri + 1]) { hit = true; break; }
1211
+ }
1212
+ if (hit) { flat_ranges.add(cs.acked, cs.in_flight[cpn].range); delete cs.in_flight[cpn]; }
1213
+ }
1214
+ cs.backoff = 0;
1215
+ return;
1216
+ }
1217
+
618
1218
  if (space === 'app') {
619
1219
  // RTT measurement
620
1220
  if ('largest' in frame && 'delay' in frame) {
621
1221
  var largest_pn = frame.largest;
622
- if (context.sending_app_pn_in_flight.has(largest_pn)) {
1222
+ // Only measure from the largest *newly* acked, ack-eliciting packet
1223
+ // (RFC 9002 §5.1). sending_app_pn_in_flight holds data-carrying PNs —
1224
+ // the closest signal until sent_packets tracks ack_eliciting per packet
1225
+ // (so PING-only packets are not yet used as RTT samples).
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)) {
623
1239
  var now = Date.now();
624
- var ack_delay_ms = Math.round((frame.delay * Math.pow(2, 3)) / 1000);
1240
+ // ACK Delay is microseconds scaled by 2^ack_delay_exponent; use the peer's
1241
+ // value (parsed from its transport params; defaults to 3 until then).
1242
+ var ack_delay_ms = Math.round((frame.delay * Math.pow(2, context.peer_ack_delay_exponent)) / 1000);
625
1243
  var pn_index = largest_pn - (context.send_pn.app - context.sending_app_pn_history.length);
626
1244
  if (pn_index >= 0 && pn_index < context.sending_app_pn_history.length) {
627
- var measured_rtt = now - context.sending_app_pn_history[pn_index][0] - ack_delay_ms;
628
- if (measured_rtt > 0) context.rtt_history.push([now, measured_rtt]);
1245
+ var latest_rtt = now - context.sending_app_pn_history[pn_index][0];
1246
+ if (latest_rtt > 0) updateRtt(latest_rtt, ack_delay_ms);
629
1247
  }
630
1248
  }
631
1249
  }
632
1250
 
633
- // Mark in-flight as acked
1251
+ // Mark in-flight as acked. Collect the acked PNs first, then mutate —
1252
+ // deleting from a Set while iterating it with for-of is fragile.
1253
+ var ackedPns = [];
634
1254
  for (var pn of context.sending_app_pn_in_flight) {
635
- var is_acked = false;
636
1255
  for (var ri = 0; ri < ackedRanges.length; ri += 2) {
637
- if (pn >= ackedRanges[ri] && pn <= ackedRanges[ri + 1]) { is_acked = true; break; }
1256
+ if (pn >= ackedRanges[ri] && pn <= ackedRanges[ri + 1]) { ackedPns.push(pn); break; }
638
1257
  }
639
- if (is_acked) {
640
- context.sending_app_pn_in_flight.delete(pn);
641
- for (var sid in context.send_streams) {
642
- var st = context.send_streams[sid];
643
- if (st.in_flight_ranges && pn in st.in_flight_ranges) {
644
- flat_ranges.add(st.acked_ranges, st.in_flight_ranges[pn]);
645
- delete st.in_flight_ranges[pn];
646
- if (st.total_size > 0 && st.acked_ranges.length === 2 &&
647
- st.acked_ranges[0] === 0 && st.acked_ranges[1] >= st.total_size) {
648
- delete context.send_streams[sid];
649
- }
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
+
1288
+ var newlyAckedBytes = 0; // goodput bytes confirmed by this ACK (for delivery_rate)
1289
+ var oldestAckedPn = null; // smallest newly-acked PN → longest delivery interval
1290
+ for (var ai = 0; ai < ackedPns.length; ai++) {
1291
+ var apn = ackedPns[ai];
1292
+ if (oldestAckedPn === null || apn < oldestAckedPn) oldestAckedPn = apn;
1293
+ context.sending_app_pn_in_flight.delete(apn);
1294
+ for (var sid in context.send_streams) {
1295
+ var st = context.send_streams[sid];
1296
+ if (st.in_flight_ranges && apn in st.in_flight_ranges) {
1297
+ newlyAckedBytes += creditSpan(st, st.in_flight_ranges[apn][0], st.in_flight_ranges[apn][1]);
1298
+ delete st.in_flight_ranges[apn];
1299
+ retireStreamIfComplete(sid, st);
1300
+ }
1301
+ }
1302
+ }
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
+
1327
+ // Cumulative delivered advances by this ACK's goodput; the delivery clock
1328
+ // is the wall-time of this update.
1329
+ context.delivered += newlyAckedBytes;
1330
+
1331
+ // latest_delivery_rate / max_delivery_rate: per-ACK observations (kept for
1332
+ // visibility). NOTE: these are NOT fed to the BtlBw filter — per-ACK samples
1333
+ // spike under ACK aggregation and the windowed-max locks onto the spike
1334
+ // (the 20→39 Mbps over-read). BtlBw is sampled once per round instead (below).
1335
+ if (oldestAckedPn !== null && context.sending_app_pn_history.length > 0) {
1336
+ var rs_idx = oldestAckedPn - (context.send_pn.app - context.sending_app_pn_history.length);
1337
+ if (rs_idx >= 0 && rs_idx < context.sending_app_pn_history.length) {
1338
+ var deliveredAtSend = context.sending_app_pn_history[rs_idx][2];
1339
+ var deliveredTimeAtSend = context.sending_app_pn_history[rs_idx][3];
1340
+ var interval = ackNow - deliveredTimeAtSend;
1341
+ var deltaDelivered = context.delivered - deliveredAtSend;
1342
+ if (interval >= 1 && deltaDelivered > 0) {
1343
+ context.latest_delivery_rate = (deltaDelivered * 1000) / interval;
1344
+ if (context.max_delivery_rate === null ||
1345
+ context.latest_delivery_rate > context.max_delivery_rate) {
1346
+ context.max_delivery_rate = context.latest_delivery_rate;
650
1347
  }
651
1348
  }
652
1349
  }
653
1350
  }
1351
+ context.delivered_time = ackNow;
1352
+
1353
+ // BBR round tracking: a round (~1 RTT) completes when this ACK acknowledges
1354
+ // a packet sent at/after the round boundary. We then recompute the windowed
1355
+ // BtlBw (max delivery rate over the last N rounds) and the derived BDP. This
1356
+ // is the per-RTT cadence at which Phase 4b step 3 will write current_limit_*.
1357
+ if ('largest' in frame && frame.largest >= context.bbr_round_start_pn) {
1358
+ // Per-round rate sample: total bytes delivered during the round over the
1359
+ // round's duration (≈ 1 RTT). Averaging across the whole round washes out
1360
+ // ACK-aggregation spikes that corrupt per-ACK samples.
1361
+ var roundDur = ackNow - context.bbr_round_start_time;
1362
+ var roundDelivered = context.delivered - context.bbr_round_start_delivered;
1363
+ if (roundDur >= 1 && roundDelivered > 0) {
1364
+ context.bbr_btlbw_samples.push({ round: context.bbr_round_count, rate: (roundDelivered * 1000) / roundDur });
1365
+ }
1366
+
1367
+ context.bbr_round_count++;
1368
+ context.bbr_round_start_pn = context.send_pn.app; // next PN must be acked for the next round
1369
+ context.bbr_round_start_delivered = context.delivered;
1370
+ context.bbr_round_start_time = ackNow;
1371
+
1372
+ var BBR_BTLBW_WINDOW = 10; // rounds
1373
+ var cutoff = context.bbr_round_count - BBR_BTLBW_WINDOW;
1374
+ var kept = [], maxRate = null;
1375
+ for (var bi = 0; bi < context.bbr_btlbw_samples.length; bi++) {
1376
+ var smp = context.bbr_btlbw_samples[bi];
1377
+ if (smp.round >= cutoff) {
1378
+ kept.push(smp);
1379
+ if (maxRate === null || smp.rate > maxRate) maxRate = smp.rate;
1380
+ }
1381
+ }
1382
+ context.bbr_btlbw_samples = kept;
1383
+ context.bbr_btlbw = maxRate;
1384
+
1385
+ // Derived in-flight target. Uses bbr_min_rtt (RTprop, windowed) in seconds.
1386
+ if (context.bbr_btlbw !== null && context.bbr_min_rtt !== null) {
1387
+ // BDP = BtlBw × RTprop. On near-zero-RTT paths (localhost, LAN) RTprop
1388
+ // collapses toward 0, so BDP → 0 and cwnd would clamp to the min floor
1389
+ // (≈2 packets) — starving throughput on the fastest links. Floor RTprop at
1390
+ // a few ms for this calc: it only affects sub-floor RTTs (where the link is
1391
+ // fast and can absorb the extra window) and never binds on normal links.
1392
+ // (A tick-adaptive floor was tried and reverted — see the pacer note.)
1393
+ var BBR_RTPROP_FLOOR_MS = 5;
1394
+ var rttForBdp = Math.max(context.bbr_min_rtt, BBR_RTPROP_FLOOR_MS);
1395
+ context.bbr_bdp = context.bbr_btlbw * (rttForBdp / 1000);
1396
+
1397
+ // ── State machine → (pacing_gain, cwnd_gain) ──────────────────────
1398
+ var STARTUP_GAIN = 2.89; // ≈ 2/ln2 — exponential ramp
1399
+ var pacing_gain, cwnd_gain;
1400
+ if (context.bbr_state === 'startup') {
1401
+ pacing_gain = STARTUP_GAIN; cwnd_gain = STARTUP_GAIN;
1402
+ // Plateau detector: BtlBw must grow ≥25% per round to stay in Startup.
1403
+ if (context.bbr_btlbw >= context.bbr_full_bw * 1.25) {
1404
+ context.bbr_full_bw = context.bbr_btlbw;
1405
+ context.bbr_full_bw_count = 0;
1406
+ } else if (++context.bbr_full_bw_count >= 3) {
1407
+ context.bbr_state = 'drain'; // pipe is full
1408
+ }
1409
+ } else if (context.bbr_state === 'drain') {
1410
+ pacing_gain = 1 / STARTUP_GAIN; cwnd_gain = STARTUP_GAIN; // drain the queue
1411
+ if (appBytesInFlight() <= context.bbr_bdp) context.bbr_state = 'probe_bw';
1412
+ } else { // probe_bw — cruise at the bottleneck rate while gently probing.
1413
+ // pacing_gain cycles 1.25 (probe for more bw) → 0.75 (drain the queue
1414
+ // that 1.25 just built) → 1.0×6 (cruise). The 0.75 phase is what keeps
1415
+ // the standing queue near-empty: without it, cwnd=2·BDP lets ~1 BDP of
1416
+ // queue sit permanently (≈ the bufferbloat we measured). One phase per round.
1417
+ var PROBE_BW_CYCLE = [1.25, 0.75, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0];
1418
+ pacing_gain = PROBE_BW_CYCLE[context.bbr_cycle_idx];
1419
+ // cwnd_gain bounds the standing queue directly: in JS userland the pacer
1420
+ // isn't precise enough to hold in-flight at exactly BDP (kernel BBR uses
1421
+ // cwnd_gain 2), so a tighter ceiling is what actually limits bufferbloat.
1422
+ // 1.25 → at most ~0.25·BDP of queue, while leaving room for the 1.25 probe.
1423
+ cwnd_gain = 1.25;
1424
+ context.bbr_cycle_idx = (context.bbr_cycle_idx + 1) % PROBE_BW_CYCLE.length;
1425
+ }
1426
+
1427
+ // ── Write the controls, clamped to [min,max] (the hard safety net). ──
1428
+ var clamp = function (v, lo, hi) { return Math.max(lo, Math.min(hi, v)); };
1429
+
1430
+ var targetInflight = clamp(cwnd_gain * context.bbr_bdp,
1431
+ context.min_limit_bytes_in_flight, context.max_limit_bytes_in_flight);
1432
+ context.current_limit_bytes_in_flight = targetInflight;
1433
+ context.current_limit_packets_in_flight = clamp(
1434
+ Math.round(targetInflight / context.current_limit_packet_payload),
1435
+ context.min_limit_packets_in_flight, context.max_limit_packets_in_flight);
1436
+
1437
+ var targetRate = clamp(pacing_gain * context.bbr_btlbw,
1438
+ context.min_limit_bytes_per_sec, context.max_limit_bytes_per_sec);
1439
+ context.current_limit_bytes_per_sec = targetRate;
1440
+ context.current_limit_packets_per_sec = clamp(
1441
+ Math.round(targetRate / context.current_limit_packet_payload),
1442
+ 1, context.max_limit_packets_per_sec);
1443
+ }
1444
+ if (DEBUG_BBR && context.bbr_bdp !== null) {
1445
+ console.log('[bbr] round=' + context.bbr_round_count + ' ' + context.bbr_state +
1446
+ ' BtlBw=' + (context.bbr_btlbw * 8 / 1e6).toFixed(2) + 'Mbps' +
1447
+ ' RTprop=' + context.bbr_min_rtt + 'ms' +
1448
+ ' BDP=' + Math.round(context.bbr_bdp) + 'B' +
1449
+ ' → cwnd=' + Math.round(context.current_limit_bytes_in_flight) + 'B' +
1450
+ ' pace=' + (context.current_limit_bytes_per_sec * 8 / 1e6).toFixed(2) + 'Mbps');
1451
+ }
1452
+ }
654
1453
 
655
1454
  // ACK freed capacity — try to send more
656
1455
  plan_quic_burst();
@@ -666,15 +1465,127 @@ function QUICConnection(options) {
666
1465
  var space = epoch === 'initial' ? 'initial' : epoch === 'handshake' ? 'handshake' : 'app';
667
1466
  var offset = context.crypto_send_offset[space];
668
1467
  context.crypto_send_offset[space] += data.byteLength;
1468
+ // Retain the bytes so a lost CRYPTO frame can be re-sent (app/1-RTT crypto
1469
+ // never loses — it rides the app stream-recovery path and isn't tracked here).
1470
+ if (space === 'initial' || space === 'handshake') {
1471
+ context.crypto_sent[space].buf.push({ off: offset, data: data });
1472
+ }
669
1473
  sendFrames(space, [{ type: 'crypto', offset: offset, data: data }]);
1474
+ if (space === 'initial' || space === 'handshake') scheduleCryptoRetx();
1475
+ }
1476
+
1477
+ // Assemble the bytes for crypto byte-range [from,to) of `space` from retained
1478
+ // fragments (contiguous, appended in order by cryptoWrite).
1479
+ function cryptoSlice(space, from, to) {
1480
+ var out = new Uint8Array(to - from);
1481
+ var frags = context.crypto_sent[space].buf;
1482
+ for (var i = 0; i < frags.length; i++) {
1483
+ var fOff = frags[i].off, fEnd = fOff + frags[i].data.byteLength;
1484
+ var lo = Math.max(from, fOff), hi = Math.min(to, fEnd);
1485
+ if (lo < hi) out.set(frags[i].data.subarray(lo - fOff, hi - fOff), lo - from);
1486
+ }
1487
+ return out;
1488
+ }
1489
+
1490
+ // Time-based loss recovery for the Initial/Handshake CRYPTO streams — the
1491
+ // analog of expireInFlight for the app path. No RTT sample exists during the
1492
+ // handshake, so the timeout starts at kInitialRtt (333ms) and backs off ×2 per
1493
+ // unacked round (reset by an ACK in processAckFrame).
1494
+ function cryptoTimeout(space) {
1495
+ var base = (context.srtt === null) ? 333 : context.srtt + Math.max(4 * context.rttvar, 1);
1496
+ var bo = Math.min(context.crypto_sent[space].backoff, 6); // cap ×64
1497
+ return base * Math.pow(2, bo);
1498
+ }
1499
+
1500
+ // Re-send any crypto byte-range that is neither acked nor currently in-flight.
1501
+ // Mirrors get_stream_chunks: missing = invert(acked ∪ in_flight, 0, total).
1502
+ function resendMissingCrypto(space) {
1503
+ var cs = context.crypto_sent[space];
1504
+ var total = context.crypto_send_offset[space];
1505
+ if (total === 0) return false;
1506
+
1507
+ var known = cs.acked.slice();
1508
+ for (var pn in cs.in_flight) flat_ranges.add(known, cs.in_flight[pn].range);
1509
+ var missing = flat_ranges.invert(known, 0, total);
1510
+ if (!missing || missing.length === 0) return false;
1511
+
1512
+ var MAX = Math.max(256, context.current_limit_packet_payload - 64); // leave header room
1513
+ var sentAny = false;
1514
+ for (var i = 0; i < missing.length; i += 2) {
1515
+ var from = missing[i], to = missing[i + 1];
1516
+ while (from < to) {
1517
+ var end = Math.min(from + MAX, to);
1518
+ sendFrames(space, [{ type: 'crypto', offset: from, data: cryptoSlice(space, from, end) }]);
1519
+ sentAny = true;
1520
+ from = end;
1521
+ }
1522
+ }
1523
+ return sentAny;
1524
+ }
1525
+
1526
+ // Expire in-flight crypto whose timeout elapsed (range returns to "missing").
1527
+ function expireCryptoInFlight() {
1528
+ var now = Date.now(), spaces = ['initial', 'handshake'], expired = false;
1529
+ for (var s = 0; s < spaces.length; s++) {
1530
+ var space = spaces[s], cs = context.crypto_sent[space], timeout = cryptoTimeout(space);
1531
+ for (var pn in cs.in_flight) {
1532
+ if (now - cs.in_flight[pn].time_sent >= timeout) { delete cs.in_flight[pn]; expired = true; }
1533
+ }
1534
+ }
1535
+ return expired;
1536
+ }
1537
+
1538
+ function cryptoHasInFlight() {
1539
+ return Object.keys(context.crypto_sent.initial.in_flight).length > 0 ||
1540
+ Object.keys(context.crypto_sent.handshake.in_flight).length > 0;
1541
+ }
1542
+
1543
+ // Self-terminating ticker: keeps firing while any CRYPTO byte is unacked
1544
+ // (the analog of plan_quic_burst's hasInFlight reschedule — needed because the
1545
+ // first flight can be lost with no ACK to trigger anything). It is driven by
1546
+ // acked-state, NOT local handshake_done: the server reaches handshake_done the
1547
+ // moment it sends Finished, but must keep resending until the client acks.
1548
+ function cryptoTick() {
1549
+ context.crypto_timer = null;
1550
+ if (expireCryptoInFlight()) {
1551
+ if (resendMissingCrypto('initial')) context.crypto_sent.initial.backoff++;
1552
+ if (resendMissingCrypto('handshake')) context.crypto_sent.handshake.backoff++;
1553
+ }
1554
+ if (cryptoHasInFlight()) scheduleCryptoRetx();
1555
+ }
1556
+
1557
+ function scheduleCryptoRetx() {
1558
+ if (context.crypto_timer !== null) return; // idempotent
1559
+ var iv = Math.min(cryptoTimeout('initial'), cryptoTimeout('handshake'));
1560
+ context.crypto_timer = setTimeout(cryptoTick, iv);
1561
+ if (context.crypto_timer.unref) context.crypto_timer.unref();
1562
+ }
1563
+
1564
+ function stopCryptoRetx() {
1565
+ if (context.crypto_timer !== null) { clearTimeout(context.crypto_timer); context.crypto_timer = null; }
1566
+ context.crypto_sent.initial = { buf: [], in_flight: {}, acked: [], backoff: 0 };
1567
+ context.crypto_sent.handshake = { buf: [], in_flight: {}, acked: [], backoff: 0 };
670
1568
  }
671
1569
 
672
1570
  function sendFrames(space, frameList) {
1571
+ // RFC 9000 §10.2: a draining endpoint MUST NOT send packets at all; a
1572
+ // closing endpoint may only (re)send its CONNECTION_CLOSE. Belt-and-
1573
+ // suspenders with the timer cleanup — any stray timer (crypto retx, burst,
1574
+ // keep-alive) that fires after the state change dies here instead of
1575
+ // emitting packets at a dead connection.
1576
+ if (context.state === 'draining' || context.state === 'closed') return;
1577
+ if (context.state === 'closing' &&
1578
+ !(frameList.length === 1 && frameList[0].type === 'connection_close')) return;
1579
+
673
1580
  var writeKeys = space === 'initial' ? context.initial_write
674
1581
  : space === 'handshake' ? context.handshake_write : context.app_write;
675
- if (!writeKeys) { console.log('[quic] sendFrames(' + space + ') — no keys'); return; }
1582
+ if (!writeKeys) { if (DEBUG) console.log('[quic] sendFrames(' + space + ') — no keys'); return; }
676
1583
 
677
- var pn = context.send_pn[space]++;
1584
+ // Read the next packet number but DON'T commit it yet — only advance
1585
+ // send_pn once the packet is successfully encrypted and emitted (below).
1586
+ // A failed encryption must not consume a PN: a gap would desync the
1587
+ // pn → sending_app_pn_history index math (and RTT). See LOSS_PTO_PLAN §5.
1588
+ var pn = context.send_pn[space];
678
1589
  var packetType = space === 'initial' ? 'initial' : space === 'handshake' ? 'handshake' : '1rtt';
679
1590
 
680
1591
  var dcid, scid;
@@ -712,12 +1623,16 @@ function QUICConnection(options) {
712
1623
  );
713
1624
 
714
1625
  if (encrypted) {
1626
+ // Commit the packet number now that the packet is real and about to go
1627
+ // out on the wire — atomic with the bookkeeping below, so send_pn and
1628
+ // sending_app_pn_history always advance together (no phantom PN).
1629
+ context.send_pn[space] = pn + 1;
715
1630
  var fnames = frameList.map(function(f){ return f.type + (f.type === 'ack' ? '(lg=' + f.largest + ')' : ''); }).join(',');
716
- console.log('[quic] → ' + packetType + ' pn=' + pn + ' frames=[' + fnames + '] len=' + encrypted.length);
1631
+ if (DEBUG) console.log('[quic] → ' + packetType + ' pn=' + pn + ' frames=[' + fnames + '] len=' + encrypted.length);
717
1632
  touchActivity();
718
1633
 
719
1634
  if (space === 'app') {
720
- context.sending_app_pn_history.push([Date.now(), encoded.length]);
1635
+ context.sending_app_pn_history.push([Date.now(), encoded.length, context.delivered, context.delivered_time]);
721
1636
  var has_data = false;
722
1637
  for (var i = 0; i < frameList.length; i++) {
723
1638
  if (frameList[i].type === 'stream' || frameList[i].type === 'crypto') { has_data = true; break; }
@@ -746,6 +1661,18 @@ function QUICConnection(options) {
746
1661
  }
747
1662
  }
748
1663
 
1664
+ // Initial/Handshake: record which byte-range of the crypto stream this PN
1665
+ // carried, so a lost packet's range can be re-sent (see crypto_sent).
1666
+ if (space === 'initial' || space === 'handshake') {
1667
+ for (var ci = 0; ci < frameList.length; ci++) {
1668
+ if (frameList[ci].type === 'crypto') {
1669
+ var cFrom = frameList[ci].offset;
1670
+ var cTo = cFrom + (frameList[ci].data ? frameList[ci].data.byteLength : 0);
1671
+ if (cTo > cFrom) context.crypto_sent[space].in_flight[pn] = { range: [cFrom, cTo], time_sent: Date.now() };
1672
+ }
1673
+ }
1674
+ }
1675
+
749
1676
  ev.emit('packet', encrypted);
750
1677
  }
751
1678
  }
@@ -756,6 +1683,29 @@ function QUICConnection(options) {
756
1683
  // Adapted from old quic_socket.js, improved with set_context
757
1684
  // ============================================================
758
1685
 
1686
+ /**
1687
+ * Initial send-limit for a stream we write to, per the peer's transport
1688
+ * params. Which param applies depends on the stream's direction and who
1689
+ * initiated it — the params are named from the PEER's perspective
1690
+ * (RFC 9000 §18.2):
1691
+ * uni stream we send on → initial_max_stream_data_uni
1692
+ * bidi stream WE initiated → ..._bidi_remote ("remote" from the peer's view)
1693
+ * bidi stream THEY initiated → ..._bidi_local
1694
+ * A MAX_STREAM_DATA that arrived before the stream existed is honored via
1695
+ * remote_max_stream_data_by_sid (monotonic max wins).
1696
+ */
1697
+ function initialStreamSendLimit(sid) {
1698
+ var isUni = (sid & 0x2) === 0x2; // bit 1: uni vs bidi
1699
+ var clientInitiated = (sid & 0x1) === 0x0; // bit 0: initiator
1700
+ var weInitiated = context.isServer ? !clientInitiated : clientInitiated;
1701
+ var base;
1702
+ if (isUni) base = context.peer_initial_max_stream_data_uni;
1703
+ else if (weInitiated) base = context.peer_initial_max_stream_data_bidi_remote;
1704
+ else base = context.peer_initial_max_stream_data_bidi_local;
1705
+ var pending = context.remote_max_stream_data_by_sid[sid] || 0;
1706
+ return Math.max(base, pending);
1707
+ }
1708
+
759
1709
  /**
760
1710
  * Buffer data for a stream. Does NOT send immediately.
761
1711
  * Calls plan_quic_burst() to schedule sending.
@@ -765,7 +1715,14 @@ function QUICConnection(options) {
765
1715
  context.send_streams[streamId] = {
766
1716
  pending_data: null, pending_offset_start: 0,
767
1717
  write_offset: 0, send_offset: 0, total_size: 0,
768
- fin_sent: false, acked_ranges: [], in_flight_ranges: {}
1718
+ fin_sent: false, acked_ranges: [], in_flight_ranges: {},
1719
+ // Send-side FC (RFC 9000 §4.1): the peer's absolute offset cap for
1720
+ // this stream, and our high-water mark (highest offset ever yielded).
1721
+ // New data = bytes extending max_sent_offset; only those consume the
1722
+ // connection-level budget. See get_stream_chunks.
1723
+ remote_max_stream_data: initialStreamSendLimit(streamId),
1724
+ max_sent_offset: 0,
1725
+ fc_blocked: false
769
1726
  };
770
1727
  }
771
1728
 
@@ -775,14 +1732,14 @@ function QUICConnection(options) {
775
1732
  if (options.add_chunk.data === null || options.add_chunk.data === undefined) {
776
1733
  if (stream.total_size === 0) {
777
1734
  stream.total_size = stream.write_offset;
778
- console.log('[quic] stream ' + streamId + ' FIN via null data, total_size=' + stream.total_size);
1735
+ if (DEBUG) console.log('[quic] stream ' + streamId + ' FIN via null data, total_size=' + stream.total_size);
779
1736
  }
780
1737
  } else {
781
1738
  var chunk = options.add_chunk.data;
782
1739
  if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
783
1740
  var start = stream.write_offset;
784
1741
  stream.write_offset += chunk.byteLength;
785
- console.log('[quic] stream ' + streamId + ' add_chunk len=' + chunk.byteLength + ' write_offset=' + stream.write_offset + ' fin=' + !!options.add_chunk.fin);
1742
+ if (DEBUG) console.log('[quic] stream ' + streamId + ' add_chunk len=' + chunk.byteLength + ' write_offset=' + stream.write_offset + ' fin=' + !!options.add_chunk.fin);
786
1743
 
787
1744
  if (stream.pending_data === null) {
788
1745
  stream.pending_data = chunk;
@@ -799,7 +1756,7 @@ function QUICConnection(options) {
799
1756
  }
800
1757
  if (options.add_chunk.fin) {
801
1758
  stream.total_size = stream.write_offset;
802
- console.log('[quic] stream ' + streamId + ' FIN via add_chunk.fin, total_size=' + stream.total_size);
1759
+ if (DEBUG) console.log('[quic] stream ' + streamId + ' FIN via add_chunk.fin, total_size=' + stream.total_size);
803
1760
  }
804
1761
  }
805
1762
  }
@@ -813,9 +1770,28 @@ function QUICConnection(options) {
813
1770
  }
814
1771
 
815
1772
  function sendDatagram(data) {
816
- if (context.state !== 'connected') return;
1773
+ if (context.state !== 'connected') return false;
817
1774
  if (typeof data === 'string') data = new TextEncoder().encode(data);
1775
+ // A DATAGRAM frame must fit within a single packet — datagrams are never
1776
+ // fragmented (RFC 9221 §3). Reject payloads that exceed our per-packet
1777
+ // budget instead of emitting an oversized packet. Overhead is the 1-byte
1778
+ // 0x30 DATAGRAM frame type.
1779
+ if (data.byteLength > context.current_limit_packet_payload - 1) {
1780
+ if (DEBUG) console.log('[quic] sendDatagram: payload too large (' + data.byteLength + ' > ' + (context.current_limit_packet_payload - 1) + ')');
1781
+ return false;
1782
+ }
818
1783
  sendFrames('app', [{ type: 'datagram', data: data }]);
1784
+ return true;
1785
+ }
1786
+
1787
+ // Largest datagram payload (in bytes) that can be sent right now in a single
1788
+ // packet, or 0 if datagrams can't be sent yet (connection not 'connected').
1789
+ // This is a local single-packet estimate; QUICO does not currently parse the
1790
+ // peer's max_datagram_frame_size transport parameter, so it does not reflect
1791
+ // a negotiated peer limit.
1792
+ function maxDatagramSize() {
1793
+ if (context.state !== 'connected') return 0;
1794
+ return Math.max(0, context.current_limit_packet_payload - 1);
819
1795
  }
820
1796
 
821
1797
 
@@ -823,6 +1799,112 @@ function QUICConnection(options) {
823
1799
  * plan_quic_burst — calculates how many packets we can send,
824
1800
  * calls execute_quic_burst, schedules next burst if needed.
825
1801
  */
1802
+ // Expire packets that have been in flight longer than the RTT-derived timeout
1803
+ // — the round-robin sender's equivalent of loss detection. A timed-out range
1804
+ // is deleted from in_flight_ranges, so the next pass sees it as "missing" and
1805
+ // resends it under a new packet number. Time-based ONLY (reordering must not
1806
+ // trigger a resend). A global backoff keyed off last_ack_time widens the
1807
+ // timeout while the peer isn't ACKing, so a dead path isn't flooded.
1808
+ function expireInFlight() {
1809
+ var now = Date.now();
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;
1825
+
1826
+ // Global backoff: while no ACK has arrived, widen the timeout geometrically.
1827
+ // Resets the instant an ACK updates last_ack_time.
1828
+ var sinceAck = now - context.last_ack_time;
1829
+ var mult = 1;
1830
+ while (sinceAck > base * mult * 2 && mult < 1024) mult *= 2;
1831
+ var timeout = base * mult;
1832
+
1833
+ // PN of history index 0 (the array is a contiguous suffix of recent PNs).
1834
+ var pnBase = context.send_pn.app - context.sending_app_pn_history.length;
1835
+
1836
+ for (var sid in context.send_streams) {
1837
+ var st = context.send_streams[sid];
1838
+ if (!st.in_flight_ranges) continue;
1839
+ for (var pn in st.in_flight_ranges) {
1840
+ if (pn === '_burst') continue; // temporary within-burst sentinel
1841
+ var pnum = Number(pn);
1842
+ var idx = pnum - pnBase;
1843
+ var expired;
1844
+ if (idx < 0 || idx >= context.sending_app_pn_history.length) {
1845
+ expired = true; // no send-time on record → assume expired (fail-safe)
1846
+ } else {
1847
+ expired = (now - context.sending_app_pn_history[idx][0] >= timeout);
1848
+ }
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
+
1857
+ delete st.in_flight_ranges[pn]; // → becomes "missing" again, resent next pass
1858
+ // Count the loss once per packet: the Set holds each PN once, so the
1859
+ // first stream to expire it returns true; later same-PN deletes (a
1860
+ // multi-stream packet) return false and don't double-count.
1861
+ if (context.sending_app_pn_in_flight.delete(pnum)) context.lost_count++;
1862
+ }
1863
+ }
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
+ }
1872
+ }
1873
+
1874
+
1875
+ // Consume the fc_blocked flags set by get_stream_chunks and, rate-limited,
1876
+ // tell the peer we're stalled on its window (RFC 9000 §4.1 SHOULD). Beyond
1877
+ // being polite, this is the loss-recovery path for window updates: the
1878
+ // receiver answers a BLOCKED frame by re-sending its current MAX_DATA /
1879
+ // MAX_STREAM_DATA, so a single lost update can no longer deadlock the
1880
+ // sender. Returns true if any flag was set this pass (used to keep the
1881
+ // scheduler ticking while blocked with nothing in flight).
1882
+ var FC_BLOCKED_RESEND_MS = 500;
1883
+ function maybeSendBlockedFrames() {
1884
+ var now = Date.now();
1885
+ var wasBlocked = false;
1886
+ if (context.fc_blocked_conn) {
1887
+ context.fc_blocked_conn = false;
1888
+ wasBlocked = true;
1889
+ if (now - context.last_data_blocked_sent >= FC_BLOCKED_RESEND_MS) {
1890
+ context.last_data_blocked_sent = now;
1891
+ sendFrames('app', [{ type: 'data_blocked', limit: context.remote_max_data }]);
1892
+ }
1893
+ }
1894
+ for (var sid in context.send_streams) {
1895
+ var st = context.send_streams[sid];
1896
+ if (!st.fc_blocked) continue;
1897
+ st.fc_blocked = false;
1898
+ wasBlocked = true;
1899
+ if (now - (context.last_stream_blocked_sent[sid] || 0) >= FC_BLOCKED_RESEND_MS) {
1900
+ context.last_stream_blocked_sent[sid] = now;
1901
+ sendFrames('app', [{ type: 'stream_data_blocked', id: Number(sid), limit: st.remote_max_stream_data }]);
1902
+ }
1903
+ }
1904
+ return wasBlocked;
1905
+ }
1906
+
1907
+
826
1908
  function plan_quic_burst() {
827
1909
  if (!context.app_write) return;
828
1910
  if (context.state !== 'connected') return;
@@ -834,6 +1916,10 @@ function QUICConnection(options) {
834
1916
  context.burst_timer = null;
835
1917
  }
836
1918
 
1919
+ // Time out anything that's been in flight too long, so it resurfaces as
1920
+ // "missing" below and gets resent. (Runs before inflight/hasData are read.)
1921
+ expireInFlight();
1922
+
837
1923
  var now = Date.now();
838
1924
  var oneSecAgo = now - 1000;
839
1925
 
@@ -853,26 +1939,81 @@ function QUICConnection(options) {
853
1939
  context.sending_app_pn_history.shift();
854
1940
  }
855
1941
 
1942
+ // Effective limits. For rate: min(current_, max_). For in-flight: clamp
1943
+ // current_ to [min_, max_] — the min_ floor is the min-cwnd that keeps the
1944
+ // connection alive (a cap below one packet would otherwise round to 0 and
1945
+ // deadlock); max_ is the absolute ceiling and wins any min_>max_ misconfig.
1946
+ var effBytesPerSec = Math.min(context.current_limit_bytes_per_sec, context.max_limit_bytes_per_sec);
1947
+ var effPacketsPerSec = Math.min(context.current_limit_packets_per_sec, context.max_limit_packets_per_sec);
1948
+ var effPktsInFlight = Math.min(context.max_limit_packets_in_flight, Math.max(context.min_limit_packets_in_flight, context.current_limit_packets_in_flight));
1949
+ var effBytesInFlight = Math.min(context.max_limit_bytes_in_flight, Math.max(context.min_limit_bytes_in_flight, context.current_limit_bytes_in_flight));
1950
+
856
1951
  // Rate limits
857
- var bytesRemaining = context.max_bytes_per_sec - bytesSentLastSec;
858
- var packetsRemaining = context.max_packets_per_sec - packetsSentLastSec;
1952
+ var bytesRemaining = effBytesPerSec - bytesSentLastSec;
1953
+ var packetsRemaining = effPacketsPerSec - packetsSentLastSec;
859
1954
  if (bytesRemaining < 0) bytesRemaining = 0;
860
1955
  if (packetsRemaining < 0) packetsRemaining = 0;
861
1956
 
862
- // In-flight limits
1957
+ // In-flight limits (packets)
863
1958
  var inflightCount = context.sending_app_pn_in_flight.size;
864
- var inflightRoom = context.max_packets_in_flight - inflightCount;
1959
+ var inflightRoom = effPktsInFlight - inflightCount;
865
1960
  if (inflightRoom < 0) inflightRoom = 0;
866
1961
 
1962
+ // In-flight limits (bytes). Sum the application bytes currently in flight
1963
+ // (the spans still tracked in each stream's in_flight_ranges), then turn the
1964
+ // remaining byte budget into a packet count. Divide by the packet payload
1965
+ // (not the 35-byte min used for the rate limit): this is a HARD ceiling, so
1966
+ // we under-estimate how many packets fit and never overshoot the cap.
1967
+ var bytesInFlight = 0;
1968
+ for (var sidB in context.send_streams) {
1969
+ var stB = context.send_streams[sidB];
1970
+ if (!stB.in_flight_ranges) continue;
1971
+ for (var pnB in stB.in_flight_ranges) {
1972
+ if (pnB === '_burst') continue;
1973
+ bytesInFlight += stB.in_flight_ranges[pnB][1] - stB.in_flight_ranges[pnB][0];
1974
+ }
1975
+ }
1976
+ var bytesInFlightRoom = effBytesInFlight - bytesInFlight;
1977
+ if (bytesInFlightRoom < 0) bytesInFlightRoom = 0;
1978
+ var inflightBytesPackets = Math.floor(bytesInFlightRoom / context.current_limit_packet_payload);
1979
+
867
1980
  // Calculate burst size
868
1981
  var burstCount = Math.min(
869
1982
  context.max_packets_per_burst,
870
1983
  packetsRemaining,
871
1984
  inflightRoom,
1985
+ inflightBytesPackets, // bytes-in-flight cap
872
1986
  Math.floor(bytesRemaining / Math.max(1, 35)) // min packet ~35 bytes
873
1987
  );
874
1988
  if (burstCount < 0) burstCount = 0;
875
1989
 
1990
+ // Token-bucket pacer. Credit accrues continuously at the pacing rate; a burst
1991
+ // can spend up to PACE_BURST_MS worth of accrued tokens. This paces the long-run
1992
+ // rate to current_limit_bytes_per_sec while tolerating setTimeout jitter (a late
1993
+ // tick just accrues more tokens to catch up), and the small cap bounds how much
1994
+ // can enter the bottleneck at once = the max standing queue. (A zero-burst gate
1995
+ // starved throughput on JS timers; an unpaced ACK-clock parked ~1 BDP of queue.)
1996
+ //
1997
+ // KNOWN CEILING (documented, deliberate): on coarse-timer platforms
1998
+ // (Windows setTimeout ≈ 15ms) the 5ms cap discards most of a late tick's
1999
+ // accrual, capping throughput near cap/tick (~6 Mbps observed). Widening
2000
+ // the cap/bursts to cover a measured tick was tried and REVERTED: bigger
2001
+ // bursts overflow the small default UDP socket buffers (esp. same-process
2002
+ // loopback on Windows) → silent local drops → the loss-recovery machinery
2003
+ // turns a lossless link into a crawling one. Raising this ceiling properly
2004
+ // needs socket buffer sizing (setRecvBufferSize/setSendBufferSize at the
2005
+ // dgram layer) validated on a measurable rig — CC-calibration work, not a
2006
+ // constant to guess at. Stability beats throughput until then.
2007
+ var PACE_BURST_MS = 5;
2008
+ var rateBps = context.current_limit_bytes_per_sec;
2009
+ var nowR = Date.now();
2010
+ context.pacing_tokens += (nowR - context.pacing_last_refill) / 1000 * rateBps;
2011
+ context.pacing_last_refill = nowR;
2012
+ var tokenCap = Math.max(2 * context.current_limit_packet_payload, rateBps * (PACE_BURST_MS / 1000));
2013
+ if (context.pacing_tokens > tokenCap) context.pacing_tokens = tokenCap;
2014
+ var tokenPackets = Math.floor(context.pacing_tokens / context.current_limit_packet_payload);
2015
+ if (burstCount > tokenPackets) burstCount = tokenPackets;
2016
+
876
2017
  // Check if there's anything to send
877
2018
  var hasData = false;
878
2019
  for (var sid in context.send_streams) {
@@ -891,29 +2032,76 @@ function QUICConnection(options) {
891
2032
  // Also check pending ACK
892
2033
  var hasPendingAck = context.pending_ack.app.length > 0;
893
2034
 
894
- if (!hasData && !hasPendingAck) return;
2035
+ // Any unacked data still in flight? Keep the scheduler alive so
2036
+ // expireInFlight can time it out and resend even when nothing is
2037
+ // immediately sendable — otherwise a lost range would stall here forever.
2038
+ var hasInFlight = false;
2039
+ for (var sidF in context.send_streams) {
2040
+ var stF = context.send_streams[sidF];
2041
+ if (!stF.in_flight_ranges) continue;
2042
+ for (var pnF in stF.in_flight_ranges) {
2043
+ if (pnF !== '_burst') { hasInFlight = true; break; }
2044
+ }
2045
+ if (hasInFlight) break;
2046
+ }
2047
+
2048
+ if (!hasData && !hasPendingAck && !hasInFlight) return;
2049
+
2050
+ // Paced out: have data + cwnd room, but not enough tokens yet for a packet.
2051
+ var pacedOut = hasData && burstCount === 0 && tokenPackets < 1 &&
2052
+ context.sending_app_pn_in_flight.size < effPktsInFlight;
895
2053
 
896
2054
  // Execute burst
897
- var sent = false;
2055
+ var sent = 0;
898
2056
  if (burstCount > 0) {
899
- sent = execute_quic_burst(burstCount);
2057
+ sent = execute_quic_burst(burstCount); // returns packets ACTUALLY sent
2058
+ // Charge tokens for what went out — not for the burstCount ceiling.
2059
+ // Charging the ceiling overbilled short bursts (e.g. a 2-packet stream
2060
+ // tail billed as 64) and starved the pacer for a full tick afterwards.
2061
+ if (sent > 0) context.pacing_tokens -= sent * context.current_limit_packet_payload;
900
2062
  }
901
2063
 
2064
+ // FC-blocked signaling — consume flags set inside the burst just executed.
2065
+ var fcBlocked = maybeSendBlockedFrames();
2066
+
902
2067
  // Schedule next burst if needed
903
- if (sent && hasData) {
904
- // More data to send — schedule next burst immediately (no timer delay)
905
- context.burst_timer = setImmediate(function () {
2068
+ if (hasData && (sent || pacedOut)) {
2069
+ // Wait roughly until one more packet's worth of tokens has accrued.
2070
+ var delayMs = (rateBps > 0) ? (context.current_limit_packet_payload * 1000 / rateBps) : 1;
2071
+ if (delayMs < 1) delayMs = 1; // setTimeout floor
2072
+ if (delayMs > 100) delayMs = 100; // don't stall on a pathologically low rate
2073
+ context.burst_timer = setTimeout(function () {
906
2074
  context.burst_timer = null;
907
2075
  plan_quic_burst();
908
- });
2076
+ }, delayMs);
2077
+ if (context.burst_timer.unref) context.burst_timer.unref();
909
2078
  } else if (burstCount === 0 && hasData) {
910
- // Rate limited — wait for capacity
2079
+ // Blocked by cwnd / rate window (not pacing) — wait for capacity / ACKs.
911
2080
  var waitMs = (packetsRemaining <= 0 || bytesRemaining < 35) ? 50 : 10;
912
2081
  context.burst_timer = setTimeout(function () {
913
2082
  context.burst_timer = null;
914
2083
  plan_quic_burst();
915
2084
  }, waitMs);
916
2085
  if (context.burst_timer.unref) context.burst_timer.unref();
2086
+ } else if (hasInFlight) {
2087
+ // Nothing to send right now, but data is still in flight and unacked.
2088
+ // Keep a light timer ticking so expireInFlight (top of each pass) can
2089
+ // time it out and resend. Self-terminating once everything is acked.
2090
+ context.burst_timer = setTimeout(function () {
2091
+ context.burst_timer = null;
2092
+ plan_quic_burst();
2093
+ }, 20);
2094
+ if (context.burst_timer.unref) context.burst_timer.unref();
2095
+ } else if (fcBlocked && hasData) {
2096
+ // FC-blocked with nothing in flight: without this the scheduler would go
2097
+ // idle here and the (rate-limited) BLOCKED signal above would fire only
2098
+ // once — and a once-sent frame can be lost too. Keep ticking until the
2099
+ // peer's window update arrives and unblocks hasData → sent.
2100
+ context.burst_timer = setTimeout(function () {
2101
+ context.burst_timer = null;
2102
+ plan_quic_burst();
2103
+ }, 250);
2104
+ if (context.burst_timer.unref) context.burst_timer.unref();
917
2105
  }
918
2106
  }
919
2107
 
@@ -921,12 +2109,13 @@ function QUICConnection(options) {
921
2109
  /**
922
2110
  * execute_quic_burst — fill up to packet_count packets.
923
2111
  * Round-robin across active streams.
924
- * Returns true if at least one packet was sent.
2112
+ * Returns the number of packets actually sent (0 if none) — the pacer
2113
+ * charges tokens per REAL packet, not per the packetCount ceiling.
925
2114
  */
926
2115
  function execute_quic_burst(packetCount) {
927
- var MAX_PAYLOAD = context.max_packet_payload;
2116
+ var MAX_PAYLOAD = context.current_limit_packet_payload;
928
2117
  var OVERHEAD = 24; // STREAM frame header overhead estimate
929
- var sentAny = false;
2118
+ var sentCount = 0;
930
2119
 
931
2120
  function getActiveIds() {
932
2121
  var ids = [];
@@ -1013,9 +2202,9 @@ function QUICConnection(options) {
1013
2202
  for (var pn in st.in_flight_ranges) flat_ranges.add(known, st.in_flight_ranges[pn]);
1014
2203
  }
1015
2204
  var missing = flat_ranges.invert(known, 0, st.total_size);
1016
- console.log('[quic] FIN-only check stream=' + sid + ' total_size=' + st.total_size + ' fin_sent=' + st.fin_sent + ' missing=' + missing.length + ' known=' + JSON.stringify(known));
2205
+ if (DEBUG) console.log('[quic] FIN-only check stream=' + sid + ' total_size=' + st.total_size + ' fin_sent=' + st.fin_sent + ' missing=' + missing.length + ' known=' + JSON.stringify(known));
1017
2206
  if (missing.length === 0) {
1018
- console.log('[quic] → sending FIN-only for stream ' + sid);
2207
+ if (DEBUG) console.log('[quic] → sending FIN-only for stream ' + sid);
1019
2208
  // All data sent — send FIN-only STREAM frame
1020
2209
  frames.push({ type: 'stream', id: Number(sid), offset: st.total_size, fin: true, data: new Uint8Array(0) });
1021
2210
  st.fin_sent = true;
@@ -1025,7 +2214,7 @@ function QUICConnection(options) {
1025
2214
  // Send packet if it has content beyond just ACK-only
1026
2215
  if (frames.length > 0) {
1027
2216
  sendFrames('app', frames);
1028
- sentAny = true;
2217
+ sentCount++;
1029
2218
  // Clean up temp _burst in_flight keys (real PN keys now recorded by sendFrames)
1030
2219
  for (var sid in burstYielded) {
1031
2220
  var st = context.send_streams[sid];
@@ -1042,7 +2231,7 @@ function QUICConnection(options) {
1042
2231
  if (activeIds.length === 0) break;
1043
2232
  }
1044
2233
 
1045
- return sentAny;
2234
+ return sentCount;
1046
2235
  }
1047
2236
 
1048
2237
 
@@ -1060,10 +2249,28 @@ function QUICConnection(options) {
1060
2249
  var stream = context.send_streams[streamId];
1061
2250
  if (!stream || !stream.pending_data || stream.pending_data.byteLength === 0) return [];
1062
2251
 
1063
- // Flow control: cap by remaining remote_max_data budget
1064
- var fcBudget = context.remote_max_data - context.bytes_sent;
1065
- if (fcBudget <= 0) return []; // blocked by flow control
1066
- maxBytes = Math.min(maxBytes, fcBudget);
2252
+ // ── Flow control, send side (RFC 9000 §4.1) — two levels, one rule ──────
2253
+ // Both limits cap the HIGHEST OFFSET we may send, so they bind only NEW
2254
+ // data (bytes extending max_sent_offset). Retransmissions re-send offsets
2255
+ // that were already inside the allowance when first sent — they consume no
2256
+ // budget and MUST NOT be blocked. (The previous connection-only check
2257
+ // gated ALL bytes on remote_max_data − bytes_sent: under loss at the edge
2258
+ // of an exhausted window, the lost range itself was FC-blocked from being
2259
+ // repaired — a deadlock the peer can't always resolve, since the data it
2260
+ // would raise the window for is exactly the data that never arrived.)
2261
+ // streamLimit — absolute offset cap for THIS stream (seeded from the
2262
+ // peer's initial_max_stream_data_*, raised by
2263
+ // MAX_STREAM_DATA). Monotonic, so it never binds on a
2264
+ // retransmit of previously-allowed offsets.
2265
+ // connRemaining — connection-level budget for new bytes: the peer's
2266
+ // max_data minus the sum of all streams' high-water
2267
+ // marks (context.max_data_sent). Decremented locally as
2268
+ // this call yields new bytes; committed via
2269
+ // max_data_sent at yield time (charging a chunk that a
2270
+ // later encrypt-failure drops only wastes budget —
2271
+ // conservative — never overspends it).
2272
+ var streamLimit = stream.remote_max_stream_data;
2273
+ var connRemaining = context.remote_max_data - context.max_data_sent;
1067
2274
 
1068
2275
  var data = stream.pending_data;
1069
2276
  var baseOffset = stream.pending_offset_start;
@@ -1107,9 +2314,35 @@ function QUICConnection(options) {
1107
2314
  if (relEnd > data.byteLength) { relEnd = data.byteLength; len = relEnd - relStart; }
1108
2315
  if (len <= 0) continue;
1109
2316
 
2317
+ // FC clamp 1: never extend past the peer's per-stream offset cap.
2318
+ if (from + len > streamLimit) {
2319
+ stream.fc_blocked = true; // the cap bound — signal STREAM_DATA_BLOCKED
2320
+ if (from >= streamLimit) continue;
2321
+ len = streamLimit - from; relEnd = relStart + len;
2322
+ }
2323
+
2324
+ // FC clamp 2: charge the connection budget for NEW bytes only (the part
2325
+ // of this chunk above the stream's high-water mark).
2326
+ var newStart = Math.max(from, stream.max_sent_offset);
2327
+ var newBytes = Math.max(0, (from + len) - newStart);
2328
+ if (newBytes > connRemaining) {
2329
+ context.fc_blocked_conn = true; // the budget bound — signal DATA_BLOCKED
2330
+ len -= (newBytes - connRemaining);
2331
+ relEnd = relStart + len;
2332
+ newBytes = connRemaining;
2333
+ }
2334
+ if (len <= 0) continue;
2335
+
1110
2336
  chunks.push({ offset: from, data: data.slice(relStart, relEnd) });
1111
2337
  used += len;
1112
2338
 
2339
+ // Commit FC usage at yield time (see header note).
2340
+ if (newBytes > 0) {
2341
+ stream.max_sent_offset = from + len;
2342
+ context.max_data_sent += newBytes;
2343
+ connRemaining -= newBytes;
2344
+ }
2345
+
1113
2346
  // Advance send_offset
1114
2347
  if (from + len > stream.send_offset) {
1115
2348
  stream.send_offset = from + len;
@@ -1143,8 +2376,30 @@ function QUICConnection(options) {
1143
2376
  if (relEnd > data.byteLength) { relEnd = data.byteLength; len = relEnd - relStart; }
1144
2377
  if (len <= 0) continue;
1145
2378
 
2379
+ // FC fail-safe: retransmits sit below the high-water mark, which was
2380
+ // itself capped when first sent, and limits are monotonic — so these
2381
+ // clamps should never bind here. Applied anyway so the invariant
2382
+ // "nothing ever leaves above streamLimit / beyond the conn budget"
2383
+ // holds unconditionally, whatever the scheduler does around us.
2384
+ if (mFrom >= streamLimit) continue;
2385
+ if (mFrom + len > streamLimit) { len = streamLimit - mFrom; relEnd = relStart + len; }
2386
+ var newStart2 = Math.max(mFrom, stream.max_sent_offset);
2387
+ var newBytes2 = Math.max(0, (mFrom + len) - newStart2);
2388
+ if (newBytes2 > connRemaining) {
2389
+ len -= (newBytes2 - connRemaining);
2390
+ relEnd = relStart + len;
2391
+ newBytes2 = connRemaining;
2392
+ }
2393
+ if (len <= 0) continue;
2394
+
1146
2395
  chunks.push({ offset: mFrom, data: data.slice(relStart, relEnd) });
1147
2396
  used += len;
2397
+
2398
+ if (newBytes2 > 0) {
2399
+ stream.max_sent_offset = mFrom + len;
2400
+ context.max_data_sent += newBytes2;
2401
+ connRemaining -= newBytes2;
2402
+ }
1148
2403
  }
1149
2404
  }
1150
2405
 
@@ -1166,14 +2421,26 @@ function QUICConnection(options) {
1166
2421
  // Close
1167
2422
  // ============================================================
1168
2423
 
2424
+ function sendConnectionClose() {
2425
+ if (!context.close_frame) return;
2426
+ sendFrames('app', [context.close_frame]);
2427
+ context.last_close_echo = Date.now();
2428
+ }
2429
+
1169
2430
  function close(errorCode, reason) {
1170
- if (context.state === 'closed' || context.state === 'draining') return;
2431
+ if (context.state === 'closed' || context.state === 'draining' || context.state === 'closing') return;
1171
2432
  if (context.handshake_timer) { clearTimeout(context.handshake_timer); context.handshake_timer = null; }
1172
- sendFrames('app', [{
2433
+ stopCryptoRetx();
2434
+ context.close_frame = {
1173
2435
  type: 'connection_close', application: false,
1174
2436
  error: errorCode || 0, frameType: 0, reason: reason || ''
1175
- }]);
1176
- set_context({ state: 'draining' });
2437
+ };
2438
+ sendConnectionClose();
2439
+ // RFC 9000 §10.2.1: the endpoint that *sends* CONNECTION_CLOSE enters the
2440
+ // closing state — it re-echoes the CC (rate-limited) in response to incoming
2441
+ // packets, rather than going silent. The peer, which *receives* the CC,
2442
+ // enters draining (handled where connection_close frames are processed).
2443
+ set_context({ state: 'closing' });
1177
2444
  }
1178
2445
 
1179
2446
 
@@ -1204,7 +2471,7 @@ function QUICConnection(options) {
1204
2471
  });
1205
2472
 
1206
2473
  // Initialize TLS (client mode)
1207
- console.log('[quic] client connecting, dcid=' + Array.from(dcid).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(''));
2474
+ if (DEBUG) console.log('[quic] client connecting, dcid=' + Array.from(dcid).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(''));
1208
2475
  initTLS();
1209
2476
 
1210
2477
  // Trigger ClientHello by feeding empty message
@@ -1221,18 +2488,25 @@ function QUICConnection(options) {
1221
2488
  on: function (name, fn) { ev.on(name, fn); },
1222
2489
  off: function (name, fn) { ev.off(name, fn); },
1223
2490
  feedDatagram: feedDatagram,
2491
+ feedPackets: feedPackets,
1224
2492
  sendStream: sendStream,
1225
2493
  sendDatagram: sendDatagram,
2494
+ maxDatagramSize: maxDatagramSize,
1226
2495
  close: close,
1227
2496
  connect: connect,
1228
2497
  set_context: set_context,
1229
2498
  get state() { return context.state; }
1230
2499
  };
1231
2500
 
2501
+ // Copy api onto `this`. Use descriptors (not `this[k] = api[k]`) so that
2502
+ // accessor properties like `get state()` are preserved as live getters
2503
+ // instead of being frozen to their construction-time value.
1232
2504
  for (var k in api) {
1233
- if (Object.prototype.hasOwnProperty.call(api, k)) this[k] = api[k];
2505
+ if (Object.prototype.hasOwnProperty.call(api, k)) {
2506
+ Object.defineProperty(this, k, Object.getOwnPropertyDescriptor(api, k));
2507
+ }
1234
2508
  }
1235
2509
  return this;
1236
2510
  }
1237
2511
 
1238
- export { QUICConnection };
2512
+ export { QUICConnection };