@torrent-tv/proxy 2.71.0 → 2.71.1
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 +6 -0
- package/package.json +1 -1
- package/services/download/SwarmSelection.js +55 -1
- package/services/piece-store/shared-piece-store.js +16 -0
- package/services/playback-planner.js +15 -1
- package/services/torrent-worker/piece-reader.js +6 -8
- package/test/swarm-selection.test.js +29 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,9 @@
|
|
|
1
|
+
## 2.71.1
|
|
2
|
+
|
|
3
|
+
- **Fix**: A reader stated the same thing twice — `protectRange` to the piece store for memory, and a window to the torrent for download — and the two were separate lists that could drift. There is one statement now: `SwarmSelection.reconcile` derives both views from the register, so the swarm and the store are told what to do from the same words. Only the urgent levels reach memory: it holds what will be READ soon, and protecting the speculative tail would push out a piece the decoder is about to want.
|
|
4
|
+
- **New**: A file this machine cannot sustain at ANY height is refused rather than served badly. Both offered lists empty means not even copying the picture — which costs no encoder at all — can keep up, so a session made there produces a slideshow and takes the swarm and the processor from whoever is already watching. Field 2026-08-28: five sessions on one file put every rung at 0.04x of realtime. The plan now carries `cannotServe` with the reason, which is a different thing from a spinner that never ends.
|
|
5
|
+
- **Chore**: `SwarmSelection` takes its store lookup as a parameter, so the memory projection is driven by a test without constructing a real piece store.
|
|
6
|
+
|
|
1
7
|
## 2.71.0
|
|
2
8
|
|
|
3
9
|
- **Fix**: The torrent thread's log lines never reached the log file. A worker thread loads its own instance of every module, so the logger's file handle — set once, on the main thread — was null there for the life of the process. Measured over a whole 49 938-line file: zero lines from the piece reader and zero from the torrent pool, against 52 and 36 of them in the container's output, which every release destroys. That is why the comparison of the two claim strategies could never be read: it was being printed into a place we wipe ourselves. The worker now sends its lines to the main thread, which is the only writer — two threads appending to one file would race on the rotation and could interleave mid-line.
|
package/package.json
CHANGED
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
Urgency,
|
|
30
30
|
urgencyName
|
|
31
31
|
} from "../demand/index.js";
|
|
32
|
+
import { findSharedStore } from "../piece-store/shared-piece-store.js";
|
|
32
33
|
|
|
33
34
|
export class SwarmSelection {
|
|
34
35
|
#torrent;
|
|
@@ -37,15 +38,22 @@ export class SwarmSelection {
|
|
|
37
38
|
#stated = new Map();
|
|
38
39
|
/** Pieces this instance marked for displacement, so it clears only its own. */
|
|
39
40
|
#displacing = null;
|
|
41
|
+
/** Claimants whose windows are currently protected in memory. */
|
|
42
|
+
#protectedInMemory = new Set();
|
|
43
|
+
#findStore;
|
|
40
44
|
|
|
41
45
|
/**
|
|
42
46
|
* @param {object} params
|
|
43
47
|
* @param {import("webtorrent").Torrent} params.torrent
|
|
44
48
|
* @param {import("../demand/index.js").DemandRegister} params.register
|
|
49
|
+
* @param {(torrent: object) => object | null} [params.findStore] - How the
|
|
50
|
+
* piece store is reached. Injectable so a test can drive the memory
|
|
51
|
+
* projection without constructing a real store.
|
|
45
52
|
*/
|
|
46
|
-
constructor({ torrent, register }) {
|
|
53
|
+
constructor({ torrent, register, findStore = findSharedStore }) {
|
|
47
54
|
this.#torrent = torrent;
|
|
48
55
|
this.#register = register;
|
|
56
|
+
this.#findStore = findStore;
|
|
49
57
|
}
|
|
50
58
|
|
|
51
59
|
/**
|
|
@@ -107,6 +115,7 @@ export class SwarmSelection {
|
|
|
107
115
|
}
|
|
108
116
|
|
|
109
117
|
this.#markDisplacement();
|
|
118
|
+
this.#projectIntoMemory();
|
|
110
119
|
return { stated, withdrawn };
|
|
111
120
|
}
|
|
112
121
|
|
|
@@ -117,6 +126,11 @@ export class SwarmSelection {
|
|
|
117
126
|
}
|
|
118
127
|
this.#stated.clear();
|
|
119
128
|
this.#clearDisplacement();
|
|
129
|
+
const store = this.#findStore(this.#torrent);
|
|
130
|
+
for (const claimant of this.#protectedInMemory) {
|
|
131
|
+
store?.releaseProtection?.(claimant);
|
|
132
|
+
}
|
|
133
|
+
this.#protectedInMemory.clear();
|
|
120
134
|
}
|
|
121
135
|
|
|
122
136
|
/**
|
|
@@ -182,6 +196,46 @@ export class SwarmSelection {
|
|
|
182
196
|
}
|
|
183
197
|
}
|
|
184
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Tell the piece store which bytes will be read soon, from the same stated
|
|
201
|
+
* needs the swarm is told about.
|
|
202
|
+
*
|
|
203
|
+
* The second half of stating a need once. Until 2026-09-02 a reader said the
|
|
204
|
+
* same thing twice — `protectRange` to the store for memory and a selection
|
|
205
|
+
* to the torrent for download — and a third piece of code read the first to
|
|
206
|
+
* rebuild the second. Now there is one statement and two views of it, both
|
|
207
|
+
* computed here.
|
|
208
|
+
*
|
|
209
|
+
* Only the urgent levels. Memory holds what will be READ soon; the tail and
|
|
210
|
+
* the gap behind the playhead are fetched speculatively and must not push a
|
|
211
|
+
* piece the decoder is about to want out of memory.
|
|
212
|
+
*
|
|
213
|
+
* @returns {void}
|
|
214
|
+
*/
|
|
215
|
+
#projectIntoMemory() {
|
|
216
|
+
const store = this.#findStore(this.#torrent);
|
|
217
|
+
if (!store || typeof store.protectRange !== "function") {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
const holding = new Set();
|
|
221
|
+
for (const urgency of [Urgency.BLOCKED, Urgency.NEAR, Urgency.AHEAD]) {
|
|
222
|
+
for (const window of this.#register.at(urgency)) {
|
|
223
|
+
const range = this.#piecesFor(window);
|
|
224
|
+
if (!range) {
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
store.protectRange(window.claimant, range.from, range.to);
|
|
228
|
+
holding.add(window.claimant);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
for (const claimant of this.#protectedInMemory) {
|
|
232
|
+
if (!holding.has(claimant)) {
|
|
233
|
+
store.releaseProtection?.(claimant);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
this.#protectedInMemory = holding;
|
|
237
|
+
}
|
|
238
|
+
|
|
185
239
|
/** @returns {void} */
|
|
186
240
|
#clearDisplacement() {
|
|
187
241
|
if (!this.#displacing || !Array.isArray(this.#torrent._critical)) {
|
|
@@ -373,6 +373,8 @@ export class SharedPieceStore {
|
|
|
373
373
|
#blocksAllocated = 0;
|
|
374
374
|
/** Whether a reader has ever declared a window here. See `wantedBytes`. */
|
|
375
375
|
#everHadReader = false;
|
|
376
|
+
/** Whether the last revision had to exceed the machine's share. */
|
|
377
|
+
#beyondTheMachine = false;
|
|
376
378
|
/**
|
|
377
379
|
* How long a block sat free before it was taken again, in milliseconds.
|
|
378
380
|
* Bounded, because what is wanted is the longest gap of RECENT work: an
|
|
@@ -467,6 +469,19 @@ export class SharedPieceStore {
|
|
|
467
469
|
};
|
|
468
470
|
}
|
|
469
471
|
|
|
472
|
+
/**
|
|
473
|
+
* Whether the machine's share of memory is smaller than one reader's window.
|
|
474
|
+
*
|
|
475
|
+
* The store holds the window anyway — refusing would leave the read it is
|
|
476
|
+
* serving unable to finish, which is worse — but it is the honest measure of
|
|
477
|
+
* "this machine cannot take any more", and it is measured rather than
|
|
478
|
+
* guessed: it is the last revision's own comparison of what the machine
|
|
479
|
+
* allowed against what the widest reader declared.
|
|
480
|
+
*/
|
|
481
|
+
get isBeyondTheMachine() {
|
|
482
|
+
return this.#beyondTheMachine;
|
|
483
|
+
}
|
|
484
|
+
|
|
470
485
|
/** What this store holds right now, in bytes. */
|
|
471
486
|
get residentBytes() {
|
|
472
487
|
return this.#buffers.size * this.#chunkLength;
|
|
@@ -527,6 +542,7 @@ export class SharedPieceStore {
|
|
|
527
542
|
const belowAWindow = demand.readers > 0
|
|
528
543
|
&& Number.isFinite(wanted)
|
|
529
544
|
&& wanted < demand.widestPieces;
|
|
545
|
+
this.#beyondTheMachine = belowAWindow;
|
|
530
546
|
// The LRU is told too. It was constructed with the store's original
|
|
531
547
|
// capacity and never revised, so `isFull()` answered against a number that
|
|
532
548
|
// had not been the limit for some time — dormant only because nothing calls
|
|
@@ -475,7 +475,7 @@ export function createPlaybackPlanner({
|
|
|
475
475
|
}
|
|
476
476
|
|
|
477
477
|
function withHostTimings(plan) {
|
|
478
|
-
|
|
478
|
+
const withOffer = {
|
|
479
479
|
...plan,
|
|
480
480
|
expectedFirstSegmentMs: expectedFirstSegmentMs?.() ?? null,
|
|
481
481
|
expectedSessionCreateMs: expectedSessionCreateMs?.() ?? null,
|
|
@@ -490,6 +490,20 @@ export function createPlaybackPlanner({
|
|
|
490
490
|
: null,
|
|
491
491
|
mediaInfoForOffer: undefined
|
|
492
492
|
};
|
|
493
|
+
// Refused rather than served badly. Both lists empty means this machine
|
|
494
|
+
// cannot sustain this file at ANY height — not even by copying the picture,
|
|
495
|
+
// which costs no encoder at all — so a session made here would produce a
|
|
496
|
+
// slideshow and take the swarm and the processor from whoever is already
|
|
497
|
+
// watching. Field 2026-08-28: five sessions on one file put every rung at
|
|
498
|
+
// 0.04x of realtime and the viewer watched one before the process was
|
|
499
|
+
// killed. The viewer is told why, which is a different thing from a spinner
|
|
500
|
+
// that never ends.
|
|
501
|
+
const offer = withOffer.offeredHeights;
|
|
502
|
+
if (offer && offer.copy.length === 0 && offer.transcode.length === 0) {
|
|
503
|
+
withOffer.cannotServe =
|
|
504
|
+
"This proxy cannot keep up with this file at any quality right now.";
|
|
505
|
+
}
|
|
506
|
+
return withOffer;
|
|
493
507
|
}
|
|
494
508
|
|
|
495
509
|
return {
|
|
@@ -861,14 +861,13 @@ export async function* readFragments({
|
|
|
861
861
|
for (const band of wanted) {
|
|
862
862
|
stateBand(band);
|
|
863
863
|
}
|
|
864
|
+
// One call, and it does both: the swarm is told what to fetch and the store
|
|
865
|
+
// is told what will be read soon, from the same stated needs. The store used
|
|
866
|
+
// to be told separately here, which made the same intent two lists that
|
|
867
|
+
// could drift.
|
|
864
868
|
selection.reconcile();
|
|
865
869
|
claimed = wanted;
|
|
866
870
|
window = next;
|
|
867
|
-
// Tell the store these pieces are wanted, so it evicts something else.
|
|
868
|
-
// Without it the piece the decoder reads next looks exactly as stale as one
|
|
869
|
-
// the encoder fetched forty minutes ahead, and the second kind is what
|
|
870
|
-
// fills the store while the encoder runs ahead of the viewer.
|
|
871
|
-
store.protectRange?.(readerId, next.from, next.to);
|
|
872
871
|
if (isJump) {
|
|
873
872
|
waitBelongsToJump = true;
|
|
874
873
|
// A jump — a seek, not the window sliding along — can land on pieces that
|
|
@@ -1200,9 +1199,8 @@ export async function* readFragments({
|
|
|
1200
1199
|
if (blockedStated) {
|
|
1201
1200
|
register.withdraw(`${readerId}:${urgencyName(Urgency.BLOCKED)}`);
|
|
1202
1201
|
}
|
|
1203
|
-
|
|
1204
|
-
//
|
|
1205
|
-
// asked to reconcile four times on the way out.
|
|
1202
|
+
// Once, after everything has been withdrawn — and it releases this reader's
|
|
1203
|
+
// hold on memory as well, because both views come from the same statement.
|
|
1206
1204
|
selection.reconcile();
|
|
1207
1205
|
}
|
|
1208
1206
|
}
|
|
@@ -26,6 +26,7 @@ function stubTorrent({ have = [], files = 1 } = {}) {
|
|
|
26
26
|
const items = [];
|
|
27
27
|
return {
|
|
28
28
|
pieceLength: PIECE,
|
|
29
|
+
store: null,
|
|
29
30
|
files: Array.from({ length: files }, (unused, index) => ({
|
|
30
31
|
offset: index * 10 * PIECE,
|
|
31
32
|
length: 10 * PIECE
|
|
@@ -189,3 +190,31 @@ test("releasing everything leaves the library holding nothing of ours", () => {
|
|
|
189
190
|
assert.equal(selection.statedRanges().length, 0);
|
|
190
191
|
assert.equal(torrent._critical.some((marked) => marked === true), false);
|
|
191
192
|
});
|
|
193
|
+
|
|
194
|
+
test("the store is told what will be read soon, from the same stated needs", () => {
|
|
195
|
+
const torrent = stubTorrent();
|
|
196
|
+
const protectedBy = new Map();
|
|
197
|
+
torrent.store = {
|
|
198
|
+
protectRange: (claimant, from, to) => protectedBy.set(claimant, `${from}-${to}`),
|
|
199
|
+
releaseProtection: (claimant) => protectedBy.delete(claimant)
|
|
200
|
+
};
|
|
201
|
+
const register = new DemandRegister();
|
|
202
|
+
const selection = new SwarmSelection({ torrent, register, findStore: () => torrent.store });
|
|
203
|
+
|
|
204
|
+
register.state({ claimant: "video", fileIndex: 0, byteStart: 0, byteEnd: PIECE - 1, urgency: Urgency.NEAR });
|
|
205
|
+
register.state({ claimant: "fill", fileIndex: 0, byteStart: 5 * PIECE, byteEnd: 9 * PIECE - 1, urgency: Urgency.TAIL });
|
|
206
|
+
selection.reconcile();
|
|
207
|
+
|
|
208
|
+
// One statement, two views of it. Until 2026-09-02 a reader said the same
|
|
209
|
+
// thing twice — once to the store for memory, once to the torrent for
|
|
210
|
+
// download — and a third piece of code read the first to rebuild the second.
|
|
211
|
+
assert.equal(protectedBy.get("video"), "0-0");
|
|
212
|
+
// But only the urgent levels: memory holds what will be READ soon, and the
|
|
213
|
+
// tail is fetched speculatively. Protecting it would push out a piece the
|
|
214
|
+
// decoder is about to want.
|
|
215
|
+
assert.equal(protectedBy.has("fill"), false);
|
|
216
|
+
|
|
217
|
+
register.withdraw("video");
|
|
218
|
+
selection.reconcile();
|
|
219
|
+
assert.equal(protectedBy.size, 0, "a reader that withdrew still held memory");
|
|
220
|
+
});
|