@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.
- package/CHANGELOG.md +1169 -1158
- package/bin/cli.js +494 -478
- package/package.json +1 -1
- package/services/data-channel-handler.js +1133 -949
- package/services/delivery-probe.js +338 -255
- package/services/packet-witness.js +784 -406
- package/test/delivery-probe.test.js +124 -78
- package/test/packet-witness-ring.test.js +236 -0
- package/test/packet-witness.test.js +148 -120
- package/test/wedge-certainty.test.js +131 -0
|
@@ -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
|
|
39
|
-
*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
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
|
+
}
|