@torrent-tv/proxy 2.99.10 → 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.10",
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,19 +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
- /** Concurrent file reports share one snapshot of every live download map. */
34
- export function forecastDownloads() {
35
- if (!futurePending) {
36
- const entries = [...live];
37
- const revisions = entries.map(entry => entry.register.revision);
38
- const withdrawals = entries.map(entry => entry.withdrawalRevision);
39
- const isCurrent = () => entries.length === live.size && !entries.some((entry, index) =>
40
- !live.has(entry) || entry.register.revision !== revisions[index] ||
41
- entry.withdrawalRevision !== withdrawals[index]);
42
- futurePending = futureDownload(entries, { isCurrent }).then(result =>
43
- isCurrent() ? result : new Map()).finally(() => { futurePending = null; });
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
+ */
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 = () => {};
46
+
47
+ /**
48
+ * Say where a failed forecast is reported.
49
+ *
50
+ * @param {(error: unknown) => void} report
51
+ * @returns {void}
52
+ */
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();
44
107
  }
45
- return 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();
46
145
  }
47
146
 
48
147
  /**
@@ -58,10 +157,11 @@ export function demandFor(torrent) {
58
157
  }
59
158
  const register = new DemandRegister();
60
159
  const selection = new SwarmSelection({ torrent, register });
61
- const entry = { torrent, register, selection, limit: new SelectionLimit({ torrent, register, selection }), withdrawalRevision: 0 };
160
+ const entry = { torrent, register, selection, limit: new SelectionLimit({ torrent, register, selection }), withdrawalRevision: 0,
161
+ verifiedRevision: 0 };
62
162
  byTorrent.set(torrent, entry);
63
163
  live.add(entry);
64
- entry.onBytesChanged = () => reconcileAll();
164
+ entry.onBytesChanged = () => { entry.verifiedRevision++; reconcileAll(); };
65
165
  entry.onWithdrawn = () => { entry.withdrawalRevision++; reconcileAll(); };
66
166
  torrent.on?.("verified", entry.onBytesChanged);
67
167
  torrent.on?.("piece-withdrawn", entry.onWithdrawn);
@@ -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 });