@torrent-tv/proxy 2.105.11 → 2.105.13
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/package.json
CHANGED
|
@@ -63,10 +63,10 @@ const loopbackDispatcher = new Agent({ headersTimeout: 0, bodyTimeout: 0 });
|
|
|
63
63
|
* @property {(message: string) => void} [onLog]
|
|
64
64
|
* Optional log sink.
|
|
65
65
|
* @property {{ maybeCapture: (trigger: {
|
|
66
|
-
* sessionId: string, tag: string, label: string,
|
|
66
|
+
* sessionId: string, tag: string, captureId: string, label: string,
|
|
67
67
|
* remote: { address: string, port: number } | null,
|
|
68
68
|
* queuedBytes: number, stuckForMs: number
|
|
69
|
-
* }) => boolean }} [witness]
|
|
69
|
+
* }) => boolean, cancelCapture?: (captureId: string) => Promise<boolean> }} [witness]
|
|
70
70
|
* The packet witness (services/transport/packet-witness.js). When {@link wedgeIsCertain}
|
|
71
71
|
* says delivery has stopped, the watcher hands it the transport snapshot's
|
|
72
72
|
* remote endpoint: the ring's history is kept and a tail capture records what
|
|
@@ -182,7 +182,7 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
|
|
|
182
182
|
// closing, delete the survivor's entry. `captureStarted` rides on the same
|
|
183
183
|
// record: both channels of one wedged connection must ask the witness once,
|
|
184
184
|
// not once per channel.
|
|
185
|
-
/** @type {Map<string, { channels: Map<DataChannel, string>, at: number, previous: object | null, unknown: number, captureStarted: boolean }>} */
|
|
185
|
+
/** @type {Map<string, { channels: Map<DataChannel, string>, at: number, previous: object | null, unknown: number, captureStarted: boolean, captureId: string | null, captureChannel: DataChannel | null, captureSequence: number }>} */
|
|
186
186
|
const connections = new Map();
|
|
187
187
|
|
|
188
188
|
/**
|
|
@@ -288,6 +288,9 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
|
|
|
288
288
|
previous: null,
|
|
289
289
|
unknown: 0,
|
|
290
290
|
captureStarted: false,
|
|
291
|
+
captureId: null,
|
|
292
|
+
captureChannel: null,
|
|
293
|
+
captureSequence: 0,
|
|
291
294
|
// The accepted-byte counter and when it last moved, plus the rate it
|
|
292
295
|
// was moving at. `wedgeIsCertain` divides the queue by that rate.
|
|
293
296
|
rateAt: 0,
|
|
@@ -324,6 +327,14 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
|
|
|
324
327
|
}
|
|
325
328
|
stopped = true;
|
|
326
329
|
witness?.releaseRing?.();
|
|
330
|
+
if (connection.captureChannel === channel) {
|
|
331
|
+
if (connection.captureId) {
|
|
332
|
+
void witness?.cancelCapture?.(connection.captureId);
|
|
333
|
+
}
|
|
334
|
+
connection.captureStarted = false;
|
|
335
|
+
connection.captureId = null;
|
|
336
|
+
connection.captureChannel = null;
|
|
337
|
+
}
|
|
327
338
|
if (timer) {
|
|
328
339
|
clearInterval(timer);
|
|
329
340
|
}
|
|
@@ -454,7 +465,14 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
|
|
|
454
465
|
// The queue moved, so whatever was called a wedge has cleared. Let a
|
|
455
466
|
// later one be recorded too: one mistaken call must not spend the
|
|
456
467
|
// session's only capture.
|
|
457
|
-
connection.
|
|
468
|
+
if (connection.captureChannel === channel) {
|
|
469
|
+
if (connection.captureId) {
|
|
470
|
+
void witness?.cancelCapture?.(connection.captureId);
|
|
471
|
+
}
|
|
472
|
+
connection.captureStarted = false;
|
|
473
|
+
connection.captureId = null;
|
|
474
|
+
connection.captureChannel = null;
|
|
475
|
+
}
|
|
458
476
|
return;
|
|
459
477
|
}
|
|
460
478
|
const now = Date.now();
|
|
@@ -535,14 +553,23 @@ function makeSendQueueWatcher({ log, getTransportSnapshot, witness, usrsctpState
|
|
|
535
553
|
peerStillSending
|
|
536
554
|
) {
|
|
537
555
|
connection.captureStarted = true;
|
|
538
|
-
|
|
556
|
+
const captureId = `${sessionId}:send-queue:${++connection.captureSequence}`;
|
|
557
|
+
connection.captureId = captureId;
|
|
558
|
+
connection.captureChannel = channel;
|
|
559
|
+
const started = witness.maybeCapture({
|
|
539
560
|
sessionId,
|
|
540
561
|
tag,
|
|
562
|
+
captureId,
|
|
541
563
|
label,
|
|
542
564
|
remote: snapshot.remote ?? null,
|
|
543
565
|
queuedBytes: queued,
|
|
544
566
|
stuckForMs: now - stuckSince
|
|
545
567
|
});
|
|
568
|
+
if (!started) {
|
|
569
|
+
connection.captureStarted = false;
|
|
570
|
+
connection.captureId = null;
|
|
571
|
+
connection.captureChannel = null;
|
|
572
|
+
}
|
|
546
573
|
}
|
|
547
574
|
if (usrsctpState && verdict.certain && peerStillSending) {
|
|
548
575
|
// Its own single-flight and cooldown, independent of the witness's —
|
|
@@ -246,7 +246,9 @@ export function probeWedgeIsCertain({
|
|
|
246
246
|
* @property {number | null} peerBytesAtTick - The same, as it stood at the previous tick.
|
|
247
247
|
* @property {number | null} peerLoopLagMs - The far end's own event-loop delay, as last reported.
|
|
248
248
|
* @property {string | null} peerVisibility - Whether the far end's tab is visible or hidden, as last reported.
|
|
249
|
-
* @property {boolean} probeCaptureStarted - One evidence-gathering attempt per wedge; reset once
|
|
249
|
+
* @property {boolean} probeCaptureStarted - One evidence-gathering attempt per wedge; reset once the verdict clears.
|
|
250
|
+
* @property {number} probeCaptureSequence
|
|
251
|
+
* @property {string | null} probeCaptureId
|
|
250
252
|
* @property {ReturnType<typeof setInterval> | null} timer
|
|
251
253
|
*/
|
|
252
254
|
|
|
@@ -409,7 +411,7 @@ export function readProbeState(state) {
|
|
|
409
411
|
* @param {(sessionId: string) => object | null} [options.getTransportSnapshot]
|
|
410
412
|
* Needed only to hand the witness a remote endpoint when this probe is the
|
|
411
413
|
* one declaring a wedge.
|
|
412
|
-
* @param {{ maybeCapture: (trigger: object) => boolean }} [options.witness]
|
|
414
|
+
* @param {{ maybeCapture: (trigger: object) => boolean, cancelCapture?: (captureId: string) => Promise<boolean> }} [options.witness]
|
|
413
415
|
* @param {{ maybeRead: (reasonText: string) => boolean }} [options.usrsctpState]
|
|
414
416
|
* @returns {{
|
|
415
417
|
* attach: (sessionId: string, tag: string, label: string, channel: import('node-datachannel').DataChannel) => void,
|
|
@@ -599,6 +601,8 @@ export function createDeliveryProbe({
|
|
|
599
601
|
});
|
|
600
602
|
if (isCertain && !connection.probeCaptureStarted) {
|
|
601
603
|
connection.probeCaptureStarted = true;
|
|
604
|
+
const captureId = `${connection.id}:probe:${++connection.probeCaptureSequence}`;
|
|
605
|
+
connection.probeCaptureId = captureId;
|
|
602
606
|
const reasonText =
|
|
603
607
|
`probe seen-counter unmoved ${Math.round(stuckForMs / 1000)}s against the ` +
|
|
604
608
|
`${(needMs / 1000).toFixed(1)}s this connection's own history says is legitimate`;
|
|
@@ -607,6 +611,7 @@ export function createDeliveryProbe({
|
|
|
607
611
|
const started = witness.maybeCapture({
|
|
608
612
|
sessionId: connection.id,
|
|
609
613
|
tag: connection.tag,
|
|
614
|
+
captureId,
|
|
610
615
|
label: "probe",
|
|
611
616
|
remote: snapshot?.remote ?? null,
|
|
612
617
|
queuedBytes: 0,
|
|
@@ -614,16 +619,27 @@ export function createDeliveryProbe({
|
|
|
614
619
|
});
|
|
615
620
|
if (!started) {
|
|
616
621
|
connection.probeCaptureStarted = false;
|
|
622
|
+
connection.probeCaptureId = null;
|
|
617
623
|
}
|
|
618
624
|
}
|
|
619
625
|
if (usrsctpState) {
|
|
620
626
|
usrsctpState.maybeRead(reasonText);
|
|
621
627
|
}
|
|
628
|
+
} else if (!isCertain) {
|
|
629
|
+
connection.probeCaptureStarted = false;
|
|
630
|
+
if (connection.probeCaptureId) {
|
|
631
|
+
void witness?.cancelCapture?.(connection.probeCaptureId);
|
|
632
|
+
connection.probeCaptureId = null;
|
|
633
|
+
}
|
|
622
634
|
}
|
|
623
635
|
} else {
|
|
624
636
|
// Not association-stopped any more: whatever was flagged has cleared,
|
|
625
637
|
// and a later wedge on the same connection deserves its own attempt.
|
|
626
638
|
connection.probeCaptureStarted = false;
|
|
639
|
+
if (connection.probeCaptureId) {
|
|
640
|
+
void witness?.cancelCapture?.(connection.probeCaptureId);
|
|
641
|
+
connection.probeCaptureId = null;
|
|
642
|
+
}
|
|
627
643
|
}
|
|
628
644
|
}
|
|
629
645
|
|
|
@@ -672,6 +688,8 @@ export function createDeliveryProbe({
|
|
|
672
688
|
longestHealthySeenGapMs: 0,
|
|
673
689
|
sentAtBySeq: new Map(),
|
|
674
690
|
probeCaptureStarted: false,
|
|
691
|
+
probeCaptureSequence: 0,
|
|
692
|
+
probeCaptureId: null,
|
|
675
693
|
timer: null
|
|
676
694
|
};
|
|
677
695
|
connections.set(sessionId, connection);
|
|
@@ -701,6 +719,10 @@ export function createDeliveryProbe({
|
|
|
701
719
|
if (connections.get(sessionId) === connection) {
|
|
702
720
|
connections.delete(sessionId);
|
|
703
721
|
}
|
|
722
|
+
if (connection.probeCaptureId) {
|
|
723
|
+
void witness?.cancelCapture?.(connection.probeCaptureId);
|
|
724
|
+
connection.probeCaptureId = null;
|
|
725
|
+
}
|
|
704
726
|
}
|
|
705
727
|
},
|
|
706
728
|
|
|
@@ -720,6 +742,10 @@ export function createDeliveryProbe({
|
|
|
720
742
|
}
|
|
721
743
|
connection.channels.clear();
|
|
722
744
|
connections.delete(sessionId);
|
|
745
|
+
if (connection.probeCaptureId) {
|
|
746
|
+
void witness?.cancelCapture?.(connection.probeCaptureId);
|
|
747
|
+
connection.probeCaptureId = null;
|
|
748
|
+
}
|
|
723
749
|
},
|
|
724
750
|
|
|
725
751
|
noteEcho(sessionId, echo) {
|
|
@@ -97,12 +97,26 @@ export const WITNESS_RING_BASENAME = "packet-witness-ring.pcap";
|
|
|
97
97
|
* @typedef {Object} WitnessTrigger
|
|
98
98
|
* @property {string} sessionId
|
|
99
99
|
* @property {string} tag - Session id, first 8 characters.
|
|
100
|
+
* @property {string} captureId - Identity of the diagnosis that owns this capture.
|
|
100
101
|
* @property {string} label - Data channel label ("proxy", "proxy-control").
|
|
101
102
|
* @property {WitnessRemoteEndpoint | null} remote
|
|
102
103
|
* @property {number} queuedBytes
|
|
103
104
|
* @property {number} stuckForMs
|
|
104
105
|
*/
|
|
105
106
|
|
|
107
|
+
/**
|
|
108
|
+
* @typedef {Object} PacketCapture
|
|
109
|
+
* @property {string} connection
|
|
110
|
+
* @property {Set<string>} incidents
|
|
111
|
+
* @property {boolean} cancelled
|
|
112
|
+
* @property {import("node:child_process").ChildProcess | null} child
|
|
113
|
+
* @property {NodeJS.Timeout | null} cancelKill
|
|
114
|
+
* @property {string} filePrefix
|
|
115
|
+
* @property {Set<string>} files
|
|
116
|
+
* @property {Promise<void>} finished
|
|
117
|
+
* @property {() => void} finish
|
|
118
|
+
*/
|
|
119
|
+
|
|
106
120
|
/**
|
|
107
121
|
* Strip a zone suffix and keep only IPv4/IPv6 literals.
|
|
108
122
|
*
|
|
@@ -356,6 +370,7 @@ let logLine = () => {};
|
|
|
356
370
|
* @returns {{
|
|
357
371
|
* dir: string,
|
|
358
372
|
* maybeCapture: (trigger: WitnessTrigger) => boolean,
|
|
373
|
+
* cancelCapture: (captureId: string) => Promise<boolean>,
|
|
359
374
|
* holdRing: () => void,
|
|
360
375
|
* releaseRing: () => void
|
|
361
376
|
* }}
|
|
@@ -367,16 +382,11 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
367
382
|
/** @type {{ running: boolean, lastStartedAt: number, availability: "unknown" | "yes" | "no" }} */
|
|
368
383
|
const state = { running: false, lastStartedAt: 0, availability: "unknown" };
|
|
369
384
|
|
|
370
|
-
/**
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
* Field 2026-09-06: thirteen captures of ONE wedge over six hours, 74 MB, all
|
|
376
|
-
* saying what the first one said.
|
|
377
|
-
*
|
|
378
|
-
* @type {Set<string>}
|
|
379
|
-
*/
|
|
385
|
+
/** @type {Map<string, PacketCapture>} */
|
|
386
|
+
const capturesByConnection = new Map();
|
|
387
|
+
/** @type {Map<string, PacketCapture>} */
|
|
388
|
+
const capturesByIncident = new Map();
|
|
389
|
+
/** @type {Set<string>} */
|
|
380
390
|
const captured = new Set();
|
|
381
391
|
|
|
382
392
|
/**
|
|
@@ -416,9 +426,10 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
416
426
|
*
|
|
417
427
|
* @param {WitnessTrigger} trigger
|
|
418
428
|
* @param {number} stampSeconds - Shared with the tail, so the pair sorts together.
|
|
429
|
+
* @param {PacketCapture} capture
|
|
419
430
|
* @returns {Promise<string[]>} Names of the copies that landed.
|
|
420
431
|
*/
|
|
421
|
-
const preserveRing = async (trigger, stampSeconds) => {
|
|
432
|
+
const preserveRing = async (trigger, stampSeconds, capture) => {
|
|
422
433
|
/** @type {string[]} */
|
|
423
434
|
const kept = [];
|
|
424
435
|
ring.preserving = true;
|
|
@@ -431,11 +442,15 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
431
442
|
return kept;
|
|
432
443
|
}
|
|
433
444
|
for (const name of names) {
|
|
445
|
+
if (capture.cancelled) {
|
|
446
|
+
break;
|
|
447
|
+
}
|
|
434
448
|
const suffix = name.slice(WITNESS_RING_BASENAME.length) || "0";
|
|
435
449
|
const target = `packet-witness.${trigger.tag}.${stampSeconds}.before${suffix}.pcap`;
|
|
436
450
|
try {
|
|
437
451
|
await copyFile(path.join(resolvedDir, name), path.join(resolvedDir, target));
|
|
438
452
|
kept.push(target);
|
|
453
|
+
capture.files.add(target);
|
|
439
454
|
} catch {
|
|
440
455
|
// silent-ok: nothing to copy, or it vanished under us.
|
|
441
456
|
}
|
|
@@ -458,9 +473,10 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
458
473
|
* @param {WitnessTrigger} trigger
|
|
459
474
|
* @param {string} address - Validated remote IP literal.
|
|
460
475
|
* @param {number} stampSeconds
|
|
476
|
+
* @param {PacketCapture} capture
|
|
461
477
|
* @returns {Promise<void>}
|
|
462
478
|
*/
|
|
463
|
-
const runCapture = async (trigger, address, stampSeconds) => {
|
|
479
|
+
const runCapture = async (trigger, address, stampSeconds, capture) => {
|
|
464
480
|
const prefix = path.join(
|
|
465
481
|
resolvedDir,
|
|
466
482
|
`packet-witness.${trigger.tag}.${stampSeconds}.tail.pcap`
|
|
@@ -472,6 +488,7 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
472
488
|
fileMegabytes: WITNESS_TAIL_FILE_MB,
|
|
473
489
|
files: WITNESS_TAIL_FILES
|
|
474
490
|
});
|
|
491
|
+
capture.filePrefix = path.basename(prefix);
|
|
475
492
|
logLine(
|
|
476
493
|
`packet witness: capturing ${WITNESS_TAIL_SECONDS}s of udp port ${port} ↔ ${address} ` +
|
|
477
494
|
`(session ${trigger.tag}, "${trigger.label}" queue ${trigger.queuedBytes}B wedged ` +
|
|
@@ -492,6 +509,7 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
492
509
|
resolve();
|
|
493
510
|
return;
|
|
494
511
|
}
|
|
512
|
+
capture.child = child;
|
|
495
513
|
const finish = () => {
|
|
496
514
|
if (killer) {
|
|
497
515
|
clearTimeout(killer);
|
|
@@ -501,6 +519,13 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
501
519
|
clearTimeout(hardKill);
|
|
502
520
|
hardKill = null;
|
|
503
521
|
}
|
|
522
|
+
if (capture.child === child) {
|
|
523
|
+
capture.child = null;
|
|
524
|
+
}
|
|
525
|
+
if (capture.cancelKill) {
|
|
526
|
+
clearTimeout(capture.cancelKill);
|
|
527
|
+
capture.cancelKill = null;
|
|
528
|
+
}
|
|
504
529
|
resolve();
|
|
505
530
|
};
|
|
506
531
|
child.on("error", (error) => {
|
|
@@ -533,6 +558,7 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
533
558
|
try {
|
|
534
559
|
const info = await stat(path.join(resolvedDir, name));
|
|
535
560
|
mine.push(`${name} ${(info.size / 1024).toFixed(1)} KB`);
|
|
561
|
+
capture.files.add(name);
|
|
536
562
|
} catch { /* gone between listing and reading */ }
|
|
537
563
|
}
|
|
538
564
|
logLine(`packet witness: wrote ${mine.length} file(s): ${mine.join(", ") || "none"}`);
|
|
@@ -553,12 +579,20 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
553
579
|
if (!address || !Number.isInteger(portNumber) || portNumber < 1 || portNumber > 65535) {
|
|
554
580
|
return false;
|
|
555
581
|
}
|
|
556
|
-
if (!shouldStartCapture(state)) {
|
|
557
|
-
return false;
|
|
558
|
-
}
|
|
559
582
|
const connection = `${address}:${portNumber}`;
|
|
583
|
+
const captureId = typeof trigger?.captureId === "string" && trigger.captureId.length > 0
|
|
584
|
+
? trigger.captureId
|
|
585
|
+
: `${trigger?.sessionId ?? trigger?.tag ?? connection}:${trigger?.label ?? "unknown"}`;
|
|
586
|
+
const existing = capturesByConnection.get(connection);
|
|
587
|
+
if (existing && !existing.cancelled) {
|
|
588
|
+
existing.incidents.add(captureId);
|
|
589
|
+
capturesByIncident.set(captureId, existing);
|
|
590
|
+
return true;
|
|
591
|
+
}
|
|
560
592
|
if (captured.has(connection)) {
|
|
561
|
-
|
|
593
|
+
return false;
|
|
594
|
+
}
|
|
595
|
+
if (!shouldStartCapture(state)) {
|
|
562
596
|
return false;
|
|
563
597
|
}
|
|
564
598
|
// The evidence takes room from the product, so it is asked for. Refused, it
|
|
@@ -567,7 +601,21 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
567
601
|
if (typeof mayKeep === "function" && !mayKeep(expectedBytes)) {
|
|
568
602
|
return false;
|
|
569
603
|
}
|
|
570
|
-
|
|
604
|
+
/** @type {PacketCapture} */
|
|
605
|
+
const capture = {
|
|
606
|
+
connection,
|
|
607
|
+
incidents: new Set([captureId]),
|
|
608
|
+
cancelled: false,
|
|
609
|
+
child: null,
|
|
610
|
+
cancelKill: null,
|
|
611
|
+
filePrefix: "",
|
|
612
|
+
files: new Set(),
|
|
613
|
+
finished: Promise.resolve(),
|
|
614
|
+
finish: () => {}
|
|
615
|
+
};
|
|
616
|
+
capture.finished = new Promise((resolve) => { capture.finish = resolve; });
|
|
617
|
+
capturesByConnection.set(connection, capture);
|
|
618
|
+
capturesByIncident.set(captureId, capture);
|
|
571
619
|
state.running = true;
|
|
572
620
|
state.lastStartedAt = Date.now();
|
|
573
621
|
const startedAt = state.lastStartedAt;
|
|
@@ -576,6 +624,9 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
576
624
|
if (state.availability === "unknown") {
|
|
577
625
|
state.availability = (await probeAvailability()) ? "yes" : "no";
|
|
578
626
|
}
|
|
627
|
+
if (capture.cancelled) {
|
|
628
|
+
return;
|
|
629
|
+
}
|
|
579
630
|
if (state.availability === "no") {
|
|
580
631
|
logLine(
|
|
581
632
|
"packet witness: unavailable — no tcpdump on this host; " +
|
|
@@ -584,29 +635,111 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
584
635
|
return;
|
|
585
636
|
}
|
|
586
637
|
const stampSeconds = Math.floor(Date.now() / 1000);
|
|
587
|
-
const before = await preserveRing(trigger, stampSeconds);
|
|
638
|
+
const before = await preserveRing(trigger, stampSeconds, capture);
|
|
639
|
+
if (capture.cancelled) {
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
588
642
|
logLine(
|
|
589
643
|
before.length > 0
|
|
590
644
|
? `packet witness: kept ${before.length} ring file(s) from before the wedge: ${before.join(", ")}`
|
|
591
645
|
: "packet witness: no ring history to keep — the ring was not running"
|
|
592
646
|
);
|
|
593
|
-
await runCapture(trigger, address, stampSeconds);
|
|
647
|
+
await runCapture(trigger, address, stampSeconds, capture);
|
|
648
|
+
if (capture.cancelled) {
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
594
651
|
// The disk bound has to hold between restarts too: one episode writes
|
|
595
652
|
// up to four ring copies and two tail files, and the cooldown allows
|
|
596
653
|
// six episodes an hour. Pruning only at startup let that accumulate.
|
|
597
654
|
await pruneWitnessCaptures(resolvedDir, chooseRemovals);
|
|
655
|
+
captured.add(connection);
|
|
598
656
|
} finally {
|
|
657
|
+
if (capture.cancelled) {
|
|
658
|
+
await removeCaptureFiles(capture);
|
|
659
|
+
}
|
|
660
|
+
if (capturesByConnection.get(connection) === capture) {
|
|
661
|
+
capturesByConnection.delete(connection);
|
|
662
|
+
}
|
|
663
|
+
for (const incident of capture.incidents) {
|
|
664
|
+
if (capturesByIncident.get(incident) === capture) {
|
|
665
|
+
capturesByIncident.delete(incident);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
599
668
|
state.running = false;
|
|
600
669
|
// Keep the requested spacing honest even when the capture ended early.
|
|
601
670
|
const earliestNext = startedAt + WITNESS_COOLDOWN_MS;
|
|
602
671
|
if (state.lastStartedAt < earliestNext) {
|
|
603
672
|
state.lastStartedAt = earliestNext;
|
|
604
673
|
}
|
|
674
|
+
capture.finish();
|
|
605
675
|
}
|
|
606
676
|
})();
|
|
607
677
|
return true;
|
|
608
678
|
};
|
|
609
679
|
|
|
680
|
+
/**
|
|
681
|
+
* Cancel one diagnosis's ownership of a capture. The evidence is removed only
|
|
682
|
+
* after every diagnosis that shared this connection's capture has cleared.
|
|
683
|
+
*
|
|
684
|
+
* @param {string} captureId
|
|
685
|
+
* @returns {Promise<boolean>} Whether this diagnosis owned a live capture.
|
|
686
|
+
*/
|
|
687
|
+
const cancelCapture = async (captureId) => {
|
|
688
|
+
const capture = capturesByIncident.get(captureId);
|
|
689
|
+
if (!capture) {
|
|
690
|
+
return false;
|
|
691
|
+
}
|
|
692
|
+
capturesByIncident.delete(captureId);
|
|
693
|
+
capture.incidents.delete(captureId);
|
|
694
|
+
if (capture.incidents.size > 0) {
|
|
695
|
+
return true;
|
|
696
|
+
}
|
|
697
|
+
capture.cancelled = true;
|
|
698
|
+
captured.delete(capture.connection);
|
|
699
|
+
if (capture.child) {
|
|
700
|
+
capture.cancelKill = setTimeout(() => {
|
|
701
|
+
try {
|
|
702
|
+
capture.child?.kill("SIGKILL");
|
|
703
|
+
} catch {
|
|
704
|
+
// The child may have exited between the check and the signal.
|
|
705
|
+
}
|
|
706
|
+
}, WITNESS_KILL_GRACE_MS);
|
|
707
|
+
capture.cancelKill.unref?.();
|
|
708
|
+
try {
|
|
709
|
+
capture.child.kill("SIGTERM");
|
|
710
|
+
} catch {
|
|
711
|
+
// The child may have exited between the check and the signal.
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
await capture.finished;
|
|
715
|
+
return true;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
/** @param {PacketCapture} capture */
|
|
719
|
+
const removeCaptureFiles = async (capture) => {
|
|
720
|
+
if (capture.filePrefix) {
|
|
721
|
+
try {
|
|
722
|
+
for (const name of await readdir(resolvedDir)) {
|
|
723
|
+
if (name.startsWith(capture.filePrefix) && isWitnessCapture(name)) {
|
|
724
|
+
capture.files.add(name);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
} catch {
|
|
728
|
+
// No output directory or no files to remove.
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
for (const name of capture.files) {
|
|
732
|
+
try {
|
|
733
|
+
await rm(path.join(resolvedDir, name), { force: true });
|
|
734
|
+
} catch {
|
|
735
|
+
// Best effort; a later pruning pass handles files that could not be removed.
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
if (capture.files.size > 0) {
|
|
739
|
+
logLine(`packet witness: removed ${capture.files.size} capture file(s) after recovery`);
|
|
740
|
+
}
|
|
741
|
+
};
|
|
742
|
+
|
|
610
743
|
/**
|
|
611
744
|
* The rolling ring: one tcpdump, reference-counted by open data channels.
|
|
612
745
|
*
|
|
@@ -803,6 +936,6 @@ export function createPacketWitness({ log, dir = "", port, spawnProcess = spawn,
|
|
|
803
936
|
await clearRingFiles();
|
|
804
937
|
};
|
|
805
938
|
|
|
806
|
-
return { dir: resolvedDir, maybeCapture, holdRing, releaseRing, dispose, ring };
|
|
939
|
+
return { dir: resolvedDir, maybeCapture, cancelCapture, holdRing, releaseRing, dispose, ring };
|
|
807
940
|
|
|
808
941
|
}
|
|
@@ -519,17 +519,27 @@ export function predictPlaybackReadiness(input = {}) {
|
|
|
519
519
|
const ahead = secondsAhead(earliest(before, trackEnd), at);
|
|
520
520
|
constrain(completion.at - ahead, "delivery", trackIndex, completion.segment.index, completion.at, ahead, false,
|
|
521
521
|
completion.sourceArrivalAt, completion.sourcePlanned);
|
|
522
|
-
|
|
522
|
+
const beyond = completion.segment.ranges.filter(({ start }) => compare(start, before) > 0);
|
|
523
|
+
if (compare(after, before) <= 0 && beyond.length > 0) {
|
|
523
524
|
// A timestamp hole cannot be repaired by waiting longer. Keep it
|
|
524
525
|
// distinct from an unavailable rate or a slow but finite service.
|
|
525
|
-
|
|
526
|
+
// Said with the track and both edges: the refusals of
|
|
527
|
+
// torrent-tv/meta#105 could not be told apart afterwards.
|
|
528
|
+
return { ...result(false, null, buffered, reserve, null, 'media-continuity-unavailable', preparedSegments),
|
|
529
|
+
discontinuity: { trackId: state.track.id ?? trackIndex, segmentIndex: completion.segment.index,
|
|
530
|
+
contiguousEndSeconds: toSeconds(before),
|
|
531
|
+
rangeStartSeconds: toSeconds(earliest(...beyond.map(({ start }) => start))) } };
|
|
526
532
|
}
|
|
527
533
|
}
|
|
528
534
|
if (compare(after, trackEnd) < 0) {
|
|
529
535
|
// Everything this track still needs has been delivered in the schedule
|
|
530
536
|
// and its media still stops short of the end: a hole between ranges
|
|
531
537
|
// already held, which no arrival crosses.
|
|
532
|
-
|
|
538
|
+
const next = delivered.find(({ start }) => compare(start, after) > 0);
|
|
539
|
+
return { ...result(false, null, buffered, reserve, null, 'media-continuity-unavailable', preparedSegments),
|
|
540
|
+
discontinuity: { trackId: state.track.id ?? trackIndex, segmentIndex: null,
|
|
541
|
+
contiguousEndSeconds: toSeconds(after), rangeStartSeconds: next ? toSeconds(next.start) : null,
|
|
542
|
+
trackEndSeconds: toSeconds(trackEnd) } };
|
|
533
543
|
}
|
|
534
544
|
for (const production of productions.filter((item) => item.trackIndex === trackIndex)) {
|
|
535
545
|
const before = contiguousEnd(prepared, at);
|