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