@torrent-tv/proxy 2.102.1 → 2.102.3

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.102.1",
3
+ "version": "2.102.3",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -23,7 +23,7 @@ function getPayload(body) {
23
23
  return {};
24
24
  }
25
25
 
26
- export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers }) {
26
+ export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers, associateWaitTrace }) {
27
27
  const payload = getPayload(req.body);
28
28
  const sourceType = typeof payload.sourceType === "string" ? payload.sourceType : "";
29
29
  const source = typeof payload.source === "string" ? payload.source : "";
@@ -34,6 +34,7 @@ export async function handleApiSourcesPost(req, reply, { sourceRegistry, viewers
34
34
  const consumerId = typeof payload.consumerId === "string" ? payload.consumerId.trim() : "";
35
35
  const viewer = consumerId ? viewers?.present(consumerId) : null;
36
36
  const sourceKey = await sourceRegistry.upsert(sourceType, source);
37
+ associateWaitTrace?.(req, sourceKey);
37
38
  if (viewer && viewers.get(consumerId) === viewer) viewers.selectsSource(consumerId, sourceKey);
38
39
  return reply.send({ sourceKey, playbackMapVersion: 1 });
39
40
  }
@@ -2,7 +2,7 @@ import { parseRange } from "../../utils/parse-range.js";
2
2
  import { Readable } from "node:stream";
3
3
 
4
4
  /** FFmpeg reads only original byte ranges already owned by its live run. */
5
- export async function handleEncodeInputGet(req, reply, { inputOf, refused = () => {} }) {
5
+ export async function handleEncodeInputGet(req, reply, { inputOf, refused = () => {}, noteFirstInputBytes = () => {} }) {
6
6
  const address = req.raw?.socket?.remoteAddress;
7
7
  if (!["127.0.0.1", "::1", "::ffff:127.0.0.1"].includes(address)) return reply.code(403).send();
8
8
  const token = Number(req.params.token), fileIndex = Number(req.params.fileIndex);
@@ -53,6 +53,16 @@ export async function handleEncodeInputGet(req, reply, { inputOf, refused = () =
53
53
  return reply;
54
54
  }
55
55
  const body = held.stream ?? (held.chunks ? Readable.from(held.chunks, { objectMode: false }) : held.bytes);
56
+ if (body && typeof body.once === "function") {
57
+ let noted = false;
58
+ body.once("data", chunk => {
59
+ if (noted) return;
60
+ noted = true;
61
+ noteFirstInputBytes(token, fileIndex, chunk?.length ?? 0);
62
+ });
63
+ } else {
64
+ noteFirstInputBytes(token, fileIndex, body?.length ?? 0);
65
+ }
56
66
  // What was handed to the system for this answer before its connection
57
67
  // closed, and whether all of it was: an answer runs to the end of its
58
68
  // admitted range and FFmpeg closes the connection once it has what it wants,
@@ -229,7 +229,8 @@ function serveWholeFile(req, reply, file, read) {
229
229
  }
230
230
  }
231
231
 
232
- export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null }) {
232
+ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool, noteInputBytes = null, noteInputWaiting = null,
233
+ noteFirstInputBytes = null }) {
233
234
  const fileIndexRaw = typeof req.query.fileIndex === "string" ? req.query.fileIndex : "";
234
235
  const fileIndex = Number(fileIndexRaw);
235
236
  const { sourceType, source, sourceKey } = getSourceParams(req.query, sourceRegistry);
@@ -386,6 +387,9 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
386
387
  reply.raw.write(fragment.bytes, (error) => (error ? reject(error) : resolve()));
387
388
  });
388
389
  sent += fragment.bytes.length;
390
+ if (sent === fragment.bytes.length && noteFirstInputBytes && Number.isSafeInteger(runToken)) {
391
+ noteFirstInputBytes(runToken, fileIndex, fragment.bytes.length);
392
+ }
389
393
  // Say so, if this read belongs to a transcode session. It is the only
390
394
  // proof that a session which has produced nothing yet is nevertheless
391
395
  // being fed: the encoder's own progress cannot move until its first
@@ -412,10 +416,15 @@ export async function handleStreamGet(req, reply, { sourceRegistry, torrentPool,
412
416
  if (continueFrom) {
413
417
  fragments.cancel();
414
418
  const fromTorrent = sent;
419
+ let firstWholeInputNoted = false;
415
420
  sent += await writeFromWholeFile(reply.raw, continueFrom, start + sent, end, (length) => {
416
421
  if (noteInputBytes) {
417
422
  noteInputBytes(sessionId, length);
418
423
  }
424
+ if (!firstWholeInputNoted && noteFirstInputBytes && Number.isSafeInteger(runToken)) {
425
+ firstWholeInputNoted = true;
426
+ noteFirstInputBytes(runToken, fileIndex, length);
427
+ }
419
428
  });
420
429
  logger.info(
421
430
  `stream: read of "${file.name}" bytes ${start}-${end} continued from the whole file: ` +
package/server.js CHANGED
@@ -178,7 +178,8 @@ export async function startProxyServer({
178
178
  await app.register(fastifyCors, {
179
179
  origin: true,
180
180
  methods: ["GET", "POST", "OPTIONS"],
181
- allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation"]
181
+ allowedHeaders: ["Content-Type", "Range", "X-Ttv-Wait-Trace", "X-Ttv-Wait-Attempt", "X-Ttv-Wait-Operation",
182
+ "X-Ttv-Wait-Process"]
182
183
  });
183
184
 
184
185
  // Allow browser requests from an HTTPS page to this private-network proxy
@@ -223,7 +224,15 @@ export async function startProxyServer({
223
224
  // 10 MB file took 12-23 s against 125 ms to hand it to the channel. The
224
225
  // adapter keeps TorrentPool's interface, so nothing downstream changed.
225
226
  const torrentPool = new WorkerTorrentPool({
226
- onObserved: fields => waitTrace.source(fields.sourceKey, "torrent-ready", fields),
227
+ isWaitTraced: sourceKey => waitTrace.has(sourceKey),
228
+ onWaitTrace: event => waitTrace.process(event.sourceKey, event.process, event.phase, event),
229
+ onObserved: fields => {
230
+ waitTrace.source(fields.sourceKey, "process", { process: "P1", phase: "begin", at: fields.startedAt,
231
+ outcome: fields.outcome });
232
+ waitTrace.source(fields.sourceKey, "process", { process: "P1", phase: "end", at: fields.endedAt,
233
+ startedAt: fields.startedAt, endedAt: fields.endedAt, durationMs: fields.endedAt - fields.startedAt,
234
+ outcome: fields.outcome });
235
+ },
227
236
  memoryBytes,
228
237
  stateDir,
229
238
  heapSnapshots,
@@ -254,8 +263,8 @@ export async function startProxyServer({
254
263
  // only once the worker thread is up and a piece has verified, which is long
255
264
  // after the declarations below have run.
256
265
  onPiecesArrived: ({ sourceKey, fileIndexes, arrived }) => {
257
- waitTrace.source(sourceKey, "held-bytes-changed", { fileIndexes, arrived: arrived?.slice?.(0, 32),
258
- arrivedCount: arrived?.length });
266
+ waitTrace.source(sourceKey, "process", { process: "W9", phase: "end", fileIndexes,
267
+ arrived: arrived?.slice?.(0, 32), arrivedCount: arrived?.length });
259
268
  for (const fileIndex of fileIndexes) {
260
269
  mediaReads.bytesChanged(sourceKey, fileIndex);
261
270
  probeReads.bytesChanged(sourceKey, fileIndex);
@@ -654,6 +663,7 @@ export async function startProxyServer({
654
663
  // already from the projection; this packs them with the native ones.
655
664
  pack: (zones) => coalescedZones(zones),
656
665
  log: (line) => logger.info(line),
666
+ observeWait: (sourceKey, event, fields) => waitTrace.source(sourceKey, event, fields),
657
667
  resolvePlayback: async (map) => {
658
668
  const fileKey = `${map.sourceKey}:${map.fileIndex}`;
659
669
  if (map.zones.length === 0) {
@@ -877,7 +887,16 @@ export async function startProxyServer({
877
887
  } }),
878
888
  label: String(file.name ?? ""),
879
889
  fileOffset: file.offset,
880
- onObserved: fields => waitTrace.source(sourceKey, "container-read", { fileIndex, requestId, ...fields }),
890
+ onObserved: fields => {
891
+ const process = fields.statement === "keyframes" ? "P5"
892
+ : String(fields.statement).startsWith("ffprobe:") ? "X1" : "P2";
893
+ const details = { process, fileIndex, requestId, statement: fields.statement, cached: fields.cached,
894
+ result: fields.result, reason: fields.reason, ranges: fields.ranges,
895
+ queuedMs: fields.queuedMs, readMs: fields.readMs };
896
+ waitTrace.source(sourceKey, "process", { ...details, phase: "begin", at: fields.askedAt });
897
+ waitTrace.source(sourceKey, "process", { ...details, phase: "end", at: fields.endedAt,
898
+ startedAt: fields.askedAt, endedAt: fields.endedAt });
899
+ },
881
900
  onTracks: (tracks) => contentsOf(torrent).noteVideo(fileIndex, tracks.some((track) => track.type === "video")),
882
901
  onReadStart: () => ({ storage: mediaReads.revision(sourceKey, fileIndex), memory: mediaReads.memoryRevision(), demand: downloadMaps.epoch(sourceKey, fileIndex) }),
883
902
  onReadResult: async (statement, result, revision) => {
@@ -1474,7 +1493,8 @@ export async function startProxyServer({
1474
1493
  app.post("/api/sources", async (req, reply) => {
1475
1494
  // A cold start begins with the first source opened (torrent-tv/meta#177).
1476
1495
  startupProfile.begin();
1477
- return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers });
1496
+ return handleApiSourcesPost(req, reply, { sourceRegistry, viewers: outputParts.viewers,
1497
+ associateWaitTrace: (request, sourceKey) => waitTrace.associate(request, sourceKey) });
1478
1498
  });
1479
1499
  app.get("/api/sources/:sourceKey/stats", async (req, reply) =>
1480
1500
  handleApiSourceStatsGet(req, reply, { sourceRegistry, torrentPool, viewerRequests: outputParts.viewerRequests })
@@ -1563,6 +1583,7 @@ export async function startProxyServer({
1563
1583
  );
1564
1584
  app.get("/encode-input/:token/:fileIndex", (req, reply) =>
1565
1585
  handleEncodeInputGet(req, reply, { inputOf: token => outputParts.encodeRuns.originalInputOf(token),
1586
+ noteFirstInputBytes: (token, fileIndex, bytes) => outputParts.encodeRuns.noteFirstInputBytes(token, fileIndex, bytes),
1566
1587
  refused: ({ fileIndex, start, runTag }) => logger.warn(`encode input: run ${runTag ?? "?"} asked for byte ${start} of file ${fileIndex}, which it was not given`) })
1567
1588
  );
1568
1589
  app.get("/stream", async (req, reply) =>
@@ -1573,6 +1594,7 @@ export async function startProxyServer({
1573
1594
  // fed. The route knows only files; the session id rides on the URL the
1574
1595
  // session itself built.
1575
1596
  noteInputBytes: (sessionId, bytes) => outputParts.encodeRuns.noteInputBytes(sessionId, bytes),
1597
+ noteFirstInputBytes: (token, fileIndex, bytes) => outputParts.encodeRuns.noteFirstInputBytes(token, fileIndex, bytes),
1576
1598
  // When the encoder reading this waits for its input, so its speed is
1577
1599
  // measured over its own work and not over the swarm's.
1578
1600
  noteInputWaiting: (runToken, waiting) => outputParts.encodeRuns.noteInputWaiting(runToken, waiting)
@@ -65,10 +65,13 @@ export class EncodeInputs {
65
65
  * @type {(output: object, from: number, to: number, input: object) => void}
66
66
  */
67
67
  #stretchCopied;
68
+ #observeWait;
68
69
 
69
70
  constructor({ resolve, readRanges, openWholeFile = null, heldRanges = null, reviseBudget, changed, failed, capacity = () => null, urgent = () => true,
70
- rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {} }) {
71
+ rankAt = () => null, openOf = () => null, log = () => {}, now = Date.now, stretchCopied = () => {},
72
+ observeWait = null }) {
71
73
  this.#stretchCopied = stretchCopied;
74
+ this.#observeWait = observeWait;
72
75
  this.#openOf = openOf;
73
76
  this.#resolve = resolve;
74
77
  this.#read = readRanges;
@@ -103,6 +106,14 @@ export class EncodeInputs {
103
106
  `${rank ? `, rank ${rank.rank} of ${rank.topRank}` : ""})${detail}`);
104
107
  }
105
108
 
109
+ #observe(request, phase, fields = {}) {
110
+ const sourceKey = request.output?.file?.sourceKey;
111
+ if (typeof sourceKey !== "string") return;
112
+ const process = request.output?.spec?.carries === "audio-only" ? "P8.a" : "P8.v";
113
+ this.#observeWait?.(sourceKey, "process", { process, phase, at: performance.now(),
114
+ output: request.output.outputKey ?? request.output.id, from: request.from, to: request.to, ...fields });
115
+ }
116
+
106
117
  /** The container's statement of an interval, its time added to the request's account. */
107
118
  async #resolveTimed(timing, output, from, to, isCurrent = null) {
108
119
  const startedAt = this.#now();
@@ -258,7 +269,8 @@ export class EncodeInputs {
258
269
  return existing.result;
259
270
  }
260
271
  if (existing?.pending || existing?.revision === this.#revision || existing?.result?.kind === "terminal") return null;
261
- const request = existing ?? { output, from, to, key, serves };
272
+ const request = existing ?? { output, from, to, key, serves, traceStartedAt: performance.now() };
273
+ if (!existing) this.#observe(request, "begin", { request: key });
262
274
  this.#requests.set(key, request);
263
275
  this.#prepare(request);
264
276
  return null;
@@ -403,6 +415,7 @@ export class EncodeInputs {
403
415
  request.timing ??= { askedAt: this.#now(), attempts: 0, resolutions: 0, resolveMs: 0,
404
416
  reads: 0, queuedMs: 0, readMs: 0, heldMs: 0, budgetMs: 0 };
405
417
  request.timing.attempts += 1;
418
+ this.#observe(request, "attempt", { request: request.key, attempt: request.timing.attempts });
406
419
  request.promise = (async () => {
407
420
  // A request the plan withdraws stops its reads: a soundtrack piece's
408
421
  // packet walk, left to run, held the file's reads and the piece the
@@ -449,10 +462,20 @@ export class EncodeInputs {
449
462
  : `, copied in ${result.copyMs}ms`) : "")
450
463
  : `${result.kind}${result.reason ? ` ${result.reason}` : ""}${Number.isFinite(result.bytes) ? ` ${result.bytes} bytes` : ""}`,
451
464
  describeTiming(request.timing, this.#now()));
465
+ if (result.kind === "result" || result.kind === "terminal") {
466
+ this.#observe(request, "end", { request: request.key, startedAt: request.traceStartedAt,
467
+ endedAt: performance.now(), outcome: result.kind, reason: result.reason ?? null,
468
+ bytes: result.bytes ?? null, attempts: request.timing.attempts });
469
+ } else {
470
+ this.#observe(request, "waiting", { request: request.key, reason: result.kind,
471
+ bytes: result.bytes ?? null, attempts: request.timing.attempts });
472
+ }
452
473
  if (result.kind === "result" || result.kind === "terminal") this.#changed(request.output, result);
453
474
  })().catch(error => {
454
475
  if (this.#requests.get(request.key) === request) {
455
476
  request.result = { kind: "terminal", reason: "input-preparation-failed", message: error.message };
477
+ this.#observe(request, "end", { request: request.key, startedAt: request.traceStartedAt,
478
+ endedAt: performance.now(), outcome: "error", reason: "input-preparation-failed", attempts: request.timing.attempts });
456
479
  this.#failed(request.output, error);
457
480
  }
458
481
  }).finally(() => {
@@ -285,6 +285,21 @@ export class EncodeRuns {
285
285
  }
286
286
  }
287
287
 
288
+ noteFirstInputBytes(runToken, fileIndex, bytes) {
289
+ const run = this.#runsByInputToken.get(runToken);
290
+ if (!run || run.firstWaitTraceInputAt !== undefined) return;
291
+ const endedAt = performance.now();
292
+ run.firstWaitTraceInputAt = endedAt;
293
+ const { sourceKey, process } = run.waitTraceInput ?? {};
294
+ this.#host.observeWait?.(sourceKey, "process", { process, phase: "end", runToken, fileIndex, bytes,
295
+ startedAt: run.startedAt, endedAt, durationMs: endedAt - run.startedAt });
296
+ const f2Process = process === "F1.a" ? "F2.a" : "F2.v";
297
+ run.waitTraceInput.f2Process = f2Process;
298
+ run.waitTraceInput.f2StartedAt = endedAt;
299
+ this.#host.observeWait?.(sourceKey, "process", { process: f2Process, phase: "begin", runToken,
300
+ fileIndex, bytes, startedAt: endedAt });
301
+ }
302
+
288
303
  originalInputOf(runToken) {
289
304
  return this.#runsByInputToken.get(runToken)?.originalInput ?? null;
290
305
  }
@@ -561,6 +576,7 @@ export class EncodeRuns {
561
576
  }
562
577
 
563
578
  planEncodersNow() {
579
+ const planStartedAt = performance.now();
564
580
  const byOutput = this.#outputsWithSessions();
565
581
  for (const [address, sessions] of byOutput) {
566
582
  // From the TIMELINE, which is where how a file is cut has lived since
@@ -609,7 +625,22 @@ export class EncodeRuns {
609
625
  ) ?? []
610
626
  );
611
627
  }
628
+ for (const [address, sessions] of byOutput) {
629
+ const sourceKey = sessions[0]?.file?.sourceKey;
630
+ if (typeof sourceKey !== "string") continue;
631
+ const process = sessions[0]?.spec?.carries === "audio-only" ? "P7.a" : "P7.v";
632
+ this.#host.observeWait?.(sourceKey, "process", { process, phase: "begin", at: planStartedAt,
633
+ output: address, sessions: sessions.length });
634
+ }
612
635
  this.#host.encodeOrchestrator.reconcile();
636
+ const planEndedAt = performance.now();
637
+ for (const [address, sessions] of byOutput) {
638
+ const sourceKey = sessions[0]?.file?.sourceKey;
639
+ if (typeof sourceKey !== "string") continue;
640
+ const process = sessions[0]?.spec?.carries === "audio-only" ? "P7.a" : "P7.v";
641
+ this.#host.observeWait?.(sourceKey, "process", { process, phase: "end", at: planEndedAt,
642
+ startedAt: planStartedAt, endedAt: planEndedAt, output: address, sessions: sessions.length });
643
+ }
613
644
  }
614
645
 
615
646
  /**
@@ -1164,6 +1195,13 @@ export class EncodeRuns {
1164
1195
  // It belongs to no requested output segment and must not condemn the
1165
1196
  // complete input or overwrite a neighboring run's segment.
1166
1197
  if (Number.isInteger(index) && (index < safeIndex || index > run.to)) return null;
1198
+ if (index === safeIndex && run.waitTraceInput?.f2StartedAt && !run.waitTraceInput.f2EndedAt) {
1199
+ const endedAt = performance.now();
1200
+ run.waitTraceInput.f2EndedAt = endedAt;
1201
+ this.#host.observeWait?.(session.file.sourceKey, "process", { process: run.waitTraceInput.f2Process,
1202
+ phase: "end", runToken: inputToken, segment: index, startedAt: run.waitTraceInput.f2StartedAt,
1203
+ endedAt, durationMs: endedAt - run.waitTraceInput.f2StartedAt, outcome: "piece-closed" });
1204
+ }
1167
1205
  let bytes = null;
1168
1206
  const address = session.outputKey ?? "";
1169
1207
  // Read before anything rewrites it: when the encoder finished it.
@@ -1223,6 +1261,13 @@ export class EncodeRuns {
1223
1261
  this.#host.noteRunSpeedMeasured?.(session, run);
1224
1262
  },
1225
1263
  onEnded: (ended) => {
1264
+ if (run.waitTraceInput?.f2StartedAt && !run.waitTraceInput.f2EndedAt) {
1265
+ const endedAt = performance.now();
1266
+ run.waitTraceInput.f2EndedAt = endedAt;
1267
+ this.#host.observeWait?.(session.file.sourceKey, "process", { process: run.waitTraceInput.f2Process,
1268
+ phase: "end", runToken: inputToken, segment: safeIndex, startedAt: run.waitTraceInput.f2StartedAt,
1269
+ endedAt, durationMs: endedAt - run.waitTraceInput.f2StartedAt, outcome: ended.ending ?? "ended" });
1270
+ }
1226
1271
  this.#runsByInputToken.delete(inputToken);
1227
1272
  if (admittedInput.original) {
1228
1273
  const reads = admittedInput.reads?.();
@@ -1244,6 +1289,11 @@ export class EncodeRuns {
1244
1289
  this.#host.encodersChanged?.();
1245
1290
  }
1246
1291
  });
1292
+ const f1Process = session.spec.carries === "audio-only" ? "F1.a" : "F1.v";
1293
+ run.waitTraceInput = { sourceKey: session.file.sourceKey, process: f1Process };
1294
+ this.#host.observeWait?.(session.file.sourceKey, "process", { process: f1Process,
1295
+ phase: "begin", runToken: inputToken, startedAt: run.startedAt, output: session.outputKey,
1296
+ from: run.from, to: run.to });
1247
1297
  if (admittedInput.original) {
1248
1298
  run.originalInput = admittedInput;
1249
1299
  // Every number of the stretch answers to its one key, so a failure
@@ -305,6 +305,16 @@ export class LocalObservations {
305
305
  * @returns {number | null}
306
306
  */
307
307
  firstFrameSeconds(features) {
308
+ return this.firstFrameEstimate(features)?.seconds ?? null;
309
+ }
310
+
311
+ /**
312
+ * Return the current stage-median forecast and the measurements that formed it.
313
+ *
314
+ * @param {object} features
315
+ * @returns {{ seconds: number, stagesMs: object, observations: number } | null}
316
+ */
317
+ firstFrameEstimate(features) {
308
318
  if (!features || typeof features !== "object") return null;
309
319
  const stages = ["transportMs", "planMs", "prepareMs", "prebufferMs", "presentationMs"];
310
320
  const candidates = [];
@@ -319,7 +329,11 @@ export class LocalObservations {
319
329
  const prices = stages.map(stage => median(candidates.map(reading => reading[stage])
320
330
  .filter(Number.isFinite)));
321
331
  if (prices.some(value => value === null)) return null;
322
- return prices.reduce((sum, value) => sum + value, 0) / 1000;
332
+ return {
333
+ seconds: prices.reduce((sum, value) => sum + value, 0) / 1000,
334
+ stagesMs: Object.fromEntries(stages.map((stage, index) => [stage, prices[index]])),
335
+ observations: candidates.length
336
+ };
323
337
  }
324
338
 
325
339
  #firstFrameKey() {
@@ -164,9 +164,8 @@ export class ContainerOrchestrator {
164
164
  const result = await this.#inspectRead({ ...params, isCurrent }, statement);
165
165
  const endedAt = performance.now();
166
166
  params.onTimed?.(statement, startedAt - askedAt, endedAt - startedAt);
167
- if (statement !== "source-ranges" || endedAt - askedAt >= 10) params.onObserved?.({ statement, askedAt,
168
- startedAt, endedAt, queuedMs: startedAt - askedAt, readMs: endedAt - startedAt, cached: aside,
169
- result: result.kind, reason: result.reason, ranges: result.ranges });
167
+ params.onObserved?.({ statement, askedAt, startedAt, endedAt, queuedMs: startedAt - askedAt,
168
+ readMs: endedAt - startedAt, cached: aside, result: result.kind, reason: result.reason, ranges: result.ranges });
170
169
  return { result, revision };
171
170
  });
172
171
  if (!aside) this.#reads.set(key, reading);
@@ -110,7 +110,17 @@ export class SegmentServing {
110
110
  async getFileStream(sessionId, fileName, options = {}) {
111
111
  const output = this.#host.outputs.get(sessionId);
112
112
  if (!output) return { kind: "not-found" };
113
- return this.#withMaterialRead(output.outputKey, () => this.#getFileStream(sessionId, fileName, options));
113
+ const isInit = output.segmentFormat?.initFileName !== null && fileName === output.segmentFormat?.initFileName;
114
+ const startedAt = performance.now();
115
+ const answer = await this.#withMaterialRead(output.outputKey, () => this.#getFileStream(sessionId, fileName, options));
116
+ if (isInit && typeof output.file?.sourceKey === "string") {
117
+ const process = output.spec?.carries === "audio-only" ? "P13.a" : "P13.v";
118
+ const fields = { output: output.outputKey, fileName, outcome: answer?.kind ?? "unknown" };
119
+ this.#host.observeWait?.(output.file.sourceKey, "process", { ...fields, process, phase: "begin", at: startedAt });
120
+ this.#host.observeWait?.(output.file.sourceKey, "process", { ...fields, process, phase: "end",
121
+ at: performance.now(), startedAt, endedAt: performance.now() });
122
+ }
123
+ return answer;
114
124
  }
115
125
 
116
126
  async #withMaterialRead(key, read) {
@@ -120,30 +120,34 @@ export class ViewerRequests {
120
120
  };
121
121
  const current = this.#earlyWaits.get(key);
122
122
  if (current?.waitId === waitId && current.hasSourceObservation) {
123
- const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features,
123
+ const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features,
124
124
  revision: current.revision, current, waitElapsedMs });
125
125
  const changed = this.#recordEarlyWaitSnapshot(context, context.features);
126
126
  if (changed) context.revision += 1;
127
127
  this.#pruneEarlyWaits();
128
- const totalSeconds = Number.isFinite(context.activeTotalSeconds)
129
- ? context.activeTotalSeconds
130
- : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
131
- const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
128
+ const elapsedMs = waitElapsedSince(context, waitElapsedMs);
129
+ const historical = Number.isFinite(context.activeTotalSeconds) ? null : this.#historicalFirstFrame(context.features);
130
+ const totalSeconds = Number.isFinite(context.activeTotalSeconds) ? context.activeTotalSeconds : historical?.seconds ?? null;
131
+ const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
132
132
  if (!Number.isFinite(seconds) || seconds < 0) return null;
133
+ this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
134
+ firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds });
133
135
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
134
136
  }
135
137
  const sameWait = current?.waitId === waitId;
136
138
  const revision = sameWait ? current.revision : 0;
137
- const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
139
+ const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
138
140
  waitElapsedMs });
139
141
  const changed = this.#recordEarlyWaitSnapshot(context, context.features);
140
142
  if (sameWait && changed) context.revision += 1;
141
143
  this.#pruneEarlyWaits();
142
- const totalSeconds = Number.isFinite(context.activeTotalSeconds)
143
- ? context.activeTotalSeconds
144
- : this.#host.firstFrameSecondsOf?.(context.features) ?? null;
145
- const seconds = earlyWaitRemainingSeconds(totalSeconds, waitElapsedSince(context, waitElapsedMs));
144
+ const elapsedMs = waitElapsedSince(context, waitElapsedMs);
145
+ const historical = Number.isFinite(context.activeTotalSeconds) ? null : this.#historicalFirstFrame(context.features);
146
+ const totalSeconds = Number.isFinite(context.activeTotalSeconds) ? context.activeTotalSeconds : historical?.seconds ?? null;
147
+ const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
146
148
  if (!Number.isFinite(seconds) || seconds < 0) return null;
149
+ this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
150
+ firstRequiredSegmentsSeconds: context.activeFirstRequiredSegmentsSeconds });
147
151
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
148
152
  }
149
153
 
@@ -182,7 +186,7 @@ export class ViewerRequests {
182
186
  const current = this.#earlyWaits.get(key);
183
187
  const sameWait = current?.waitId === waitId;
184
188
  const revision = sameWait ? current.revision : 0;
185
- const context = this.#setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
189
+ const context = this.#setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
186
190
  sourceObserved: true, waitElapsedMs });
187
191
  const featuresChanged = this.#recordEarlyWaitSnapshot(context, context.features);
188
192
  const elapsedMs = waitElapsedSince(context, waitElapsedMs);
@@ -192,20 +196,47 @@ export class ViewerRequests {
192
196
  ? elapsedMs / 1000 + measuredWorkSeconds
193
197
  : context.activeTotalSeconds;
194
198
  const activeEstimateChanged = hasActiveEstimate && activeTotalSeconds !== context.activeTotalSeconds;
195
- if (hasActiveEstimate) context.activeTotalSeconds = activeTotalSeconds;
199
+ if (hasActiveEstimate) {
200
+ context.activeTotalSeconds = activeTotalSeconds;
201
+ context.activeFirstRequiredSegmentsSeconds = measuredWorkSeconds;
202
+ }
196
203
  const changed = featuresChanged || activeEstimateChanged;
197
204
  if (sameWait && changed) context.revision += 1;
198
205
  this.#pruneEarlyWaits();
199
206
  const progressHasComponentForecast = Object.hasOwn(processing, "firstRequiredSegmentsSeconds");
200
207
  let totalSeconds = activeTotalSeconds;
208
+ let historical = null;
201
209
  if (!Number.isFinite(totalSeconds) && !progressHasComponentForecast) {
202
- totalSeconds = this.#host.firstFrameSecondsOf?.(context.features) ?? null;
210
+ historical = this.#historicalFirstFrame(context.features);
211
+ totalSeconds = historical?.seconds ?? null;
203
212
  }
204
213
  const seconds = earlyWaitRemainingSeconds(totalSeconds, elapsedMs);
205
214
  if (!Number.isFinite(seconds) || seconds < 0) return null;
215
+ this.#logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical,
216
+ firstRequiredSegmentsSeconds: hasActiveEstimate ? measuredWorkSeconds : context.activeFirstRequiredSegmentsSeconds });
206
217
  return { version: 1, waitId, revision: context.revision, remainingSeconds: seconds, ageMs: 0 };
207
218
  }
208
219
 
220
+ #historicalFirstFrame(features) {
221
+ const estimate = this.#host.firstFrameSecondsOf?.(features) ?? null;
222
+ return Number.isFinite(estimate) ? { seconds: estimate, stagesMs: null, observations: null } : estimate;
223
+ }
224
+
225
+ #logEarlyForecast(context, { elapsedMs, seconds, totalSeconds, historical, firstRequiredSegmentsSeconds }) {
226
+ if (context.loggedForecastRevision === context.revision) return;
227
+ const basis = Number.isFinite(firstRequiredSegmentsSeconds) ? "first-required" : "historical-stages";
228
+ const estimate = basis === "first-required"
229
+ ? { firstRequiredSegmentsSeconds }
230
+ : { stagesMs: historical?.stagesMs ?? null, observations: historical?.observations ?? null };
231
+ logger.info(`early wait forecast ${JSON.stringify({ waitId: context.waitId, consumer: context.consumerId,
232
+ generation: context.generation, features: context.features, elapsedMs, revision: context.revision,
233
+ predicted: { remainingSeconds: seconds, totalSeconds, basis, ...estimate } })}`);
234
+ this.#host.observeWait?.(context.sourceKey, "process", { process: "P15", phase: "event",
235
+ waitId: context.waitId, consumerId: context.consumerId, generation: context.generation,
236
+ elapsedMs, revision: context.revision, remainingSeconds: seconds, totalSeconds, basis, ...estimate });
237
+ context.loggedForecastRevision = context.revision;
238
+ }
239
+
209
240
  /**
210
241
  * Learn from the browser's presented-frame measurement for the current
211
242
  * viewing. The saved source and link readings are the ones available at
@@ -230,12 +261,13 @@ export class ViewerRequests {
230
261
  early.updatedAt = Date.now();
231
262
  }
232
263
 
233
- #setEarlyWait({ consumerId, generation, fileIndex, waitId, features, revision, current,
264
+ #setEarlyWait({ consumerId, generation, sourceKey, fileIndex, waitId, features, revision, current,
234
265
  sourceObserved = false, waitElapsedMs }) {
235
266
  const sameWait = current?.waitId === waitId;
236
267
  const context = sameWait ? current : {
237
268
  consumerId,
238
269
  generation,
270
+ sourceKey,
239
271
  fileIndex,
240
272
  waitId,
241
273
  snapshots: [],
@@ -251,7 +283,7 @@ export class ViewerRequests {
251
283
  ? Math.max(0, measuredAt - context.featuresMeasuredAt)
252
284
  : 0;
253
285
  const mergedFeatures = mergeWaitFeatures(context.features, features, ageElapsedMs);
254
- Object.assign(context, { consumerId, generation, fileIndex, waitId, features: mergedFeatures,
286
+ Object.assign(context, { consumerId, generation, sourceKey, fileIndex, waitId, features: mergedFeatures,
255
287
  featuresMeasuredAt: measuredAt, startedAt, updatedAt: Date.now(), revision,
256
288
  hasSourceObservation: context.hasSourceObservation || sourceObserved });
257
289
  this.#earlyWaits.set(`${consumerId}:${waitId}`, context);
@@ -1006,6 +1038,11 @@ export class ViewerRequests {
1006
1038
  if (measurement.forecastReason !== forecast.reason || measurement.forecastInputKey !== forecastInputKey) {
1007
1039
  measurement.forecastReason = forecast.reason;
1008
1040
  measurement.forecastInputKey = forecastInputKey;
1041
+ if (forecast.subtitles) this.#host.observeWait?.(session.file.sourceKey, "process", {
1042
+ process: "C3", phase: "event", fileIndex: session.file.fileIndex,
1043
+ trackIndex: forecast.subtitles.trackIndex, ready: forecast.subtitles.ready,
1044
+ reason: forecast.reason, consumerId
1045
+ });
1009
1046
  logger.info(`playback readiness ${session.id} consumer=${consumerId} ${JSON.stringify({
1010
1047
  ...forecast,
1011
1048
  positionSeconds,
@@ -535,6 +535,7 @@ export function wireOutputs({
535
535
  });
536
536
  // Answering a request for a playlist, an init segment or a segment: from the store when the piece is made and whole, otherwise held until it is.
537
537
  parts.serving = new SegmentServing({
538
+ observeWait,
538
539
  // What a key names, read back from the key: how to find a gone output's
539
540
  // stored pieces and whether they are cut at given times.
540
541
  segmentFormatOfKey,
@@ -624,6 +625,7 @@ export function wireOutputs({
624
625
  });
625
626
  // What a viewer asks for: an output of a file, a position, a report of progress.
626
627
  parts.viewerRequests = new ViewerRequests({
628
+ observeWait,
627
629
  // Whether this machine has a place for one more viewer on this output —
628
630
  // asked in the same synchronous stretch as they are put on it.
629
631
  admitsWatching: (output) => parts.admission.admitsWatching(output?.outputKey ?? ""),
@@ -651,7 +653,7 @@ export function wireOutputs({
651
653
  const predicted = Number(qualityStateOf(output).predictedSpeedWhenOffered);
652
654
  return Number.isFinite(predicted) && predicted > 0 ? predicted : null;
653
655
  },
654
- firstFrameSecondsOf: (features) => parts.localObservations?.firstFrameSeconds(features) ?? null,
656
+ firstFrameSecondsOf: (features) => parts.localObservations?.firstFrameEstimate(features) ?? null,
655
657
  noteFirstFrame: (observation) => parts.localObservations?.noteFirstFrame(observation),
656
658
  audioDescriptionOf,
657
659
  openSecondsOf: (output) => runStartSecondsOf(output),
@@ -1189,6 +1191,7 @@ export function wireOutputs({
1189
1191
  // The piece's rank in the output's own map, said with an input's state.
1190
1192
  rankAt: (output, index) => parts.encodeOrchestrator?.rankAt?.(output.outputKey ?? "", index) ?? null,
1191
1193
  log: line => logger.info(line),
1194
+ observeWait,
1192
1195
  changed: (output, result) => {
1193
1196
  if (result.kind === "terminal") {
1194
1197
  logger.warn(`encode input output=${output.outputKey} terminal=${result.reason}: ${result.message ?? ""}` +
@@ -969,6 +969,8 @@ export class TorrentPool {
969
969
  * @type {WeakMap<import("webtorrent").Torrent, { addedAt: number, firstPeerAt: number | null }>}
970
970
  */
971
971
  #swarmTimingByTorrent = new WeakMap();
972
+ #waitTraceByTorrent = new WeakMap();
973
+ #waitTraceHooks = new WeakSet();
972
974
 
973
975
  /**
974
976
  * Sources every file of which this proxy holds whole.
@@ -1502,7 +1504,60 @@ export class TorrentPool {
1502
1504
  * @param {import("webtorrent").Torrent} torrent
1503
1505
  * @returns {void}
1504
1506
  */
1505
- #attachSwarmDiagnostics(torrent) {
1507
+ #attachSwarmDiagnostics(torrent, waitTrace = null) {
1508
+ if (waitTrace?.trace === true && typeof waitTrace.sourceKey === "string") {
1509
+ this.#waitTraceByTorrent.set(torrent, waitTrace);
1510
+ }
1511
+ const trace = (process, phase, fields = {}) => {
1512
+ const context = this.#waitTraceByTorrent.get(torrent);
1513
+ if (context?.trace === true) context.emitWaitTrace?.(context.sourceKey, process, phase, fields);
1514
+ };
1515
+ const wrap = (method, process) => {
1516
+ const original = torrent[method];
1517
+ if (typeof original !== "function") return;
1518
+ torrent[method] = function (...args) {
1519
+ const startedAt = performance.now();
1520
+ trace(process, "begin", { startedAt });
1521
+ try {
1522
+ const result = original.apply(this, args);
1523
+ if (result && typeof result.then === "function") {
1524
+ return result.then(value => {
1525
+ trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "resolved" });
1526
+ return value;
1527
+ }, error => {
1528
+ trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "error",
1529
+ error: String(error?.message ?? error) });
1530
+ throw error;
1531
+ });
1532
+ }
1533
+ trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "returned" });
1534
+ return result;
1535
+ } catch (error) {
1536
+ trace(process, "end", { startedAt, endedAt: performance.now(), outcome: "error",
1537
+ error: String(error?.message ?? error) });
1538
+ throw error;
1539
+ }
1540
+ };
1541
+ };
1542
+ if (waitTrace?.trace === true && !this.#waitTraceHooks.has(torrent)) {
1543
+ this.#waitTraceHooks.add(torrent);
1544
+ wrap("_onListening", "W1");
1545
+ wrap("_startDiscovery", "W2");
1546
+ wrap("_onStore", "W6");
1547
+ const verify = torrent._verifyPieces;
1548
+ if (typeof verify === "function") {
1549
+ torrent._verifyPieces = function (callback, ...args) {
1550
+ const startedAt = performance.now();
1551
+ trace("W5", "begin", { startedAt });
1552
+ return verify.call(this, (...results) => {
1553
+ trace("W5", "end", { startedAt, endedAt: performance.now(), outcome: "verified" });
1554
+ callback?.(...results);
1555
+ }, ...args);
1556
+ };
1557
+ }
1558
+ torrent.on("metadata", metadata => trace("W4", "event", { files: metadata?.files?.length ?? null }));
1559
+ torrent.on("wire", wire => trace("W3", "event", { peer: String(wire?.remoteAddress ?? "?") }));
1560
+ }
1506
1561
  // Attached ONCE per torrent. WebTorrent answers a duplicate add by handing
1507
1562
  // back the torrent it already has, and this used to run again on it: a
1508
1563
  // torrent with twelve connections and a first peer five minutes old had its
@@ -1510,7 +1565,8 @@ export class TorrentPool {
1510
1565
  // connection printed "first peer connected after 0.3s" — a false statement
1511
1566
  // about a swarm that had been healthy for minutes. The same film opened
1512
1567
  // once as a .torrent and once as a magnet is exactly that case, and the
1513
- // roadmap already records it happening.
1568
+ // roadmap already records it happening. Trace hooks above are installed
1569
+ // separately and only for an explicitly traced source.
1514
1570
  if (this.#swarmTimingByTorrent.has(torrent)) {
1515
1571
  return;
1516
1572
  }
@@ -1560,6 +1616,7 @@ export class TorrentPool {
1560
1616
  const queued = this.requests.length;
1561
1617
  const answer = request.call(this, index, offset, length, callback);
1562
1618
  if (this.requests.length > queued) {
1619
+ trace("W8", "event", { moment: "block-requested", piece: index, offset, length, peer });
1563
1620
  const now = Date.now();
1564
1621
  timings.asked(index, offset, peer, now);
1565
1622
  // When this wire was asked for the block: how long it has held it is
@@ -1568,7 +1625,10 @@ export class TorrentPool {
1568
1625
  }
1569
1626
  return answer;
1570
1627
  };
1571
- wire.on("piece", (index, offset) => timings.arrived(index, offset, peer, Date.now()));
1628
+ wire.on("piece", (index, offset) => {
1629
+ timings.arrived(index, offset, peer, Date.now());
1630
+ trace("W8", "event", { moment: "block-received", piece: index, offset, peer });
1631
+ });
1572
1632
  });
1573
1633
  torrent.on("verified", (piece) => {
1574
1634
  logger.info(`download ${label()} verified piece=${piece}${describePieceTiming(timings.verified(piece, Date.now()))}`);
@@ -1694,7 +1754,7 @@ export class TorrentPool {
1694
1754
  * @param {string} source - Magnet URI or base64-encoded .torrent bytes.
1695
1755
  * @returns {Promise<import("webtorrent").Torrent>}
1696
1756
  */
1697
- async getTorrent(sourceType, source) {
1757
+ async getTorrent(sourceType, source, waitTrace = null) {
1698
1758
  // Keyed by the torrent's own infohash, not the source bytes — a magnet
1699
1759
  // and a `.torrent` file for the same content must name the same swarm.
1700
1760
  const key = await deriveSourceKey(sourceType, source);
@@ -1702,6 +1762,7 @@ export class TorrentPool {
1702
1762
  // Already resolved — return immediately.
1703
1763
  const existing = this.torrents.get(key);
1704
1764
  if (existing) {
1765
+ this.#attachSwarmDiagnostics(existing, waitTrace);
1705
1766
  this.#lastAccess.set(existing, Date.now());
1706
1767
  return existing;
1707
1768
  }
@@ -1740,7 +1801,7 @@ export class TorrentPool {
1740
1801
  );
1741
1802
  existing.destroy({ destroyStore: false }, () => {
1742
1803
  this.#pending.delete(key);
1743
- resolve(this.getTorrent(sourceType, source));
1804
+ resolve(this.getTorrent(sourceType, source, waitTrace));
1744
1805
  });
1745
1806
  return;
1746
1807
  }
@@ -1792,10 +1853,10 @@ export class TorrentPool {
1792
1853
  this.torrents.set(key, replacement);
1793
1854
  this.#lastAccess.set(replacement, Date.now());
1794
1855
  this.#pending.delete(key);
1795
- this.#attachSwarmDiagnostics(replacement);
1856
+ this.#attachSwarmDiagnostics(replacement, waitTrace);
1796
1857
  resolve(replacement);
1797
1858
  });
1798
- this.#attachSwarmDiagnostics(addedReplacement);
1859
+ this.#attachSwarmDiagnostics(addedReplacement, waitTrace);
1799
1860
  });
1800
1861
  return;
1801
1862
  }
@@ -1816,6 +1877,10 @@ export class TorrentPool {
1816
1877
  // piece across threads detached memory still in use. Ours owns what it
1817
1878
  // hands out, holds pieces in shared memory the main thread can read
1818
1879
  // directly, and spills to disk instead of losing them.
1880
+ const skipVerify = this.#wholeSources.has(key);
1881
+ if (skipVerify && waitTrace?.trace === true) {
1882
+ waitTrace.emitWaitTrace?.(waitTrace.sourceKey, "W5", "event", { outcome: "skipped" });
1883
+ }
1819
1884
  const added = this.client.add(torrentId, {
1820
1885
  store: this.#pieceStore,
1821
1886
  storeCacheSlots: 0,
@@ -1831,7 +1896,7 @@ export class TorrentPool {
1831
1896
  // whole: what would be verified was written out of pieces this client
1832
1897
  // had hashed, and the file's size was checked against what the torrent
1833
1898
  // says. See `#wholeSources`.
1834
- skipVerify: this.#wholeSources.has(key)
1899
+ skipVerify
1835
1900
  }, (readyTorrent) => {
1836
1901
  this.client.off("error", onError);
1837
1902
  this.torrents.set(key, readyTorrent);
@@ -1842,7 +1907,7 @@ export class TorrentPool {
1842
1907
  // back the one it already had. Watching only what `add` returned would
1843
1908
  // leave the survivor unwatched. Attaching twice costs nothing — the
1844
1909
  // guard makes the second call a no-op when it is the same object.
1845
- this.#attachSwarmDiagnostics(readyTorrent);
1910
+ this.#attachSwarmDiagnostics(readyTorrent, waitTrace);
1846
1911
  // On the clock from the moment it exists. A torrent nobody ever states
1847
1912
  // anything about — a file list fetched and never played — used to be
1848
1913
  // held for the life of the process, because the only thing that ever
@@ -1858,7 +1923,7 @@ export class TorrentPool {
1858
1923
  // `wire` is emitted at the moment of connection and never replayed. The
1859
1924
  // torrent that waited minutes for its first peer would have been the one
1860
1925
  // case this could not measure.
1861
- this.#attachSwarmDiagnostics(added);
1926
+ this.#attachSwarmDiagnostics(added, waitTrace);
1862
1927
  });
1863
1928
 
1864
1929
  this.#pending.set(key, promise);
@@ -137,11 +137,13 @@ export class TorrentWorkerClient {
137
137
  #onSourceForgotten;
138
138
  /** Told when what this proxy holds changed; see `Event.HOLDINGS_CHANGED`. */
139
139
  #onHoldingsChanged;
140
+ #onWaitTrace;
140
141
 
141
142
  /**
142
143
  * @param {{ memoryBytes?: number, stateDir?: string, onPiecesArrived?: (event: object) => void }} [options]
143
144
  */
144
- constructor({ memoryBytes, stateDir, heapSnapshots = false, onPiecesArrived, onSourceForgotten, onHoldingsChanged } = {}) {
145
+ constructor({ memoryBytes, stateDir, heapSnapshots = false, onPiecesArrived, onSourceForgotten, onHoldingsChanged,
146
+ onWaitTrace } = {}) {
145
147
  this.#worker = new Worker(fileURLToPath(WORKER_URL), {
146
148
  // `stateDir` travels because the worker writes heap snapshots of its own
147
149
  // isolate there. It cannot choose a directory any other way: a worker may
@@ -153,6 +155,7 @@ export class TorrentWorkerClient {
153
155
  this.#onPiecesArrived = onPiecesArrived ?? (() => undefined);
154
156
  this.#onSourceForgotten = onSourceForgotten ?? (() => undefined);
155
157
  this.#onHoldingsChanged = onHoldingsChanged ?? (() => undefined);
158
+ this.#onWaitTrace = onWaitTrace ?? (() => undefined);
156
159
 
157
160
  this.#worker.on("message", (message) => {
158
161
  // A failed read must fail its stream. This is checked BEFORE the caller
@@ -254,6 +257,9 @@ export class TorrentWorkerClient {
254
257
  // taken twice on two different histories.
255
258
  writeAlreadyDecided(message.level ?? "info", `torrent-worker: ${message.message}`);
256
259
  break;
260
+ case Event.WAIT_TRACE:
261
+ this.#onWaitTrace(message);
262
+ break;
257
263
  case Event.FILE_COMPLETE:
258
264
  // Recorded here so the stream route can answer from the file without
259
265
  // going near the torrent thread at all.
@@ -346,8 +352,8 @@ export class TorrentWorkerClient {
346
352
  * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
347
353
  * @returns {Promise<{ infoHash: string, name: string, files: { index: number, name: string, path: string, length: number }[] }>}
348
354
  */
349
- async addSource({ sourceKey, sourceType, source }) {
350
- return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source });
355
+ async addSource({ sourceKey, sourceType, source, trace = false }) {
356
+ return this.#caller.call(Command.ADD_SOURCE, { sourceKey, sourceType, source, trace });
351
357
  }
352
358
 
353
359
  /**
@@ -675,8 +681,8 @@ export class TorrentWorkerClient {
675
681
  * @param {{ sourceKey: string, sourceType: "magnet" | "torrent", source: string }} params
676
682
  * @returns {Promise<{ infoHash: string, name: string, pieceLength: number, sourceKey: string, files: object[] }>}
677
683
  */
678
- async getTorrent({ sourceKey, sourceType, source }) {
679
- const info = await this.addSource({ sourceKey, sourceType, source });
684
+ async getTorrent({ sourceKey, sourceType, source, trace = false }) {
685
+ const info = await this.addSource({ sourceKey, sourceType, source, trace });
680
686
  const client = this;
681
687
  return {
682
688
  infoHash: info.infoHash,
@@ -32,6 +32,7 @@ export class WorkerTorrentPool {
32
32
  #torrents = new Map();
33
33
  #pending = new Map();
34
34
  #observed;
35
+ #isWaitTraced;
35
36
 
36
37
  /**
37
38
  * @param {{ memoryBytes?: number, stateDir?: string }} [options]
@@ -39,6 +40,7 @@ export class WorkerTorrentPool {
39
40
  */
40
41
  constructor(options = {}, client = null) {
41
42
  this.#observed = options.onObserved;
43
+ this.#isWaitTraced = options.isWaitTraced ?? (() => false);
42
44
  this.#client = client ?? new TorrentWorkerClient({ ...options, onSourceForgotten: event => {
43
45
  this.forget(event.sourceKey);
44
46
  options.onSourceForgotten?.(event);
@@ -52,7 +54,7 @@ export class WorkerTorrentPool {
52
54
  * @param {string} source
53
55
  * @returns {Promise<object>}
54
56
  */
55
- async getTorrent(sourceType, source) {
57
+ async getTorrent(sourceType, source, options = {}) {
56
58
  const startedAt = performance.now();
57
59
  const sourceKey = await deriveSourceKey(sourceType, source);
58
60
  const observed = outcome => this.#observed?.({ sourceKey, startedAt, endedAt: performance.now(), outcome });
@@ -63,7 +65,8 @@ export class WorkerTorrentPool {
63
65
  }
64
66
  const joined = this.#pending.get(sourceKey);
65
67
  if (joined) return joined.finally(() => observed("joined"));
66
- const pending = this.#client.getTorrent({ sourceKey, sourceType, source }).then(torrent => {
68
+ const pending = this.#client.getTorrent({ sourceKey, sourceType, source,
69
+ trace: options.trace === true || this.#isWaitTraced(sourceKey) }).then(torrent => {
67
70
  if (this.#pending.get(sourceKey) !== pending) {
68
71
  const error = new Error("Source was forgotten while its metadata was being read.");
69
72
  error.code = "SOURCE_FORGOTTEN";
@@ -152,6 +152,8 @@ export const Event = {
152
152
  CHUNK_ACK: "chunk-ack",
153
153
  /** A log line, so worker output reaches the same place as everything else. */
154
154
  LOG: "log",
155
+ /** A structured process observation from the torrent thread. */
156
+ WAIT_TRACE: "wait-trace",
155
157
  /**
156
158
  * Pieces of these files have arrived.
157
159
  *
@@ -92,6 +92,13 @@ const pool = new TorrentPool({
92
92
  const torrentsByKey = new Map();
93
93
  /** Resolved handles for reads that must never wait for or revive a source. */
94
94
  const resolvedTorrents = new Map();
95
+ const waitTraceSources = new Set();
96
+
97
+ function emitWaitTrace(sourceKey, process, phase, fields = {}) {
98
+ if (!waitTraceSources.has(sourceKey)) return;
99
+ parentPort.postMessage({ type: Event.WAIT_TRACE, version: 1, clock: "torrent-worker", sourceKey,
100
+ process, phase, at: performance.now(), ...fields });
101
+ }
95
102
 
96
103
  /** In-flight reads, so a cancel can stop one mid-body. */
97
104
  const readsById = new Map();
@@ -327,6 +334,7 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
327
334
  async function runCommand(command, params, id) {
328
335
  switch (command) {
329
336
  case Command.ADD_SOURCE: {
337
+ if (params.trace === true) waitTraceSources.add(params.sourceKey);
330
338
  const retained = wholeSources.describe(params.sourceKey);
331
339
  if (retained) {
332
340
  announceWholeFiles(params.sourceKey, retained);
@@ -338,7 +346,10 @@ async function runCommand(command, params, id) {
338
346
  // racing to open the same torrent into one.
339
347
  let pending = torrentsByKey.get(params.sourceKey);
340
348
  if (!pending) {
341
- pending = pool.getTorrent(params.sourceType, params.source);
349
+ emitWaitTrace(params.sourceKey, "W1", "event", { moment: "add-request" });
350
+ pending = pool.getTorrent(params.sourceType, params.source, {
351
+ sourceKey: params.sourceKey, trace: params.trace === true, emitWaitTrace
352
+ });
342
353
  torrentsByKey.set(params.sourceKey, pending);
343
354
  // A failed add must not be remembered, or every later attempt at this
344
355
  // source replays the same failure. The handler also marks the rejection
@@ -463,9 +474,14 @@ async function runCommand(command, params, id) {
463
474
 
464
475
 
465
476
  case Command.FILE_STATS: {
477
+ const statsStartedAt = performance.now();
478
+ emitWaitTrace(params.sourceKey, "W10", "begin", { startedAt: statsStartedAt,
479
+ fileIndex: params.fileIndex, forecast: params.forecast === true });
466
480
  const torrent = await knownTorrent(params.sourceKey);
467
481
  if (!torrent) {
468
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 });
469
485
  return whole ? wholeFileStats(whole.length) : { pending: true };
470
486
  }
471
487
  // What this file's own interruptions demand, measured by the reader in
@@ -485,6 +501,9 @@ async function runCommand(command, params, id) {
485
501
  const end = Math.min(torrent.length, (piece + 1) * torrent.pieceLength);
486
502
  if (end > start) ranges.push({ start, end, availableAt });
487
503
  }
504
+ emitWaitTrace(params.sourceKey, "W10", "end", { startedAt: statsStartedAt,
505
+ endedAt: performance.now(), outcome: "ok", fileIndex: params.fileIndex,
506
+ forecast: params.forecast === true });
488
507
  return {
489
508
  ...stats,
490
509
  ...(params.forecast === true ? { downloadForecast: {
@@ -514,9 +533,12 @@ async function runCommand(command, params, id) {
514
533
  // zone becomes a window of the register and the swarm is told again
515
534
  // (torrent-tv/meta#166).
516
535
  const startedAt = performance.now();
536
+ emitWaitTrace(params.sourceKey, "W7", "begin", { fileIndex: params.fileIndex });
517
537
  ensureArrivalsWired(params.sourceKey, torrent);
518
538
  pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
519
539
  torrent.emit("priority-map-changed", params.fileIndex);
540
+ emitWaitTrace(params.sourceKey, "W7", "end", { startedAt, endedAt: performance.now(),
541
+ fileIndex: params.fileIndex });
520
542
  return { appliedMs: performance.now() - startedAt, receivedAt, repliedAt: performance.timeOrigin + performance.now() };
521
543
  }
522
544
 
@@ -918,6 +940,8 @@ function describePieceBuffers() {
918
940
  * @returns {void}
919
941
  */
920
942
  function announceArrivals(sourceKey, torrent, reason = "verified", piece = null) {
943
+ if (Number.isSafeInteger(piece)) emitWaitTrace(sourceKey, "W9", "event", { piece, reason,
944
+ bytes: bytesOfPiece(torrent, piece) });
921
945
  const fileIndexes = [...demandFor(torrent).register.files()];
922
946
  if (fileIndexes.length === 0) {
923
947
  return;
@@ -20,9 +20,10 @@ export class DownloadMaps {
20
20
  #clock;
21
21
  /** What the torrent is told of the playback zones: as many windows as say the same thing. */
22
22
  #pack;
23
+ #observeWait;
23
24
 
24
25
  constructor({ publish, resolvePlayback = async map => map.zones, log = null, now = Date.now,
25
- clock = () => performance.timeOrigin + performance.now(), pack = zones => zones }) {
26
+ clock = () => performance.timeOrigin + performance.now(), pack = zones => zones, observeWait = null }) {
26
27
  if (typeof publish !== "function") throw new TypeError("Download map publication is required.");
27
28
  this.#publish = publish;
28
29
  this.#resolvePlayback = resolvePlayback;
@@ -30,6 +31,7 @@ export class DownloadMaps {
30
31
  this.#log = typeof log === "function" ? log : null;
31
32
  this.#now = now;
32
33
  this.#clock = clock;
34
+ this.#observeWait = typeof observeWait === "function" ? observeWait : null;
33
35
  }
34
36
 
35
37
  async playback({ sourceKey, fileIndex, durationSeconds, zones }) {
@@ -117,8 +119,23 @@ export class DownloadMaps {
117
119
  this.#setZones(file, withRetained(zones, retained));
118
120
  this.#emit(file).catch(() => undefined);
119
121
  };
120
- const resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones,
121
- isCurrent, workingOut });
122
+ const resolverStartedAt = performance.now();
123
+ this.#observeWait?.(sourceKey, "process", { process: "P4", phase: "begin", fileIndex,
124
+ revision, zones: file.sourceZones.length, startedAt: resolverStartedAt });
125
+ let resolved;
126
+ let resolverOutcome = "ok";
127
+ try {
128
+ resolved = await this.#resolvePlayback({ sourceKey, fileIndex, durationSeconds, zones: file.sourceZones,
129
+ isCurrent, workingOut });
130
+ } catch (error) {
131
+ resolverOutcome = "error";
132
+ throw error;
133
+ } finally {
134
+ const resolverEndedAt = performance.now();
135
+ this.#observeWait?.(sourceKey, "process", { process: "P4", phase: "end", fileIndex, revision,
136
+ startedAt: resolverStartedAt, endedAt: resolverEndedAt, durationMs: resolverEndedAt - resolverStartedAt,
137
+ outcome: resolverOutcome, resolvedZones: Array.isArray(resolved) ? resolved.length : null });
138
+ }
122
139
  if (revision !== file.revision) return;
123
140
  this.#setZones(file, resolved);
124
141
  return this.#emit(file);
@@ -1,4 +1,6 @@
1
1
  /** Bounded, opt-in startup observations. No torrent or application imports. */
2
+ const SOURCE_CONTEXT_TTL_MS = 30 * 60_000;
3
+
2
4
  export class SourceWaitTrace {
3
5
  constructor({ emit, now = () => performance.now() }) {
4
6
  Object.assign(this, { emit, now, sources: new Map(), requests: new WeakMap() });
@@ -9,16 +11,34 @@ export class SourceWaitTrace {
9
11
  operation: context.operation, event, at: this.now(), ...fields });
10
12
  }
11
13
 
14
+ process(sourceKey, process, phase, fields = {}) {
15
+ const context = this.sources.get(sourceKey);
16
+ if (!context || this.now() - context.seen > SOURCE_CONTEXT_TTL_MS) return;
17
+ this.write({ ...context, operation: null }, "process", { sourceKey, process, phase, ...fields });
18
+ }
19
+
20
+ has(sourceKey) {
21
+ const context = this.sources.get(sourceKey);
22
+ return Boolean(context && this.now() - context.seen <= SOURCE_CONTEXT_TTL_MS);
23
+ }
24
+
25
+ associate(request, sourceKey) {
26
+ const context = this.requests.get(request);
27
+ if (!context || typeof sourceKey !== "string" || !sourceKey) return false;
28
+ this.remember(sourceKey, context);
29
+ return true;
30
+ }
31
+
12
32
  remember(sourceKey, context) {
13
33
  if (typeof sourceKey !== "string" || !sourceKey) return;
14
- for (const [key, value] of this.sources) if (this.now() - value.seen > 60_000) this.sources.delete(key);
34
+ for (const [key, value] of this.sources) if (this.now() - value.seen > SOURCE_CONTEXT_TTL_MS) this.sources.delete(key);
15
35
  if (this.sources.size >= 64 && !this.sources.has(sourceKey)) this.sources.delete(this.sources.keys().next().value);
16
36
  this.sources.set(sourceKey, { ...context, seen: this.now() });
17
37
  }
18
38
 
19
39
  source(sourceKey, event, fields = {}) {
20
40
  const context = this.sources.get(sourceKey);
21
- if (!context || this.now() - context.seen > 60_000) return;
41
+ if (!context || this.now() - context.seen > SOURCE_CONTEXT_TTL_MS) return;
22
42
  this.write({ ...context, operation: null }, event, { sourceKey, ...fields });
23
43
  }
24
44
 
@@ -26,19 +46,23 @@ export class SourceWaitTrace {
26
46
  app.addHook("onRequest", async (req, reply) => {
27
47
  const wait = req.headers["x-ttv-wait-trace"];
28
48
  const operation = req.headers["x-ttv-wait-operation"];
49
+ const statedProcess = req.headers["x-ttv-wait-process"];
29
50
  const attempt = Number(req.headers["x-ttv-wait-attempt"]);
30
51
  if (typeof wait !== "string" || !/^[a-zA-Z0-9.-]{1,80}$/.test(wait) ||
31
52
  typeof operation !== "string" || !/^b[0-9]{1,12}$/.test(operation) ||
32
53
  !Number.isSafeInteger(attempt) || attempt < 0) return;
33
54
  const context = { wait, attempt, operation, startedAt: this.now() };
34
55
  this.requests.set(req, context);
35
- this.write(context, "http-start", { method: req.method, path: req.url,
56
+ const process = typeof statedProcess === "string" && /^[A-Z][0-9]+(?:\.(?:[0-9]+[a-z]?|[va]))?$/.test(statedProcess)
57
+ ? statedProcess : processForRequest(req.url);
58
+ this.write(context, "http-start", { process, phase: "begin", method: req.method, path: req.url,
36
59
  requestId: req.headers["x-ttv-forwarded-request-id"] ?? req.id });
37
60
  let finished = false;
38
61
  const end = outcome => {
39
62
  if (finished) return;
40
63
  finished = true;
41
- this.write(context, "http-end", { outcome, status: reply.statusCode, duration: this.now() - context.startedAt });
64
+ this.write(context, "http-end", { process, phase: "end", outcome, status: reply.statusCode,
65
+ startedAt: context.startedAt, endedAt: this.now(), duration: this.now() - context.startedAt });
42
66
  };
43
67
  reply.raw.once("finish", () => end("finished"));
44
68
  reply.raw.once("close", () => end(reply.raw.writableFinished ? "finished" : "closed"));
@@ -66,3 +90,13 @@ export class SourceWaitTrace {
66
90
  });
67
91
  }
68
92
  }
93
+
94
+ function processForRequest(path) {
95
+ if (path.startsWith("/api/transcode-sessions")) return "P6.v";
96
+ if (path.startsWith("/api/playback-plan")) return "P3";
97
+ if (/\/transcode\/.*\.m3u8(?:\?|$)/.test(path)) return "P12";
98
+ if (/\/transcode\/.*(?:init|\.mp4)(?:\?|$)/.test(path)) return "P12";
99
+ if (/\/transcode\/.*(?:\.m4s|\.ts)(?:\?|$)/.test(path)) return "P12";
100
+ if (path.startsWith("/api/session-progress")) return "P14";
101
+ return null;
102
+ }