@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.
- package/CHANGELOG.md +12 -0
- package/package.json +5 -2
- package/routes/api/sources/stats/get.js +69 -66
- package/server.js +3 -1
- 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 +315 -0
- package/services/torrent-pool.js +18 -2
- package/services/torrent-worker/channel.js +238 -222
- package/services/torrent-worker/install-webrtc-shim.js +32 -0
- package/services/torrent-worker/webrtc-shim.js +219 -0
- package/services/torrent-worker/worker.js +12 -1
- package/test/piece-lru.test.js +109 -0
- package/test/shared-piece-store.test.js +226 -0
- package/test/webrtc-shim.test.js +150 -0
- package/test/worker-channel.test.js +120 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file `webrtc-polyfill`'s interface, served by a pure-JavaScript WebRTC stack.
|
|
3
|
+
*
|
|
4
|
+
* **Why this exists.** node-datachannel is native, and its native side is not
|
|
5
|
+
* safe to use from two V8 isolates at once: the process aborts with
|
|
6
|
+
* `HandleScope: Entering the V8 API without proper locking in place`. Measured
|
|
7
|
+
* on win32/x64 and linux/arm64 alike — one isolate is fine (either the main
|
|
8
|
+
* thread or a worker), two at the same time is fatal, and preloading in both
|
|
9
|
+
* does not help. The upstream issue about workers (#129) was closed in 0.4.0
|
|
10
|
+
* but only covers use from a worker ALONE, which does work.
|
|
11
|
+
*
|
|
12
|
+
* We need it twice: `webrtc-manager.js` runs the video channel to the browser on
|
|
13
|
+
* the main thread, and the torrent client — which is now on its own thread —
|
|
14
|
+
* creates peer connections of its own to announce on `wss://` trackers. Before
|
|
15
|
+
* the thread split both lived in one isolate and nothing was wrong; afterwards
|
|
16
|
+
* any torrent carrying a wss tracker took the whole proxy down.
|
|
17
|
+
*
|
|
18
|
+
* So the native stack stays where it earns its keep — the main thread, carrying
|
|
19
|
+
* video — and the torrent's trackers get a JavaScript implementation, where the
|
|
20
|
+
* traffic is a handful of signalling messages. `services/torrent-worker/worker.js`
|
|
21
|
+
* installs a module resolution hook that points `webrtc-polyfill` here; nothing
|
|
22
|
+
* outside the worker thread is affected, and no dependency is patched (the addon
|
|
23
|
+
* installs with `--ignore-scripts`, so a postinstall patch would never run).
|
|
24
|
+
*
|
|
25
|
+
* **What the shim has to fix.** werift's peer connection matches the interface
|
|
26
|
+
* `simple-peer` expects, but its data channel differs in two ways that matter:
|
|
27
|
+
*
|
|
28
|
+
* - it has no `binaryType`, and hands `onmessage` a `Buffer`. `simple-peer`
|
|
29
|
+
* only recognises `ArrayBuffer`; anything else goes through `text2arr`,
|
|
30
|
+
* which would corrupt every byte of torrent payload.
|
|
31
|
+
* - it has no `onbufferedamountlow`. `simple-peer` builds its backpressure on
|
|
32
|
+
* that event, so without it a send that hits the high-water mark never
|
|
33
|
+
* resumes.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
RTCPeerConnection as WeriftPeerConnection,
|
|
38
|
+
RTCIceCandidate
|
|
39
|
+
} from "werift";
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* A session description built the way browsers build it — from one object.
|
|
43
|
+
*
|
|
44
|
+
* werift's own class takes two positional arguments, `(sdp, type)`. Callers
|
|
45
|
+
* written against the browser pass `{ type, sdp }`, so with werift's class the
|
|
46
|
+
* type lands in the sdp slot and the description is rejected: field 2026-08-03,
|
|
47
|
+
* wss announces succeeded but every peer connection died with "Connection
|
|
48
|
+
* error: invalid sessionDescription".
|
|
49
|
+
*
|
|
50
|
+
* `RTCIceCandidate` needs no such treatment — werift already takes an object.
|
|
51
|
+
*/
|
|
52
|
+
class ShimSessionDescription {
|
|
53
|
+
/**
|
|
54
|
+
* @param {{ type?: string, sdp?: string } | string} init
|
|
55
|
+
* @param {string} [type] - Tolerated for callers using werift's own order.
|
|
56
|
+
*/
|
|
57
|
+
constructor(init, type) {
|
|
58
|
+
if (typeof init === "string") {
|
|
59
|
+
this.sdp = init;
|
|
60
|
+
this.type = type;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
this.type = init?.type;
|
|
64
|
+
this.sdp = init?.sdp;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
toJSON() {
|
|
68
|
+
return { type: this.type, sdp: this.sdp };
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A werift data channel wearing the browser's interface.
|
|
74
|
+
*
|
|
75
|
+
* Only what `simple-peer` touches is implemented — promising more would be
|
|
76
|
+
* pretending, since anything else has no caller and would never be exercised.
|
|
77
|
+
*/
|
|
78
|
+
class ShimDataChannel {
|
|
79
|
+
#channel;
|
|
80
|
+
/** @type {"blob" | "arraybuffer"} */
|
|
81
|
+
binaryType = "blob";
|
|
82
|
+
/** @type {((event: { data: ArrayBuffer | Buffer }) => void) | null} */
|
|
83
|
+
onmessage = null;
|
|
84
|
+
/** @type {(() => void) | null} */
|
|
85
|
+
onopen = null;
|
|
86
|
+
/** @type {(() => void) | null} */
|
|
87
|
+
onclose = null;
|
|
88
|
+
/** @type {((event: { error?: Error }) => void) | null} */
|
|
89
|
+
onerror = null;
|
|
90
|
+
/** @type {(() => void) | null} */
|
|
91
|
+
onbufferedamountlow = null;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {object} channel - werift's `RTCDataChannel`.
|
|
95
|
+
*/
|
|
96
|
+
constructor(channel) {
|
|
97
|
+
this.#channel = channel;
|
|
98
|
+
|
|
99
|
+
channel.onmessage = (event) => {
|
|
100
|
+
const data = event?.data ?? event;
|
|
101
|
+
this.onmessage?.({ data: this.#toWireFormat(data) });
|
|
102
|
+
};
|
|
103
|
+
channel.onopen = () => this.onopen?.();
|
|
104
|
+
channel.onclose = () => this.onclose?.();
|
|
105
|
+
channel.onerror = (event) => this.onerror?.(event ?? {});
|
|
106
|
+
|
|
107
|
+
// werift reports this as an observable rather than a handler property.
|
|
108
|
+
channel.bufferedAmountLow?.subscribe?.(() => this.onbufferedamountlow?.());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Match what a browser would deliver for the requested `binaryType`.
|
|
113
|
+
*
|
|
114
|
+
* `simple-peer` checks `instanceof ArrayBuffer` and sends everything else
|
|
115
|
+
* through a text decoder, so a `Buffer` handed over unchanged arrives
|
|
116
|
+
* mangled.
|
|
117
|
+
*
|
|
118
|
+
* @param {unknown} data
|
|
119
|
+
* @returns {ArrayBuffer | unknown}
|
|
120
|
+
*/
|
|
121
|
+
#toWireFormat(data) {
|
|
122
|
+
if (this.binaryType !== "arraybuffer" || typeof data === "string") {
|
|
123
|
+
return data;
|
|
124
|
+
}
|
|
125
|
+
if (ArrayBuffer.isView(data)) {
|
|
126
|
+
return data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
|
|
127
|
+
}
|
|
128
|
+
return data;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
get label() {
|
|
132
|
+
return this.#channel.label;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
get readyState() {
|
|
136
|
+
return this.#channel.readyState;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
get bufferedAmount() {
|
|
140
|
+
return this.#channel.bufferedAmount;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
get bufferedAmountLowThreshold() {
|
|
144
|
+
return this.#channel.bufferedAmountLowThreshold;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
set bufferedAmountLowThreshold(value) {
|
|
148
|
+
this.#channel.bufferedAmountLowThreshold = value;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* @param {string | ArrayBuffer | ArrayBufferView} data
|
|
153
|
+
* @returns {void}
|
|
154
|
+
*/
|
|
155
|
+
send(data) {
|
|
156
|
+
if (typeof data === "string") {
|
|
157
|
+
this.#channel.send(data);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
this.#channel.send(ArrayBuffer.isView(data) ? Buffer.from(data.buffer, data.byteOffset, data.byteLength) : Buffer.from(data));
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
close() {
|
|
164
|
+
this.#channel.close();
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* werift's peer connection, handing out data channels that carry the browser's
|
|
170
|
+
* interface. Everything else is inherited unchanged — the peer connection side
|
|
171
|
+
* already matches.
|
|
172
|
+
*/
|
|
173
|
+
class ShimPeerConnection extends WeriftPeerConnection {
|
|
174
|
+
/**
|
|
175
|
+
* @param {...unknown} args - Passed through to werift.
|
|
176
|
+
*/
|
|
177
|
+
constructor(...args) {
|
|
178
|
+
super(...args);
|
|
179
|
+
|
|
180
|
+
// `ondatachannel` has to be redefined on the instance, not declared as an
|
|
181
|
+
// accessor on this class: werift assigns it as an own field in its own
|
|
182
|
+
// constructor, and an own property shadows a prototype accessor — so a
|
|
183
|
+
// subclass setter is simply never called, and the caller receives werift's
|
|
184
|
+
// bare channel instead of the wrapped one.
|
|
185
|
+
let handler = null;
|
|
186
|
+
const deliver = (event) => {
|
|
187
|
+
handler?.({ ...event, channel: new ShimDataChannel(event?.channel ?? event) });
|
|
188
|
+
};
|
|
189
|
+
Object.defineProperty(this, "ondatachannel", {
|
|
190
|
+
configurable: true,
|
|
191
|
+
// werift reads this property to dispatch, so it must hand back the
|
|
192
|
+
// wrapper rather than what the caller assigned.
|
|
193
|
+
get: () => (handler ? deliver : null),
|
|
194
|
+
set: (value) => {
|
|
195
|
+
handler = value;
|
|
196
|
+
}
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* @param {string} label
|
|
202
|
+
* @param {object} [options]
|
|
203
|
+
* @returns {ShimDataChannel}
|
|
204
|
+
*/
|
|
205
|
+
createDataChannel(label, options) {
|
|
206
|
+
return new ShimDataChannel(super.createDataChannel(label, options));
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export {
|
|
211
|
+
ShimPeerConnection as RTCPeerConnection,
|
|
212
|
+
ShimSessionDescription as RTCSessionDescription,
|
|
213
|
+
RTCIceCandidate
|
|
214
|
+
};
|
|
215
|
+
export default {
|
|
216
|
+
RTCPeerConnection: ShimPeerConnection,
|
|
217
|
+
RTCSessionDescription: ShimSessionDescription,
|
|
218
|
+
RTCIceCandidate
|
|
219
|
+
};
|
|
@@ -17,11 +17,22 @@
|
|
|
17
17
|
* threads.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
// MUST stay first: it redirects `webrtc-polyfill` to a JavaScript WebRTC stack
|
|
21
|
+
// before WebTorrent can reach the native one. Two isolates using
|
|
22
|
+
// node-datachannel at once abort the process, and the torrent's wss trackers
|
|
23
|
+
// create peer connections of their own.
|
|
24
|
+
import "./install-webrtc-shim.js";
|
|
20
25
|
import { parentPort, workerData } from "node:worker_threads";
|
|
21
|
-
import { TorrentPool } from "../torrent-pool.js";
|
|
22
26
|
import { createSendStream } from "./channel.js";
|
|
23
27
|
import { Command, Event, STREAM_CHUNK_BYTES } from "./protocol.js";
|
|
24
28
|
|
|
29
|
+
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
30
|
+
// during linking, before any module body runs, so a statically imported pool
|
|
31
|
+
// would drag in WebTorrent — and with it the real `webrtc-polyfill` — before
|
|
32
|
+
// the hook above had a chance to register. Verified the hard way: with a static
|
|
33
|
+
// import the process still aborted, and the stack named the genuine polyfill.
|
|
34
|
+
const { TorrentPool } = await import("../torrent-pool.js");
|
|
35
|
+
|
|
25
36
|
const pool = new TorrentPool({ maxDiskBytes: workerData?.maxDiskBytes });
|
|
26
37
|
|
|
27
38
|
/** Torrents by sourceKey — the main thread names them, this thread owns them. */
|
|
@@ -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,226 @@
|
|
|
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("destroy removes the spill file", async () => {
|
|
213
|
+
const { store, directory } = await makeStore({ pieces: 2, totalPieces: 8 });
|
|
214
|
+
await put(store, 0, piece(0));
|
|
215
|
+
await put(store, 1, piece(1));
|
|
216
|
+
await put(store, 2, piece(2)); // forces a spill
|
|
217
|
+
|
|
218
|
+
const before = await fs.readdir(directory);
|
|
219
|
+
assert.ok(before.length > 0, "nothing was written to spill");
|
|
220
|
+
|
|
221
|
+
await new Promise((resolve) => store.destroy(resolve));
|
|
222
|
+
const after = await fs.readdir(directory);
|
|
223
|
+
assert.equal(after.length, 0, "the spill file outlived the store");
|
|
224
|
+
|
|
225
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
226
|
+
});
|