@torrent-tv/proxy 2.99.11 → 2.99.12

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.99.11",
3
+ "version": "2.99.12",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "repository": {
@@ -20,6 +20,7 @@ import { SwarmSelection } from "./SwarmSelection.js";
20
20
  import { SelectionLimit } from "./SelectionLimit.js";
21
21
  import { futureDownload } from "./FutureDownload.js";
22
22
  import { urgencyName } from "../demand/index.js";
23
+ import { pieceStoreOf } from "../piece-store-of.js";
23
24
 
24
25
  /** @type {WeakMap<object, { register: DemandRegister, selection: SwarmSelection }>} */
25
26
  const byTorrent = new WeakMap();
@@ -30,44 +31,117 @@ let publishingSelections = false;
30
31
  /** What the last reconciliation told the swarm to fetch first, for the pool to say. */
31
32
  let firstClassStated = "none";
32
33
 
33
- /** The last forecast that finished on the maps it was begun on, and how to tell it still describes them. */
34
+ /**
35
+ * The last forecast that finished on the maps it was begun on: its result, the
36
+ * demand and withdrawal revisions of each torrent it was worked out on, and
37
+ * whether nothing has changed since; overdue once a missing piece outlived its
38
+ * forecast time.
39
+ *
40
+ * @type {{ result: Map<object, { measuredAt: number, arrivals: Map<number, number | null> }>,
41
+ * versions: Map<object, { demand: number, withdrawal: number }>, describes: () => boolean, overdue?: boolean } | null}
42
+ */
34
43
  let latestForecast = null;
44
+ /** Who is told that a forecast failed: this layer decides from demand and storage and writes no log of its own. */
45
+ let reportForecastFailure = () => {};
35
46
 
36
47
  /**
37
- * The arrival forecast of every live download map, shared by concurrent file
38
- * reports.
39
- *
40
- * ANSWERED WITH THE LAST ONE FINISHED. Each forecast states when it was made
41
- * (`measuredAt`), and a newer one is worked out beside the answer whenever the
42
- * maps or the held pieces have changed since, one at a time. Awaited afresh on
43
- * every report, it held a waiting viewer's answer 3-11.6 s on Home Assistant
44
- * while it took about a quarter of a second of work, the machine 83-94 % busy:
45
- * the page asks again only once answered, so the hold after a seek to 100 s
46
- * lasted 52.5 s (r51, torrent-tv/meta#178). Only the first report waits, when
47
- * there is no forecast yet. A forecast whose maps change while it is worked out
48
- * is not kept.
49
- *
50
- * @returns {Promise<Map<object, { measuredAt: number, arrivals: Map<number, number | null> }>>}
48
+ * Say where a failed forecast is reported.
49
+ *
50
+ * @param {(error: unknown) => void} report
51
+ * @returns {void}
51
52
  */
52
- export function forecastDownloads() {
53
- if (!futurePending && !latestForecast?.isCurrent()) {
54
- const entries = [...live];
55
- const revisions = entries.map(entry => entry.register.revision);
56
- const withdrawals = entries.map(entry => entry.withdrawalRevision);
57
- const verified = entries.map(entry => entry.verifiedRevision);
58
- const isCurrent = () => entries.length === live.size && !entries.some((entry, index) =>
59
- !live.has(entry) || entry.register.revision !== revisions[index] ||
60
- entry.withdrawalRevision !== withdrawals[index]);
61
- // Kept while no piece has arrived either: a piece forecast to come has
62
- // either come, or its time is past and it must be forecast again.
63
- const describes = () => isCurrent() && entries.every((entry, index) => entry.verifiedRevision === verified[index]);
64
- futurePending = futureDownload(entries, { isCurrent }).then(result => {
65
- if (!isCurrent()) return new Map();
66
- latestForecast = { result, isCurrent: describes };
67
- return result;
68
- }).finally(() => { futurePending = null; });
53
+ export function onForecastFailure(report) {
54
+ reportForecastFailure = report;
55
+ }
56
+
57
+ /**
58
+ * When this torrent's wanted pieces are forecast to be held, as far as the last
59
+ * finished forecast says so for the map it now has; null when that forecast
60
+ * does not describe it.
61
+ *
62
+ * NEVER AWAITED. Awaited afresh on every report, the forecast held a waiting
63
+ * viewer's answer 3-11.6 s on Home Assistant while it took about a quarter of a
64
+ * second of work, the machine 83-94 % busy: the page asks again only once
65
+ * answered, so the hold after a seek to 100 s lasted 52.5 s (r51,
66
+ * torrent-tv/meta#178). A newer forecast is worked out beside the answer,
67
+ * one at a time, whenever the maps, the withdrawals or the held pieces have
68
+ * changed since the last one.
69
+ *
70
+ * ONLY FOR THE MAP IT WAS WORKED OUT ON. The order pieces come in follows the
71
+ * map, so a forecast made before the map changed says nothing of the new one,
72
+ * and neither does one made before a piece was withdrawn: that piece goes back
73
+ * into the queue ahead of others. Such a torrent gets null, and the caller
74
+ * estimates its missing bytes from the measured rate, as before any forecast.
75
+ *
76
+ * AND WHAT IS HELD IS READ NOW, FROM THE STORE. A piece held now is available
77
+ * now whatever was forecast. A piece still missing whose forecast time has
78
+ * passed did not come when forecast — its peer stopped, or it failed
79
+ * verification — and is unknown, not available: read as `max(0, at - now)`,
80
+ * 2.99.11's stale time made a missing piece count as held.
81
+ *
82
+ * @param {object} torrent
83
+ * @param {{ now?: number, findStore?: (torrent: object) => object | null }} [options]
84
+ * @returns {{ measuredAt: number, arrivals: Map<number, number | null> } | null}
85
+ */
86
+ export function downloadForecastOf(torrent, { now = Date.now(), findStore = pieceStoreOf } = {}) {
87
+ const entry = byTorrent.get(torrent);
88
+ if (!entry) return null;
89
+ refreshForecast();
90
+ const forecast = latestForecast?.result.get(torrent);
91
+ const version = latestForecast?.versions.get(entry);
92
+ if (!forecast || !version || version.demand !== entry.register.revision ||
93
+ version.withdrawal !== entry.withdrawalRevision) return null;
94
+ const store = findStore(torrent);
95
+ const arrivals = new Map();
96
+ let overdue = false;
97
+ for (const [piece, at] of forecast.arrivals) {
98
+ const held = store?.locationOf ? store.locationOf(piece) !== "missing" : torrent.bitfield?.get(piece) === true;
99
+ if (!held && Number.isFinite(at) && at <= now) overdue = true;
100
+ arrivals.set(piece, held ? now : Number.isFinite(at) && at > now ? at : null);
101
+ }
102
+ // A piece that did not come when forecast is a change no event announces:
103
+ // the next forecast is begun on it, as on one.
104
+ if (overdue) {
105
+ latestForecast.overdue = true;
106
+ refreshForecast();
69
107
  }
70
- return latestForecast ? Promise.resolve(latestForecast.result) : futurePending;
108
+ return { measuredAt: forecast.measuredAt, arrivals };
109
+ }
110
+
111
+ /**
112
+ * Begin a forecast of every live map unless one is being worked out or the
113
+ * last one still describes them.
114
+ *
115
+ * A failed forecast is said and leaves the last one in place; the next report
116
+ * begins another. Left unhandled, its rejection would end nobody's wait and
117
+ * reach the thread's own handler as an unhandled rejection.
118
+ *
119
+ * @returns {void}
120
+ */
121
+ function refreshForecast() {
122
+ if (futurePending || (latestForecast?.describes() && !latestForecast.overdue)) return;
123
+ const entries = [...live];
124
+ const revisions = entries.map(entry => entry.register.revision);
125
+ const withdrawals = entries.map(entry => entry.withdrawalRevision);
126
+ const verified = entries.map(entry => entry.verifiedRevision);
127
+ const isCurrent = () => entries.length === live.size && !entries.some((entry, index) =>
128
+ !live.has(entry) || entry.register.revision !== revisions[index] ||
129
+ entry.withdrawalRevision !== withdrawals[index]);
130
+ // Worked out again once a piece has arrived as well: the pieces queued
131
+ // behind it now come sooner.
132
+ const describes = () => isCurrent() && entries.every((entry, index) => entry.verifiedRevision === verified[index]);
133
+ const versions = new Map(entries.map((entry, index) =>
134
+ [entry, { demand: revisions[index], withdrawal: withdrawals[index] }]));
135
+ futurePending = futureDownload(entries, { isCurrent }).then(result => {
136
+ if (isCurrent()) latestForecast = { result, versions, describes };
137
+ }, error => {
138
+ try { reportForecastFailure(error); } catch { /* a failing report must not end the next forecast */ }
139
+ }).finally(() => { futurePending = null; });
140
+ }
141
+
142
+ /** The forecast being worked out, for checks that wait for it to finish. */
143
+ export function forecastSettled() {
144
+ return futurePending ?? Promise.resolve();
71
145
  }
72
146
 
73
147
  /**
@@ -28,7 +28,7 @@ import { Readable } from "node:stream";
28
28
  import { heldFileBytes } from "./held-file-bytes.js";
29
29
  import { createSendStream } from "./channel.js";
30
30
  import { readFragments, supplyFiguresFor } from "./piece-reader.js";
31
- import { demandFor, forecastDownloads, selectionLimitOf } from "../download/registry.js";
31
+ import { demandFor, downloadForecastOf, onForecastFailure, selectionLimitOf } from "../download/registry.js";
32
32
  import { heldRangesOf, ownsItsMemory, readHeldBytes, readHeldRanges } from "./held-bytes.js";
33
33
  import { CompletedFiles, completedFilesRoot } from "../../storage/files/CompletedFiles.js";
34
34
  import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../../storage/files/piece-from-whole-file.js";
@@ -79,6 +79,8 @@ const { SharedPieceStore, collectStoreStats, findSharedStore, machineReserveByte
79
79
  // been asked for anything yet.
80
80
  const dhtBootstrap = await resolveDhtBootstrap();
81
81
 
82
+ onForecastFailure(error => logger.warn(`download: the arrival forecast failed, the last one stands: ${error?.stack ?? error}`));
83
+
82
84
  const pool = new TorrentPool({
83
85
  memoryBytes: workerData?.memoryBytes,
84
86
  dhtBootstrap,
@@ -473,7 +475,7 @@ async function runCommand(command, params, id) {
473
475
  // interval invented from one point is exactly what this work removes.
474
476
  const file = Array.isArray(torrent?.files) ? torrent.files[params.fileIndex] : null;
475
477
  const forecastAt = performance.now();
476
- const forecast = file && params.forecast === true ? (await forecastDownloads()).get(torrent) : null;
478
+ const forecast = file && params.forecast === true ? downloadForecastOf(torrent) : null;
477
479
  const forecastMs = params.forecast === true ? performance.now() - forecastAt : 0;
478
480
  const stats = pool.getFileStats(torrent, params.fileIndex);
479
481
  const ranges = [];
@@ -350,7 +350,12 @@ export function predictPlaybackReadiness(input = {}) {
350
350
  for (const range of source.downloadForecast.ranges ?? []) {
351
351
  if (!Number.isFinite(range?.availableAt) || !Number.isSafeInteger(range.start) ||
352
352
  !Number.isSafeInteger(range.end) || range.start < 0 || range.end <= range.start) continue;
353
- const arrival = Math.max(0, (range.availableAt - now) / 1000);
353
+ // A forecast time already past says nothing of when the bytes come:
354
+ // held bytes are in the residence, and missing ones are estimated
355
+ // below. Read as an arrival at zero, a piece forecast to come and
356
+ // still missing counted as held (torrent-tv/meta#178).
357
+ if (range.availableAt <= now) continue;
358
+ const arrival = (range.availableAt - now) / 1000;
354
359
  // The entire protocol piece becomes readable after verification;
355
360
  // bytes inside it never arrive proportionally to their file address.
356
361
  sourceByteService.get(id).push({ start: range.start, end: range.end, begin: arrival, finish: arrival });