@torrent-tv/proxy 2.56.0 → 2.57.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.
@@ -1,255 +1,338 @@
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
+ * @param {{ queuedBytes: number, bytesPerSecond: number, rttMs: number, intervalMs?: number }} state
62
+ * @returns {number | null} Probes that may legitimately be outstanding, or null
63
+ * when no rate has been measured yet and nothing can be said.
64
+ */
65
+ export function allowedGap({ queuedBytes, bytesPerSecond, rttMs, intervalMs = PROBE_INTERVAL_MS }) {
66
+ if (!(bytesPerSecond > 0) || !(intervalMs > 0)) {
67
+ return null;
68
+ }
69
+ const drainMs = (Math.max(queuedBytes, 0) / bytesPerSecond) * 1000;
70
+ const waitMs = drainMs + Math.max(rttMs, 0);
71
+ // At least one: a probe sent and not yet echoed is the ordinary state.
72
+ return Math.max(1, Math.ceil(waitMs / intervalMs));
73
+ }
74
+
75
+ /** The label the browser gives the unordered, non-retransmitting channel. */
76
+ export const UNRELIABLE_LABEL = "proxy-fast";
77
+
78
+ /**
79
+ * How old an echo may be before the reverse direction counts as gone, when
80
+ * nothing better can be derived.
81
+ *
82
+ * Used only while no rate has been measured. Otherwise the caller passes
83
+ * `echoStaleMs`, worked out the same way as {@link allowedGap}: the browser
84
+ * only echoes a probe it has RECEIVED, so an echo waits behind our own queue
85
+ * exactly as the probe did.
86
+ */
87
+ const ECHO_STALE_FALLBACK_MS = 5_000;
88
+
89
+ /** How often the probe state is written to the log while nothing changes. */
90
+ const REPORT_INTERVAL_MS = 5_000;
91
+
92
+ /**
93
+ * One connection's probe state.
94
+ *
95
+ * @typedef {Object} ProbeConnection
96
+ * @property {string} tag
97
+ * @property {Map<import('node-datachannel').DataChannel, string>} channels
98
+ * @property {number} seq - Highest probe number sent.
99
+ * @property {number} sentAt - When that probe was sent.
100
+ * @property {Map<string, number>} seen - Label to the highest number the browser reported.
101
+ * @property {number} echoAt - When the last echo arrived (0 = never).
102
+ * @property {number} echoes - How many echoes have arrived.
103
+ * @property {string} verdict - Last verdict reported, so a change is logged at once.
104
+ * @property {number} reportedAt - When the state was last written to the log.
105
+ * @property {ReturnType<typeof setInterval> | null} timer
106
+ */
107
+
108
+ /**
109
+ * Read the gaps and say what they mean.
110
+ *
111
+ * Exported so the rule is testable without a connection: the same numbers
112
+ * always produce the same word.
113
+ *
114
+ * `allowed` carries, per channel label, how many probes may legitimately be
115
+ * outstanding right now — {@link allowedGap} computes it from that channel's
116
+ * own queue and the connection's measured rate. A label with no entry, or an
117
+ * entry of null, cannot be judged: with no rate measured there is nothing to
118
+ * divide the queue by, and the verdict says that rather than inventing one.
119
+ *
120
+ * @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
121
+ * @returns {{ verdict: string, detail: string }}
122
+ */
123
+ export function readProbeState(state) {
124
+ const seenOf = (label) =>
125
+ state.seen instanceof Map ? state.seen.get(label) : state.seen?.[label];
126
+ const allowedOf = (label) => {
127
+ const source = state.allowed;
128
+ const value = source instanceof Map ? source.get(label) : source?.[label];
129
+ return Number.isInteger(value) ? Number(value) : null;
130
+ };
131
+ const parts = [];
132
+ let orderedBehind = false;
133
+ let unreliableBehind = false;
134
+ let unreliableKnown = false;
135
+ let judgeable = false;
136
+ for (const label of state.labels) {
137
+ const seen = seenOf(label);
138
+ const gap = Number.isInteger(seen) ? state.seq - Number(seen) : null;
139
+ const allowance = allowedOf(label);
140
+ parts.push(`${label}=${seen ?? "?"}(gap ${gap ?? "?"} of ${allowance ?? "?"})`);
141
+ if (allowance === null) {
142
+ continue;
143
+ }
144
+ judgeable = true;
145
+ const behind = gap === null || gap > allowance;
146
+ if (label === UNRELIABLE_LABEL) {
147
+ unreliableKnown = true;
148
+ unreliableBehind = behind;
149
+ } else if (behind) {
150
+ orderedBehind = true;
151
+ }
152
+ }
153
+ const detail =
154
+ `sent=${state.seq} ${parts.join(" ")} ` +
155
+ `echoAge=${state.echoAgeMs === null ? "never" : `${state.echoAgeMs}ms`}`;
156
+
157
+ if (state.echoes === 0) {
158
+ return { verdict: "no-echo-yet", detail };
159
+ }
160
+ if (!judgeable) {
161
+ return { verdict: "no-rate-yet", detail };
162
+ }
163
+ const staleAfterMs = Number.isFinite(state.echoStaleMs) && state.echoStaleMs > 0
164
+ ? state.echoStaleMs
165
+ : ECHO_STALE_FALLBACK_MS;
166
+ if (state.echoAgeMs !== null && state.echoAgeMs > staleAfterMs) {
167
+ return { verdict: "reverse-direction-gone", detail };
168
+ }
169
+ if (!orderedBehind && !(unreliableKnown && unreliableBehind)) {
170
+ return { verdict: "flowing", detail };
171
+ }
172
+ if (orderedBehind && unreliableKnown && !unreliableBehind) {
173
+ return { verdict: "stream-stuck", detail };
174
+ }
175
+ if (orderedBehind) {
176
+ return {
177
+ verdict: unreliableKnown ? "association-stopped" : "ordered-behind-no-comparison",
178
+ detail
179
+ };
180
+ }
181
+ return { verdict: "unreliable-behind-only", detail };
182
+ }
183
+
184
+ /**
185
+ * Create the probe service. One instance serves every session.
186
+ *
187
+ * @param {Object} options
188
+ * @param {(message: string) => void} options.log
189
+ * @param {number} [options.intervalMs]
190
+ * @returns {{
191
+ * attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
192
+ * detach: (sessionId: string, channel: import('node-datachannel').DataChannel) => void,
193
+ * noteEcho: (sessionId: string, echo: object) => void,
194
+ * dispose: () => void
195
+ * }}
196
+ */
197
+ export function createDeliveryProbe({ log, intervalMs = PROBE_INTERVAL_MS, readDelivery }) {
198
+ /** @type {Map<string, ProbeConnection>} */
199
+ const connections = new Map();
200
+
201
+ /**
202
+ * Send this tick's probe on every channel of one connection, then report.
203
+ *
204
+ * @param {ProbeConnection} connection
205
+ * @returns {void}
206
+ */
207
+ function tick(connection) {
208
+ const now = Date.now();
209
+ connection.seq += 1;
210
+ connection.sentAt = now;
211
+ const message = JSON.stringify({ type: "probe", seq: connection.seq, sentAt: now });
212
+ for (const channel of connection.channels.keys()) {
213
+ try {
214
+ channel.sendMessage(message);
215
+ } catch {
216
+ // A channel closing between the check and the send is ordinary.
217
+ }
218
+ }
219
+
220
+ // What this connection is getting away, and how far behind it therefore
221
+ // sits. Both come from the send-queue watcher, which measures them anyway.
222
+ const delivery = typeof readDelivery === "function" ? readDelivery(connection.id) : null;
223
+ const bytesPerSecond = Number(delivery?.bytesPerSecond) || 0;
224
+ const rttMs = Number(delivery?.rttMs) || 0;
225
+ /** @type {Map<string, number | null>} */
226
+ const allowed = new Map();
227
+ for (const [channel, label] of connection.channels) {
228
+ let queuedBytes = 0;
229
+ try {
230
+ queuedBytes = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
231
+ } catch {
232
+ queuedBytes = 0;
233
+ }
234
+ const allowance = allowedGap({ queuedBytes, bytesPerSecond, rttMs, intervalMs });
235
+ // Several channels can carry one label only in malformed cases; the
236
+ // larger allowance is the safer of the two.
237
+ const held = allowed.get(label);
238
+ if (allowance !== null && (!Number.isInteger(held) || allowance > held)) {
239
+ allowed.set(label, allowance);
240
+ } else if (!allowed.has(label)) {
241
+ allowed.set(label, allowance);
242
+ }
243
+ }
244
+ const widest = [...allowed.values()].reduce(
245
+ (most, value) => (Number.isInteger(value) && value > most ? value : most),
246
+ 0
247
+ );
248
+ const { verdict, detail } = readProbeState({
249
+ seq: connection.seq,
250
+ seen: connection.seen,
251
+ labels: [...new Set(connection.channels.values())],
252
+ echoes: connection.echoes,
253
+ echoAgeMs: connection.echoAt === 0 ? null : now - connection.echoAt,
254
+ allowed,
255
+ echoStaleMs: widest > 0 ? widest * intervalMs + rttMs : 0
256
+ });
257
+ if (verdict !== connection.verdict || now - connection.reportedAt >= REPORT_INTERVAL_MS) {
258
+ connection.verdict = verdict;
259
+ connection.reportedAt = now;
260
+ log(`[dc-probe] ${connection.tag} ${verdict} — ${detail} at=${new Date(now).toISOString()}`);
261
+ }
262
+ }
263
+
264
+ return {
265
+ attach(sessionId, tag, label, channel) {
266
+ let connection = connections.get(sessionId);
267
+ if (!connection) {
268
+ connection = {
269
+ id: sessionId,
270
+ tag,
271
+ channels: new Map(),
272
+ seq: 0,
273
+ sentAt: 0,
274
+ seen: new Map(),
275
+ echoAt: 0,
276
+ echoes: 0,
277
+ verdict: "",
278
+ reportedAt: 0,
279
+ timer: null
280
+ };
281
+ connections.set(sessionId, connection);
282
+ }
283
+ connection.channels.set(channel, label);
284
+ if (connection.timer === null) {
285
+ const held = connection;
286
+ connection.timer = setInterval(() => tick(held), intervalMs);
287
+ // The probe must never be the reason a process stays alive.
288
+ if (typeof connection.timer.unref === "function") {
289
+ connection.timer.unref();
290
+ }
291
+ }
292
+ },
293
+
294
+ detach(sessionId, channel) {
295
+ const connection = connections.get(sessionId);
296
+ if (!connection) {
297
+ return;
298
+ }
299
+ connection.channels.delete(channel);
300
+ if (connection.channels.size === 0) {
301
+ if (connection.timer !== null) {
302
+ clearInterval(connection.timer);
303
+ connection.timer = null;
304
+ }
305
+ if (connections.get(sessionId) === connection) {
306
+ connections.delete(sessionId);
307
+ }
308
+ }
309
+ },
310
+
311
+ noteEcho(sessionId, echo) {
312
+ const connection = connections.get(sessionId);
313
+ if (!connection || !echo || typeof echo !== "object") {
314
+ return;
315
+ }
316
+ const seen = echo.seen;
317
+ if (seen && typeof seen === "object") {
318
+ for (const [label, value] of Object.entries(seen)) {
319
+ if (Number.isInteger(value)) {
320
+ connection.seen.set(label, value);
321
+ }
322
+ }
323
+ }
324
+ connection.echoAt = Date.now();
325
+ connection.echoes += 1;
326
+ },
327
+
328
+ dispose() {
329
+ for (const connection of connections.values()) {
330
+ if (connection.timer !== null) {
331
+ clearInterval(connection.timer);
332
+ connection.timer = null;
333
+ }
334
+ }
335
+ connections.clear();
336
+ }
337
+ };
338
+ }