quico 0.1.3 → 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.
- package/README.md +381 -216
- package/index.cjs +45 -0
- package/index.d.ts +158 -0
- package/index.js +49 -23
- package/package.json +83 -60
- package/src/agent.js +283 -0
- package/src/client.js +444 -0
- package/src/crypto.js +534 -0
- package/src/h3.js +920 -0
- package/src/h3_client.js +332 -0
- package/src/h3_server.js +392 -0
- package/src/quic_connection.js +2338 -0
- package/src/quic_server.js +314 -0
- package/src/quic_socket.js +76 -0
- package/src/server.js +354 -0
- package/src/streams.js +471 -0
- package/src/tls_bridge.js +273 -0
- package/src/transport.js +667 -0
- package/src/utils.js +345 -0
- package/src/webtransport.js +655 -0
- package/CHANGELOG.md +0 -31
- package/examples/webtransport_datagram_echo_server/client.html +0 -58
- package/examples/webtransport_datagram_echo_server/server.js +0 -23
- package/h3_server.js +0 -416
- package/h3_socket.js +0 -473
- package/libs/crypto.js +0 -1841
- package/libs/h3.js +0 -1461
- package/libs/utils.js +0 -404
- package/quic_socket.js +0 -1550
- package/quico.svg +0 -1
|
@@ -0,0 +1,2338 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* quico: HTTP/3 and QUIC implementation for Node.js
|
|
3
|
+
* Copyright 2025 colocohen
|
|
4
|
+
*
|
|
5
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
6
|
+
* you may not use this file except in compliance with the License.
|
|
7
|
+
* You may obtain a copy of the License at:
|
|
8
|
+
*
|
|
9
|
+
* https://www.apache.org/licenses/LICENSE-2.0
|
|
10
|
+
*
|
|
11
|
+
* This file is part of the open-source project hosted at:
|
|
12
|
+
* https://github.com/colocohen/quico
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import flat_ranges from 'flat-ranges';
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
DEBUG,
|
|
19
|
+
Emitter,
|
|
20
|
+
concatUint8Arrays,
|
|
21
|
+
uint8Equal,
|
|
22
|
+
ack_frame_to_ranges,
|
|
23
|
+
ranges_to_ack_frame
|
|
24
|
+
} from './utils.js';
|
|
25
|
+
|
|
26
|
+
import {
|
|
27
|
+
quic_derive_init_secrets,
|
|
28
|
+
quic_derive_from_tls_secrets,
|
|
29
|
+
quic_derive_key_update,
|
|
30
|
+
encrypt_quic_packet,
|
|
31
|
+
decrypt_quic_packet,
|
|
32
|
+
extract_tls_messages_from_chunks
|
|
33
|
+
} from './crypto.js';
|
|
34
|
+
|
|
35
|
+
import {
|
|
36
|
+
parse_quic_datagram,
|
|
37
|
+
encode_quic_frames,
|
|
38
|
+
parse_quic_frames
|
|
39
|
+
} from './transport.js';
|
|
40
|
+
|
|
41
|
+
import { TLSBridge } from './tls_bridge.js';
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
function QUICConnection(options) {
|
|
45
|
+
if (!(this instanceof QUICConnection)) return new QUICConnection(options);
|
|
46
|
+
options = options || {};
|
|
47
|
+
|
|
48
|
+
var ev = Emitter();
|
|
49
|
+
var tls = null;
|
|
50
|
+
|
|
51
|
+
// ============================================================
|
|
52
|
+
// Context
|
|
53
|
+
// ============================================================
|
|
54
|
+
|
|
55
|
+
var context = {
|
|
56
|
+
isServer: options.isServer !== false,
|
|
57
|
+
state: 'idle',
|
|
58
|
+
handshake_done: false,
|
|
59
|
+
handshake_done_sent: false,
|
|
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
|
+
|
|
65
|
+
version: 1,
|
|
66
|
+
original_dcid: null,
|
|
67
|
+
my_cids: [],
|
|
68
|
+
their_cids: [],
|
|
69
|
+
|
|
70
|
+
// Keys
|
|
71
|
+
initial_read: null,
|
|
72
|
+
initial_write: null,
|
|
73
|
+
handshake_read: null,
|
|
74
|
+
handshake_write: null,
|
|
75
|
+
app_read: null,
|
|
76
|
+
app_write: null,
|
|
77
|
+
app_prev_read: null, // previous read keys (kept during transition)
|
|
78
|
+
key_phase: false, // our current send key_phase
|
|
79
|
+
read_key_phase: false, // expected key_phase on incoming packets
|
|
80
|
+
app_read_secret: null, // current read secret (for deriving next)
|
|
81
|
+
app_write_secret: null, // current write secret (for deriving next)
|
|
82
|
+
cipher_hash: 'sha256', // hash for key derivation
|
|
83
|
+
cipher_suite: null, // TLS cipher suite code (for key length)
|
|
84
|
+
|
|
85
|
+
// Packet numbers — sending
|
|
86
|
+
send_pn: { initial: 0, handshake: 0, app: 0 },
|
|
87
|
+
|
|
88
|
+
// Packet numbers — receiving
|
|
89
|
+
recv_pn_largest: { initial: -1, handshake: -1, app: -1 },
|
|
90
|
+
recv_pn_ranges: { initial: [], handshake: [], app: [] },
|
|
91
|
+
|
|
92
|
+
// CRYPTO
|
|
93
|
+
crypto_chunks: { initial: {}, handshake: {} }, // RECEIVE-side reassembly
|
|
94
|
+
crypto_offset: { initial: 0, handshake: 0 },
|
|
95
|
+
crypto_send_offset: { initial: 0, handshake: 0, app: 0 },
|
|
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
|
+
|
|
112
|
+
// ACK
|
|
113
|
+
pending_ack: { initial: [], handshake: [], app: [] },
|
|
114
|
+
|
|
115
|
+
// Receiving streams
|
|
116
|
+
recv_streams: {},
|
|
117
|
+
|
|
118
|
+
// Sending streams
|
|
119
|
+
send_streams: {},
|
|
120
|
+
|
|
121
|
+
// In-flight tracking (Phase 1)
|
|
122
|
+
sending_app_pn_in_flight: new Set(),
|
|
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
|
+
|
|
183
|
+
burst_timer: null,
|
|
184
|
+
pacing_tokens: 0, // token-bucket pacer: accumulated send credit (bytes)
|
|
185
|
+
pacing_last_refill: Date.now(),
|
|
186
|
+
|
|
187
|
+
// Pending app packets
|
|
188
|
+
pending_app_packets: [],
|
|
189
|
+
|
|
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)
|
|
201
|
+
remote_max_data: 1048576, // peer's limit on what we can send (default 1MB until parsed)
|
|
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
|
+
|
|
235
|
+
remote_max_streams_bidi: 100,
|
|
236
|
+
remote_max_streams_uni: 3,
|
|
237
|
+
|
|
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)
|
|
309
|
+
|
|
310
|
+
// Timers
|
|
311
|
+
idle_timeout: options.idleTimeout || 30000,
|
|
312
|
+
handshake_timeout: options.handshakeTimeout || 10000,
|
|
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(),
|
|
319
|
+
idle_timer: null,
|
|
320
|
+
handshake_timer: null,
|
|
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
|
+
|
|
333
|
+
SNICallback: options.SNICallback || null,
|
|
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
|
+
})(),
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
// ============================================================
|
|
347
|
+
// set_context
|
|
348
|
+
// ============================================================
|
|
349
|
+
|
|
350
|
+
function set_context(updates) {
|
|
351
|
+
if (!updates || typeof updates !== 'object') return;
|
|
352
|
+
var changed = {};
|
|
353
|
+
|
|
354
|
+
if ('state' in updates && updates.state !== context.state) {
|
|
355
|
+
context.state = updates.state;
|
|
356
|
+
changed.state = true;
|
|
357
|
+
}
|
|
358
|
+
if ('handshake_done' in updates && updates.handshake_done !== context.handshake_done) {
|
|
359
|
+
context.handshake_done = updates.handshake_done;
|
|
360
|
+
changed.handshake_done = true;
|
|
361
|
+
}
|
|
362
|
+
if ('handshake_done_sent' in updates) {
|
|
363
|
+
context.handshake_done_sent = updates.handshake_done_sent;
|
|
364
|
+
}
|
|
365
|
+
if ('original_dcid' in updates && updates.original_dcid !== null && context.original_dcid === null) {
|
|
366
|
+
context.original_dcid = updates.original_dcid;
|
|
367
|
+
changed.original_dcid = true;
|
|
368
|
+
}
|
|
369
|
+
if ('version' in updates && updates.version !== context.version) {
|
|
370
|
+
context.version = updates.version;
|
|
371
|
+
}
|
|
372
|
+
if ('add_their_cid' in updates) {
|
|
373
|
+
var cid = updates.add_their_cid;
|
|
374
|
+
var found = false;
|
|
375
|
+
for (var i = 0; i < context.their_cids.length; i++) {
|
|
376
|
+
if (uint8Equal(cid, context.their_cids[i])) { found = true; break; }
|
|
377
|
+
}
|
|
378
|
+
if (!found) context.their_cids.push(cid);
|
|
379
|
+
}
|
|
380
|
+
if ('initial_read' in updates) { context.initial_read = updates.initial_read; changed.initial_read = true; }
|
|
381
|
+
if ('initial_write' in updates) { context.initial_write = updates.initial_write; changed.initial_write = true; }
|
|
382
|
+
if ('handshake_read' in updates) { context.handshake_read = updates.handshake_read; }
|
|
383
|
+
if ('handshake_write' in updates){ context.handshake_write = updates.handshake_write; }
|
|
384
|
+
if ('app_read' in updates) { context.app_read = updates.app_read; }
|
|
385
|
+
if ('app_write' in updates) { context.app_write = updates.app_write; }
|
|
386
|
+
if ('remote_max_data' in updates && updates.remote_max_data > context.remote_max_data) {
|
|
387
|
+
context.remote_max_data = updates.remote_max_data; changed.remote_max_data = true;
|
|
388
|
+
}
|
|
389
|
+
if ('remote_max_streams_bidi' in updates) { context.remote_max_streams_bidi = updates.remote_max_streams_bidi; }
|
|
390
|
+
if ('remote_max_streams_uni' in updates) { context.remote_max_streams_uni = updates.remote_max_streams_uni; }
|
|
391
|
+
if ('key_phase' in updates && updates.key_phase !== context.key_phase) {
|
|
392
|
+
context.key_phase = updates.key_phase; changed.key_phase = true;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// ---- Reactive ----
|
|
396
|
+
|
|
397
|
+
if (changed.initial_read || changed.initial_write) {
|
|
398
|
+
if (context.state === 'idle') { context.state = 'handshaking'; changed.state = true; }
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
if (changed.handshake_done && context.handshake_done === true) {
|
|
402
|
+
if (DEBUG) console.log('[quic] handshake done — flushing ' + context.pending_app_packets.length + ' pending');
|
|
403
|
+
if (context.pending_app_packets.length > 0) {
|
|
404
|
+
var pending = context.pending_app_packets;
|
|
405
|
+
context.pending_app_packets = [];
|
|
406
|
+
for (var i = 0; i < pending.length; i++) {
|
|
407
|
+
processDecryptedPacket('app', pending[i].packet_number, pending[i].plaintext);
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
startIdleTimer();
|
|
411
|
+
startKeepAliveTimer();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (changed.state && context.state === 'connected') {
|
|
415
|
+
// 'connect' event is emitted explicitly by TLS handlers (not here)
|
|
416
|
+
// Server emits on appSecrets, Client emits on secureConnect
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (changed.state && (context.state === 'draining' || context.state === 'closing')) {
|
|
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();
|
|
426
|
+
var _drainTimer = setTimeout(function () {
|
|
427
|
+
if (context.state === 'draining' || context.state === 'closing') { context.state = 'closed'; ev.emit('close'); }
|
|
428
|
+
}, Math.min(3000, context.idle_timeout / 3));
|
|
429
|
+
if (_drainTimer.unref) _drainTimer.unref();
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
if (changed.state && context.state === 'closed') { clearIdleTimer(); ev.emit('close'); }
|
|
433
|
+
if (changed.remote_max_data) { plan_quic_burst(); }
|
|
434
|
+
if (changed.key_phase) {
|
|
435
|
+
// We initiated key update — derive new write keys
|
|
436
|
+
if (context.app_write_secret) {
|
|
437
|
+
var next = quic_derive_key_update(context.app_write_secret, context.cipher_hash, context.cipher_suite);
|
|
438
|
+
if (DEBUG) console.log('[quic] key update initiated — new write keys');
|
|
439
|
+
context.app_write = { key: next.key, iv: next.iv, hp: context.app_write.hp };
|
|
440
|
+
context.app_write_secret = next.secret;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
// ============================================================
|
|
447
|
+
// Idle timeout
|
|
448
|
+
// ============================================================
|
|
449
|
+
|
|
450
|
+
function touchActivity() { context.last_activity = Date.now(); }
|
|
451
|
+
|
|
452
|
+
function startIdleTimer() {
|
|
453
|
+
clearIdleTimer();
|
|
454
|
+
if (context.idle_timeout <= 0) return;
|
|
455
|
+
context.idle_timer = setInterval(function () {
|
|
456
|
+
if (Date.now() - context.last_activity >= context.idle_timeout) {
|
|
457
|
+
if (DEBUG) console.log('[quic] idle timeout');
|
|
458
|
+
close(0, 'idle timeout');
|
|
459
|
+
}
|
|
460
|
+
}, Math.max(1000, Math.floor(context.idle_timeout / 4)));
|
|
461
|
+
if (context.idle_timer.unref) context.idle_timer.unref();
|
|
462
|
+
}
|
|
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
|
+
|
|
480
|
+
function clearIdleTimer() {
|
|
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; }
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
// ============================================================
|
|
489
|
+
// TLS Bridge
|
|
490
|
+
// ============================================================
|
|
491
|
+
|
|
492
|
+
function initTLS() {
|
|
493
|
+
// Start handshake timeout — if TLS doesn't complete in time, close
|
|
494
|
+
if (context.handshake_timeout > 0 && !context.handshake_timer) {
|
|
495
|
+
context.handshake_timer = setTimeout(function () {
|
|
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)');
|
|
498
|
+
ev.emit('error', new Error('QUIC handshake timeout'));
|
|
499
|
+
close(0x100, 'handshake timeout'); // 0x100 = CRYPTO_ERROR
|
|
500
|
+
}
|
|
501
|
+
}, context.handshake_timeout);
|
|
502
|
+
if (context.handshake_timer.unref) context.handshake_timer.unref();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
tls = new TLSBridge({
|
|
506
|
+
isServer: context.isServer,
|
|
507
|
+
SNICallback: context.SNICallback,
|
|
508
|
+
originalDcid: context.original_dcid,
|
|
509
|
+
localCid: context.my_cids.length > 0 ? context.my_cids[0] : new Uint8Array(0),
|
|
510
|
+
hostname: context.hostname,
|
|
511
|
+
alpn: context.alpn
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
tls.on('send', function (epoch, data) { cryptoWrite(epoch, data); });
|
|
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
|
+
|
|
570
|
+
tls.on('handshakeSecrets', function (secrets) {
|
|
571
|
+
set_context({
|
|
572
|
+
handshake_read: quic_derive_from_tls_secrets(secrets.remote, secrets.hash, secrets.cipher),
|
|
573
|
+
handshake_write: quic_derive_from_tls_secrets(secrets.local, secrets.hash, secrets.cipher)
|
|
574
|
+
});
|
|
575
|
+
});
|
|
576
|
+
|
|
577
|
+
tls.on('appSecrets', function (secrets) {
|
|
578
|
+
// Handshake complete — clear timeout
|
|
579
|
+
if (context.handshake_timer) {
|
|
580
|
+
clearTimeout(context.handshake_timer);
|
|
581
|
+
context.handshake_timer = null;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Store secrets for future key updates (RFC 9001 §6)
|
|
585
|
+
context.app_read_secret = secrets.remote;
|
|
586
|
+
context.app_write_secret = secrets.local;
|
|
587
|
+
context.cipher_hash = secrets.hash;
|
|
588
|
+
context.cipher_suite = secrets.cipher;
|
|
589
|
+
|
|
590
|
+
set_context({
|
|
591
|
+
app_read: quic_derive_from_tls_secrets(secrets.remote, secrets.hash, secrets.cipher),
|
|
592
|
+
app_write: quic_derive_from_tls_secrets(secrets.local, secrets.hash, secrets.cipher),
|
|
593
|
+
state: 'connected',
|
|
594
|
+
handshake_done: true
|
|
595
|
+
});
|
|
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
|
+
|
|
603
|
+
// Server: emit connect now (Finished already sent)
|
|
604
|
+
// Client: wait for secureConnect (after client Finished is generated)
|
|
605
|
+
if (context.isServer) {
|
|
606
|
+
ev.emit('connect');
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
tls.on('secureConnect', function () {
|
|
611
|
+
// Client: Finished has been generated and queued.
|
|
612
|
+
// Flush handshake first, then emit connect so 1-RTT goes after Finished.
|
|
613
|
+
if (!context.isServer) {
|
|
614
|
+
plan_quic_burst(); // flush any pending handshake data (Finished)
|
|
615
|
+
ev.emit('connect');
|
|
616
|
+
}
|
|
617
|
+
});
|
|
618
|
+
|
|
619
|
+
// Note: QUIC handles key updates via key_phase bit, not TLS KeyUpdate message.
|
|
620
|
+
// RFC 9001 §6: "endpoints MUST treat the receipt of a TLS KeyUpdate message
|
|
621
|
+
// as a connection error of type 0x010a" — but we silently ignore it.
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
// ============================================================
|
|
626
|
+
// Incoming packets
|
|
627
|
+
// ============================================================
|
|
628
|
+
|
|
629
|
+
function feedDatagram(from_ip, from_port, data) {
|
|
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
|
+
}
|
|
646
|
+
touchActivity();
|
|
647
|
+
for (var i = 0; i < packets.length; 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
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
function feedPacket(pkt) {
|
|
663
|
+
if (context.state === 'draining' || context.state === 'closed') return;
|
|
664
|
+
|
|
665
|
+
if (pkt.version && pkt.version !== context.version) set_context({ version: pkt.version });
|
|
666
|
+
if (pkt.dcid && pkt.dcid.byteLength > 0) set_context({ original_dcid: pkt.dcid });
|
|
667
|
+
if (pkt.scid && pkt.scid.byteLength > 0) set_context({ add_their_cid: pkt.scid });
|
|
668
|
+
|
|
669
|
+
var space = pkt.type === 'initial' ? 'initial'
|
|
670
|
+
: pkt.type === 'handshake' ? 'handshake'
|
|
671
|
+
: pkt.type === '1rtt' ? 'app' : null;
|
|
672
|
+
if (!space) return;
|
|
673
|
+
|
|
674
|
+
if (space === 'initial' && !context.initial_read && context.original_dcid) {
|
|
675
|
+
set_context({
|
|
676
|
+
initial_read: quic_derive_init_secrets(context.original_dcid, context.version, 'read'),
|
|
677
|
+
initial_write: quic_derive_init_secrets(context.original_dcid, context.version, 'write')
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
var readKeys = space === 'initial' ? context.initial_read
|
|
682
|
+
: space === 'handshake' ? context.handshake_read : context.app_read;
|
|
683
|
+
if (!readKeys) return;
|
|
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
|
+
|
|
695
|
+
var decrypted = decrypt_quic_packet(
|
|
696
|
+
pkt.raw, readKeys.key, readKeys.iv, readKeys.hp,
|
|
697
|
+
recvCid, context.recv_pn_largest[space]
|
|
698
|
+
);
|
|
699
|
+
|
|
700
|
+
// Key Update: if app decrypt fails, try with derived next keys
|
|
701
|
+
if ((!decrypted || !decrypted.plaintext) && space === 'app' && context.app_read_secret) {
|
|
702
|
+
var next = quic_derive_key_update(context.app_read_secret, context.cipher_hash, context.cipher_suite);
|
|
703
|
+
decrypted = decrypt_quic_packet(
|
|
704
|
+
pkt.raw, next.key, next.iv, readKeys.hp, // HP doesn't change
|
|
705
|
+
recvCid, context.recv_pn_largest[space]
|
|
706
|
+
);
|
|
707
|
+
|
|
708
|
+
if (decrypted && decrypted.plaintext && decrypted.plaintext.byteLength > 0) {
|
|
709
|
+
// Key update confirmed — install new read keys
|
|
710
|
+
if (DEBUG) console.log('[quic] key update detected — installing new read keys');
|
|
711
|
+
context.app_prev_read = context.app_read;
|
|
712
|
+
context.app_read = { key: next.key, iv: next.iv, hp: readKeys.hp };
|
|
713
|
+
context.app_read_secret = next.secret;
|
|
714
|
+
context.read_key_phase = !context.read_key_phase;
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Also try prev keys for reordered packets from before key update
|
|
719
|
+
if ((!decrypted || !decrypted.plaintext) && space === 'app' && context.app_prev_read) {
|
|
720
|
+
decrypted = decrypt_quic_packet(
|
|
721
|
+
pkt.raw, context.app_prev_read.key, context.app_prev_read.iv, context.app_prev_read.hp,
|
|
722
|
+
recvCid, context.recv_pn_largest[space]
|
|
723
|
+
);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
if (!decrypted || !decrypted.plaintext || decrypted.plaintext.byteLength === 0) {
|
|
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]);
|
|
728
|
+
return;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
if (DEBUG) console.log('[quic] decrypted ' + space + ' pn=' + decrypted.packet_number + ' len=' + decrypted.plaintext.byteLength);
|
|
732
|
+
|
|
733
|
+
var pn = decrypted.packet_number;
|
|
734
|
+
var ranges = context.recv_pn_ranges[space];
|
|
735
|
+
var isNew = true;
|
|
736
|
+
for (var ri = 0; ri < ranges.length; ri += 2) {
|
|
737
|
+
if (pn >= ranges[ri] && pn < ranges[ri + 1]) { isNew = false; break; }
|
|
738
|
+
}
|
|
739
|
+
if (isNew) {
|
|
740
|
+
flat_ranges.add(ranges, [pn, pn + 1]);
|
|
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
|
+
}
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
if (DEBUG) console.log('[quic] pn=' + pn + ' isNew=' + isNew);
|
|
751
|
+
if (!isNew) return;
|
|
752
|
+
|
|
753
|
+
if (space === 'app' && !context.handshake_done) {
|
|
754
|
+
context.pending_app_packets.push(decrypted);
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
processDecryptedPacket(space, pn, decrypted.plaintext);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
// ============================================================
|
|
763
|
+
// Frame processing
|
|
764
|
+
// ============================================================
|
|
765
|
+
|
|
766
|
+
function processDecryptedPacket(space, packetNumber, plaintext) {
|
|
767
|
+
var frames = parse_quic_frames(plaintext);
|
|
768
|
+
if (DEBUG) console.log('[quic] frames: ' + space + ' pn=' + packetNumber + ' [' + frames.map(function(f){ return f.type; }).join(',') + ']');
|
|
769
|
+
var ackEliciting = false;
|
|
770
|
+
|
|
771
|
+
if (context.isServer && space === 'app' && !context.handshake_done_sent) {
|
|
772
|
+
set_context({ handshake_done_sent: true });
|
|
773
|
+
sendFrames('app', [{ type: 'handshake_done' }]);
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
for (var i = 0; i < frames.length; i++) {
|
|
777
|
+
var frame = frames[i];
|
|
778
|
+
|
|
779
|
+
if (frame.type === 'crypto') {
|
|
780
|
+
ackEliciting = true;
|
|
781
|
+
processCryptoFrame(space, frame.offset, frame.data);
|
|
782
|
+
} else if (frame.type === 'stream') {
|
|
783
|
+
ackEliciting = true;
|
|
784
|
+
processStreamFrame(frame);
|
|
785
|
+
} else if (frame.type === 'ack') {
|
|
786
|
+
processAckFrame(space, frame);
|
|
787
|
+
} else if (frame.type === 'ping') {
|
|
788
|
+
ackEliciting = true;
|
|
789
|
+
} else if (frame.type === 'handshake_done') {
|
|
790
|
+
ackEliciting = true;
|
|
791
|
+
} else if (frame.type === 'path_challenge') {
|
|
792
|
+
ackEliciting = true;
|
|
793
|
+
sendFrames(space, [{ type: 'path_response', data: frame.data }]);
|
|
794
|
+
} else if (frame.type === 'new_connection_id') {
|
|
795
|
+
ackEliciting = true;
|
|
796
|
+
} else if (frame.type === 'connection_close') {
|
|
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 || '') + '"');
|
|
798
|
+
set_context({ state: 'draining' });
|
|
799
|
+
return;
|
|
800
|
+
} else if (frame.type === 'stop_sending') {
|
|
801
|
+
// Peer says: stop sending on this stream
|
|
802
|
+
ackEliciting = true;
|
|
803
|
+
if (frame.id in context.send_streams) {
|
|
804
|
+
if (DEBUG) console.log('[quic] STOP_SENDING stream=' + frame.id + ' error=' + frame.error);
|
|
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];
|
|
808
|
+
}
|
|
809
|
+
} else if (frame.type === 'reset_stream') {
|
|
810
|
+
// Peer cancelled their stream
|
|
811
|
+
ackEliciting = true;
|
|
812
|
+
if (frame.id in context.recv_streams) {
|
|
813
|
+
if (DEBUG) console.log('[quic] RESET_STREAM stream=' + frame.id + ' error=' + frame.error);
|
|
814
|
+
delete context.recv_streams[frame.id];
|
|
815
|
+
}
|
|
816
|
+
} else if (frame.type === 'max_data') {
|
|
817
|
+
ackEliciting = true;
|
|
818
|
+
set_context({ remote_max_data: frame.max });
|
|
819
|
+
} else if (frame.type === 'max_streams_bidi') {
|
|
820
|
+
ackEliciting = true;
|
|
821
|
+
set_context({ remote_max_streams_bidi: frame.max });
|
|
822
|
+
} else if (frame.type === 'max_streams_uni') {
|
|
823
|
+
ackEliciting = true;
|
|
824
|
+
set_context({ remote_max_streams_uni: frame.max });
|
|
825
|
+
} else if (frame.type === 'max_stream_data') {
|
|
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 }]);
|
|
855
|
+
} else if (frame.type === 'datagram') {
|
|
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);
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (ackEliciting) {
|
|
865
|
+
flat_ranges.add(context.pending_ack[space], [packetNumber, packetNumber + 1]);
|
|
866
|
+
var ackFrame = ranges_to_ack_frame(context.pending_ack[space], null, 0);
|
|
867
|
+
if (ackFrame) {
|
|
868
|
+
sendFrames(space, [ackFrame]);
|
|
869
|
+
if (space !== 'app') context.pending_ack[space] = [];
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
// ============================================================
|
|
876
|
+
// CRYPTO → TLS
|
|
877
|
+
// ============================================================
|
|
878
|
+
|
|
879
|
+
function processCryptoFrame(space, offset, data) {
|
|
880
|
+
if (space !== 'initial' && space !== 'handshake') return;
|
|
881
|
+
if (DEBUG) console.log('[quic] CRYPTO: space=' + space + ' offset=' + offset + ' len=' + data.length);
|
|
882
|
+
|
|
883
|
+
var chunks = context.crypto_chunks[space];
|
|
884
|
+
var fromOffset = context.crypto_offset[space];
|
|
885
|
+
if (!(offset in chunks) || chunks[offset].byteLength < data.byteLength) chunks[offset] = data;
|
|
886
|
+
|
|
887
|
+
var result = extract_tls_messages_from_chunks(chunks, fromOffset);
|
|
888
|
+
if (DEBUG) console.log('[quic] TLS messages: ' + (result ? result.tls_messages.length : 'none'));
|
|
889
|
+
if (!result) return;
|
|
890
|
+
|
|
891
|
+
context.crypto_offset[space] = result.new_from_offset;
|
|
892
|
+
if (!tls) { if (DEBUG) console.log('[quic] initializing TLS bridge'); initTLS(); }
|
|
893
|
+
|
|
894
|
+
for (var i = 0; i < result.tls_messages.length; i++) {
|
|
895
|
+
var msg = result.tls_messages[i];
|
|
896
|
+
var msgType = msg[0];
|
|
897
|
+
if (DEBUG) console.log('[quic] → TLS msg #' + i + ' type=0x' + msgType.toString(16) + ' len=' + msg.length);
|
|
898
|
+
|
|
899
|
+
// Detect HelloRetryRequest (type 0x02 with special random)
|
|
900
|
+
if (msgType === 0x02 && msg.length >= 38) {
|
|
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];
|
|
902
|
+
var isHRR = true;
|
|
903
|
+
for (var j = 0; j < 32; j++) { if (msg[6 + j] !== hrrRandom[j]) { isHRR = false; break; } }
|
|
904
|
+
if (isHRR && DEBUG) console.log('[quic] ⚠️ HelloRetryRequest detected! LemonTLS needs HRR support.');
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
tls.feedMessage(msg);
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
|
|
912
|
+
// ============================================================
|
|
913
|
+
// Stream receive
|
|
914
|
+
// ============================================================
|
|
915
|
+
|
|
916
|
+
function processStreamFrame(frame) {
|
|
917
|
+
var sid = frame.id;
|
|
918
|
+
if (!(sid in context.recv_streams)) {
|
|
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 };
|
|
922
|
+
}
|
|
923
|
+
var stream = context.recv_streams[sid];
|
|
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
|
+
|
|
948
|
+
var alreadyHave = false;
|
|
949
|
+
for (var ri = 0; ri < stream.ranges.length; ri += 2) {
|
|
950
|
+
if (frame.offset >= stream.ranges[ri] && frame.offset + frame.data.length <= stream.ranges[ri + 1]) {
|
|
951
|
+
alreadyHave = true; break;
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
if (!alreadyHave) {
|
|
955
|
+
flat_ranges.add(stream.ranges, [frame.offset, frame.offset + frame.data.length]);
|
|
956
|
+
if (!(frame.offset in stream.chunks) || stream.chunks[frame.offset].byteLength < frame.data.byteLength) {
|
|
957
|
+
stream.chunks[frame.offset] = frame.data;
|
|
958
|
+
}
|
|
959
|
+
// Raw stat only — FC usage is max_recv_offset above, not this.
|
|
960
|
+
context.bytes_received += frame.data.byteLength;
|
|
961
|
+
}
|
|
962
|
+
if (frame.fin && stream.total_size === 0) stream.total_size = frame.offset + frame.data.length;
|
|
963
|
+
flushStream(sid);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
// (checkLocalFlowControl — the old unbounded ×2 doubling — was replaced by
|
|
967
|
+
// the consumption-based sliding window inside flushStream above.)
|
|
968
|
+
|
|
969
|
+
function flushStream(sid) {
|
|
970
|
+
var stream = context.recv_streams[sid];
|
|
971
|
+
if (!stream) return;
|
|
972
|
+
var parts = [];
|
|
973
|
+
var offset = stream.flushed_to;
|
|
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) {
|
|
985
|
+
var chunk = stream.chunks[offset];
|
|
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;
|
|
1002
|
+
}
|
|
1003
|
+
if (parts.length === 0) return;
|
|
1004
|
+
var delivered = offset - stream.flushed_to; // in-order bytes handed to the app now
|
|
1005
|
+
stream.flushed_to = offset;
|
|
1006
|
+
var data = parts.length === 1 ? parts[0] : concatUint8Arrays(parts);
|
|
1007
|
+
var fin = (stream.total_size > 0 && offset >= stream.total_size);
|
|
1008
|
+
ev.emit('stream', Number(sid), data, fin);
|
|
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);
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
|
|
1085
|
+
// ============================================================
|
|
1086
|
+
// ACK processing (Phase 1)
|
|
1087
|
+
// ============================================================
|
|
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
|
+
|
|
1104
|
+
function processAckFrame(space, frame) {
|
|
1105
|
+
var ackedRanges = ack_frame_to_ranges(frame);
|
|
1106
|
+
if (!ackedRanges || ackedRanges.length === 0) return;
|
|
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
|
+
|
|
1130
|
+
if (space === 'app') {
|
|
1131
|
+
// RTT measurement
|
|
1132
|
+
if ('largest' in frame && 'delay' in frame) {
|
|
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).
|
|
1138
|
+
if (context.sending_app_pn_in_flight.has(largest_pn)) {
|
|
1139
|
+
var now = Date.now();
|
|
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);
|
|
1143
|
+
var pn_index = largest_pn - (context.send_pn.app - context.sending_app_pn_history.length);
|
|
1144
|
+
if (pn_index >= 0 && pn_index < context.sending_app_pn_history.length) {
|
|
1145
|
+
var latest_rtt = now - context.sending_app_pn_history[pn_index][0];
|
|
1146
|
+
if (latest_rtt > 0) updateRtt(latest_rtt, ack_delay_ms);
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
|
|
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 = [];
|
|
1154
|
+
for (var pn of context.sending_app_pn_in_flight) {
|
|
1155
|
+
for (var ri = 0; ri < ackedRanges.length; ri += 2) {
|
|
1156
|
+
if (pn >= ackedRanges[ri] && pn <= ackedRanges[ri + 1]) { ackedPns.push(pn); break; }
|
|
1157
|
+
}
|
|
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];
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
}
|
|
1179
|
+
}
|
|
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
|
+
|
|
1308
|
+
// ACK freed capacity — try to send more
|
|
1309
|
+
plan_quic_burst();
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
|
|
1314
|
+
// ============================================================
|
|
1315
|
+
// Sending
|
|
1316
|
+
// ============================================================
|
|
1317
|
+
|
|
1318
|
+
function cryptoWrite(epoch, data) {
|
|
1319
|
+
var space = epoch === 'initial' ? 'initial' : epoch === 'handshake' ? 'handshake' : 'app';
|
|
1320
|
+
var offset = context.crypto_send_offset[space];
|
|
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
|
+
}
|
|
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 };
|
|
1422
|
+
}
|
|
1423
|
+
|
|
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
|
+
|
|
1434
|
+
var writeKeys = space === 'initial' ? context.initial_write
|
|
1435
|
+
: space === 'handshake' ? context.handshake_write : context.app_write;
|
|
1436
|
+
if (!writeKeys) { if (DEBUG) console.log('[quic] sendFrames(' + space + ') — no keys'); return; }
|
|
1437
|
+
|
|
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];
|
|
1443
|
+
var packetType = space === 'initial' ? 'initial' : space === 'handshake' ? 'handshake' : '1rtt';
|
|
1444
|
+
|
|
1445
|
+
var dcid, scid;
|
|
1446
|
+
if (packetType === '1rtt') {
|
|
1447
|
+
dcid = context.their_cids.length > 0 ? context.their_cids[0] : new Uint8Array(0);
|
|
1448
|
+
scid = new Uint8Array(0);
|
|
1449
|
+
} else if (context.isServer) {
|
|
1450
|
+
dcid = context.their_cids.length > 0 ? context.their_cids[0] : new Uint8Array(0);
|
|
1451
|
+
scid = context.original_dcid || new Uint8Array(0);
|
|
1452
|
+
} else {
|
|
1453
|
+
// Client: Initial uses original_dcid (for key derivation),
|
|
1454
|
+
// Handshake uses server's SCID (RFC 9000 §7.2)
|
|
1455
|
+
if (space === 'initial' || context.their_cids.length === 0) {
|
|
1456
|
+
dcid = context.original_dcid || new Uint8Array(0);
|
|
1457
|
+
} else {
|
|
1458
|
+
dcid = context.their_cids[0];
|
|
1459
|
+
}
|
|
1460
|
+
scid = context.my_cids.length > 0 ? context.my_cids[0] : new Uint8Array(0);
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
var encoded = encode_quic_frames(frameList);
|
|
1464
|
+
|
|
1465
|
+
// RFC 9000 §14.1: Initial datagrams must be >= 1200 bytes
|
|
1466
|
+
if (space === 'initial') {
|
|
1467
|
+
var overhead = 1 + 4 + 1 + dcid.byteLength + 1 + scid.byteLength + 1 + 2 + 1 + 16;
|
|
1468
|
+
var minPayload = 1200 - overhead;
|
|
1469
|
+
if (encoded.length < minPayload) {
|
|
1470
|
+
encoded = concatUint8Arrays([encoded, new Uint8Array(minPayload - encoded.length)]);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
var encrypted = encrypt_quic_packet(
|
|
1475
|
+
packetType, encoded, writeKeys.key, writeKeys.iv, writeKeys.hp, pn, dcid, scid, null,
|
|
1476
|
+
space === 'app' ? context.key_phase : false
|
|
1477
|
+
);
|
|
1478
|
+
|
|
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;
|
|
1484
|
+
var fnames = frameList.map(function(f){ return f.type + (f.type === 'ack' ? '(lg=' + f.largest + ')' : ''); }).join(',');
|
|
1485
|
+
if (DEBUG) console.log('[quic] → ' + packetType + ' pn=' + pn + ' frames=[' + fnames + '] len=' + encrypted.length);
|
|
1486
|
+
touchActivity();
|
|
1487
|
+
|
|
1488
|
+
if (space === 'app') {
|
|
1489
|
+
context.sending_app_pn_history.push([Date.now(), encoded.length, context.delivered, context.delivered_time]);
|
|
1490
|
+
var has_data = false;
|
|
1491
|
+
for (var i = 0; i < frameList.length; i++) {
|
|
1492
|
+
if (frameList[i].type === 'stream' || frameList[i].type === 'crypto') { has_data = true; break; }
|
|
1493
|
+
}
|
|
1494
|
+
if (has_data) context.sending_app_pn_in_flight.add(pn);
|
|
1495
|
+
|
|
1496
|
+
for (var i = 0; i < frameList.length; i++) {
|
|
1497
|
+
if (frameList[i].type === 'stream') {
|
|
1498
|
+
var sid = frameList[i].id;
|
|
1499
|
+
var dataLen = frameList[i].data ? frameList[i].data.byteLength : 0;
|
|
1500
|
+
|
|
1501
|
+
// Track connection-level bytes sent
|
|
1502
|
+
context.bytes_sent += dataLen;
|
|
1503
|
+
|
|
1504
|
+
if (sid in context.send_streams) {
|
|
1505
|
+
if (!context.send_streams[sid].in_flight_ranges) context.send_streams[sid].in_flight_ranges = {};
|
|
1506
|
+
var from = frameList[i].offset;
|
|
1507
|
+
var to = from + (frameList[i].data ? frameList[i].data.byteLength : 0);
|
|
1508
|
+
if (!context.send_streams[sid].in_flight_ranges[pn]) {
|
|
1509
|
+
context.send_streams[sid].in_flight_ranges[pn] = [from, to];
|
|
1510
|
+
} else {
|
|
1511
|
+
flat_ranges.add(context.send_streams[sid].in_flight_ranges[pn], [from, to]);
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
}
|
|
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
|
+
|
|
1530
|
+
ev.emit('packet', encrypted);
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
|
|
1535
|
+
// ============================================================
|
|
1536
|
+
// Stream send + Burst scheduler (Phase 2)
|
|
1537
|
+
// Adapted from old quic_socket.js, improved with set_context
|
|
1538
|
+
// ============================================================
|
|
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
|
+
|
|
1563
|
+
/**
|
|
1564
|
+
* Buffer data for a stream. Does NOT send immediately.
|
|
1565
|
+
* Calls plan_quic_burst() to schedule sending.
|
|
1566
|
+
*/
|
|
1567
|
+
function set_sending_stream(streamId, options) {
|
|
1568
|
+
if (!(streamId in context.send_streams)) {
|
|
1569
|
+
context.send_streams[streamId] = {
|
|
1570
|
+
pending_data: null, pending_offset_start: 0,
|
|
1571
|
+
write_offset: 0, send_offset: 0, total_size: 0,
|
|
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
|
|
1580
|
+
};
|
|
1581
|
+
}
|
|
1582
|
+
|
|
1583
|
+
var stream = context.send_streams[streamId];
|
|
1584
|
+
|
|
1585
|
+
if (typeof options === 'object' && 'add_chunk' in options) {
|
|
1586
|
+
if (options.add_chunk.data === null || options.add_chunk.data === undefined) {
|
|
1587
|
+
if (stream.total_size === 0) {
|
|
1588
|
+
stream.total_size = stream.write_offset;
|
|
1589
|
+
if (DEBUG) console.log('[quic] stream ' + streamId + ' FIN via null data, total_size=' + stream.total_size);
|
|
1590
|
+
}
|
|
1591
|
+
} else {
|
|
1592
|
+
var chunk = options.add_chunk.data;
|
|
1593
|
+
if (typeof chunk === 'string') chunk = new TextEncoder().encode(chunk);
|
|
1594
|
+
var start = stream.write_offset;
|
|
1595
|
+
stream.write_offset += chunk.byteLength;
|
|
1596
|
+
if (DEBUG) console.log('[quic] stream ' + streamId + ' add_chunk len=' + chunk.byteLength + ' write_offset=' + stream.write_offset + ' fin=' + !!options.add_chunk.fin);
|
|
1597
|
+
|
|
1598
|
+
if (stream.pending_data === null) {
|
|
1599
|
+
stream.pending_data = chunk;
|
|
1600
|
+
stream.pending_offset_start = start;
|
|
1601
|
+
} else {
|
|
1602
|
+
var old = stream.pending_data, old_off = stream.pending_offset_start;
|
|
1603
|
+
var ns = Math.min(old_off, start);
|
|
1604
|
+
var ne = Math.max(old_off + old.length, start + chunk.length);
|
|
1605
|
+
var merged = new Uint8Array(ne - ns);
|
|
1606
|
+
merged.set(old, old_off - ns);
|
|
1607
|
+
merged.set(chunk, start - ns);
|
|
1608
|
+
stream.pending_data = merged;
|
|
1609
|
+
stream.pending_offset_start = ns;
|
|
1610
|
+
}
|
|
1611
|
+
if (options.add_chunk.fin) {
|
|
1612
|
+
stream.total_size = stream.write_offset;
|
|
1613
|
+
if (DEBUG) console.log('[quic] stream ' + streamId + ' FIN via add_chunk.fin, total_size=' + stream.total_size);
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// Don't send directly — let burst scheduler handle it
|
|
1619
|
+
plan_quic_burst();
|
|
1620
|
+
}
|
|
1621
|
+
|
|
1622
|
+
function sendStream(streamId, data, fin) {
|
|
1623
|
+
set_sending_stream(streamId, { add_chunk: { data: data, fin: fin } });
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
function sendDatagram(data) {
|
|
1627
|
+
if (context.state !== 'connected') return false;
|
|
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
|
+
}
|
|
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);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
|
|
1652
|
+
/**
|
|
1653
|
+
* plan_quic_burst — calculates how many packets we can send,
|
|
1654
|
+
* calls execute_quic_burst, schedules next burst if needed.
|
|
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
|
+
|
|
1734
|
+
function plan_quic_burst() {
|
|
1735
|
+
if (!context.app_write) return;
|
|
1736
|
+
if (context.state !== 'connected') return;
|
|
1737
|
+
|
|
1738
|
+
// Cancel pending timer
|
|
1739
|
+
if (context.burst_timer !== null) {
|
|
1740
|
+
clearTimeout(context.burst_timer);
|
|
1741
|
+
clearImmediate(context.burst_timer);
|
|
1742
|
+
context.burst_timer = null;
|
|
1743
|
+
}
|
|
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
|
+
|
|
1749
|
+
var now = Date.now();
|
|
1750
|
+
var oneSecAgo = now - 1000;
|
|
1751
|
+
|
|
1752
|
+
// Count bytes/packets sent in last second
|
|
1753
|
+
var bytesSentLastSec = 0;
|
|
1754
|
+
var packetsSentLastSec = 0;
|
|
1755
|
+
for (var i = 0; i < context.sending_app_pn_history.length; i++) {
|
|
1756
|
+
if (context.sending_app_pn_history[i][0] > oneSecAgo) {
|
|
1757
|
+
bytesSentLastSec += context.sending_app_pn_history[i][1];
|
|
1758
|
+
packetsSentLastSec++;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Prune old history (older than 2 seconds)
|
|
1763
|
+
var twoSecAgo = now - 2000;
|
|
1764
|
+
while (context.sending_app_pn_history.length > 0 && context.sending_app_pn_history[0][0] < twoSecAgo) {
|
|
1765
|
+
context.sending_app_pn_history.shift();
|
|
1766
|
+
}
|
|
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
|
+
|
|
1777
|
+
// Rate limits
|
|
1778
|
+
var bytesRemaining = effBytesPerSec - bytesSentLastSec;
|
|
1779
|
+
var packetsRemaining = effPacketsPerSec - packetsSentLastSec;
|
|
1780
|
+
if (bytesRemaining < 0) bytesRemaining = 0;
|
|
1781
|
+
if (packetsRemaining < 0) packetsRemaining = 0;
|
|
1782
|
+
|
|
1783
|
+
// In-flight limits (packets)
|
|
1784
|
+
var inflightCount = context.sending_app_pn_in_flight.size;
|
|
1785
|
+
var inflightRoom = effPktsInFlight - inflightCount;
|
|
1786
|
+
if (inflightRoom < 0) inflightRoom = 0;
|
|
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
|
+
|
|
1806
|
+
// Calculate burst size
|
|
1807
|
+
var burstCount = Math.min(
|
|
1808
|
+
context.max_packets_per_burst,
|
|
1809
|
+
packetsRemaining,
|
|
1810
|
+
inflightRoom,
|
|
1811
|
+
inflightBytesPackets, // bytes-in-flight cap
|
|
1812
|
+
Math.floor(bytesRemaining / Math.max(1, 35)) // min packet ~35 bytes
|
|
1813
|
+
);
|
|
1814
|
+
if (burstCount < 0) burstCount = 0;
|
|
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
|
+
|
|
1843
|
+
// Check if there's anything to send
|
|
1844
|
+
var hasData = false;
|
|
1845
|
+
for (var sid in context.send_streams) {
|
|
1846
|
+
var st = context.send_streams[sid];
|
|
1847
|
+
if (!st.pending_data || st.pending_data.byteLength === 0) continue;
|
|
1848
|
+
var total = st.total_size > 0 ? st.total_size : st.write_offset;
|
|
1849
|
+
var known = st.acked_ranges.slice();
|
|
1850
|
+
if (st.in_flight_ranges) {
|
|
1851
|
+
for (var pn in st.in_flight_ranges) flat_ranges.add(known, st.in_flight_ranges[pn]);
|
|
1852
|
+
}
|
|
1853
|
+
var missing = flat_ranges.invert(known, 0, total);
|
|
1854
|
+
if (missing.length > 0) { hasData = true; break; }
|
|
1855
|
+
// Also check: all data sent but FIN not yet sent?
|
|
1856
|
+
if (st.total_size > 0 && !st.fin_sent && missing.length === 0) { hasData = true; break; }
|
|
1857
|
+
}
|
|
1858
|
+
// Also check pending ACK
|
|
1859
|
+
var hasPendingAck = context.pending_ack.app.length > 0;
|
|
1860
|
+
|
|
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;
|
|
1879
|
+
|
|
1880
|
+
// Execute burst
|
|
1881
|
+
var sent = 0;
|
|
1882
|
+
if (burstCount > 0) {
|
|
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;
|
|
1888
|
+
}
|
|
1889
|
+
|
|
1890
|
+
// FC-blocked signaling — consume flags set inside the burst just executed.
|
|
1891
|
+
var fcBlocked = maybeSendBlockedFrames();
|
|
1892
|
+
|
|
1893
|
+
// Schedule next burst if needed
|
|
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 () {
|
|
1900
|
+
context.burst_timer = null;
|
|
1901
|
+
plan_quic_burst();
|
|
1902
|
+
}, delayMs);
|
|
1903
|
+
if (context.burst_timer.unref) context.burst_timer.unref();
|
|
1904
|
+
} else if (burstCount === 0 && hasData) {
|
|
1905
|
+
// Blocked by cwnd / rate window (not pacing) — wait for capacity / ACKs.
|
|
1906
|
+
var waitMs = (packetsRemaining <= 0 || bytesRemaining < 35) ? 50 : 10;
|
|
1907
|
+
context.burst_timer = setTimeout(function () {
|
|
1908
|
+
context.burst_timer = null;
|
|
1909
|
+
plan_quic_burst();
|
|
1910
|
+
}, waitMs);
|
|
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();
|
|
1931
|
+
}
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
|
|
1935
|
+
/**
|
|
1936
|
+
* execute_quic_burst — fill up to packet_count packets.
|
|
1937
|
+
* Round-robin across active streams.
|
|
1938
|
+
* Returns the number of packets actually sent (0 if none) — the pacer
|
|
1939
|
+
* charges tokens per REAL packet, not per the packetCount ceiling.
|
|
1940
|
+
*/
|
|
1941
|
+
function execute_quic_burst(packetCount) {
|
|
1942
|
+
var MAX_PAYLOAD = context.current_limit_packet_payload;
|
|
1943
|
+
var OVERHEAD = 24; // STREAM frame header overhead estimate
|
|
1944
|
+
var sentCount = 0;
|
|
1945
|
+
|
|
1946
|
+
function getActiveIds() {
|
|
1947
|
+
var ids = [];
|
|
1948
|
+
for (var sid in context.send_streams) {
|
|
1949
|
+
var st = context.send_streams[sid];
|
|
1950
|
+
if (!st.pending_data || st.pending_data.byteLength === 0) continue;
|
|
1951
|
+
var total = st.total_size > 0 ? st.total_size : st.write_offset;
|
|
1952
|
+
// Build known_sent = acked + in_flight
|
|
1953
|
+
var known = st.acked_ranges.slice();
|
|
1954
|
+
if (st.in_flight_ranges) {
|
|
1955
|
+
for (var pn in st.in_flight_ranges) flat_ranges.add(known, st.in_flight_ranges[pn]);
|
|
1956
|
+
}
|
|
1957
|
+
var missing = flat_ranges.invert(known, 0, total);
|
|
1958
|
+
if (missing.length > 0) ids.push(Number(sid));
|
|
1959
|
+
}
|
|
1960
|
+
return ids;
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
var activeIds = getActiveIds();
|
|
1964
|
+
var burstYielded = {}; // track ranges yielded per-stream within this burst
|
|
1965
|
+
|
|
1966
|
+
for (var p = 0; p < packetCount; p++) {
|
|
1967
|
+
var frames = [];
|
|
1968
|
+
var used = 0;
|
|
1969
|
+
|
|
1970
|
+
// (1) ACK in first packet if pending
|
|
1971
|
+
if (p === 0 && context.pending_ack.app.length > 0) {
|
|
1972
|
+
var ackFrame = ranges_to_ack_frame(context.pending_ack.app, null, 0);
|
|
1973
|
+
if (ackFrame) {
|
|
1974
|
+
var ackEncoded = encode_quic_frames([ackFrame]);
|
|
1975
|
+
if (ackEncoded.byteLength <= MAX_PAYLOAD) {
|
|
1976
|
+
frames.push(ackFrame);
|
|
1977
|
+
used += ackEncoded.byteLength;
|
|
1978
|
+
context.pending_ack.app = [];
|
|
1979
|
+
}
|
|
1980
|
+
}
|
|
1981
|
+
}
|
|
1982
|
+
|
|
1983
|
+
// (2) Round-robin STREAM data across active streams
|
|
1984
|
+
if (activeIds.length > 0 && used < MAX_PAYLOAD) {
|
|
1985
|
+
var progress = true;
|
|
1986
|
+
while (used < MAX_PAYLOAD && progress) {
|
|
1987
|
+
progress = false;
|
|
1988
|
+
|
|
1989
|
+
for (var i = 0; i < activeIds.length; i++) {
|
|
1990
|
+
if (used >= MAX_PAYLOAD) break;
|
|
1991
|
+
|
|
1992
|
+
var sid = activeIds[i];
|
|
1993
|
+
var st = context.send_streams[sid];
|
|
1994
|
+
if (!st || !st.pending_data) continue;
|
|
1995
|
+
|
|
1996
|
+
var budget = Math.max(0, MAX_PAYLOAD - used - OVERHEAD);
|
|
1997
|
+
if (budget <= 0) break;
|
|
1998
|
+
|
|
1999
|
+
var chunks = get_stream_chunks(sid, budget);
|
|
2000
|
+
if (!chunks || chunks.length === 0) continue;
|
|
2001
|
+
|
|
2002
|
+
for (var c = 0; c < chunks.length; c++) {
|
|
2003
|
+
var ch = chunks[c];
|
|
2004
|
+
var fin = (st.total_size > 0 && ch.offset + ch.data.byteLength >= st.total_size);
|
|
2005
|
+
frames.push({ type: 'stream', id: sid, offset: ch.offset, fin: fin, data: ch.data });
|
|
2006
|
+
used += ch.data.byteLength + OVERHEAD;
|
|
2007
|
+
progress = true;
|
|
2008
|
+
|
|
2009
|
+
// Immediately track as yielded so next round-robin iteration won't resend
|
|
2010
|
+
if (!burstYielded[sid]) burstYielded[sid] = [];
|
|
2011
|
+
flat_ranges.add(burstYielded[sid], [ch.offset, ch.offset + ch.data.byteLength]);
|
|
2012
|
+
// Also add to in_flight with temp key so get_stream_chunks sees it
|
|
2013
|
+
if (!st.in_flight_ranges) st.in_flight_ranges = {};
|
|
2014
|
+
if (!st.in_flight_ranges['_burst']) st.in_flight_ranges['_burst'] = [];
|
|
2015
|
+
flat_ranges.add(st.in_flight_ranges['_burst'], [ch.offset, ch.offset + ch.data.byteLength]);
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
}
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
// (3) FIN-only frames: if all data sent/in-flight but FIN not yet sent
|
|
2022
|
+
for (var sid in context.send_streams) {
|
|
2023
|
+
var st = context.send_streams[sid];
|
|
2024
|
+
if (!st || st.total_size <= 0 || st.fin_sent) continue;
|
|
2025
|
+
// Check if all bytes are covered (acked + in_flight)
|
|
2026
|
+
var known = st.acked_ranges.slice();
|
|
2027
|
+
if (st.in_flight_ranges) {
|
|
2028
|
+
for (var pn in st.in_flight_ranges) flat_ranges.add(known, st.in_flight_ranges[pn]);
|
|
2029
|
+
}
|
|
2030
|
+
var missing = flat_ranges.invert(known, 0, st.total_size);
|
|
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));
|
|
2032
|
+
if (missing.length === 0) {
|
|
2033
|
+
if (DEBUG) console.log('[quic] → sending FIN-only for stream ' + sid);
|
|
2034
|
+
// All data sent — send FIN-only STREAM frame
|
|
2035
|
+
frames.push({ type: 'stream', id: Number(sid), offset: st.total_size, fin: true, data: new Uint8Array(0) });
|
|
2036
|
+
st.fin_sent = true;
|
|
2037
|
+
}
|
|
2038
|
+
}
|
|
2039
|
+
|
|
2040
|
+
// Send packet if it has content beyond just ACK-only
|
|
2041
|
+
if (frames.length > 0) {
|
|
2042
|
+
sendFrames('app', frames);
|
|
2043
|
+
sentCount++;
|
|
2044
|
+
// Clean up temp _burst in_flight keys (real PN keys now recorded by sendFrames)
|
|
2045
|
+
for (var sid in burstYielded) {
|
|
2046
|
+
var st = context.send_streams[sid];
|
|
2047
|
+
if (st && st.in_flight_ranges && st.in_flight_ranges['_burst']) {
|
|
2048
|
+
delete st.in_flight_ranges['_burst'];
|
|
2049
|
+
}
|
|
2050
|
+
}
|
|
2051
|
+
} else {
|
|
2052
|
+
break; // nothing to fill further packets
|
|
2053
|
+
}
|
|
2054
|
+
|
|
2055
|
+
// Refresh active list
|
|
2056
|
+
activeIds = getActiveIds();
|
|
2057
|
+
if (activeIds.length === 0) break;
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
return sentCount;
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* get_stream_chunks — find what bytes are missing (not acked) and
|
|
2066
|
+
* extract up to maxBytes from pending_data.
|
|
2067
|
+
* Uses flat_ranges.invert(acked_ranges) — the heart of retransmit.
|
|
2068
|
+
* Adapted from old get_stream_chunks_to_send().
|
|
2069
|
+
*
|
|
2070
|
+
* Strategy: first forward from send_offset (new data),
|
|
2071
|
+
* then fill gaps before send_offset (retransmit).
|
|
2072
|
+
* pending_data is NEVER deleted — only when stream is fully acked.
|
|
2073
|
+
*/
|
|
2074
|
+
function get_stream_chunks(streamId, maxBytes) {
|
|
2075
|
+
var stream = context.send_streams[streamId];
|
|
2076
|
+
if (!stream || !stream.pending_data || stream.pending_data.byteLength === 0) return [];
|
|
2077
|
+
|
|
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;
|
|
2100
|
+
|
|
2101
|
+
var data = stream.pending_data;
|
|
2102
|
+
var baseOffset = stream.pending_offset_start;
|
|
2103
|
+
var totalSize = stream.total_size > 0 ? stream.total_size : stream.write_offset;
|
|
2104
|
+
|
|
2105
|
+
// Build "known sent" = acked + in_flight (don't resend what's already in transit)
|
|
2106
|
+
var knownSent = stream.acked_ranges.slice(); // copy
|
|
2107
|
+
if (stream.in_flight_ranges) {
|
|
2108
|
+
for (var pn in stream.in_flight_ranges) {
|
|
2109
|
+
flat_ranges.add(knownSent, stream.in_flight_ranges[pn]);
|
|
2110
|
+
}
|
|
2111
|
+
}
|
|
2112
|
+
|
|
2113
|
+
// What truly needs sending? = invert of knownSent over [0, totalSize)
|
|
2114
|
+
var missing = flat_ranges.invert(knownSent, 0, totalSize);
|
|
2115
|
+
if (!missing || missing.length === 0) return [];
|
|
2116
|
+
|
|
2117
|
+
var sendOffset = stream.send_offset;
|
|
2118
|
+
var chunks = [];
|
|
2119
|
+
var used = 0;
|
|
2120
|
+
|
|
2121
|
+
// Phase 1: forward from send_offset (new data, priority)
|
|
2122
|
+
for (var i = 0; i < missing.length; i += 2) {
|
|
2123
|
+
if (used >= maxBytes) break;
|
|
2124
|
+
var mFrom = missing[i];
|
|
2125
|
+
var mTo = missing[i + 1];
|
|
2126
|
+
|
|
2127
|
+
// Only ranges at or after send_offset
|
|
2128
|
+
if (mTo <= sendOffset) continue;
|
|
2129
|
+
var from = Math.max(mFrom, sendOffset);
|
|
2130
|
+
var to = mTo;
|
|
2131
|
+
|
|
2132
|
+
// Clamp to budget
|
|
2133
|
+
var len = Math.min(to - from, maxBytes - used);
|
|
2134
|
+
if (len <= 0) continue;
|
|
2135
|
+
|
|
2136
|
+
// Clamp to buffer bounds
|
|
2137
|
+
var relStart = from - baseOffset;
|
|
2138
|
+
var relEnd = relStart + len;
|
|
2139
|
+
if (relStart < 0 || relStart >= data.byteLength) continue;
|
|
2140
|
+
if (relEnd > data.byteLength) { relEnd = data.byteLength; len = relEnd - relStart; }
|
|
2141
|
+
if (len <= 0) continue;
|
|
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
|
+
|
|
2162
|
+
chunks.push({ offset: from, data: data.slice(relStart, relEnd) });
|
|
2163
|
+
used += len;
|
|
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
|
+
|
|
2172
|
+
// Advance send_offset
|
|
2173
|
+
if (from + len > stream.send_offset) {
|
|
2174
|
+
stream.send_offset = from + len;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
|
|
2178
|
+
// Phase 2: fill gaps before send_offset (retransmit lost data)
|
|
2179
|
+
// First update knownSent with what Phase 1 just yielded
|
|
2180
|
+
if (chunks.length > 0) {
|
|
2181
|
+
for (var ci = 0; ci < chunks.length; ci++) {
|
|
2182
|
+
flat_ranges.add(knownSent, [chunks[ci].offset, chunks[ci].offset + chunks[ci].data.byteLength]);
|
|
2183
|
+
}
|
|
2184
|
+
missing = flat_ranges.invert(knownSent, 0, totalSize);
|
|
2185
|
+
}
|
|
2186
|
+
if (used < maxBytes) {
|
|
2187
|
+
for (var i = 0; i < missing.length; i += 2) {
|
|
2188
|
+
if (used >= maxBytes) break;
|
|
2189
|
+
var mFrom = missing[i];
|
|
2190
|
+
var mTo = missing[i + 1];
|
|
2191
|
+
|
|
2192
|
+
// Only ranges before send_offset
|
|
2193
|
+
if (mFrom >= sendOffset) break;
|
|
2194
|
+
var to = Math.min(mTo, sendOffset);
|
|
2195
|
+
|
|
2196
|
+
var len = Math.min(to - mFrom, maxBytes - used);
|
|
2197
|
+
if (len <= 0) continue;
|
|
2198
|
+
|
|
2199
|
+
var relStart = mFrom - baseOffset;
|
|
2200
|
+
var relEnd = relStart + len;
|
|
2201
|
+
if (relStart < 0 || relStart >= data.byteLength) continue;
|
|
2202
|
+
if (relEnd > data.byteLength) { relEnd = data.byteLength; len = relEnd - relStart; }
|
|
2203
|
+
if (len <= 0) continue;
|
|
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
|
+
|
|
2221
|
+
chunks.push({ offset: mFrom, data: data.slice(relStart, relEnd) });
|
|
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
|
+
}
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
|
|
2232
|
+
// Trim front of pending_data if beginning is fully acked (optimization)
|
|
2233
|
+
if (stream.acked_ranges.length >= 2 && stream.acked_ranges[0] === 0 && stream.acked_ranges[1] > baseOffset) {
|
|
2234
|
+
var trimTo = stream.acked_ranges[1];
|
|
2235
|
+
var trimBytes = trimTo - baseOffset;
|
|
2236
|
+
if (trimBytes > 0 && trimBytes < data.byteLength) {
|
|
2237
|
+
stream.pending_data = data.slice(trimBytes);
|
|
2238
|
+
stream.pending_offset_start = trimTo;
|
|
2239
|
+
}
|
|
2240
|
+
}
|
|
2241
|
+
|
|
2242
|
+
return chunks;
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
|
|
2246
|
+
// ============================================================
|
|
2247
|
+
// Close
|
|
2248
|
+
// ============================================================
|
|
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
|
+
|
|
2256
|
+
function close(errorCode, reason) {
|
|
2257
|
+
if (context.state === 'closed' || context.state === 'draining' || context.state === 'closing') return;
|
|
2258
|
+
if (context.handshake_timer) { clearTimeout(context.handshake_timer); context.handshake_timer = null; }
|
|
2259
|
+
stopCryptoRetx();
|
|
2260
|
+
context.close_frame = {
|
|
2261
|
+
type: 'connection_close', application: false,
|
|
2262
|
+
error: errorCode || 0, frameType: 0, reason: reason || ''
|
|
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' });
|
|
2270
|
+
}
|
|
2271
|
+
|
|
2272
|
+
|
|
2273
|
+
// ============================================================
|
|
2274
|
+
// Client connect (Phase 7)
|
|
2275
|
+
// ============================================================
|
|
2276
|
+
|
|
2277
|
+
function connect() {
|
|
2278
|
+
if (context.isServer) return; // server waits for feedPacket, doesn't connect
|
|
2279
|
+
|
|
2280
|
+
// Generate random DCID (8 bytes)
|
|
2281
|
+
var dcid = new Uint8Array(8);
|
|
2282
|
+
for (var i = 0; i < 8; i++) dcid[i] = Math.floor(Math.random() * 256);
|
|
2283
|
+
|
|
2284
|
+
// Generate random SCID (8 bytes)
|
|
2285
|
+
var scid = new Uint8Array(8);
|
|
2286
|
+
for (var i = 0; i < 8; i++) scid[i] = Math.floor(Math.random() * 256);
|
|
2287
|
+
|
|
2288
|
+
context.original_dcid = dcid;
|
|
2289
|
+
context.my_cids.push(scid);
|
|
2290
|
+
|
|
2291
|
+
// Client initial keys: SWAPPED from server
|
|
2292
|
+
// Client writes with "client in" keys (direction='read' in our function)
|
|
2293
|
+
// Client reads with "server in" keys (direction='write' in our function)
|
|
2294
|
+
set_context({
|
|
2295
|
+
initial_write: quic_derive_init_secrets(dcid, context.version, 'read'),
|
|
2296
|
+
initial_read: quic_derive_init_secrets(dcid, context.version, 'write')
|
|
2297
|
+
});
|
|
2298
|
+
|
|
2299
|
+
// Initialize TLS (client mode)
|
|
2300
|
+
if (DEBUG) console.log('[quic] client connecting, dcid=' + Array.from(dcid).map(function(b){ return b.toString(16).padStart(2,'0'); }).join(''));
|
|
2301
|
+
initTLS();
|
|
2302
|
+
|
|
2303
|
+
// Trigger ClientHello by feeding empty message
|
|
2304
|
+
tls.feedMessage(new Uint8Array(0));
|
|
2305
|
+
}
|
|
2306
|
+
|
|
2307
|
+
|
|
2308
|
+
// ============================================================
|
|
2309
|
+
// Public API
|
|
2310
|
+
// ============================================================
|
|
2311
|
+
|
|
2312
|
+
var api = {
|
|
2313
|
+
context: context,
|
|
2314
|
+
on: function (name, fn) { ev.on(name, fn); },
|
|
2315
|
+
off: function (name, fn) { ev.off(name, fn); },
|
|
2316
|
+
feedDatagram: feedDatagram,
|
|
2317
|
+
feedPackets: feedPackets,
|
|
2318
|
+
sendStream: sendStream,
|
|
2319
|
+
sendDatagram: sendDatagram,
|
|
2320
|
+
maxDatagramSize: maxDatagramSize,
|
|
2321
|
+
close: close,
|
|
2322
|
+
connect: connect,
|
|
2323
|
+
set_context: set_context,
|
|
2324
|
+
get state() { return context.state; }
|
|
2325
|
+
};
|
|
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.
|
|
2330
|
+
for (var k in api) {
|
|
2331
|
+
if (Object.prototype.hasOwnProperty.call(api, k)) {
|
|
2332
|
+
Object.defineProperty(this, k, Object.getOwnPropertyDescriptor(api, k));
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
return this;
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
export { QUICConnection };
|