@debugg-ai/debugg-ai-mcp 4.3.0 → 4.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/dist/handlers/probePageHandler.js +14 -7
- package/dist/handlers/runTestSuiteHandler.js +6 -5
- package/dist/handlers/testPageChangesHandler.js +8 -6
- package/dist/handlers/triggerCrawlHandler.js +7 -6
- package/dist/services/ngrok/tunnelManager.js +157 -161
- package/dist/services/tunnel/debuggTransport.js +439 -0
- package/dist/services/tunnel/ngrokTransport.js +165 -0
- package/dist/services/tunnel/probeRegistry.js +52 -0
- package/dist/services/tunnel/protocol/codec.js +327 -0
- package/dist/services/tunnel/protocol/conformance.js +171 -0
- package/dist/services/tunnel/protocol/constants.js +178 -0
- package/dist/services/tunnel/protocol/errors.js +51 -0
- package/dist/services/tunnel/protocol/handshake.js +134 -0
- package/dist/services/tunnel/protocol/index.js +17 -0
- package/dist/services/tunnel/protocol/session.js +668 -0
- package/dist/services/tunnel/protocol/stream.js +109 -0
- package/dist/services/tunnel/protocol/transport.js +106 -0
- package/dist/services/tunnel/transport.js +15 -0
- package/dist/services/tunnels.js +71 -3
- package/dist/utils/localReachability.js +160 -3
- package/dist/utils/tunnelContext.js +6 -3
- package/dist/utils/tunnelDisposition.js +16 -1
- package/dist/utils/tunnelDomains.js +113 -0
- package/dist/utils/urlParser.js +8 -2
- package/package.json +3 -1
|
@@ -0,0 +1,668 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debugg tunnel wire protocol v1 — the multiplexed session.
|
|
3
|
+
*
|
|
4
|
+
* One TunnelSession owns one websocket and every stream on it. The two roles are
|
|
5
|
+
* asymmetric in exactly two ways: only a 'server' session opens streams and
|
|
6
|
+
* answers PROBEs, and only a 'client' session receives streams and sends PROBEs.
|
|
7
|
+
* Everything else — flow control, scheduling, liveness, GOAWAY — is symmetric.
|
|
8
|
+
*
|
|
9
|
+
* Events:
|
|
10
|
+
* 'stream' (stream: TunnelStream) client role: the peer opened a stream
|
|
11
|
+
* 'goaway' (info: GoAwayInfo) the peer is shutting down
|
|
12
|
+
* 'close' (info: SessionCloseInfo) the session is over; fires exactly once
|
|
13
|
+
* 'error' (err: Error) non-fatal diagnostics, only if listened for
|
|
14
|
+
*
|
|
15
|
+
* Spec: bead debugg_ai_mcp-xkoh.1.2.
|
|
16
|
+
*/
|
|
17
|
+
import { EventEmitter } from 'node:events';
|
|
18
|
+
import { decodeFrame, encodeFrame, } from './codec.js';
|
|
19
|
+
import { ProtocolError, SessionClosedError, StreamResetError } from './errors.js';
|
|
20
|
+
import { TunnelStream } from './stream.js';
|
|
21
|
+
import { CLOSE_CODE_BASE, CloseCode, DEAD_PEER_TIMEOUT_MS, ErrorCode, INITIAL_WINDOW, MAX_CONCURRENT_STREAMS, MAX_DATA_PAYLOAD, MAX_GOAWAY_REASON_BYTES, MAX_STREAM_ID, MAX_UNACKED_SEND_BYTES, MAX_WINDOW, PING_INTERVAL_MS, PROBE_TIMEOUT_MS, WINDOW_UPDATE_THRESHOLD, } from './constants.js';
|
|
22
|
+
/**
|
|
23
|
+
* How long a GOAWAY'd session waits before giving up on the drain. A server
|
|
24
|
+
* should pass its own (ECS stopTimeout); this default only exists so a session
|
|
25
|
+
* that goes away with nothing to drain still closes eventually.
|
|
26
|
+
*/
|
|
27
|
+
export const DEFAULT_DRAIN_TIMEOUT_MS = 30_000;
|
|
28
|
+
const KNOWN_ERROR_CODES = new Set(Object.values(ErrorCode));
|
|
29
|
+
/** Unknown codes must never be special-cased; they read as a plain reset. */
|
|
30
|
+
function normalizeResetCode(code) {
|
|
31
|
+
return KNOWN_ERROR_CODES.has(code) ? code : ErrorCode.RESET;
|
|
32
|
+
}
|
|
33
|
+
function truncateReason(reason) {
|
|
34
|
+
const buf = Buffer.from(reason, 'utf8');
|
|
35
|
+
return buf.length <= MAX_GOAWAY_REASON_BYTES ? reason : buf.subarray(0, MAX_GOAWAY_REASON_BYTES).toString('utf8');
|
|
36
|
+
}
|
|
37
|
+
function unref(timer) {
|
|
38
|
+
timer.unref?.();
|
|
39
|
+
return timer;
|
|
40
|
+
}
|
|
41
|
+
export class TunnelSession extends EventEmitter {
|
|
42
|
+
role;
|
|
43
|
+
transport;
|
|
44
|
+
onProbe;
|
|
45
|
+
streams = new Map();
|
|
46
|
+
/** Round-robin ring of stream ids with something to send. */
|
|
47
|
+
ready = [];
|
|
48
|
+
/** Control frames jump ahead of queued DATA. */
|
|
49
|
+
control = [];
|
|
50
|
+
pendingPings = new Map();
|
|
51
|
+
pendingProbes = new Map();
|
|
52
|
+
goAwayWaiters = [];
|
|
53
|
+
unackedBytes = 0;
|
|
54
|
+
pumping = false;
|
|
55
|
+
nextStreamId = 1;
|
|
56
|
+
highestStreamId = 0;
|
|
57
|
+
nextProbeId = 1;
|
|
58
|
+
nextNonce = 1n;
|
|
59
|
+
goAwaySent = false;
|
|
60
|
+
goAwayReceived = false;
|
|
61
|
+
drainStarted = false;
|
|
62
|
+
isClosed = false;
|
|
63
|
+
closeCode;
|
|
64
|
+
pingIntervalMs;
|
|
65
|
+
deadPeerTimeoutMs;
|
|
66
|
+
pingTimer;
|
|
67
|
+
deadTimer;
|
|
68
|
+
drainTimer;
|
|
69
|
+
constructor(transport, options) {
|
|
70
|
+
super();
|
|
71
|
+
this.role = options.role;
|
|
72
|
+
this.transport = transport;
|
|
73
|
+
this.onProbe = options.onProbe;
|
|
74
|
+
this.pingIntervalMs = options.pingIntervalMs ?? PING_INTERVAL_MS;
|
|
75
|
+
this.deadPeerTimeoutMs = options.deadPeerTimeoutMs ?? DEAD_PEER_TIMEOUT_MS;
|
|
76
|
+
transport.setHandlers({
|
|
77
|
+
onFrame: (bytes) => this.handleBytes(bytes),
|
|
78
|
+
onInvalidMessage: (detail) => this.fatal(ErrorCode.PROTOCOL_ERROR, `not a protocol frame: ${detail}`),
|
|
79
|
+
onClose: (code, reason) => this.finish(code, reason, true),
|
|
80
|
+
});
|
|
81
|
+
this.pingTimer = unref(setInterval(() => {
|
|
82
|
+
// Liveness only: no RTT to report, so no promise to leave dangling.
|
|
83
|
+
this.queueControl({ type: 'PING', streamId: 0, nonce: this.nextNonce++ });
|
|
84
|
+
}, this.pingIntervalMs));
|
|
85
|
+
this.armDeadPeerTimer();
|
|
86
|
+
}
|
|
87
|
+
/** Streams that are not yet fully closed, i.e. what counts against the cap. */
|
|
88
|
+
get activeStreamCount() {
|
|
89
|
+
return this.streams.size;
|
|
90
|
+
}
|
|
91
|
+
get closed() {
|
|
92
|
+
return this.isClosed;
|
|
93
|
+
}
|
|
94
|
+
// ── public API ─────────────────────────────────────────────────────────────
|
|
95
|
+
/**
|
|
96
|
+
* Server role only. Throws StreamResetError(REFUSED) at MAX_CONCURRENT_STREAMS
|
|
97
|
+
* or once GOAWAY has been sent or received, and SessionClosedError once closed.
|
|
98
|
+
*/
|
|
99
|
+
openStream(metadata = {}) {
|
|
100
|
+
if (this.role !== 'server')
|
|
101
|
+
throw new Error('only a server session opens streams');
|
|
102
|
+
if (this.isClosed)
|
|
103
|
+
throw new SessionClosedError(this.closeCode ?? CloseCode.GOING_AWAY);
|
|
104
|
+
if (this.goAwaySent || this.goAwayReceived) {
|
|
105
|
+
throw new StreamResetError(ErrorCode.REFUSED, 'session is going away', false);
|
|
106
|
+
}
|
|
107
|
+
if (this.streams.size >= MAX_CONCURRENT_STREAMS) {
|
|
108
|
+
throw new StreamResetError(ErrorCode.REFUSED, `already at ${MAX_CONCURRENT_STREAMS} concurrent streams`, false);
|
|
109
|
+
}
|
|
110
|
+
if (this.nextStreamId > MAX_STREAM_ID) {
|
|
111
|
+
throw new StreamResetError(ErrorCode.REFUSED, 'stream ids exhausted; reconnect', false);
|
|
112
|
+
}
|
|
113
|
+
const streamId = this.nextStreamId++;
|
|
114
|
+
this.highestStreamId = streamId;
|
|
115
|
+
// encodeFrame normalises the metadata, so nothing address-shaped is emitted.
|
|
116
|
+
const open = { type: 'OPEN', streamId, metadata };
|
|
117
|
+
const record = this.createRecord(streamId, decodeFrame(encodeFrame(open)));
|
|
118
|
+
this.queueControl(open);
|
|
119
|
+
return record.stream;
|
|
120
|
+
}
|
|
121
|
+
/** Send PING; resolves with the round trip in ms when the PONG comes back. */
|
|
122
|
+
ping() {
|
|
123
|
+
if (this.isClosed)
|
|
124
|
+
return Promise.reject(new SessionClosedError(this.closeCode ?? CloseCode.NORMAL));
|
|
125
|
+
const nonce = this.nextNonce++;
|
|
126
|
+
const sentAt = Date.now();
|
|
127
|
+
return new Promise((resolve, reject) => {
|
|
128
|
+
this.pendingPings.set(nonce.toString(), { resolve, reject, sentAt });
|
|
129
|
+
this.queueControl({ type: 'PING', streamId: 0, nonce });
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Client role only. Never throws or rejects — a deadline resolves
|
|
134
|
+
* `{ error: 'TIMEOUT' }` and a closed session `{ error: 'CLOSED' }`, because
|
|
135
|
+
* probeTunnelHealth's contract is that a probe always answers.
|
|
136
|
+
*/
|
|
137
|
+
probe(path, opts = {}) {
|
|
138
|
+
if (this.role !== 'client')
|
|
139
|
+
throw new Error('only a client session sends probes');
|
|
140
|
+
const startedAt = Date.now();
|
|
141
|
+
if (this.isClosed)
|
|
142
|
+
return Promise.resolve({ error: 'CLOSED', elapsedMs: 0 });
|
|
143
|
+
const probeId = this.nextProbeId++;
|
|
144
|
+
const timeoutMs = opts.timeoutMs ?? PROBE_TIMEOUT_MS;
|
|
145
|
+
return new Promise((resolve) => {
|
|
146
|
+
const timer = unref(setTimeout(() => {
|
|
147
|
+
this.pendingProbes.delete(probeId);
|
|
148
|
+
resolve({ error: 'TIMEOUT', elapsedMs: Date.now() - startedAt });
|
|
149
|
+
}, timeoutMs));
|
|
150
|
+
this.pendingProbes.set(probeId, { resolve, timer, startedAt });
|
|
151
|
+
this.queueControl({ type: 'PROBE', streamId: probeId, request: { path } });
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Send GOAWAY, refuse further OPENs in both directions, and close once every
|
|
156
|
+
* open stream has finished or the drain deadline passes.
|
|
157
|
+
*/
|
|
158
|
+
goAway(opts = {}) {
|
|
159
|
+
if (this.isClosed)
|
|
160
|
+
return Promise.resolve();
|
|
161
|
+
if (!this.goAwaySent) {
|
|
162
|
+
this.goAwaySent = true;
|
|
163
|
+
this.drainStarted = true;
|
|
164
|
+
this.queueControl({
|
|
165
|
+
type: 'GOAWAY',
|
|
166
|
+
streamId: 0,
|
|
167
|
+
code: opts.code ?? ErrorCode.GOING_AWAY,
|
|
168
|
+
reason: truncateReason(opts.reason ?? ''),
|
|
169
|
+
});
|
|
170
|
+
// The socket deliberately stays open even with nothing to drain: the peer
|
|
171
|
+
// may still have OPENs in flight, and those must be refused rather than
|
|
172
|
+
// dropped on the floor.
|
|
173
|
+
this.drainTimer = unref(setTimeout(() => this.endDrain(true), opts.drainTimeoutMs ?? DEFAULT_DRAIN_TIMEOUT_MS));
|
|
174
|
+
}
|
|
175
|
+
return new Promise((resolve) => {
|
|
176
|
+
this.goAwayWaiters.push(resolve);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
/** Tear the session down now: fail open streams and close the socket. */
|
|
180
|
+
close(code = ErrorCode.GOING_AWAY, reason = '') {
|
|
181
|
+
if (this.isClosed)
|
|
182
|
+
return;
|
|
183
|
+
this.sendImmediate({ type: 'GOAWAY', streamId: 0, code, reason: truncateReason(reason) });
|
|
184
|
+
this.closeTransport(CLOSE_CODE_BASE + code, reason);
|
|
185
|
+
}
|
|
186
|
+
// ── StreamHost (called by TunnelStream) ────────────────────────────────────
|
|
187
|
+
streamWrite(stream, chunk, cb) {
|
|
188
|
+
const record = this.streams.get(stream.id);
|
|
189
|
+
if (!record) {
|
|
190
|
+
// The stream is already gone (session closed, or reset). Settling without
|
|
191
|
+
// an error is deliberate: node turns an error handed to a write callback
|
|
192
|
+
// into an 'error' on the stream, which would be a second report of a
|
|
193
|
+
// teardown the stream is already reporting — and a crash if the consumer
|
|
194
|
+
// only listened for 'close'.
|
|
195
|
+
cb(null);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
if (chunk.length === 0) {
|
|
199
|
+
cb(null);
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
record.queue.push({ chunk, offset: 0, cb });
|
|
203
|
+
this.markReady(record);
|
|
204
|
+
this.pump();
|
|
205
|
+
}
|
|
206
|
+
streamFinal(stream, cb) {
|
|
207
|
+
const record = this.streams.get(stream.id);
|
|
208
|
+
if (!record) {
|
|
209
|
+
cb(null);
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
record.finalPending = true;
|
|
213
|
+
record.finalCb = cb;
|
|
214
|
+
this.markReady(record);
|
|
215
|
+
this.pump();
|
|
216
|
+
}
|
|
217
|
+
streamDestroy(stream, err, resetCode) {
|
|
218
|
+
const record = this.streams.get(stream.id);
|
|
219
|
+
if (!record)
|
|
220
|
+
return;
|
|
221
|
+
this.streams.delete(stream.id);
|
|
222
|
+
// Settled without an error on purpose: the stream is being destroyed in the
|
|
223
|
+
// same breath, and handing an error to a write callback makes node emit a
|
|
224
|
+
// SECOND 'error' on a stream that is already reporting the real one.
|
|
225
|
+
this.settleJobs(record);
|
|
226
|
+
if (!record.suppressReset) {
|
|
227
|
+
const code = resetCode ?? (err instanceof StreamResetError ? err.code : err ? ErrorCode.RESET : ErrorCode.RESET);
|
|
228
|
+
this.queueControl({ type: 'RST', streamId: stream.id, code });
|
|
229
|
+
}
|
|
230
|
+
this.afterStreamClosed();
|
|
231
|
+
}
|
|
232
|
+
streamConsumed(stream, bytes) {
|
|
233
|
+
const record = this.streams.get(stream.id);
|
|
234
|
+
if (!record)
|
|
235
|
+
return;
|
|
236
|
+
record.unannounced += bytes;
|
|
237
|
+
if (record.unannounced >= WINDOW_UPDATE_THRESHOLD) {
|
|
238
|
+
const increment = record.unannounced;
|
|
239
|
+
record.unannounced = 0;
|
|
240
|
+
record.receiveAllowance += increment;
|
|
241
|
+
this.queueControl({ type: 'WINDOW', streamId: stream.id, increment });
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
// ── inbound ────────────────────────────────────────────────────────────────
|
|
245
|
+
handleBytes(bytes) {
|
|
246
|
+
if (this.isClosed)
|
|
247
|
+
return;
|
|
248
|
+
this.armDeadPeerTimer();
|
|
249
|
+
let frame;
|
|
250
|
+
try {
|
|
251
|
+
frame = decodeFrame(bytes);
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, err instanceof ProtocolError ? err.message : String(err));
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
this.handleFrame(frame);
|
|
258
|
+
}
|
|
259
|
+
handleFrame(frame) {
|
|
260
|
+
switch (frame.type) {
|
|
261
|
+
case 'OPEN':
|
|
262
|
+
this.handleOpen(frame);
|
|
263
|
+
return;
|
|
264
|
+
case 'DATA': {
|
|
265
|
+
const record = this.lookup(frame.streamId);
|
|
266
|
+
if (!record)
|
|
267
|
+
return;
|
|
268
|
+
if (record.endReceived) {
|
|
269
|
+
this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'DATA after END');
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (frame.data.length > record.receiveAllowance) {
|
|
273
|
+
this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'flow control window overrun');
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
record.receiveAllowance -= frame.data.length;
|
|
277
|
+
if (!record.stream.destroyed)
|
|
278
|
+
record.stream.deliver(frame.data);
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
case 'END': {
|
|
282
|
+
const record = this.lookup(frame.streamId);
|
|
283
|
+
if (!record)
|
|
284
|
+
return;
|
|
285
|
+
if (record.endReceived) {
|
|
286
|
+
this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'duplicate END');
|
|
287
|
+
return;
|
|
288
|
+
}
|
|
289
|
+
record.endReceived = true;
|
|
290
|
+
if (!record.stream.destroyed)
|
|
291
|
+
record.stream.deliverEnd();
|
|
292
|
+
this.maybeFinishStream(record);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
case 'RST': {
|
|
296
|
+
const record = this.streams.get(frame.streamId);
|
|
297
|
+
if (!record) {
|
|
298
|
+
// Either it raced our own RST, or it is for a stream we never opened.
|
|
299
|
+
if (frame.streamId > this.highestStreamId) {
|
|
300
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, `RST for stream ${frame.streamId}, which was never opened`);
|
|
301
|
+
}
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
const code = normalizeResetCode(frame.code);
|
|
305
|
+
this.failStream(record, new StreamResetError(code, 'reset by peer', true));
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
case 'WINDOW': {
|
|
309
|
+
const record = this.lookup(frame.streamId);
|
|
310
|
+
if (!record)
|
|
311
|
+
return;
|
|
312
|
+
record.sendWindow += frame.increment;
|
|
313
|
+
if (record.sendWindow > MAX_WINDOW) {
|
|
314
|
+
this.resetStream(record, ErrorCode.PROTOCOL_ERROR, 'send window overflow');
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
this.markReady(record);
|
|
318
|
+
this.pump();
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
case 'PING':
|
|
322
|
+
this.queueControl({ type: 'PONG', streamId: 0, nonce: frame.nonce });
|
|
323
|
+
return;
|
|
324
|
+
case 'PONG': {
|
|
325
|
+
const pending = this.pendingPings.get(frame.nonce.toString());
|
|
326
|
+
if (!pending)
|
|
327
|
+
return; // a nonce we never sent, or a late answer
|
|
328
|
+
this.pendingPings.delete(frame.nonce.toString());
|
|
329
|
+
pending.resolve(Date.now() - pending.sentAt);
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
case 'GOAWAY':
|
|
333
|
+
this.goAwayReceived = true;
|
|
334
|
+
this.emit('goaway', { code: frame.code, reason: frame.reason });
|
|
335
|
+
return;
|
|
336
|
+
case 'PROBE':
|
|
337
|
+
if (this.role !== 'server') {
|
|
338
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, 'PROBE received by a client session');
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
this.answerProbe(frame.streamId, frame.request);
|
|
342
|
+
return;
|
|
343
|
+
case 'PROBE_RESULT': {
|
|
344
|
+
if (this.role !== 'client') {
|
|
345
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, 'PROBE_RESULT received by a server session');
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const pending = this.pendingProbes.get(frame.streamId);
|
|
349
|
+
if (!pending)
|
|
350
|
+
return; // timed out already, or never asked
|
|
351
|
+
this.pendingProbes.delete(frame.streamId);
|
|
352
|
+
clearTimeout(pending.timer);
|
|
353
|
+
pending.resolve(frame.result);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
default:
|
|
357
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, 'unknown frame');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
handleOpen(frame) {
|
|
361
|
+
if (this.role !== 'server') {
|
|
362
|
+
// Only servers open streams, so a client is the one that receives OPEN.
|
|
363
|
+
if (frame.streamId <= this.highestStreamId) {
|
|
364
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, `OPEN reuses stream id ${frame.streamId}`);
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
this.highestStreamId = frame.streamId;
|
|
368
|
+
if (this.goAwaySent || this.streams.size >= MAX_CONCURRENT_STREAMS) {
|
|
369
|
+
this.queueControl({ type: 'RST', streamId: frame.streamId, code: ErrorCode.REFUSED });
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
const record = this.createRecord(frame.streamId, frame);
|
|
373
|
+
this.emit('stream', record.stream);
|
|
374
|
+
return;
|
|
375
|
+
}
|
|
376
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, 'OPEN received by a server session');
|
|
377
|
+
}
|
|
378
|
+
answerProbe(probeId, request) {
|
|
379
|
+
const startedAt = Date.now();
|
|
380
|
+
const reply = (result) => {
|
|
381
|
+
if (this.isClosed)
|
|
382
|
+
return;
|
|
383
|
+
this.queueControl({ type: 'PROBE_RESULT', streamId: probeId, result });
|
|
384
|
+
};
|
|
385
|
+
const handler = this.onProbe;
|
|
386
|
+
if (!handler) {
|
|
387
|
+
reply({ error: 'UNSUPPORTED', elapsedMs: 0 });
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
void Promise.resolve()
|
|
391
|
+
.then(() => handler(request))
|
|
392
|
+
.then(reply)
|
|
393
|
+
.catch(() => reply({ error: 'PROBE_FAILED', elapsedMs: Date.now() - startedAt }));
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* A frame for a stream we don't have is either a race with our own RST (fine,
|
|
397
|
+
* ignore it) or a stream that never existed (fatal).
|
|
398
|
+
*/
|
|
399
|
+
lookup(streamId) {
|
|
400
|
+
const record = this.streams.get(streamId);
|
|
401
|
+
if (record)
|
|
402
|
+
return record;
|
|
403
|
+
if (streamId > this.highestStreamId) {
|
|
404
|
+
this.fatal(ErrorCode.PROTOCOL_ERROR, `frame for stream ${streamId}, which was never opened`);
|
|
405
|
+
}
|
|
406
|
+
return undefined;
|
|
407
|
+
}
|
|
408
|
+
// ── stream bookkeeping ─────────────────────────────────────────────────────
|
|
409
|
+
createRecord(streamId, open) {
|
|
410
|
+
const stream = new TunnelStream({ id: streamId, metadata: open.metadata, host: this });
|
|
411
|
+
// See failStream: the session owns an error sink so a teardown can never
|
|
412
|
+
// throw out of a stream nobody is listening to.
|
|
413
|
+
stream.on('error', () => undefined);
|
|
414
|
+
const record = {
|
|
415
|
+
stream,
|
|
416
|
+
sendWindow: INITIAL_WINDOW,
|
|
417
|
+
receiveAllowance: INITIAL_WINDOW,
|
|
418
|
+
unannounced: 0,
|
|
419
|
+
queue: [],
|
|
420
|
+
finalPending: false,
|
|
421
|
+
finalCb: undefined,
|
|
422
|
+
endSent: false,
|
|
423
|
+
endReceived: false,
|
|
424
|
+
suppressReset: false,
|
|
425
|
+
queued: false,
|
|
426
|
+
};
|
|
427
|
+
this.streams.set(streamId, record);
|
|
428
|
+
return record;
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* Destroy a stream the session is tearing down, reporting why.
|
|
432
|
+
*
|
|
433
|
+
* Safe to always pass the error because createRecord attaches a session-owned
|
|
434
|
+
* 'error' sink: without one, a stream teardown (a dropped tunnel — a routine
|
|
435
|
+
* event) would throw out of an EventEmitter and take the process with it, and
|
|
436
|
+
* node's own pipe() re-emits on a destination that has no other listener.
|
|
437
|
+
* A consumer's own 'error' listener still gets the error; one that only
|
|
438
|
+
* listens for 'close' simply doesn't.
|
|
439
|
+
*/
|
|
440
|
+
failStream(record, err) {
|
|
441
|
+
record.suppressReset = true;
|
|
442
|
+
record.stream.destroy(err);
|
|
443
|
+
}
|
|
444
|
+
/** Release the callbacks a torn-down stream still holds. See streamDestroy. */
|
|
445
|
+
settleJobs(record) {
|
|
446
|
+
for (const job of record.queue)
|
|
447
|
+
job.cb(null);
|
|
448
|
+
record.queue.length = 0;
|
|
449
|
+
const cb = record.finalCb;
|
|
450
|
+
record.finalCb = undefined;
|
|
451
|
+
cb?.(null);
|
|
452
|
+
}
|
|
453
|
+
/** A violation that belongs to one stream, not to the session. */
|
|
454
|
+
resetStream(record, code, detail) {
|
|
455
|
+
this.queueControl({ type: 'RST', streamId: record.stream.id, code });
|
|
456
|
+
this.streams.delete(record.stream.id);
|
|
457
|
+
this.failStream(record, new StreamResetError(code, detail, false));
|
|
458
|
+
this.afterStreamClosed();
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* Both ENDs exchanged: the stream is done as far as the protocol is concerned,
|
|
462
|
+
* so it stops counting against the cap and stops holding up a drain. The
|
|
463
|
+
* Duplex itself stays alive until the application has read what it buffered.
|
|
464
|
+
*/
|
|
465
|
+
maybeFinishStream(record) {
|
|
466
|
+
if (!record.endSent || !record.endReceived)
|
|
467
|
+
return;
|
|
468
|
+
if (!this.streams.has(record.stream.id))
|
|
469
|
+
return;
|
|
470
|
+
this.streams.delete(record.stream.id);
|
|
471
|
+
this.afterStreamClosed();
|
|
472
|
+
}
|
|
473
|
+
afterStreamClosed() {
|
|
474
|
+
if (this.drainStarted && this.streams.size === 0 && !this.isClosed)
|
|
475
|
+
this.endDrain(false);
|
|
476
|
+
}
|
|
477
|
+
endDrain(timedOut) {
|
|
478
|
+
if (this.isClosed)
|
|
479
|
+
return;
|
|
480
|
+
if (this.drainTimer) {
|
|
481
|
+
clearTimeout(this.drainTimer);
|
|
482
|
+
this.drainTimer = undefined;
|
|
483
|
+
}
|
|
484
|
+
if (timedOut && this.streams.size > 0) {
|
|
485
|
+
for (const record of [...this.streams.values()]) {
|
|
486
|
+
this.streams.delete(record.stream.id);
|
|
487
|
+
this.queueControl({ type: 'RST', streamId: record.stream.id, code: ErrorCode.GOING_AWAY });
|
|
488
|
+
this.failStream(record, new StreamResetError(ErrorCode.GOING_AWAY, 'drain deadline passed', false));
|
|
489
|
+
}
|
|
490
|
+
this.closeTransport(CloseCode.GOING_AWAY, 'drain deadline passed');
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
this.closeTransport(CloseCode.NORMAL, '');
|
|
494
|
+
}
|
|
495
|
+
// ── outbound ───────────────────────────────────────────────────────────────
|
|
496
|
+
queueControl(frame) {
|
|
497
|
+
if (this.isClosed)
|
|
498
|
+
return;
|
|
499
|
+
this.control.push(Buffer.from(encodeFrame(frame)));
|
|
500
|
+
this.pump();
|
|
501
|
+
}
|
|
502
|
+
/** Bypasses the queue for a frame that has to go out before the socket closes. */
|
|
503
|
+
sendImmediate(frame) {
|
|
504
|
+
if (this.isClosed)
|
|
505
|
+
return;
|
|
506
|
+
try {
|
|
507
|
+
this.transport.send(Buffer.from(encodeFrame(frame)), () => undefined);
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
/* the socket is already gone; the close below is what matters */
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
markReady(record) {
|
|
514
|
+
if (record.queued)
|
|
515
|
+
return;
|
|
516
|
+
if (!this.streams.has(record.stream.id))
|
|
517
|
+
return;
|
|
518
|
+
if (!this.hasWork(record))
|
|
519
|
+
return;
|
|
520
|
+
record.queued = true;
|
|
521
|
+
this.ready.push(record.stream.id);
|
|
522
|
+
}
|
|
523
|
+
hasWork(record) {
|
|
524
|
+
if (record.queue.length > 0)
|
|
525
|
+
return record.sendWindow > 0;
|
|
526
|
+
return record.finalPending && !record.endSent;
|
|
527
|
+
}
|
|
528
|
+
/**
|
|
529
|
+
* One writer for the whole session. Control frames first, then one DATA frame
|
|
530
|
+
* per ready stream in round-robin order — which is what keeps a 10 MB download
|
|
531
|
+
* from delaying a 1 KB request by more than a single frame.
|
|
532
|
+
*/
|
|
533
|
+
pump() {
|
|
534
|
+
if (this.pumping || this.isClosed)
|
|
535
|
+
return;
|
|
536
|
+
this.pumping = true;
|
|
537
|
+
try {
|
|
538
|
+
while (!this.isClosed && this.unackedBytes < MAX_UNACKED_SEND_BYTES) {
|
|
539
|
+
const control = this.control.shift();
|
|
540
|
+
if (control) {
|
|
541
|
+
this.writeBytes(control);
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
544
|
+
const record = this.nextReady();
|
|
545
|
+
if (!record)
|
|
546
|
+
break;
|
|
547
|
+
this.writeStreamFrame(record);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
finally {
|
|
551
|
+
this.pumping = false;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
nextReady() {
|
|
555
|
+
while (this.ready.length > 0) {
|
|
556
|
+
const streamId = this.ready.shift();
|
|
557
|
+
if (streamId === undefined)
|
|
558
|
+
return undefined;
|
|
559
|
+
const record = this.streams.get(streamId);
|
|
560
|
+
if (!record)
|
|
561
|
+
continue;
|
|
562
|
+
record.queued = false;
|
|
563
|
+
if (this.hasWork(record))
|
|
564
|
+
return record;
|
|
565
|
+
}
|
|
566
|
+
return undefined;
|
|
567
|
+
}
|
|
568
|
+
writeStreamFrame(record) {
|
|
569
|
+
const job = record.queue[0];
|
|
570
|
+
if (job) {
|
|
571
|
+
const size = Math.min(MAX_DATA_PAYLOAD, record.sendWindow, job.chunk.length - job.offset);
|
|
572
|
+
const slice = job.chunk.subarray(job.offset, job.offset + size);
|
|
573
|
+
job.offset += size;
|
|
574
|
+
record.sendWindow -= size;
|
|
575
|
+
this.writeBytes(Buffer.from(encodeFrame({ type: 'DATA', streamId: record.stream.id, data: slice })));
|
|
576
|
+
if (job.offset >= job.chunk.length) {
|
|
577
|
+
record.queue.shift();
|
|
578
|
+
job.cb(null);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
else if (record.finalPending && !record.endSent) {
|
|
582
|
+
record.endSent = true;
|
|
583
|
+
this.writeBytes(Buffer.from(encodeFrame({ type: 'END', streamId: record.stream.id })));
|
|
584
|
+
const cb = record.finalCb;
|
|
585
|
+
record.finalCb = undefined;
|
|
586
|
+
cb?.(null);
|
|
587
|
+
this.maybeFinishStream(record);
|
|
588
|
+
}
|
|
589
|
+
// Back of the ring, so every other ready stream gets a turn first.
|
|
590
|
+
this.markReady(record);
|
|
591
|
+
}
|
|
592
|
+
writeBytes(bytes) {
|
|
593
|
+
this.unackedBytes += bytes.length;
|
|
594
|
+
this.transport.send(bytes, (err) => {
|
|
595
|
+
this.unackedBytes -= bytes.length;
|
|
596
|
+
if (err) {
|
|
597
|
+
this.finish(CloseCode.ABNORMAL, err.message, false);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
this.pump();
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
// ── liveness and teardown ──────────────────────────────────────────────────
|
|
604
|
+
armDeadPeerTimer() {
|
|
605
|
+
if (this.isClosed)
|
|
606
|
+
return;
|
|
607
|
+
if (this.deadTimer)
|
|
608
|
+
clearTimeout(this.deadTimer);
|
|
609
|
+
this.deadTimer = unref(setTimeout(() => {
|
|
610
|
+
// A peer that has said nothing for a minute cannot complete a close
|
|
611
|
+
// handshake, so there is nothing to wait for.
|
|
612
|
+
this.transport.terminate();
|
|
613
|
+
this.finish(CloseCode.TIMEOUT, 'peer went silent', false);
|
|
614
|
+
}, this.deadPeerTimeoutMs));
|
|
615
|
+
}
|
|
616
|
+
fatal(code, detail) {
|
|
617
|
+
if (this.isClosed)
|
|
618
|
+
return;
|
|
619
|
+
this.sendImmediate({ type: 'GOAWAY', streamId: 0, code, reason: truncateReason(detail) });
|
|
620
|
+
this.closeTransport(CLOSE_CODE_BASE + code, detail);
|
|
621
|
+
}
|
|
622
|
+
closeTransport(closeCode, reason) {
|
|
623
|
+
if (this.isClosed)
|
|
624
|
+
return;
|
|
625
|
+
try {
|
|
626
|
+
this.transport.close(closeCode, reason);
|
|
627
|
+
}
|
|
628
|
+
catch {
|
|
629
|
+
/* already gone */
|
|
630
|
+
}
|
|
631
|
+
this.finish(closeCode, reason, false);
|
|
632
|
+
}
|
|
633
|
+
finish(closeCode, reason, remote) {
|
|
634
|
+
if (this.isClosed)
|
|
635
|
+
return;
|
|
636
|
+
this.isClosed = true;
|
|
637
|
+
this.closeCode = closeCode;
|
|
638
|
+
if (this.pingTimer)
|
|
639
|
+
clearInterval(this.pingTimer);
|
|
640
|
+
if (this.deadTimer)
|
|
641
|
+
clearTimeout(this.deadTimer);
|
|
642
|
+
if (this.drainTimer)
|
|
643
|
+
clearTimeout(this.drainTimer);
|
|
644
|
+
this.pingTimer = undefined;
|
|
645
|
+
this.deadTimer = undefined;
|
|
646
|
+
this.drainTimer = undefined;
|
|
647
|
+
const err = new SessionClosedError(closeCode, reason);
|
|
648
|
+
for (const record of [...this.streams.values()]) {
|
|
649
|
+
this.streams.delete(record.stream.id);
|
|
650
|
+
this.settleJobs(record);
|
|
651
|
+
this.failStream(record, err);
|
|
652
|
+
}
|
|
653
|
+
this.ready.length = 0;
|
|
654
|
+
this.control.length = 0;
|
|
655
|
+
for (const pending of this.pendingPings.values())
|
|
656
|
+
pending.reject(err);
|
|
657
|
+
this.pendingPings.clear();
|
|
658
|
+
for (const probe of this.pendingProbes.values()) {
|
|
659
|
+
clearTimeout(probe.timer);
|
|
660
|
+
probe.resolve({ error: 'CLOSED', elapsedMs: Date.now() - probe.startedAt });
|
|
661
|
+
}
|
|
662
|
+
this.pendingProbes.clear();
|
|
663
|
+
for (const resolve of this.goAwayWaiters)
|
|
664
|
+
resolve();
|
|
665
|
+
this.goAwayWaiters.length = 0;
|
|
666
|
+
this.emit('close', { closeCode, reason, remote });
|
|
667
|
+
}
|
|
668
|
+
}
|