@torrent-tv/proxy 2.9.72 → 2.9.74

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.
@@ -0,0 +1,150 @@
1
+ /**
2
+ * @file The JavaScript WebRTC stack the torrent thread uses instead of the native one.
3
+ *
4
+ * Covers the regression that took the whole proxy down from 2.9.71: a torrent
5
+ * carrying a `wss://` tracker made the worker create a peer connection, and a
6
+ * second isolate touching node-datachannel aborts the process outright.
7
+ *
8
+ * Also covers the two places werift's data channel differs from the browser's,
9
+ * because `simple-peer` depends on both: binary payloads must arrive as
10
+ * `ArrayBuffer` (anything else it pushes through a text decoder, corrupting
11
+ * torrent data), and the buffered-amount-low event must exist (its backpressure
12
+ * never resumes without it).
13
+ */
14
+
15
+ import test from "node:test";
16
+ import assert from "node:assert/strict";
17
+ import { Worker } from "node:worker_threads";
18
+ import { fileURLToPath } from "node:url";
19
+ import {
20
+ RTCPeerConnection,
21
+ RTCSessionDescription
22
+ } from "../services/torrent-worker/webrtc-shim.js";
23
+
24
+ /**
25
+ * Run a snippet on a worker thread and return what it reports.
26
+ *
27
+ * @param {string} source
28
+ * @returns {Promise<unknown>}
29
+ */
30
+ function onWorker(source) {
31
+ return new Promise((resolve, reject) => {
32
+ const worker = new Worker(source, { eval: true });
33
+ const done = (settle) => (value) => {
34
+ void worker.terminate();
35
+ settle(value);
36
+ };
37
+ worker.once("message", done(resolve));
38
+ worker.once("error", done(reject));
39
+ });
40
+ }
41
+
42
+ test("the worker resolves webrtc-polyfill to the JavaScript stack", async () => {
43
+ const installUrl = new URL("../services/torrent-worker/install-webrtc-shim.js", import.meta.url).href;
44
+ const reported = await onWorker(`
45
+ import { parentPort } from "node:worker_threads";
46
+ await import(${JSON.stringify(installUrl)});
47
+ const polyfill = await import("webrtc-polyfill");
48
+ const pc = new polyfill.RTCPeerConnection({});
49
+ const channel = pc.createDataChannel("probe");
50
+ parentPort.postMessage({
51
+ connection: pc.constructor.name,
52
+ channel: channel.constructor.name,
53
+ hasBinaryType: "binaryType" in channel,
54
+ hasBufferedAmountLow: "onbufferedamountlow" in channel
55
+ });
56
+ pc.close();
57
+ `);
58
+
59
+ assert.equal(reported.connection, "ShimPeerConnection");
60
+ assert.equal(reported.channel, "ShimDataChannel");
61
+ assert.ok(reported.hasBinaryType, "simple-peer sets binaryType and would get a silent no-op");
62
+ assert.ok(reported.hasBufferedAmountLow, "simple-peer's backpressure needs this event");
63
+ });
64
+
65
+ test("a native connection on this thread does not stop the worker's stack", async () => {
66
+ // The exact pairing that aborted the process before: native here, JavaScript
67
+ // there, both live at once.
68
+ const nodeDataChannel = (await import("node-datachannel")).default;
69
+ const native = new nodeDataChannel.PeerConnection("main-side", { iceServers: [] });
70
+ native.createDataChannel("keepalive");
71
+
72
+ try {
73
+ const installUrl = new URL("../services/torrent-worker/install-webrtc-shim.js", import.meta.url).href;
74
+ const reported = await onWorker(`
75
+ import { parentPort } from "node:worker_threads";
76
+ await import(${JSON.stringify(installUrl)});
77
+ const polyfill = await import("webrtc-polyfill");
78
+ const pc = new polyfill.RTCPeerConnection({});
79
+ pc.createDataChannel("probe");
80
+ const offer = await pc.createOffer();
81
+ await pc.setLocalDescription(offer);
82
+ parentPort.postMessage({ ok: String(pc.localDescription.sdp).startsWith("v=0") });
83
+ pc.close();
84
+ `);
85
+ assert.equal(reported.ok, true);
86
+ } finally {
87
+ native.close();
88
+ }
89
+ });
90
+
91
+ test("a session description is built from one object, as callers write it", async () => {
92
+ // simple-peer does `new RTCSessionDescription(data)` with `{ type, sdp }`.
93
+ // werift's own class takes `(sdp, type)` positionally, so the type would land
94
+ // in the sdp slot and every peer connection would be rejected with
95
+ // "invalid sessionDescription" — which is exactly what the field showed.
96
+ const description = new RTCSessionDescription({ type: "offer", sdp: "v=0\r\n" });
97
+ assert.equal(description.type, "offer");
98
+ assert.equal(description.sdp, "v=0\r\n");
99
+
100
+ const answerer = new RTCPeerConnection({});
101
+ const offerer = new RTCPeerConnection({});
102
+ try {
103
+ offerer.createDataChannel("probe");
104
+ const offer = await offerer.createOffer();
105
+ await offerer.setLocalDescription(offer);
106
+
107
+ // The round trip a tracker peer actually performs.
108
+ await answerer.setRemoteDescription(
109
+ new RTCSessionDescription({ type: offerer.localDescription.type, sdp: offerer.localDescription.sdp })
110
+ );
111
+ assert.equal(answerer.remoteDescription.type, "offer");
112
+ } finally {
113
+ answerer.close();
114
+ offerer.close();
115
+ }
116
+ });
117
+
118
+ test("binary payloads reach the reader as ArrayBuffer, byte for byte", async () => {
119
+ const sender = new RTCPeerConnection({});
120
+ const receiver = new RTCPeerConnection({});
121
+ const payload = Buffer.from([0xde, 0xad, 0xbe, 0xef, 0x00, 0xff, 0x7f, 0x80]);
122
+
123
+ try {
124
+ const received = new Promise((resolve, reject) => {
125
+ receiver.ondatachannel = ({ channel }) => {
126
+ channel.binaryType = "arraybuffer";
127
+ channel.onmessage = (event) => resolve(event.data);
128
+ };
129
+ setTimeout(() => reject(new Error("nothing arrived within 30s")), 30_000);
130
+ });
131
+
132
+ sender.onicecandidate = ({ candidate }) => candidate && receiver.addIceCandidate(candidate);
133
+ receiver.onicecandidate = ({ candidate }) => candidate && sender.addIceCandidate(candidate);
134
+
135
+ const channel = sender.createDataChannel("payload");
136
+ await sender.setLocalDescription(await sender.createOffer());
137
+ await receiver.setRemoteDescription(sender.localDescription);
138
+ await receiver.setLocalDescription(await receiver.createAnswer());
139
+ await sender.setRemoteDescription(receiver.localDescription);
140
+
141
+ channel.onopen = () => channel.send(payload);
142
+
143
+ const data = await received;
144
+ assert.ok(data instanceof ArrayBuffer, `simple-peer would run this through a text decoder: ${Object.prototype.toString.call(data)}`);
145
+ assert.deepEqual(Buffer.from(data), payload, "payload was altered in transit");
146
+ } finally {
147
+ sender.close();
148
+ receiver.close();
149
+ }
150
+ });
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @file The worker transport's memory contract.
3
+ *
4
+ * These cover the defect that broke playback in 2.9.71-2.9.73: the worker
5
+ * handed the main thread ownership of memory it did not own. WebTorrent's piece
6
+ * cache keeps the buffer it returns and slices it again on the next read, so
7
+ * transferring it detached the cache's own memory and every later read failed
8
+ * with "Cannot perform %TypedArray%.prototype.slice on a detached ArrayBuffer".
9
+ *
10
+ * The field could not tell us this, because the error never reached anyone: the
11
+ * worker sent READ_END from its `finally` before the error was posted, and the
12
+ * main thread had no handler for a read error at all. So a failed read looked
13
+ * exactly like an empty file.
14
+ */
15
+
16
+ import test from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { MessageChannel } from "node:worker_threads";
19
+ import { createSendStream, createReceiveStream } from "../services/torrent-worker/channel.js";
20
+
21
+ /**
22
+ * A buffer standing in for one owned by WebTorrent's piece cache: allocated
23
+ * outside Node's shared pool, covering its whole region, and still referenced
24
+ * by its owner after we hand it on.
25
+ *
26
+ * @param {number} size
27
+ * @param {number} fill
28
+ * @returns {Buffer}
29
+ */
30
+ function foreignPiece(size, fill) {
31
+ const piece = Buffer.allocUnsafeSlow(size);
32
+ piece.fill(fill);
33
+ return piece;
34
+ }
35
+
36
+ test("sending a chunk leaves the source buffer usable by its owner", async () => {
37
+ const { port1, port2 } = new MessageChannel();
38
+ try {
39
+ const sender = createSendStream({ port: port1, requestId: 1 });
40
+ const piece = foreignPiece(1024 * 1024, 7);
41
+
42
+ await sender.send(piece);
43
+
44
+ // The owner reads its own buffer again, exactly as the piece cache does on
45
+ // the next request for the same piece.
46
+ assert.equal(piece.length, 1024 * 1024, "buffer was detached by the send");
47
+ assert.equal(piece[0], 7);
48
+ assert.equal(piece.subarray(0, 16).length, 16, "slicing the source failed");
49
+ } finally {
50
+ port1.close();
51
+ port2.close();
52
+ }
53
+ });
54
+
55
+ test("a second read of the same piece still returns its bytes", async () => {
56
+ const { port1, port2 } = new MessageChannel();
57
+ try {
58
+ const piece = foreignPiece(512 * 1024, 3);
59
+
60
+ for (const requestId of [1, 2]) {
61
+ const sender = createSendStream({ port: port1, requestId });
62
+ await sender.send(piece);
63
+ sender.end();
64
+ }
65
+
66
+ assert.equal(piece[0], 3, "the piece did not survive being sent twice");
67
+ } finally {
68
+ port1.close();
69
+ port2.close();
70
+ }
71
+ });
72
+
73
+ test("chunks arrive with their contents intact", async () => {
74
+ const { port1, port2 } = new MessageChannel();
75
+ try {
76
+ const received = [];
77
+ port2.on("message", (message) => {
78
+ if (message?.type === "chunk") {
79
+ received.push(Buffer.from(message.bytes));
80
+ }
81
+ });
82
+
83
+ const sender = createSendStream({ port: port1, requestId: 1 });
84
+ const piece = foreignPiece(256 * 1024, 42);
85
+ await sender.send(piece);
86
+ sender.end();
87
+
88
+ await new Promise((resolve) => setTimeout(resolve, 50));
89
+ assert.equal(received.length, 1);
90
+ assert.equal(received[0].length, 256 * 1024);
91
+ assert.equal(received[0][0], 42);
92
+ assert.equal(received[0][received[0].length - 1], 42);
93
+ } finally {
94
+ port1.close();
95
+ port2.close();
96
+ }
97
+ });
98
+
99
+ test("a failed read surfaces on the reader instead of ending quietly", async () => {
100
+ const { port1, port2 } = new MessageChannel();
101
+ try {
102
+ const receive = createReceiveStream({
103
+ port: port1,
104
+ requestId: 1,
105
+ onCancel: () => undefined
106
+ });
107
+
108
+ receive.fail(new Error("read failed in the worker"));
109
+
110
+ const reader = receive.stream.getReader();
111
+ await assert.rejects(
112
+ () => reader.read(),
113
+ /read failed in the worker/,
114
+ "the reader saw a clean end instead of the failure"
115
+ );
116
+ } finally {
117
+ port1.close();
118
+ port2.close();
119
+ }
120
+ });