@torrent-tv/proxy 2.103.0 → 2.104.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.103.0",
3
+ "version": "2.104.1",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -27,6 +27,7 @@ import { noteTorrentDestroyed } from "./destroyed-torrents.js";
27
27
  import { PieceTimings, describePieceTiming } from "./piece-timing.js";
28
28
  import { TailRepeat } from "./worker/tail-repeat.js";
29
29
  import { tracePieceStorePut } from "./worker/trace-piece-store-put.js";
30
+ import { beginSwarmLifecycleTrace, noteSwarmDiscovery, noteSwarmMetadata, noteSwarmPeer } from "./worker/trace-swarm-lifecycle.js";
30
31
 
31
32
  /**
32
33
  * The second-peer requests for the last blocks of a piece somebody waits for,
@@ -967,7 +968,8 @@ export class TorrentPool {
967
968
  /**
968
969
  * When each torrent was added, and when its first peer connected.
969
970
  *
970
- * @type {WeakMap<import("webtorrent").Torrent, { addedAt: number, firstPeerAt: number | null }>}
971
+ * @type {WeakMap<import("webtorrent").Torrent, { addedAt: number, addedAtMonotonic: number, firstPeerAt: number | null,
972
+ * firstPeerAtMonotonic: number | null, metadataWaitStartedAt: number | null }>}
971
973
  */
972
974
  #swarmTimingByTorrent = new WeakMap();
973
975
  #waitTraceByTorrent = new WeakMap();
@@ -1509,10 +1511,16 @@ export class TorrentPool {
1509
1511
  if (waitTrace?.trace === true && typeof waitTrace.sourceKey === "string") {
1510
1512
  this.#waitTraceByTorrent.set(torrent, waitTrace);
1511
1513
  }
1514
+ const alreadyTracked = this.#swarmTimingByTorrent.has(torrent);
1515
+ if (!alreadyTracked) {
1516
+ this.#swarmTimingByTorrent.set(torrent, { addedAt: Date.now(), addedAtMonotonic: performance.now(),
1517
+ firstPeerAt: null, firstPeerAtMonotonic: null, peerWaitStartedAt: null, metadataWaitStartedAt: null });
1518
+ }
1512
1519
  const trace = (process, phase, fields = {}) => {
1513
1520
  const context = this.#waitTraceByTorrent.get(torrent);
1514
1521
  if (context?.trace === true) context.emitWaitTrace?.(context.sourceKey, process, phase, fields);
1515
1522
  };
1523
+ const pool = this;
1516
1524
  const wrap = (method, process) => {
1517
1525
  const original = torrent[method];
1518
1526
  if (typeof original !== "function") return;
@@ -1521,20 +1529,32 @@ export class TorrentPool {
1521
1529
  trace(process, "begin", { startedAt });
1522
1530
  try {
1523
1531
  const result = original.apply(this, args);
1532
+ const noteDiscoveryEnd = () => {
1533
+ if (process === "W2" && result !== false) {
1534
+ const timing = pool.#swarmTimingByTorrent.get(torrent);
1535
+ if (timing) noteSwarmDiscovery(timing, trace, performance.now());
1536
+ }
1537
+ };
1524
1538
  if (result && typeof result.then === "function") {
1525
1539
  return result.then(value => {
1526
- trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "resolved" });
1540
+ noteDiscoveryEnd();
1541
+ const endedAt = performance.now();
1542
+ trace(process, "end", { startedAt, endedAt, durationMs: endedAt - startedAt, outcome: "resolved" });
1527
1543
  return value;
1528
1544
  }, error => {
1529
- trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "error",
1545
+ const endedAt = performance.now();
1546
+ trace(process, "end", { startedAt, endedAt, durationMs: endedAt - startedAt, outcome: "error",
1530
1547
  error: String(error?.message ?? error) });
1531
1548
  throw error;
1532
1549
  });
1533
1550
  }
1534
- trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "returned" });
1551
+ noteDiscoveryEnd();
1552
+ const endedAt = performance.now();
1553
+ trace(process, "end", { startedAt, endedAt, durationMs: endedAt - startedAt, outcome: "returned" });
1535
1554
  return result;
1536
1555
  } catch (error) {
1537
- trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "error",
1556
+ const endedAt = performance.now();
1557
+ trace(process, "end", { startedAt, endedAt, durationMs: endedAt - startedAt, outcome: "error",
1538
1558
  error: String(error?.message ?? error) });
1539
1559
  throw error;
1540
1560
  }
@@ -1542,7 +1562,6 @@ export class TorrentPool {
1542
1562
  };
1543
1563
  if (waitTrace?.trace === true && !this.#waitTraceHooks.has(torrent)) {
1544
1564
  this.#waitTraceHooks.add(torrent);
1545
- wrap("_onListening", "W1");
1546
1565
  wrap("_startDiscovery", "W2");
1547
1566
  wrap("_onStore", "W6");
1548
1567
  tracePieceStorePut(pieceStoreOf(torrent), trace);
@@ -1557,8 +1576,17 @@ export class TorrentPool {
1557
1576
  }, ...args);
1558
1577
  };
1559
1578
  }
1560
- torrent.on("metadata", metadata => trace("W4", "event", { files: metadata?.files?.length ?? null }));
1561
- torrent.on("wire", wire => trace("W3", "event", { peer: String(wire?.remoteAddress ?? "?") }));
1579
+ const timing = this.#swarmTimingByTorrent.get(torrent);
1580
+ if (timing) beginSwarmLifecycleTrace(timing, trace, Boolean(torrent.metadata));
1581
+ torrent.on("metadata", metadata => {
1582
+ const current = this.#swarmTimingByTorrent.get(torrent);
1583
+ if (current) noteSwarmMetadata(current, trace, metadata?.files?.length ?? null);
1584
+ });
1585
+ torrent.on("wire", wire => {
1586
+ trace("W3", "event", { peer: String(wire?.remoteAddress ?? "?") });
1587
+ const current = this.#swarmTimingByTorrent.get(torrent);
1588
+ if (current) noteSwarmPeer(current, trace, Boolean(torrent.metadata));
1589
+ });
1562
1590
  }
1563
1591
  // Attached ONCE per torrent. WebTorrent answers a duplicate add by handing
1564
1592
  // back the torrent it already has, and this used to run again on it: a
@@ -1569,7 +1597,7 @@ export class TorrentPool {
1569
1597
  // once as a .torrent and once as a magnet is exactly that case, and the
1570
1598
  // roadmap already records it happening. Trace hooks above are installed
1571
1599
  // separately and only for an explicitly traced source.
1572
- if (this.#swarmTimingByTorrent.has(torrent)) {
1600
+ if (alreadyTracked) {
1573
1601
  return;
1574
1602
  }
1575
1603
  // A torrent nobody has asked for yet does not exist: this is called the
@@ -1590,9 +1618,6 @@ export class TorrentPool {
1590
1618
  // for the whole life of the torrent, which is the same fault as three
1591
1619
  // different hashes with the hash removed.
1592
1620
  const label = () => String(torrent.infoHash ?? "?").slice(0, 8);
1593
- const addedAt = Date.now();
1594
- this.#swarmTimingByTorrent.set(torrent, { addedAt, firstPeerAt: null });
1595
-
1596
1621
  torrent.on("download-request-cancelled", ({ piece, offset, length, reason, requestId }) => {
1597
1622
  logger.info(`download request=${requestId ?? `${torrent.infoHash ?? "unknown"}:${piece}:${offset}`} cancel bytes=${length} reason=${reason}`);
1598
1623
  });
@@ -1657,6 +1682,7 @@ export class TorrentPool {
1657
1682
  return;
1658
1683
  }
1659
1684
  timing.firstPeerAt = Date.now();
1685
+ timing.firstPeerAtMonotonic ??= performance.now();
1660
1686
  const waited = secondsToFirstPeer(timing.addedAt, timing.firstPeerAt);
1661
1687
  const byTracker = this.#lastAnnounceByTorrent.get(torrent);
1662
1688
  const offered = bestAnnounce(byTracker ? byTracker.values() : []);
@@ -0,0 +1,49 @@
1
+ /** Record first-peer and metadata waits on the torrent worker's monotonic clock. */
2
+ export function beginSwarmLifecycleTrace(timing, trace, metadataAvailable, now = () => performance.now()) {
3
+ if (timing.firstPeerAtMonotonic !== null) {
4
+ trace("W3", "event", { outcome: "peer-already-connected" });
5
+ } else if (timing.peerWaitStartedAt !== null) {
6
+ trace("W3", "begin", { startedAt: timing.peerWaitStartedAt });
7
+ }
8
+
9
+ if (metadataAvailable) {
10
+ trace("W4", "event", { outcome: "already-available" });
11
+ } else if (timing.firstPeerAtMonotonic !== null) {
12
+ timing.metadataWaitStartedAt = now();
13
+ trace("W4", "begin", { startedAt: timing.metadataWaitStartedAt });
14
+ }
15
+ }
16
+
17
+ export function noteSwarmDiscovery(timing, trace, endedAt) {
18
+ if (timing.firstPeerAtMonotonic !== null || timing.peerWaitStartedAt !== null) return;
19
+ timing.peerWaitStartedAt = endedAt;
20
+ trace("W3", "begin", { startedAt: endedAt });
21
+ }
22
+
23
+ export function noteSwarmPeer(timing, trace, metadataAvailable, now = () => performance.now()) {
24
+ if (timing.firstPeerAtMonotonic !== null) return;
25
+ const endedAt = now();
26
+ timing.firstPeerAtMonotonic = endedAt;
27
+ if (timing.peerWaitStartedAt !== null) {
28
+ trace("W3", "end", { startedAt: timing.peerWaitStartedAt, endedAt,
29
+ durationMs: endedAt - timing.peerWaitStartedAt, outcome: "peer-connected" });
30
+ } else {
31
+ trace("W3", "event", { outcome: "peer-connected-before-trace" });
32
+ }
33
+ if (!metadataAvailable) {
34
+ timing.metadataWaitStartedAt = endedAt;
35
+ trace("W4", "begin", { startedAt: endedAt });
36
+ }
37
+ }
38
+
39
+ export function noteSwarmMetadata(timing, trace, files, now = () => performance.now()) {
40
+ const endedAt = now();
41
+ if (timing.metadataWaitStartedAt !== null) {
42
+ const startedAt = timing.metadataWaitStartedAt;
43
+ timing.metadataWaitStartedAt = null;
44
+ trace("W4", "end", { startedAt, endedAt, durationMs: endedAt - startedAt,
45
+ outcome: "available", files });
46
+ } else {
47
+ trace("W4", "event", { outcome: "available", files });
48
+ }
49
+ }
@@ -345,8 +345,10 @@ async function runCommand(command, params, id) {
345
345
  // Reusing the same promise for a repeated add also collapses two callers
346
346
  // racing to open the same torrent into one.
347
347
  let pending = torrentsByKey.get(params.sourceKey);
348
+ let addStartedAt = null;
348
349
  if (!pending) {
349
- emitWaitTrace(params.sourceKey, "W1", "event", { moment: "add-request" });
350
+ addStartedAt = performance.now();
351
+ emitWaitTrace(params.sourceKey, "W1", "begin", { startedAt: addStartedAt, moment: "add-request" });
350
352
  pending = pool.getTorrent(params.sourceType, params.source, {
351
353
  sourceKey: params.sourceKey, trace: params.trace === true, emitWaitTrace
352
354
  });
@@ -366,7 +368,22 @@ async function runCommand(command, params, id) {
366
368
  // empty list, and rebuilding it here would bring it back on every stream
367
369
  // and stats request, which all come through this command. Any other
368
370
  // destroyed torrent is rebuilt, because its files still need it.
369
- const added = await pending;
371
+ let added;
372
+ try {
373
+ added = await pending;
374
+ if (params.trace === true && addStartedAt !== null) {
375
+ const endedAt = performance.now();
376
+ emitWaitTrace(params.sourceKey, "W1", "end", { endedAt, durationMs: endedAt - addStartedAt,
377
+ outcome: "ready" });
378
+ }
379
+ } catch (error) {
380
+ if (params.trace === true && addStartedAt !== null) {
381
+ const endedAt = performance.now();
382
+ emitWaitTrace(params.sourceKey, "W1", "end", { endedAt, durationMs: endedAt - addStartedAt,
383
+ outcome: "error", error: String(error?.message ?? error) });
384
+ }
385
+ throw error;
386
+ }
370
387
  const described = isUsableTorrentHandle(added) ? null : wholeSources.describe(params.sourceKey);
371
388
  if (described) {
372
389
  announceWholeFiles(params.sourceKey, described);
@@ -477,43 +494,50 @@ async function runCommand(command, params, id) {
477
494
  const statsStartedAt = performance.now();
478
495
  emitWaitTrace(params.sourceKey, "W10", "begin", { startedAt: statsStartedAt,
479
496
  fileIndex: params.fileIndex, forecast: params.forecast === true });
480
- const torrent = await knownTorrent(params.sourceKey);
481
- if (!torrent) {
482
- const whole = wholeSources.fileOf(params.sourceKey, params.fileIndex);
483
- emitWaitTrace(params.sourceKey, "W10", "end", { startedAt: statsStartedAt,
484
- endedAt: performance.now(), outcome: whole ? "whole-file" : "pending", fileIndex: params.fileIndex });
485
- return whole ? wholeFileStats(whole.length) : { pending: true };
486
- }
487
- // What this file's own interruptions demand, measured by the reader in
488
- // this thread. It travels with the stats because the caller asking for
489
- // them is the one that has to decide with them — the browser's smallest
490
- // safe buffer, and the speed a quality step must sustain. Null until a
491
- // second interruption has been seen: one wait shows no interval, and an
492
- // interval invented from one point is exactly what this work removes.
493
- const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
494
- const forecastAt = performance.now();
495
- const forecast = file && params.forecast === true ? downloadForecastOf(torrent) : null;
496
- const forecastMs = params.forecast === true ? performance.now() - forecastAt : 0;
497
- const stats = pool.getFileStats(torrent, params.fileIndex);
498
- const ranges = [];
499
- if (forecast) for (const [piece, availableAt] of forecast.arrivals) {
500
- const start = piece * torrent.pieceLength;
501
- const end = Math.min(torrent.length, (piece + 1) * torrent.pieceLength);
502
- if (end > start) ranges.push({ start, end, availableAt });
503
- }
504
- emitWaitTrace(params.sourceKey, "W10", "end", { startedAt: statsStartedAt,
505
- endedAt: performance.now(), outcome: "ok", fileIndex: params.fileIndex,
506
- forecast: params.forecast === true });
507
- return {
508
- ...stats,
509
- ...(params.forecast === true ? { downloadForecast: {
510
- measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges
511
- } } : {}),
512
- supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4),
513
- // How long this answer waited for the arrival forecast, which the
514
- // caller says beside its own time (ViewerRequests, torrent-tv/meta#178).
515
- forecastMs
497
+ const finishStatsTrace = (outcome, fields = {}) => {
498
+ const endedAt = performance.now();
499
+ emitWaitTrace(params.sourceKey, "W10", "end", { startedAt: statsStartedAt, endedAt,
500
+ durationMs: endedAt - statsStartedAt, outcome, fileIndex: params.fileIndex, ...fields });
516
501
  };
502
+ try {
503
+ const torrent = await knownTorrent(params.sourceKey);
504
+ if (!torrent) {
505
+ const whole = wholeSources.fileOf(params.sourceKey, params.fileIndex);
506
+ finishStatsTrace(whole ? "whole-file" : "pending");
507
+ return whole ? wholeFileStats(whole.length) : { pending: true };
508
+ }
509
+ // What this file's own interruptions demand, measured by the reader in
510
+ // this thread. It travels with the stats because the caller asking for
511
+ // them is the one that has to decide with them — the browser's smallest
512
+ // safe buffer, and the speed a quality step must sustain. Null until a
513
+ // second interruption has been seen: one wait shows no interval, and an
514
+ // interval invented from one point is exactly what this work removes.
515
+ const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
516
+ const forecastAt = performance.now();
517
+ const forecast = file && params.forecast === true ? downloadForecastOf(torrent) : null;
518
+ const forecastMs = params.forecast === true ? performance.now() - forecastAt : 0;
519
+ const stats = pool.getFileStats(torrent, params.fileIndex);
520
+ const ranges = [];
521
+ if (forecast) for (const [piece, availableAt] of forecast.arrivals) {
522
+ const start = piece * torrent.pieceLength;
523
+ const end = Math.min(torrent.length, (piece + 1) * torrent.pieceLength);
524
+ if (end > start) ranges.push({ start, end, availableAt });
525
+ }
526
+ finishStatsTrace("ok", { forecast: params.forecast === true });
527
+ return {
528
+ ...stats,
529
+ ...(params.forecast === true ? { downloadForecast: {
530
+ measuredAt: forecast?.measuredAt ?? Date.now(), global: true, ranges
531
+ } } : {}),
532
+ supply: supplyFiguresFor(torrent?.infoHash, file?.name, params.segmentSeconds ?? 4),
533
+ // How long this answer waited for the arrival forecast, which the
534
+ // caller says beside its own time (ViewerRequests, torrent-tv/meta#178).
535
+ forecastMs
536
+ };
537
+ } catch (error) {
538
+ finishStatsTrace("error", { error: String(error?.message ?? error) });
539
+ throw error;
540
+ }
517
541
  }
518
542
 
519
543
  case Command.PRIORITY_MAP: {
@@ -527,18 +551,27 @@ async function runCommand(command, params, id) {
527
551
  // Nor may one for a file held whole, which has nothing left to fetch.
528
552
  const torrent = await knownTorrent(params.sourceKey);
529
553
  if (!torrent) {
554
+ emitWaitTrace(params.sourceKey, "W7", "event", { outcome: "source-not-ready", fileIndex: params.fileIndex });
530
555
  return { appliedMs: null, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
531
556
  }
532
557
  // What applying a map costs this thread, told to whoever sent it: every
533
558
  // zone becomes a window of the register and the swarm is told again
534
559
  // (torrent-tv/meta#166).
535
560
  const startedAt = performance.now();
536
- emitWaitTrace(params.sourceKey, "W7", "begin", { fileIndex: params.fileIndex });
537
- ensureArrivalsWired(params.sourceKey, torrent);
538
- pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
539
- torrent.emit("priority-map-changed", params.fileIndex);
540
- emitWaitTrace(params.sourceKey, "W7", "end", { startedAt, endedAt: performance.now(),
541
- fileIndex: params.fileIndex });
561
+ emitWaitTrace(params.sourceKey, "W7", "begin", { startedAt, fileIndex: params.fileIndex });
562
+ try {
563
+ ensureArrivalsWired(params.sourceKey, torrent);
564
+ pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
565
+ torrent.emit("priority-map-changed", params.fileIndex);
566
+ const endedAt = performance.now();
567
+ emitWaitTrace(params.sourceKey, "W7", "end", { startedAt, endedAt, durationMs: endedAt - startedAt,
568
+ outcome: "applied", fileIndex: params.fileIndex });
569
+ } catch (error) {
570
+ const endedAt = performance.now();
571
+ emitWaitTrace(params.sourceKey, "W7", "end", { startedAt, endedAt, durationMs: endedAt - startedAt,
572
+ outcome: "error", error: String(error?.message ?? error), fileIndex: params.fileIndex });
573
+ throw error;
574
+ }
542
575
  return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
543
576
  }
544
577