@torrent-tv/proxy 2.105.12 → 2.106.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.105.12",
3
+ "version": "2.106.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -24,7 +24,7 @@ function getPayload(body) {
24
24
  return {};
25
25
  }
26
26
 
27
- export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers }) {
27
+ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, sourceRegistry, torrentPool, ffmpegBin, localBaseUrl, viewers, observeWait }) {
28
28
  const payload = getPayload(req.body);
29
29
  const sourceKey = typeof payload.sourceKey === "string" ? payload.sourceKey.trim() : "";
30
30
  const fileIndex = Number(payload.fileIndex);
@@ -38,10 +38,13 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, s
38
38
  if (typeof payload.positionSeconds === "number" && Number.isFinite(payload.positionSeconds) && payload.positionSeconds >= 0) selection.positionSeconds = payload.positionSeconds;
39
39
  if (typeof payload.wantsToPlay === "boolean") selection.wantsToPlay = payload.wantsToPlay;
40
40
  if (consumerId) viewers?.selectsFile(consumerId, sourceKey, fileIndex, Date.now(), selection);
41
+ const startedAt = performance.now();
41
42
 
42
43
  // Interface delegates to PlaybackController (orchestrator + domain). Keeps route thin.
43
44
  const { PlaybackController } = await import("../../../services/server/controllers/PlaybackController.js");
44
45
  const controller = new PlaybackController({ torrentPool, sourceRegistry, ffmpegBin, localBaseUrl, playbackPlanner });
46
+ const waitFields = { process: "P3", sourceKey, fileIndex };
47
+ observeWait?.(sourceKey, "process", { ...waitFields, phase: "begin", at: startedAt });
45
48
  const cancellation = new AbortController();
46
49
  const aborted = () => cancellation.abort();
47
50
  const closed = () => { if (!reply.raw?.writableEnded) aborted(); };
@@ -52,8 +55,14 @@ export async function handleApiPlaybackPlanPost(req, reply, { playbackPlanner, s
52
55
  const params = { sourceKey, fileIndex, userAgent };
53
56
  const plan = payload.waitForReady === true
54
57
  ? await controller.getReadyPlan(params, { signal: cancellation.signal }) : await controller.getPlan(params);
58
+ const endedAt = performance.now();
59
+ observeWait?.(sourceKey, "process", { ...waitFields, phase: "end", at: endedAt, startedAt,
60
+ endedAt, durationMs: endedAt - startedAt, outcome: "plan-ready" });
55
61
  return reply.send(plan);
56
62
  } catch (error) {
63
+ const endedAt = performance.now();
64
+ observeWait?.(sourceKey, "process", { ...waitFields, phase: "end", at: endedAt, startedAt,
65
+ endedAt, durationMs: endedAt - startedAt, outcome: cancellation.signal.aborted ? "cancelled" : "error" });
57
66
  if (cancellation.signal.aborted) return;
58
67
  if (error instanceof Error && error.code === "SOURCE_NOT_FOUND") {
59
68
  return reply.code(404).send({ error: error.message });
package/server.js CHANGED
@@ -1614,7 +1614,8 @@ export async function startProxyServer({
1614
1614
  handleApiClientLogsPost(req, reply, { clientLogs, viewerRequests: outputParts.viewerRequests })
1615
1615
  );
1616
1616
  app.post("/api/playback-plan", async (req, reply) =>
1617
- handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers })
1617
+ handleApiPlaybackPlanPost(req, reply, { playbackPlanner, viewers: outputParts.viewers,
1618
+ observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields) })
1618
1619
  );
1619
1620
  app.post("/api/playback-plan/audio-tracks", async (req, reply) =>
1620
1621
  handleApiPlaybackPlanAudioTracksPost(req, reply, { playbackPlanner })
@@ -1234,7 +1234,21 @@ export class EncodeRuns {
1234
1234
  if (mediaRanges === false && tentative) return null;
1235
1235
  if (mediaRanges === false) throw Object.assign(new Error(`Closed piece #${index} has incomplete media.`),
1236
1236
  { code: "ERR_INCOMPLETE_MEDIA" });
1237
- const published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
1237
+ const publishStartedAt = performance.now();
1238
+ const publishProcess = session.spec?.carries === "audio-only" ? "P11.a" : "P11.v";
1239
+ this.#host.observeWait?.(session.file.sourceKey, "process", { process: publishProcess, phase: "begin",
1240
+ output: address, segment: index, at: publishStartedAt });
1241
+ let published;
1242
+ let publishOutcome = "error";
1243
+ try {
1244
+ published = this.#host.segmentFiles.publish(address, name, session.segmentFormat, { mediaRanges, bytes });
1245
+ publishOutcome = published ? "published" : "not-published";
1246
+ } finally {
1247
+ const publishEndedAt = performance.now();
1248
+ this.#host.observeWait?.(session.file.sourceKey, "process", { process: publishProcess, phase: "end",
1249
+ output: address, segment: index, at: publishEndedAt, startedAt: publishStartedAt, endedAt: publishEndedAt,
1250
+ durationMs: publishEndedAt - publishStartedAt, outcome: publishOutcome });
1251
+ }
1238
1252
  if (!published) throw new Error(`Closed piece #${index} could not be stored.`);
1239
1253
  if (index === safeIndex) this.#host.observeWait?.(session.file.sourceKey, "first-piece-published", {
1240
1254
  output: address, segment: index, startedAt: observedStart, endedAt: performance.now(),
@@ -865,6 +865,11 @@ export class ViewerRequests {
865
865
  }
866
866
 
867
867
  async #playbackReadinessFor(session, consumerId, progress, outputMbps) {
868
+ const waitStartedAt = performance.now();
869
+ const waitFields = { process: "P14", sessionId: session.id, consumerId };
870
+ this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "begin", at: waitStartedAt });
871
+ let waitOutcome = "error";
872
+ try {
868
873
  const now = Date.now();
869
874
  // WHERE AN ANSWER'S TIME GOES, said with each answer. On Home Assistant a
870
875
  // held viewer's answer took 11.2 s while its forecast took 0.1-0.4 s and
@@ -1116,7 +1121,14 @@ export class ViewerRequests {
1116
1121
  this.#host.noteViewerWaiting?.(consumerId || session.id,
1117
1122
  holding?.kind === "production" ? { address: holding.track, index: holding.segment } : null);
1118
1123
  delete forecast.firstRequiredStages;
1124
+ waitOutcome = "forecast-ready";
1119
1125
  return forecast;
1126
+ } finally {
1127
+ const waitEndedAt = performance.now();
1128
+ this.#host.observeWait?.(session.file.sourceKey, "process", { ...waitFields, phase: "end", at: waitEndedAt,
1129
+ startedAt: waitStartedAt, endedAt: waitEndedAt, durationMs: waitEndedAt - waitStartedAt,
1130
+ outcome: waitOutcome });
1131
+ }
1120
1132
  }
1121
1133
 
1122
1134
  /**
@@ -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.captureStarted = false;
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
- witness.maybeCapture({
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 `seen` advances again.
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
- * Connections already captured, so one wedge is recorded once.
372
- *
373
- * A capture of a connection already captured adds nothing: the wedge does not
374
- * un-wedge, and the second recording of it is the same silence as the first.
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
- // Not evidence twice.
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
- captured.add(connection);
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
  }
@@ -53,8 +53,9 @@ export class SourceWaitTrace {
53
53
  !Number.isSafeInteger(attempt) || attempt < 0) return;
54
54
  const context = { wait, attempt, operation, startedAt: this.now() };
55
55
  this.requests.set(req, context);
56
- const process = typeof statedProcess === "string" && /^[A-Z][0-9]+(?:\.(?:[0-9]+[a-z]?|[va]))?$/.test(statedProcess)
57
- ? statedProcess : processForRequest(req.url);
56
+ const endpointProcess = processForRequest(req.url);
57
+ const process = endpointProcess ?? (typeof statedProcess === "string" &&
58
+ /^[A-Z][0-9]+(?:\.(?:[0-9]+[a-z]?|[va]))?$/.test(statedProcess) ? statedProcess : null);
58
59
  this.write(context, "http-start", { process, phase: "begin", method: req.method, path: req.url,
59
60
  requestId: req.headers["x-ttv-forwarded-request-id"] ?? req.id });
60
61
  let finished = false;
@@ -92,11 +93,11 @@ export class SourceWaitTrace {
92
93
  }
93
94
 
94
95
  function processForRequest(path) {
96
+ if (/^\/api\/transcode-sessions\/[^/?]+\/progress(?:\?|$)/.test(path)) return "P14";
95
97
  if (path.startsWith("/api/transcode-sessions")) return "P6.v";
96
98
  if (path.startsWith("/api/playback-plan")) return "P3";
97
99
  if (/\/transcode\/.*\.m3u8(?:\?|$)/.test(path)) return "P12";
98
100
  if (/\/transcode\/.*(?:init|\.mp4)(?:\?|$)/.test(path)) return "P12";
99
101
  if (/\/transcode\/.*(?:\.m4s|\.ts)(?:\?|$)/.test(path)) return "P12";
100
- if (path.startsWith("/api/session-progress")) return "P14";
101
102
  return null;
102
103
  }