@torrent-tv/proxy 2.9.75 → 2.9.76

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/CHANGELOG.md CHANGED
@@ -1,3 +1,8 @@
1
+ ## 2.9.76
2
+
3
+ - **Fix**: A read that failed inside the torrent thread left the reader waiting forever, and a read that failed part-way looked like a file that had simply ended. Two halves of one hole, both present since the thread split: the worker sent the end-of-read marker from its `finally` even when the read had thrown, and the main thread had no handler for a read error at all — so the report was dropped as unknown. That is why the 2.9.71 defect took three releases to find: every symptom said "empty file", never "this read failed, here is why". Now the marker is sent only on success and the failure fails the caller's stream. Covered end to end by a test that hung before the fix.
4
+ - **Fix**: Reads and commands drew request ids from two independent counters into one namespace, so a read and a command could both be in flight as the same number. The worker's reply to the read then resolved the **command** — with the read's result, silently — and the command's real answer arrived later and was discarded as unknown. Depending on which command lost the race this produced empty stats, a prefetch that returned early, or a file claim released before its read had finished. Every id now comes from one sequence, which makes the collision impossible rather than unlikely; a test hands out ids down both paths and asserts they never repeat.
5
+
1
6
  ## 2.9.75
2
7
 
3
8
  - **New**: The piece store reports what it is doing — resident pieces against the budget, how many spilled to disk, what share of reads came from memory rather than disk, and how often eviction was refused because every piece was being read. Logged once a minute and only when something changed. Without this the component that decides whether a read is free or costs a disk trip was invisible in the field, and the first oddity would have had no evidence behind it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.9.75",
3
+ "version": "2.9.76",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -42,6 +42,18 @@ export function createCaller(port) {
42
42
  const pending = new Map();
43
43
 
44
44
  return {
45
+ /**
46
+ * The one source of request ids on this side of the channel.
47
+ *
48
+ * Reads used to number themselves from a second counter, which put two
49
+ * unrelated sequences in one namespace: a read and a command could both be
50
+ * in flight as id 5, and the worker's reply to the read then resolved the
51
+ * command — with the read's result, silently, while the command's real
52
+ * answer arrived later and was dropped as unknown. Handing out every id
53
+ * from here makes that collision impossible rather than unlikely.
54
+ */
55
+ nextId,
56
+
45
57
  call(command, params = {}) {
46
58
  return new Promise((resolve, reject) => {
47
59
  const id = nextId();
@@ -37,8 +37,6 @@ export class TorrentWorkerClient {
37
37
  #caller;
38
38
  /** Receive-side handles for in-flight reads, keyed by request id. */
39
39
  #reads = new Map();
40
- /** Monotonic ids for reads, independent of the caller's own numbering. */
41
- #nextReadId = 0;
42
40
 
43
41
  /**
44
42
  * @param {{ maxDiskBytes?: number, memoryBytes?: number }} [options]
@@ -50,6 +48,18 @@ export class TorrentWorkerClient {
50
48
  this.#caller = createCaller(this.#worker);
51
49
 
52
50
  this.#worker.on("message", (message) => {
51
+ // A failed read must fail its stream. This is checked BEFORE the caller
52
+ // sees the message: until 2.9.76 nothing here handled a read error at
53
+ // all, so the worker's report was dropped as unknown, and because the
54
+ // worker sent the end-of-read marker from its `finally` even when the
55
+ // read had thrown, the reader saw a clean end of file instead. A read
56
+ // that failed before it produced anything simply hung forever.
57
+ if (message?.type === Event.ERROR && this.#reads.has(message.id)) {
58
+ const read = this.#reads.get(message.id);
59
+ this.#reads.delete(message.id);
60
+ read.fail(new Error(message.error ?? "Torrent worker read failed."));
61
+ return;
62
+ }
53
63
  if (this.#caller.handleReply(message)) {
54
64
  return;
55
65
  }
@@ -176,7 +186,8 @@ export class TorrentWorkerClient {
176
186
  * @returns {ReadableStream<Uint8Array>}
177
187
  */
178
188
  createReadStream({ sourceKey, fileIndex, start = null, end = null }) {
179
- const readId = (this.#nextReadId += 1);
189
+ // Same id sequence as commands — see `nextId` in `channel.js`.
190
+ const readId = this.#caller.nextId();
180
191
  const receive = createReceiveStream({
181
192
  port: this.#worker,
182
193
  requestId: readId,
@@ -126,6 +126,7 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
126
126
  await sender.send(merged);
127
127
  };
128
128
 
129
+ let failed = false;
129
130
  try {
130
131
  for await (const part of source) {
131
132
  if (sender.isCancelled()) {
@@ -140,10 +141,20 @@ async function streamRange({ id, sourceKey, fileIndex, start, end }) {
140
141
  if (!sender.isCancelled()) {
141
142
  await flush();
142
143
  }
144
+ } catch (error) {
145
+ // The end-of-read marker means "the body is complete". Sending it after a
146
+ // failure told the reader the file simply ended — a truncated segment that
147
+ // ffmpeg reported as `Stream ends prematurely`, with the real cause thrown
148
+ // away. Let the error propagate instead; the command handler reports it and
149
+ // the main thread fails the stream.
150
+ failed = true;
151
+ throw error;
143
152
  } finally {
144
153
  readsById.delete(id);
145
154
  releaseRead();
146
- sender.end();
155
+ if (!failed) {
156
+ sender.end();
157
+ }
147
158
  // A cancelled read must stop the underlying torrent stream too, or the
148
159
  // pieces keep being fetched for a viewer who has gone.
149
160
  if (typeof source.destroy === "function") {
@@ -16,7 +16,8 @@
16
16
  import test from "node:test";
17
17
  import assert from "node:assert/strict";
18
18
  import { MessageChannel } from "node:worker_threads";
19
- import { createSendStream, createReceiveStream } from "../services/torrent-worker/channel.js";
19
+ import { createSendStream, createReceiveStream, createCaller } from "../services/torrent-worker/channel.js";
20
+ import { TorrentWorkerClient } from "../services/torrent-worker/client.js";
20
21
 
21
22
  /**
22
23
  * A buffer standing in for one owned by WebTorrent's piece cache: allocated
@@ -96,6 +97,36 @@ test("chunks arrive with their contents intact", async () => {
96
97
  }
97
98
  });
98
99
 
100
+ test("reads and commands never share a request id", () => {
101
+ const { port1, port2 } = new MessageChannel();
102
+ try {
103
+ const commandIds = [];
104
+ port2.on("message", (message) => commandIds.push(message.id));
105
+
106
+ const caller = createCaller(port1);
107
+ const readIds = [];
108
+
109
+ // Interleaved exactly as the real client does it: commands through `call`,
110
+ // reads taking an id directly, both over the same channel.
111
+ for (let round = 0; round < 50; round += 1) {
112
+ void caller.call("noop", {});
113
+ readIds.push(caller.nextId());
114
+ }
115
+
116
+ // A repeat between the two sequences is the collision that let a read's
117
+ // reply resolve a command — with its result, silently.
118
+ const everyId = [...commandIds, ...readIds];
119
+ assert.equal(
120
+ new Set(everyId).size,
121
+ everyId.length,
122
+ "an id was handed out to both a command and a read"
123
+ );
124
+ } finally {
125
+ port1.close();
126
+ port2.close();
127
+ }
128
+ });
129
+
99
130
  test("a failed read surfaces on the reader instead of ending quietly", async () => {
100
131
  const { port1, port2 } = new MessageChannel();
101
132
  try {
@@ -118,3 +149,27 @@ test("a failed read surfaces on the reader instead of ending quietly", async ()
118
149
  port2.close();
119
150
  }
120
151
  });
152
+
153
+ test("a read of an unknown source fails the stream rather than hanging", async () => {
154
+ // End to end through a real worker, because this is where the defect lived:
155
+ // the worker reported the failure, nothing on the main thread listened, and
156
+ // the reader waited forever. A unit test on either half alone passes happily.
157
+ const client = new TorrentWorkerClient({ memoryBytes: 8 * 1024 * 1024 });
158
+ try {
159
+ const stream = client.createReadStream({ sourceKey: "no-such-source", fileIndex: 0 });
160
+ const reader = stream.getReader();
161
+
162
+ const outcome = await Promise.race([
163
+ reader.read().then(() => "resolved", (error) => `rejected: ${error?.message}`),
164
+ new Promise((resolve) => setTimeout(() => resolve("hung"), 10_000))
165
+ ]);
166
+
167
+ assert.match(
168
+ outcome,
169
+ /^rejected: .*no-such-source/,
170
+ `expected the read to fail, got "${outcome}"`
171
+ );
172
+ } finally {
173
+ await client.destroyAll();
174
+ }
175
+ });