@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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
});
|