@torrent-tv/proxy 2.56.0 → 2.57.1

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.
@@ -1,255 +1,374 @@
1
- /**
2
- * @file Numbered delivery probes, and the verdict they make possible.
3
- *
4
- * The delivery freeze (roadmap item 11) looks identical from the proxy in two
5
- * cases that need opposite fixes: usrsctp stopped transmitting, or the browser
6
- * closed its receive window because the page stopped draining the channel. The
7
- * proxy's own counters cannot separate them — libdatachannel's `bytesSent`
8
- * counts bytes ACCEPTED into usrsctp, not bytes put on the wire — so the
9
- * reading has to come from the far end.
10
- *
11
- * Two facts make that cheap. The reverse direction keeps working throughout the
12
- * freeze (browser to proxy requests arrive and are answered for the whole
13
- * episode, 88 min in the 2026-08-24 case), so the browser can always report.
14
- * And SCTP orders per STREAM, so a probe on a channel opened UNORDERED and
15
- * WITHOUT retransmission passes head-of-line blocking in another stream but
16
- * neither a closed receive window nor a transmitter that stopped.
17
- *
18
- * So: number a probe every {@link PROBE_INTERVAL_MS} on every channel of the
19
- * connection, have the browser echo back the highest number it has seen on
20
- * each, and read the gaps:
21
- *
22
- * every channel current to flowing
23
- * ordered behind, unreliable current to a retransmission stuck in a stream
24
- * both behind, echoes still arriving to the association stopped transmitting
25
- * no echo at all to the reverse direction went too
26
- *
27
- * The verdict is computed from the gaps, not chosen, and every line prints the
28
- * numbers that produced it.
29
- */
30
-
31
- /**
32
- * How often a probe is numbered and sent on every channel.
33
- *
34
- * Half a second, because the transport heartbeat is five and that was the whole
35
- * resolution the 2026-08-24 episode had: the onset could be placed no closer
36
- * than the five seconds between two lines. Sending is cheap - a probe is a few
37
- * dozen bytes - and the interval is NOT the verdict: a healthy burst can hold a
38
- * probe up behind queued data, so the verdict takes {@link MISSES_FOR_VERDICT}
39
- * consecutive probes.
40
- */
41
- export const PROBE_INTERVAL_MS = 500;
42
-
43
- /**
44
- * How many probes may be outstanding on a channel before it counts as behind.
45
- *
46
- * A segment of 6-11 MB leaves in well under a second when the link is healthy,
47
- * but it shares the association with the probe, so one or two probes can
48
- * legitimately sit behind it. Four is two seconds - longer than any measured
49
- * healthy burst, shorter than the five seconds the old heartbeat needed to say
50
- * anything at all.
51
- */
52
- export const MISSES_FOR_VERDICT = 4;
53
-
54
- /** The label the browser gives the unordered, non-retransmitting channel. */
55
- export const UNRELIABLE_LABEL = "proxy-fast";
56
-
57
- /** An echo older than this means the reverse direction has stopped too. */
58
- const ECHO_STALE_MS = 5_000;
59
-
60
- /** How often the probe state is written to the log while nothing changes. */
61
- const REPORT_INTERVAL_MS = 5_000;
62
-
63
- /**
64
- * One connection's probe state.
65
- *
66
- * @typedef {Object} ProbeConnection
67
- * @property {string} tag
68
- * @property {Map<import('node-datachannel').DataChannel, string>} channels
69
- * @property {number} seq - Highest probe number sent.
70
- * @property {number} sentAt - When that probe was sent.
71
- * @property {Map<string, number>} seen - Label to the highest number the browser reported.
72
- * @property {number} echoAt - When the last echo arrived (0 = never).
73
- * @property {number} echoes - How many echoes have arrived.
74
- * @property {string} verdict - Last verdict reported, so a change is logged at once.
75
- * @property {number} reportedAt - When the state was last written to the log.
76
- * @property {ReturnType<typeof setInterval> | null} timer
77
- */
78
-
79
- /**
80
- * Read the gaps and say what they mean.
81
- *
82
- * Exported so the rule is testable without a connection: the same numbers
83
- * always produce the same word.
84
- *
85
- * @param {{ seq: number, seen: Map<string, number> | Record<string, number>, labels: string[], echoes: number, echoAgeMs: number | null }} state
86
- * @returns {{ verdict: string, detail: string }}
87
- */
88
- export function readProbeState(state) {
89
- const seenOf = (label) =>
90
- state.seen instanceof Map ? state.seen.get(label) : state.seen?.[label];
91
- const parts = [];
92
- let orderedBehind = false;
93
- let unreliableBehind = false;
94
- let unreliableKnown = false;
95
- for (const label of state.labels) {
96
- const seen = seenOf(label);
97
- const gap = Number.isInteger(seen) ? state.seq - Number(seen) : null;
98
- parts.push(`${label}=${seen ?? "?"}(gap ${gap ?? "?"})`);
99
- const behind = gap === null || gap >= MISSES_FOR_VERDICT;
100
- if (label === UNRELIABLE_LABEL) {
101
- unreliableKnown = true;
102
- unreliableBehind = behind;
103
- } else if (behind) {
104
- orderedBehind = true;
105
- }
106
- }
107
- const detail =
108
- `sent=${state.seq} ${parts.join(" ")} ` +
109
- `echoAge=${state.echoAgeMs === null ? "never" : `${state.echoAgeMs}ms`}`;
110
-
111
- if (state.echoes === 0) {
112
- return { verdict: "no-echo-yet", detail };
113
- }
114
- if (state.echoAgeMs !== null && state.echoAgeMs > ECHO_STALE_MS) {
115
- return { verdict: "reverse-direction-gone", detail };
116
- }
117
- if (!orderedBehind && !(unreliableKnown && unreliableBehind)) {
118
- return { verdict: "flowing", detail };
119
- }
120
- if (orderedBehind && unreliableKnown && !unreliableBehind) {
121
- return { verdict: "stream-stuck", detail };
122
- }
123
- if (orderedBehind) {
124
- return {
125
- verdict: unreliableKnown ? "association-stopped" : "ordered-behind-no-comparison",
126
- detail
127
- };
128
- }
129
- return { verdict: "unreliable-behind-only", detail };
130
- }
131
-
132
- /**
133
- * Create the probe service. One instance serves every session.
134
- *
135
- * @param {Object} options
136
- * @param {(message: string) => void} options.log
137
- * @param {number} [options.intervalMs]
138
- * @returns {{
139
- * attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
140
- * detach: (sessionId: string, channel: import('node-datachannel').DataChannel) => void,
141
- * noteEcho: (sessionId: string, echo: object) => void,
142
- * dispose: () => void
143
- * }}
144
- */
145
- export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS }) {
146
- /** @type {Map<string, ProbeConnection>} */
147
- const connections = new Map();
148
-
149
- /**
150
- * Send this tick's probe on every channel of one connection, then report.
151
- *
152
- * @param {ProbeConnection} connection
153
- * @returns {void}
154
- */
155
- function tick(connection) {
156
- const now = Date.now();
157
- connection.seq += 1;
158
- connection.sentAt = now;
159
- const message = JSON.stringify({ type: "probe", seq: connection.seq, sentAt: now });
160
- for (const channel of connection.channels.keys()) {
161
- try {
162
- channel.sendMessage(message);
163
- } catch {
164
- // A channel closing between the check and the send is ordinary.
165
- }
166
- }
167
-
168
- const { verdict, detail } = readProbeState({
169
- seq: connection.seq,
170
- seen: connection.seen,
171
- labels: [...new Set(connection.channels.values())],
172
- echoes: connection.echoes,
173
- echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt
174
- });
175
- if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
176
- connection.verdict = verdict;
177
- connection.reportedAt = now;
178
- log(`[dc-probe] ${connection.tag} ${verdict} ${detail} at=${new Date(now).toISOString()}`);
179
- }
180
- }
181
-
182
- return {
183
- attach(sessionId, tag, label, channel) {
184
- let connection = connections.get(sessionId);
185
- if (!connection) {
186
- connection = {
187
- tag,
188
- channels: new Map(),
189
- seq: 0,
190
- sentAt: 0,
191
- seen: new Map(),
192
- echoAt: 0,
193
- echoes: 0,
194
- verdict: "",
195
- reportedAt: 0,
196
- timer: null
197
- };
198
- connections.set(sessionId, connection);
199
- }
200
- connection.channels.set(channel, label);
201
- if (connection.timer === null) {
202
- const held = connection;
203
- connection.timer = setInterval(() => tick(held), intervalMs);
204
- // The probe must never be the reason a process stays alive.
205
- if (typeof connection.timer.unref === "function") {
206
- connection.timer.unref();
207
- }
208
- }
209
- },
210
-
211
- detach(sessionId, channel) {
212
- const connection = connections.get(sessionId);
213
- if (!connection) {
214
- return;
215
- }
216
- connection.channels.delete(channel);
217
- if (connection.channels.size === 0) {
218
- if (connection.timer !== null) {
219
- clearInterval(connection.timer);
220
- connection.timer = null;
221
- }
222
- if (connections.get(sessionId) === connection) {
223
- connections.delete(sessionId);
224
- }
225
- }
226
- },
227
-
228
- noteEcho(sessionId, echo) {
229
- const connection = connections.get(sessionId);
230
- if (!connection || !echo || typeof echo !== "object") {
231
- return;
232
- }
233
- const seen = echo.seen;
234
- if (seen && typeof seen === "object") {
235
- for (const [label, value] of Object.entries(seen)) {
236
- if (Number.isInteger(value)) {
237
- connection.seen.set(label, value);
238
- }
239
- }
240
- }
241
- connection.echoAt = Date.now();
242
- connection.echoes += 1;
243
- },
244
-
245
- dispose() {
246
- for (const connection of connections.values()) {
247
- if (connection.timer !== null) {
248
- clearInterval(connection.timer);
249
- connection.timer = null;
250
- }
251
- }
252
- connections.clear();
253
- }
254
- };
255
- }
1
+ /**
2
+ * @file Numbered delivery probes, and the verdict they make possible.
3
+ *
4
+ * The delivery freeze (roadmap item 11) looks identical from the proxy in two
5
+ * cases that need opposite fixes: usrsctp stopped transmitting, or the browser
6
+ * closed its receive window because the page stopped draining the channel. The
7
+ * proxy's own counters cannot separate them — libdatachannel's `bytesSent`
8
+ * counts bytes ACCEPTED into usrsctp, not bytes put on the wire — so the
9
+ * reading has to come from the far end.
10
+ *
11
+ * Two facts make that cheap. The reverse direction keeps working throughout the
12
+ * freeze (browser to proxy requests arrive and are answered for the whole
13
+ * episode, 88 min in the 2026-08-24 case), so the browser can always report.
14
+ * And SCTP orders per STREAM, so a probe on a channel opened UNORDERED and
15
+ * WITHOUT retransmission passes head-of-line blocking in another stream but
16
+ * neither a closed receive window nor a transmitter that stopped.
17
+ *
18
+ * So: number a probe every {@link PROBE_INTERVAL_MS} on every channel of the
19
+ * connection, have the browser echo back the highest number it has seen on
20
+ * each, and read the gaps:
21
+ *
22
+ * every channel current to flowing
23
+ * ordered behind, unreliable current to a retransmission stuck in a stream
24
+ * both behind, echoes still arriving to the association stopped transmitting
25
+ * no echo at all to the reverse direction went too
26
+ *
27
+ * The verdict is computed from the gaps, not chosen, and every line prints the
28
+ * numbers that produced it.
29
+ */
30
+
31
+ /**
32
+ * How often a probe is numbered and sent on every channel.
33
+ *
34
+ * Half a second, because the transport heartbeat is five and that was the whole
35
+ * resolution the 2026-08-24 episode had: the onset could be placed no closer
36
+ * than the five seconds between two lines. Sending is cheap - a probe is a few
37
+ * dozen bytes - and the interval is NOT the verdict: a healthy burst can hold a
38
+ * probe up behind queued data, so how many probes may be outstanding is worked
39
+ * out per channel by {@link allowedGap} from the bytes queued ahead of the
40
+ * probe and the rate they are leaving at.
41
+ */
42
+ export const PROBE_INTERVAL_MS = 500;
43
+
44
+ /**
45
+ * How many probes may be outstanding on a channel before it counts as behind.
46
+ *
47
+ * DERIVED per channel, not chosen. A probe is handed to the same association as
48
+ * the data, and SCTP orders per stream but SCHEDULES per association: one
49
+ * congestion window, one send buffer. So a probe waits for whatever is queued
50
+ * ahead of it whichever channel it rides on - the unordered one included - and
51
+ * a fixed threshold cannot tell that wait from a stopped association. Measured
52
+ * 2026-08-26: `association-stopped` printed with all three channels at gap 4-7
53
+ * while 110-150 Mbps crossed that same association and 7.34 GB went through it
54
+ * without a single failure.
55
+ *
56
+ * What the wait costs is arithmetic on two measured quantities: the bytes
57
+ * queued ahead of the probe, and the rate at which this connection is getting
58
+ * bytes away. Add one round trip for the echo to come back. A probe is behind
59
+ * only when it is later than that.
60
+ *
61
+ * There is a third term, and leaving it out made this worse than the constant
62
+ * it replaced. The browser answers on ITS own schedule, not ours: it batches
63
+ * what it has seen and echoes on a timer, and that timer is throttled to about
64
+ * once a second whenever the tab is hidden. So with an empty queue the
65
+ * allowance collapsed to one probe and the bound to half a second, while echoes
66
+ * legitimately arrived every second - measured 2026-08-27, 67 `association-
67
+ * stopped` and 66 `reverse-direction-gone` against 84 `flowing` on a connection
68
+ * carrying 3.4 MB/s with every queue at zero. The peer's own cadence is
69
+ * measurable on the same connection, so it is measured and added rather than
70
+ * assumed.
71
+ *
72
+ * @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, echoIntervalMs?: number, intervalMs?: number }} state
73
+ * @returns {number | null} Probes that may legitimately be outstanding, or null
74
+ * when no rate has been measured yet and nothing can be said.
75
+ */
76
+ export function allowedGap({
77
+ queuedBytes,
78
+ bytesPerSecond,
79
+ rttMs,
80
+ echoIntervalMs = 0,
81
+ intervalMs = PROBE_INTERVAL_MS
82
+ }) {
83
+ if (!(bytesPerSecond > 0) || !(intervalMs > 0)) {
84
+ return null;
85
+ }
86
+ const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
87
+ const waitMs = drainMs + Math.max(rttMs, 0) + Math.max(echoIntervalMs, 0);
88
+ // At least one: a probe sent and not yet echoed is the ordinary state.
89
+ return Math.max(1, Math.ceil(waitMs / intervalMs));
90
+ }
91
+
92
+ /** The label the browser gives the unordered, non-retransmitting channel. */
93
+ export const UNRELIABLE_LABEL = "proxy-fast";
94
+
95
+ /**
96
+ * How old an echo may be before the reverse direction counts as gone, when
97
+ * nothing better can be derived.
98
+ *
99
+ * Used only while no rate has been measured. Otherwise the caller passes
100
+ * `echoStaleMs`, worked out the same way as {@link allowedGap}: the browser
101
+ * only echoes a probe it has RECEIVED, so an echo waits behind our own queue
102
+ * exactly as the probe did.
103
+ */
104
+ const ECHO_STALE_FALLBACK_MS = 5_000;
105
+
106
+ /** How often the probe state is written to the log while nothing changes. */
107
+ const REPORT_INTERVAL_MS = 5_000;
108
+
109
+ /**
110
+ * One connection's probe state.
111
+ *
112
+ * @typedef {Object} ProbeConnection
113
+ * @property {string} tag
114
+ * @property {Map<import('node-datachannel').DataChannel, string>} channels
115
+ * @property {number} seq - Highest probe number sent.
116
+ * @property {number} sentAt - When that probe was sent.
117
+ * @property {Map<string, number>} seen - Label to the highest number the browser reported.
118
+ * @property {number} echoAt - When the last echo arrived (0 = never).
119
+ * @property {number} echoes - How many echoes have arrived.
120
+ * @property {string} verdict - Last verdict reported, so a change is logged at once.
121
+ * @property {number} reportedAt - When the state was last written to the log.
122
+ * @property {ReturnType<typeof setInterval> | null} timer
123
+ */
124
+
125
+ /**
126
+ * Read the gaps and say what they mean.
127
+ *
128
+ * Exported so the rule is testable without a connection: the same numbers
129
+ * always produce the same word.
130
+ *
131
+ * `allowed` carries, per channel label, how many probes may legitimately be
132
+ * outstanding right now — {@link allowedGap} computes it from that channel's
133
+ * own queue and the connection's measured rate. A label with no entry, or an
134
+ * entry of null, cannot be judged: with no rate measured there is nothing to
135
+ * divide the queue by, and the verdict says that rather than inventing one.
136
+ *
137
+ * @param {{ seq: number, seen: Map<string, number> | Record<string, number>, labels: string[], echoes: number, echoAgeMs: number | null, allowed?: Map<string, number | null> | Record<string, number | null>, echoStaleMs?: number }} state
138
+ * @returns {{ verdict: string, detail: string }}
139
+ */
140
+ export function readProbeState(state) {
141
+ const seenOf = (label) =>
142
+ state.seen instanceof Map ? state.seen.get(label) : state.seen?.[label];
143
+ const allowedOf = (label) => {
144
+ const source = state.allowed;
145
+ const value = source instanceof Map ? source.get(label) : source?.[label];
146
+ return Number.isInteger(value) ? Number(value) : null;
147
+ };
148
+ const parts = [];
149
+ let orderedBehind = false;
150
+ let unreliableBehind = false;
151
+ let unreliableKnown = false;
152
+ let judgeable = false;
153
+ for (const label of state.labels) {
154
+ const seen = seenOf(label);
155
+ const gap = Number.isInteger(seen) ? state.seq - Number(seen) : null;
156
+ const allowance = allowedOf(label);
157
+ parts.push(`${label}=${seen ?? "?"}(gap ${gap ?? "?"} of ${allowance ?? "?"})`);
158
+ if (allowance === null) {
159
+ continue;
160
+ }
161
+ judgeable = true;
162
+ const behind = gap === null || gap > allowance;
163
+ if (label === UNRELIABLE_LABEL) {
164
+ unreliableKnown = true;
165
+ unreliableBehind = behind;
166
+ } else if (behind) {
167
+ orderedBehind = true;
168
+ }
169
+ }
170
+ const detail =
171
+ `sent=${state.seq} ${parts.join(" ")} ` +
172
+ `echoAge=${state.echoAgeMs === null ? "never" : `${state.echoAgeMs}ms`}`;
173
+
174
+ if (state.echoes === 0) {
175
+ return { verdict: "no-echo-yet", detail };
176
+ }
177
+ if (!judgeable) {
178
+ return { verdict: "no-rate-yet", detail };
179
+ }
180
+ const staleAfterMs = Number.isFinite(state.echoStaleMs) && state.echoStaleMs > 0
181
+ ? state.echoStaleMs
182
+ : ECHO_STALE_FALLBACK_MS;
183
+ if (state.echoAgeMs !== null && state.echoAgeMs > staleAfterMs) {
184
+ return { verdict: "reverse-direction-gone", detail };
185
+ }
186
+ if (!orderedBehind && !(unreliableKnown && unreliableBehind)) {
187
+ return { verdict: "flowing", detail };
188
+ }
189
+ if (orderedBehind && unreliableKnown && !unreliableBehind) {
190
+ return { verdict: "stream-stuck", detail };
191
+ }
192
+ if (orderedBehind) {
193
+ return {
194
+ verdict: unreliableKnown ? "association-stopped" : "ordered-behind-no-comparison",
195
+ detail
196
+ };
197
+ }
198
+ return { verdict: "unreliable-behind-only", detail };
199
+ }
200
+
201
+ /**
202
+ * Create the probe service. One instance serves every session.
203
+ *
204
+ * @param {Object} options
205
+ * @param {(message: string) => void} options.log
206
+ * @param {number} [options.intervalMs]
207
+ * @returns {{
208
+ * attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
209
+ * detach: (sessionId: string, channel: import('node-datachannel').DataChannel) => void,
210
+ * noteEcho: (sessionId: string, echo: object) => void,
211
+ * dispose: () => void
212
+ * }}
213
+ */
214
+ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readDelivery }) {
215
+ /** @type {Map<string, ProbeConnection>} */
216
+ const connections = new Map();
217
+
218
+ /**
219
+ * Send this tick's probe on every channel of one connection, then report.
220
+ *
221
+ * @param {ProbeConnection} connection
222
+ * @returns {void}
223
+ */
224
+ function tick(connection) {
225
+ const now = Date.now();
226
+ connection.seq += 1;
227
+ connection.sentAt = now;
228
+ const message = JSON.stringify({ type: "probe", seq: connection.seq, sentAt: now });
229
+ for (const channel of connection.channels.keys()) {
230
+ try {
231
+ channel.sendMessage(message);
232
+ } catch {
233
+ // A channel closing between the check and the send is ordinary.
234
+ }
235
+ }
236
+
237
+ // What this connection is getting away, and how far behind it therefore
238
+ // sits. Both come from the send-queue watcher, which measures them anyway.
239
+ const delivery = typeof readDelivery === "function" ? readDelivery(connection.id) : null;
240
+ const bytesPerSecond = Number(delivery?.bytesPerSecond) || 0;
241
+ const rttMs = Number(delivery?.rttMs) || 0;
242
+ /** @type {Map<string, number | null>} */
243
+ const allowed = new Map();
244
+ for (const [channel, label] of connection.channels) {
245
+ let queuedBytes = 0;
246
+ try {
247
+ queuedBytes = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
248
+ } catch {
249
+ queuedBytes = 0;
250
+ }
251
+ const allowance = allowedGap({
252
+ queuedBytes,
253
+ bytesPerSecond,
254
+ rttMs,
255
+ echoIntervalMs: connection.echoIntervalMs,
256
+ intervalMs
257
+ });
258
+ // Several channels can carry one label only in malformed cases; the
259
+ // larger allowance is the safer of the two.
260
+ const held = allowed.get(label);
261
+ if (allowance !== null && (!Number.isInteger(held) || allowance > held)) {
262
+ allowed.set(label, allowance);
263
+ } else if (!allowed.has(label)) {
264
+ allowed.set(label, allowance);
265
+ }
266
+ }
267
+ const widest = [...allowed.values()].reduce(
268
+ (most, value) => (Number.isInteger(value) && value > most ? value : most),
269
+ 0
270
+ );
271
+ const { verdict, detail } = readProbeState({
272
+ seq: connection.seq,
273
+ seen: connection.seen,
274
+ labels: [...new Set(connection.channels.values())],
275
+ echoes: connection.echoes,
276
+ echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt,
277
+ allowed,
278
+ // Same arithmetic for the echo's own age: the peer cannot answer sooner
279
+ // than its own cadence allows, and a hidden tab's is about a second.
280
+ echoStaleMs: widest > 0 ? widest * intervalMs + rttMs + connection.echoIntervalMs : 0
281
+ });
282
+ if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
283
+ connection.verdict = verdict;
284
+ connection.reportedAt = now;
285
+ log(`[dc-probe] ${connection.tag} ${verdict} — ${detail} at=${new Date(now).toISOString()}`);
286
+ }
287
+ }
288
+
289
+ return {
290
+ attach(sessionId, tag, label, channel) {
291
+ let connection = connections.get(sessionId);
292
+ if (!connection) {
293
+ connection = {
294
+ id: sessionId,
295
+ tag,
296
+ // The longest this peer has ever taken between two echoes. Its own
297
+ // schedule, measured rather than assumed - a hidden tab answers
298
+ // about once a second because the browser throttles the timer.
299
+ echoIntervalMs: 0,
300
+ channels: new Map(),
301
+ seq: 0,
302
+ sentAt: 0,
303
+ seen: new Map(),
304
+ echoAt: 0,
305
+ echoes: 0,
306
+ verdict: "",
307
+ reportedAt: 0,
308
+ timer: null
309
+ };
310
+ connections.set(sessionId, connection);
311
+ }
312
+ connection.channels.set(channel, label);
313
+ if (connection.timer === null) {
314
+ const held = connection;
315
+ connection.timer = setInterval(() => tick(held), intervalMs);
316
+ // The probe must never be the reason a process stays alive.
317
+ if (typeof connection.timer.unref === "function") {
318
+ connection.timer.unref();
319
+ }
320
+ }
321
+ },
322
+
323
+ detach(sessionId, channel) {
324
+ const connection = connections.get(sessionId);
325
+ if (!connection) {
326
+ return;
327
+ }
328
+ connection.channels.delete(channel);
329
+ if (connection.channels.size === 0) {
330
+ if (connection.timer !== null) {
331
+ clearInterval(connection.timer);
332
+ connection.timer = null;
333
+ }
334
+ if (connections.get(sessionId) === connection) {
335
+ connections.delete(sessionId);
336
+ }
337
+ }
338
+ },
339
+
340
+ noteEcho(sessionId, echo) {
341
+ const connection = connections.get(sessionId);
342
+ if (!connection || !echo || typeof echo !== "object") {
343
+ return;
344
+ }
345
+ const seen = echo.seen;
346
+ if (seen && typeof seen === "object") {
347
+ for (const [label, value] of Object.entries(seen)) {
348
+ if (Number.isInteger(value)) {
349
+ connection.seen.set(label, value);
350
+ }
351
+ }
352
+ }
353
+ const now = Date.now();
354
+ if (connection.echoAt !== 0) {
355
+ const sinceLast = now - connection.echoAt;
356
+ if (sinceLast > connection.echoIntervalMs) {
357
+ connection.echoIntervalMs = sinceLast;
358
+ }
359
+ }
360
+ connection.echoAt = now;
361
+ connection.echoes += 1;
362
+ },
363
+
364
+ dispose() {
365
+ for (const connection of connections.values()) {
366
+ if (connection.timer !== null) {
367
+ clearInterval(connection.timer);
368
+ connection.timer = null;
369
+ }
370
+ }
371
+ connections.clear();
372
+ }
373
+ };
374
+ }