@torrent-tv/proxy 2.9.73 → 2.9.75
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 +374 -362
- package/bin/cli.js +8 -0
- package/package.json +5 -2
- package/server.js +229 -228
- package/services/piece-store/disk-tier.js +151 -0
- package/services/piece-store/piece-lru.js +150 -0
- package/services/piece-store/shared-piece-store.js +416 -0
- package/services/torrent-pool.js +18 -2
- package/services/torrent-worker/channel.js +17 -13
- package/services/torrent-worker/client.js +264 -264
- package/services/torrent-worker/install-webrtc-shim.js +32 -0
- package/services/torrent-worker/pool-adapter.js +179 -179
- package/services/torrent-worker/webrtc-shim.js +219 -0
- package/services/torrent-worker/worker.js +49 -2
- package/test/piece-lru.test.js +109 -0
- package/test/shared-piece-store.test.js +302 -0
- package/test/webrtc-shim.test.js +150 -0
- package/test/worker-channel.test.js +120 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Recency and pinning for resident pieces.
|
|
3
|
+
*
|
|
4
|
+
* The pinning cases matter more than the ordering ones: proxy 2.9.71 removed a
|
|
5
|
+
* torrent's data while a reader was mid-read, and every later read hung. Here
|
|
6
|
+
* that is meant to be impossible by construction, so it is worth stating in
|
|
7
|
+
* tests rather than trusting to eviction order.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import { PieceLru } from "../services/piece-store/piece-lru.js";
|
|
13
|
+
|
|
14
|
+
test("evicts the least recently used piece", () => {
|
|
15
|
+
const lru = new PieceLru(3);
|
|
16
|
+
lru.touch(1);
|
|
17
|
+
lru.touch(2);
|
|
18
|
+
lru.touch(3);
|
|
19
|
+
|
|
20
|
+
assert.equal(lru.evictionCandidate(), 1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("using a piece again moves it out of the firing line", () => {
|
|
24
|
+
const lru = new PieceLru(3);
|
|
25
|
+
lru.touch(1);
|
|
26
|
+
lru.touch(2);
|
|
27
|
+
lru.touch(3);
|
|
28
|
+
lru.touch(1);
|
|
29
|
+
|
|
30
|
+
assert.equal(lru.evictionCandidate(), 2);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a pinned piece is never offered for eviction", () => {
|
|
34
|
+
const lru = new PieceLru(3);
|
|
35
|
+
lru.touch(1);
|
|
36
|
+
lru.touch(2);
|
|
37
|
+
lru.touch(3);
|
|
38
|
+
lru.pin(1);
|
|
39
|
+
|
|
40
|
+
assert.equal(lru.evictionCandidate(), 2, "the pinned piece was offered up");
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
test("pins nest, so one reader leaving does not expose the piece", () => {
|
|
44
|
+
const lru = new PieceLru(2);
|
|
45
|
+
lru.touch(1);
|
|
46
|
+
lru.touch(2);
|
|
47
|
+
|
|
48
|
+
// Two sessions reading the same piece — the union-window case.
|
|
49
|
+
lru.pin(1);
|
|
50
|
+
lru.pin(1);
|
|
51
|
+
lru.unpin(1);
|
|
52
|
+
|
|
53
|
+
assert.ok(lru.isPinned(1), "the piece stopped being held while a reader still had it");
|
|
54
|
+
assert.equal(lru.evictionCandidate(), 2);
|
|
55
|
+
|
|
56
|
+
lru.unpin(1);
|
|
57
|
+
assert.equal(lru.isPinned(1), false);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("reports no candidate rather than evicting a piece in use", () => {
|
|
61
|
+
const lru = new PieceLru(2);
|
|
62
|
+
lru.touch(1);
|
|
63
|
+
lru.touch(2);
|
|
64
|
+
lru.pin(1);
|
|
65
|
+
lru.pin(2);
|
|
66
|
+
|
|
67
|
+
assert.equal(
|
|
68
|
+
lru.evictionCandidate(),
|
|
69
|
+
null,
|
|
70
|
+
"with every piece held, the caller must wait — not have memory taken from under it"
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("unpinning something that was never pinned is harmless", () => {
|
|
75
|
+
const lru = new PieceLru(2);
|
|
76
|
+
lru.touch(1);
|
|
77
|
+
lru.unpin(1);
|
|
78
|
+
lru.unpin(1);
|
|
79
|
+
|
|
80
|
+
assert.equal(lru.isPinned(1), false);
|
|
81
|
+
assert.equal(lru.evictionCandidate(), 1);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
test("removal takes a piece out of the ordering", () => {
|
|
85
|
+
const lru = new PieceLru(3);
|
|
86
|
+
lru.touch(1);
|
|
87
|
+
lru.touch(2);
|
|
88
|
+
lru.remove(1);
|
|
89
|
+
|
|
90
|
+
assert.equal(lru.has(1), false);
|
|
91
|
+
assert.equal(lru.evictionCandidate(), 2);
|
|
92
|
+
assert.equal(lru.size, 1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("fullness follows capacity", () => {
|
|
96
|
+
const lru = new PieceLru(2);
|
|
97
|
+
assert.equal(lru.isFull(), false);
|
|
98
|
+
lru.touch(1);
|
|
99
|
+
lru.touch(2);
|
|
100
|
+
assert.equal(lru.isFull(), true);
|
|
101
|
+
lru.remove(1);
|
|
102
|
+
assert.equal(lru.isFull(), false);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("a nonsensical capacity is refused at construction", () => {
|
|
106
|
+
assert.throws(() => new PieceLru(0), /positive integer/);
|
|
107
|
+
assert.throws(() => new PieceLru(-1), /positive integer/);
|
|
108
|
+
assert.throws(() => new PieceLru(1.5), /positive integer/);
|
|
109
|
+
});
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Pieces in shared memory, spilling to disk.
|
|
3
|
+
*
|
|
4
|
+
* The cases worth having are the ones that describe past failures: a buffer
|
|
5
|
+
* handed out must not be invalidated by later activity (2.9.71 transferred
|
|
6
|
+
* memory it did not own), and a piece being read must not be evicted (2.9.71
|
|
7
|
+
* again, at the torrent level).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import test from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import fs from "node:fs/promises";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { SharedPieceStore } from "../services/piece-store/shared-piece-store.js";
|
|
16
|
+
|
|
17
|
+
const CHUNK = 64 * 1024;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {object} [options]
|
|
21
|
+
* @returns {Promise<{ store: SharedPieceStore, directory: string }>}
|
|
22
|
+
*/
|
|
23
|
+
async function makeStore({ pieces = 4, totalPieces = 16 } = {}) {
|
|
24
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "piece-store-"));
|
|
25
|
+
const store = new SharedPieceStore(CHUNK, {
|
|
26
|
+
length: CHUNK * totalPieces,
|
|
27
|
+
memoryBytes: CHUNK * pieces,
|
|
28
|
+
path: directory,
|
|
29
|
+
name: "test"
|
|
30
|
+
});
|
|
31
|
+
return { store, directory };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {number} index
|
|
36
|
+
* @param {number} [length]
|
|
37
|
+
* @returns {Buffer}
|
|
38
|
+
*/
|
|
39
|
+
function piece(index, length = CHUNK) {
|
|
40
|
+
const bytes = Buffer.allocUnsafeSlow(length);
|
|
41
|
+
bytes.fill(index % 256);
|
|
42
|
+
bytes.writeUInt32BE(index, 0);
|
|
43
|
+
return bytes;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Promise-shaped wrappers, so the tests read as the sequence they describe. */
|
|
47
|
+
const put = (store, index, bytes) =>
|
|
48
|
+
new Promise((resolve, reject) => store.put(index, bytes, (error) => (error ? reject(error) : resolve())));
|
|
49
|
+
const get = (store, index, options) =>
|
|
50
|
+
new Promise((resolve, reject) =>
|
|
51
|
+
store.get(index, options, (error, bytes) => (error ? reject(error) : resolve(bytes)))
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
test("a stored piece comes back byte for byte", async () => {
|
|
55
|
+
const { store, directory } = await makeStore();
|
|
56
|
+
try {
|
|
57
|
+
await put(store, 0, piece(0));
|
|
58
|
+
const read = await get(store, 0);
|
|
59
|
+
assert.deepEqual(read, piece(0));
|
|
60
|
+
} finally {
|
|
61
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
62
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("pieces past the memory budget spill to disk and read back intact", async () => {
|
|
67
|
+
const { store, directory } = await makeStore({ pieces: 4, totalPieces: 16 });
|
|
68
|
+
try {
|
|
69
|
+
for (let index = 0; index < 10; index += 1) {
|
|
70
|
+
await put(store, index, piece(index));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
assert.equal(store.residentCount, 4, "more pieces stayed resident than the budget allows");
|
|
74
|
+
assert.ok(store.spilledCount >= 6, "pieces over budget were not written out");
|
|
75
|
+
|
|
76
|
+
// The earliest pieces can only come from disk now.
|
|
77
|
+
for (const index of [0, 1, 2, 5]) {
|
|
78
|
+
assert.deepEqual(await get(store, index), piece(index), `piece ${index} came back wrong`);
|
|
79
|
+
}
|
|
80
|
+
} finally {
|
|
81
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
82
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("the short last piece keeps its own length", async () => {
|
|
87
|
+
const directory = await fs.mkdtemp(path.join(os.tmpdir(), "piece-store-"));
|
|
88
|
+
const store = new SharedPieceStore(CHUNK, {
|
|
89
|
+
length: CHUNK * 3 + 1234,
|
|
90
|
+
memoryBytes: CHUNK * 4,
|
|
91
|
+
path: directory,
|
|
92
|
+
name: "tail"
|
|
93
|
+
});
|
|
94
|
+
try {
|
|
95
|
+
const tail = piece(3, 1234);
|
|
96
|
+
await put(store, 3, tail);
|
|
97
|
+
const read = await get(store, 3);
|
|
98
|
+
assert.equal(read.length, 1234);
|
|
99
|
+
assert.deepEqual(read, tail);
|
|
100
|
+
} finally {
|
|
101
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
102
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
103
|
+
}
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("a buffer handed out survives later writes to the same slot", async () => {
|
|
107
|
+
// This is the shape of the shipped defect: the caller keeps what it was
|
|
108
|
+
// given while the store carries on working.
|
|
109
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
110
|
+
try {
|
|
111
|
+
await put(store, 0, piece(0));
|
|
112
|
+
const held = await get(store, 0);
|
|
113
|
+
|
|
114
|
+
for (let index = 1; index < 6; index += 1) {
|
|
115
|
+
await put(store, index, piece(index));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
assert.deepEqual(held, piece(0), "the buffer changed under its holder");
|
|
119
|
+
} finally {
|
|
120
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
121
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a pinned piece is not evicted to make room", async () => {
|
|
126
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
127
|
+
try {
|
|
128
|
+
await put(store, 0, piece(0));
|
|
129
|
+
await put(store, 1, piece(1));
|
|
130
|
+
|
|
131
|
+
store.pin(0);
|
|
132
|
+
await put(store, 2, piece(2));
|
|
133
|
+
|
|
134
|
+
const located = store.locate(0);
|
|
135
|
+
assert.ok(located, "the pinned piece was evicted while held");
|
|
136
|
+
const view = Buffer.from(store.sharedBuffer, located.offset, located.length);
|
|
137
|
+
assert.deepEqual(view, piece(0), "the pinned piece was overwritten in place");
|
|
138
|
+
|
|
139
|
+
store.unpin(0);
|
|
140
|
+
} finally {
|
|
141
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
142
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("refuses to make room when every resident piece is being read", async () => {
|
|
147
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
148
|
+
try {
|
|
149
|
+
await put(store, 0, piece(0));
|
|
150
|
+
await put(store, 1, piece(1));
|
|
151
|
+
store.pin(0);
|
|
152
|
+
store.pin(1);
|
|
153
|
+
|
|
154
|
+
await assert.rejects(
|
|
155
|
+
() => put(store, 2, piece(2)),
|
|
156
|
+
/pinned/,
|
|
157
|
+
"the store took memory from under a reader instead of refusing"
|
|
158
|
+
);
|
|
159
|
+
} finally {
|
|
160
|
+
store.unpin(0);
|
|
161
|
+
store.unpin(1);
|
|
162
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
163
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("a piece revived from disk is readable by offset again", async () => {
|
|
168
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
169
|
+
try {
|
|
170
|
+
await put(store, 0, piece(0));
|
|
171
|
+
await put(store, 1, piece(1));
|
|
172
|
+
await put(store, 2, piece(2)); // pushes piece 0 out to disk
|
|
173
|
+
assert.equal(store.locate(0), null, "piece 0 should have left memory");
|
|
174
|
+
|
|
175
|
+
await get(store, 0); // brings it back
|
|
176
|
+
const located = store.locate(0);
|
|
177
|
+
assert.ok(located, "piece 0 was not brought back into memory");
|
|
178
|
+
const view = Buffer.from(store.sharedBuffer, located.offset, located.length);
|
|
179
|
+
assert.deepEqual(view, piece(0));
|
|
180
|
+
} finally {
|
|
181
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
182
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("a range within a piece is served correctly from memory and from disk", async () => {
|
|
187
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
188
|
+
try {
|
|
189
|
+
const source = piece(4);
|
|
190
|
+
await put(store, 4, source);
|
|
191
|
+
|
|
192
|
+
assert.deepEqual(
|
|
193
|
+
await get(store, 4, { offset: 100, length: 256 }),
|
|
194
|
+
source.subarray(100, 356),
|
|
195
|
+
"range from memory is wrong"
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await put(store, 5, piece(5));
|
|
199
|
+
await put(store, 6, piece(6)); // piece 4 spills
|
|
200
|
+
|
|
201
|
+
assert.deepEqual(
|
|
202
|
+
await get(store, 4, { offset: 100, length: 256 }),
|
|
203
|
+
source.subarray(100, 356),
|
|
204
|
+
"range after revival is wrong"
|
|
205
|
+
);
|
|
206
|
+
} finally {
|
|
207
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
208
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("takes memory as it needs it, not the whole budget up front", async () => {
|
|
213
|
+
// The budget is per torrent, so a torrent that is merely open — or one being
|
|
214
|
+
// probed for its codecs — must not charge the host for the ceiling.
|
|
215
|
+
const { store, directory } = await makeStore({ pieces: 16, totalPieces: 64 });
|
|
216
|
+
try {
|
|
217
|
+
assert.equal(store.capacity, 16);
|
|
218
|
+
const initial = store.sharedBuffer.byteLength;
|
|
219
|
+
assert.ok(initial <= CHUNK * 2, `claimed ${initial} bytes before holding anything`);
|
|
220
|
+
|
|
221
|
+
for (let index = 0; index < 5; index += 1) {
|
|
222
|
+
await put(store, index, piece(index));
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
assert.equal(store.residentCount, 5);
|
|
226
|
+
assert.ok(
|
|
227
|
+
store.sharedBuffer.byteLength >= CHUNK * 5,
|
|
228
|
+
"the pool did not grow to hold what was put in it"
|
|
229
|
+
);
|
|
230
|
+
assert.ok(
|
|
231
|
+
store.sharedBuffer.byteLength <= CHUNK * 6,
|
|
232
|
+
"the pool grew past what was actually needed"
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
// Growing must not move bytes already written.
|
|
236
|
+
assert.deepEqual(await get(store, 0), piece(0), "an early piece was disturbed by growth");
|
|
237
|
+
} finally {
|
|
238
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
239
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test("counts where reads were served from, so the budget can be judged", async () => {
|
|
244
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
245
|
+
try {
|
|
246
|
+
await put(store, 0, piece(0));
|
|
247
|
+
await get(store, 0); // memory
|
|
248
|
+
await get(store, 0); // memory
|
|
249
|
+
|
|
250
|
+
await put(store, 1, piece(1));
|
|
251
|
+
await put(store, 2, piece(2)); // piece 0 spills
|
|
252
|
+
await get(store, 0); // disk
|
|
253
|
+
|
|
254
|
+
const stats = store.stats();
|
|
255
|
+
assert.equal(stats.fromMemory, 2, "memory reads miscounted");
|
|
256
|
+
assert.equal(stats.fromDisk, 1, "disk reads miscounted");
|
|
257
|
+
// Two: piece 0 goes out to make room for piece 2, then piece 1 goes out to
|
|
258
|
+
// make room for piece 0 coming back. Reviving costs a spill of its own, and
|
|
259
|
+
// that is worth seeing in the figures rather than hiding.
|
|
260
|
+
assert.equal(stats.spills, 2, "spills miscounted");
|
|
261
|
+
assert.equal(stats.revivals, 1, "revivals miscounted");
|
|
262
|
+
assert.equal(stats.blockedByPins, 0);
|
|
263
|
+
assert.equal(stats.capacity, 2);
|
|
264
|
+
} finally {
|
|
265
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
266
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
267
|
+
}
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test("counts a refusal caused by pinned pieces", async () => {
|
|
271
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
272
|
+
try {
|
|
273
|
+
await put(store, 0, piece(0));
|
|
274
|
+
await put(store, 1, piece(1));
|
|
275
|
+
store.pin(0);
|
|
276
|
+
store.pin(1);
|
|
277
|
+
await assert.rejects(() => put(store, 2, piece(2)));
|
|
278
|
+
|
|
279
|
+
assert.equal(store.stats().blockedByPins, 1, "a refusal went unrecorded");
|
|
280
|
+
} finally {
|
|
281
|
+
store.unpin(0);
|
|
282
|
+
store.unpin(1);
|
|
283
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
284
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test("destroy removes the spill file", async () => {
|
|
289
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
290
|
+
await put(store, 0, piece(0));
|
|
291
|
+
await put(store, 1, piece(1));
|
|
292
|
+
await put(store, 2, piece(2)); // forces a spill
|
|
293
|
+
|
|
294
|
+
const before = await fs.readdir(directory);
|
|
295
|
+
assert.ok(before.length > 0, "nothing was written to spill");
|
|
296
|
+
|
|
297
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
298
|
+
const after = await fs.readdir(directory);
|
|
299
|
+
assert.equal(after.length, 0, "the spill file outlived the store");
|
|
300
|
+
|
|
301
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
302
|
+
});
|
|
@@ -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
|
+
});
|