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