@torrent-tv/proxy 2.89.20 → 2.91.0
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/bin/cli.js +2 -0
- package/package.json +2 -2
- package/services/storage/memory-report.js +37 -1
- package/services/torrent/destroyed-torrents.js +48 -0
- package/services/torrent/torrent-pool.js +28 -6
- package/services/torrent/worker/held-objects.js +55 -0
- package/services/torrent/worker/whole-file-stats.js +46 -0
- package/services/torrent/worker/whole-sources.js +77 -0
- package/services/torrent/worker/worker.js +87 -16
- package/services/transport/tunnel-client.js +111 -20
package/bin/cli.js
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@torrent-tv/proxy",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.91.0",
|
|
4
4
|
"description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
|
|
5
5
|
"license": "GPL-3.0-or-later",
|
|
6
6
|
"repository": {
|
|
@@ -54,6 +54,6 @@
|
|
|
54
54
|
"@biomejs/biome": "^2.5.7"
|
|
55
55
|
},
|
|
56
56
|
"overrides": {
|
|
57
|
-
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.
|
|
57
|
+
"utp-native": "npm:@torrent-tv/utp-native@2.5.3-ttv.9"
|
|
58
58
|
}
|
|
59
59
|
}
|
|
@@ -382,6 +382,25 @@ export function readingIsWorthWriting({
|
|
|
382
382
|
return Math.abs(watchedBytes - lastWrittenBytes) >= changeBytes;
|
|
383
383
|
}
|
|
384
384
|
|
|
385
|
+
/**
|
|
386
|
+
* Array-buffer memory that no torrent store accounts for.
|
|
387
|
+
*
|
|
388
|
+
* The heap is not the only thing a snapshot explains: every `ArrayBuffer` is in
|
|
389
|
+
* it, with the size of its backing store and who holds it. On 2026-10-01 the
|
|
390
|
+
* torrent worker reached 4.3 GB of array buffers while its stores had committed
|
|
391
|
+
* 40 MB, the kernel killed the process twice, and no snapshot existed, because
|
|
392
|
+
* the only trigger watched a heap that stood at 300 MB. Pieces the stores hold
|
|
393
|
+
* are expected; what is above them is the question.
|
|
394
|
+
*
|
|
395
|
+
* @param {number} arrayBuffers - The isolate's own `arrayBuffers` figure.
|
|
396
|
+
* @param {{ committedBytes?: number }[]} stores
|
|
397
|
+
* @returns {number}
|
|
398
|
+
*/
|
|
399
|
+
export function unaccountedBufferBytes(arrayBuffers, stores = []) {
|
|
400
|
+
const committed = stores.reduce((sum, store) => sum + (Number(store?.committedBytes) || 0), 0);
|
|
401
|
+
return Math.max(0, (Number(arrayBuffers) || 0) - committed);
|
|
402
|
+
}
|
|
403
|
+
|
|
385
404
|
/**
|
|
386
405
|
* Report memory on a timer until stopped.
|
|
387
406
|
*
|
|
@@ -408,6 +427,9 @@ export function readingIsWorthWriting({
|
|
|
408
427
|
* @param {number} [options.snapshotFloorBytes]
|
|
409
428
|
* @param {number} [options.snapshotGrowthBytes]
|
|
410
429
|
* @param {number} [options.keepSnapshots] - Newest to keep; zero keeps all.
|
|
430
|
+
* @param {number} [options.bufferSnapshotFloorBytes] - Array buffers above what
|
|
431
|
+
* the stores hold that earn a snapshot of their own; zero never takes one.
|
|
432
|
+
* @param {number} [options.bufferSnapshotGrowthBytes]
|
|
411
433
|
* @param {() => string} [options.readExtra] - Figures to append to the line,
|
|
412
434
|
* read at the same instant as the memory itself.
|
|
413
435
|
* @returns {{ stop: () => void }}
|
|
@@ -426,9 +448,12 @@ export function startMemoryReport({
|
|
|
426
448
|
mayKeep = null,
|
|
427
449
|
snapshotFloorBytes = 500 * 1024 * 1024,
|
|
428
450
|
snapshotGrowthBytes = 100 * 1024 * 1024,
|
|
429
|
-
keepSnapshots = 0
|
|
451
|
+
keepSnapshots = 0,
|
|
452
|
+
bufferSnapshotFloorBytes = 0,
|
|
453
|
+
bufferSnapshotGrowthBytes = 500 * 1024 * 1024
|
|
430
454
|
}) {
|
|
431
455
|
let highWater = 0;
|
|
456
|
+
let bufferHighWater = 0;
|
|
432
457
|
/** @type {Record<string, number>} */
|
|
433
458
|
let lastWritten = {};
|
|
434
459
|
let lastWrittenAt = 0;
|
|
@@ -556,6 +581,17 @@ export function startMemoryReport({
|
|
|
556
581
|
highWater = heapWatched;
|
|
557
582
|
await takeSnapshot(heapWatched, "a new high-water of the heap");
|
|
558
583
|
}
|
|
584
|
+
// The same for array buffers, which a snapshot names as well as it names
|
|
585
|
+
// the heap: each one is in it with its size and its holder.
|
|
586
|
+
const unaccounted = unaccountedBufferBytes(processMemory.arrayBuffers, stores);
|
|
587
|
+
if (
|
|
588
|
+
bufferSnapshotFloorBytes > 0 &&
|
|
589
|
+
unaccounted > bufferSnapshotFloorBytes &&
|
|
590
|
+
unaccounted > bufferHighWater + bufferSnapshotGrowthBytes
|
|
591
|
+
) {
|
|
592
|
+
bufferHighWater = unaccounted;
|
|
593
|
+
await takeSnapshot(unaccounted, "a new high-water of array buffers no torrent store accounts for");
|
|
594
|
+
}
|
|
559
595
|
// Under `write` by construction: `anonymousBytes` is only read when the
|
|
560
596
|
// line is, and at one reading a second an unconditional warning would be
|
|
561
597
|
// a line a second for as long as the process stayed large.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Torrents this process has destroyed and the collector has not taken
|
|
3
|
+
* back yet.
|
|
4
|
+
*
|
|
5
|
+
* Destroying a torrent ends its swarm; it frees nothing while anything still
|
|
6
|
+
* refers to the object. On 2026-10-04 a torrent worker held 595 destroyed
|
|
7
|
+
* copies of one film, kept by the listeners of uTP connections whose close
|
|
8
|
+
* never came back and by idle timers left running for an hour, and the only
|
|
9
|
+
* sign of it was memory growing at 17 MB a minute. The heap snapshot that named
|
|
10
|
+
* it was taken by luck. This says it every time the worker reports memory.
|
|
11
|
+
*
|
|
12
|
+
* The count is the collector's own answer, so it lags: a figure that returns
|
|
13
|
+
* towards zero is ordinary, one that only grows is a reference that was not let
|
|
14
|
+
* go.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
let destroyedTotal = 0;
|
|
18
|
+
let notCollected = 0;
|
|
19
|
+
|
|
20
|
+
/** @type {WeakSet<object>} */
|
|
21
|
+
const counted = new WeakSet();
|
|
22
|
+
|
|
23
|
+
const collected = new FinalizationRegistry(() => {
|
|
24
|
+
notCollected -= 1;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Say that a torrent has been destroyed. Counted once however often it is said.
|
|
29
|
+
*
|
|
30
|
+
* @param {object} torrent
|
|
31
|
+
* @returns {void}
|
|
32
|
+
*/
|
|
33
|
+
export function noteTorrentDestroyed(torrent) {
|
|
34
|
+
if (!torrent || typeof torrent !== "object" || counted.has(torrent)) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
counted.add(torrent);
|
|
38
|
+
destroyedTotal += 1;
|
|
39
|
+
notCollected += 1;
|
|
40
|
+
collected.register(torrent, null);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @returns {{ total: number, notCollected: number }}
|
|
45
|
+
*/
|
|
46
|
+
export function destroyedTorrents() {
|
|
47
|
+
return { total: destroyedTotal, notCollected };
|
|
48
|
+
}
|
|
@@ -21,6 +21,7 @@ import { demandFor, forgetTorrent, reconcileAll, hasUnmetDemand } from "./downlo
|
|
|
21
21
|
import { withdrawClaim } from "./download/withdraw-claim.js";
|
|
22
22
|
import { isAtAWatchingViewer, isBehindEverybody, isNobodyComingNow } from "../viewer/PriorityMap.js";
|
|
23
23
|
import { deriveSourceKey } from "../../utils/torrent-source-key.js";
|
|
24
|
+
import { noteTorrentDestroyed } from "./destroyed-torrents.js";
|
|
24
25
|
|
|
25
26
|
/** How a window stated from the priority map names itself. */
|
|
26
27
|
const MAP_CLAIMANT = "priority-map";
|
|
@@ -1683,6 +1684,10 @@ export class TorrentPool {
|
|
|
1683
1684
|
logger.warn(`torrent-pool: [${label()}] warning: ${formatWarning(warning)}`);
|
|
1684
1685
|
});
|
|
1685
1686
|
|
|
1687
|
+
// Counted from here until the collector takes it, which is the only way a
|
|
1688
|
+
// destroyed torrent that something still holds can be seen at all.
|
|
1689
|
+
torrent.once("close", () => noteTorrentDestroyed(torrent));
|
|
1690
|
+
|
|
1686
1691
|
// A TORRENT THAT DIED WITHOUT US ASKING LEAVES ITS RECORD BEHIND, and the
|
|
1687
1692
|
// record goes on answering. Field 2026-09-11: the store refused a block,
|
|
1688
1693
|
// the client destroyed the torrent, and for the rest of the process every
|
|
@@ -1862,8 +1867,7 @@ export class TorrentPool {
|
|
|
1862
1867
|
this.torrents.delete(otherKey);
|
|
1863
1868
|
}
|
|
1864
1869
|
}
|
|
1865
|
-
this.#
|
|
1866
|
-
this.#readPositionByTorrent.delete(existing);
|
|
1870
|
+
this.#forgetBookkeeping(existing);
|
|
1867
1871
|
this.client.remove(existing, { destroyStore: true }, () => {
|
|
1868
1872
|
const addedReplacement = this.client.add(torrentId, {
|
|
1869
1873
|
store: this.#pieceStore,
|
|
@@ -2083,8 +2087,7 @@ export class TorrentPool {
|
|
|
2083
2087
|
return;
|
|
2084
2088
|
}
|
|
2085
2089
|
forgetTorrent(torrent);
|
|
2086
|
-
this.#
|
|
2087
|
-
this.#readPositionByTorrent.delete(torrent);
|
|
2090
|
+
this.#forgetBookkeeping(torrent);
|
|
2088
2091
|
const name = typeof torrent.name === "string" ? torrent.name : "(unknown)";
|
|
2089
2092
|
logger.warn(
|
|
2090
2093
|
`torrent-pool: forgot "${name}" [${String(torrent.infoHash ?? "?").slice(0, 8)}] — ` +
|
|
@@ -2107,6 +2110,26 @@ export class TorrentPool {
|
|
|
2107
2110
|
this.#removeTorrent(torrent, reason);
|
|
2108
2111
|
}
|
|
2109
2112
|
|
|
2113
|
+
/**
|
|
2114
|
+
* Drop everything this pool keeps per torrent, for one that is going.
|
|
2115
|
+
*
|
|
2116
|
+
* Every one of these holds the torrent strongly, and the idle timer above all:
|
|
2117
|
+
* left running, it kept each removed torrent — its pieces, its wires, the
|
|
2118
|
+
* whole object — for the hour it was set for, then fired on the corpse and
|
|
2119
|
+
* logged a second removal. Field 2026-10-04: 184 such removals of a torrent
|
|
2120
|
+
* already gone, and 595 destroyed copies of it alive in one heap.
|
|
2121
|
+
*
|
|
2122
|
+
* @param {import("webtorrent").Torrent} torrent
|
|
2123
|
+
* @returns {void}
|
|
2124
|
+
*/
|
|
2125
|
+
#forgetBookkeeping(torrent) {
|
|
2126
|
+
this.#cancelIdleRemoval(torrent);
|
|
2127
|
+
this.#lastAccess.delete(torrent);
|
|
2128
|
+
this.#readPositionByTorrent.delete(torrent);
|
|
2129
|
+
this.#stallSince.delete(torrent);
|
|
2130
|
+
this.#stallReportedAt.delete(torrent);
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2110
2133
|
#removeTorrent(torrent, reason = "unknown") {
|
|
2111
2134
|
// Everything anybody stated for this torrent goes with it.
|
|
2112
2135
|
forgetTorrent(torrent);
|
|
@@ -2126,8 +2149,7 @@ export class TorrentPool {
|
|
|
2126
2149
|
break;
|
|
2127
2150
|
}
|
|
2128
2151
|
}
|
|
2129
|
-
this.#
|
|
2130
|
-
this.#readPositionByTorrent.delete(torrent);
|
|
2152
|
+
this.#forgetBookkeeping(torrent);
|
|
2131
2153
|
logger.warn(`torrent-pool: removing torrent "${name}" [${infoHash}] reason=${reason} stated=${stated} downloaded=${hasData}B caller=${caller}`);
|
|
2132
2154
|
try {
|
|
2133
2155
|
torrent.destroy({ destroyStore: true }, () => {
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What the torrent thread holds beyond the bytes it accounts for: live
|
|
3
|
+
* and destroyed torrents, and the uTP sockets and connections behind them.
|
|
4
|
+
*
|
|
5
|
+
* These are the objects the heap snapshot of 2026-10-04 found holding the
|
|
6
|
+
* growth: 957 uTP sockets whose close never completed, 2101 of their 128 KB
|
|
7
|
+
* read buffers, and through their listeners 595 torrents destroyed long
|
|
8
|
+
* before. None of it was in any figure this proxy printed. Read on the same
|
|
9
|
+
* line as the memory, so the count and the mass are the same instant.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { createRequire } from "node:module";
|
|
13
|
+
|
|
14
|
+
const require = createRequire(import.meta.url);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The uTP module's own count of what it holds open, or null where the module is
|
|
18
|
+
* not built or is a version that does not count.
|
|
19
|
+
*
|
|
20
|
+
* Required by name, which resolves to the same copy WebTorrent loads: the image
|
|
21
|
+
* removes every nested copy and refuses to build if one survives.
|
|
22
|
+
*
|
|
23
|
+
* @returns {{ sockets: number, connections: number, closing: number } | null}
|
|
24
|
+
*/
|
|
25
|
+
export function readUtpOpenCount() {
|
|
26
|
+
try {
|
|
27
|
+
const utp = require("utp-native");
|
|
28
|
+
return typeof utp?.openCount === "function" ? utp.openCount() : null;
|
|
29
|
+
} catch {
|
|
30
|
+
// silent-ok: no uTP on this host means TCP peers only, and nothing to count.
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One clause for the memory line.
|
|
37
|
+
*
|
|
38
|
+
* @param {object} held
|
|
39
|
+
* @param {number} held.liveTorrents - Torrents the client has now.
|
|
40
|
+
* @param {{ total: number, notCollected: number }} held.destroyed
|
|
41
|
+
* @param {{ sockets: number, connections: number, closing: number } | null} held.utp
|
|
42
|
+
* @returns {string}
|
|
43
|
+
*/
|
|
44
|
+
export function describeHeldObjects({ liveTorrents, destroyed, utp }) {
|
|
45
|
+
const torrents =
|
|
46
|
+
`torrents ${liveTorrents} live, ${destroyed.notCollected} destroyed and not collected ` +
|
|
47
|
+
`(${destroyed.total} destroyed in all)`;
|
|
48
|
+
if (!utp) {
|
|
49
|
+
return torrents;
|
|
50
|
+
}
|
|
51
|
+
return (
|
|
52
|
+
`${torrents}; uTP ${utp.sockets} socket(s), ${utp.connections} connection(s), ` +
|
|
53
|
+
`${utp.closing} destroyed and still closing`
|
|
54
|
+
);
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The download figures of a file that is held whole on disk.
|
|
3
|
+
*
|
|
4
|
+
* Once every file of a torrent is a file, the torrent is removed: it has
|
|
5
|
+
* nothing left to fetch. The figures a viewer's page polls for still have an
|
|
6
|
+
* answer, and it is known without the torrent — everything is here and nothing
|
|
7
|
+
* is arriving. Asking the torrent for them instead brought it back: on
|
|
8
|
+
* 2026-10-04 a film watched from its whole file was removed and added again
|
|
9
|
+
* 671 times in two hours, once for every poll after each removal, and every
|
|
10
|
+
* copy it left behind stayed in memory.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The same shape `TorrentPool.getFileStats` answers with, for a whole file.
|
|
15
|
+
*
|
|
16
|
+
* @param {number} length - Bytes in the file.
|
|
17
|
+
* @returns {object}
|
|
18
|
+
*/
|
|
19
|
+
export function wholeFileStats(length) {
|
|
20
|
+
const fileLength = Number.isFinite(length) && length > 0 ? length : 0;
|
|
21
|
+
return {
|
|
22
|
+
numPeers: 0,
|
|
23
|
+
downloadSpeed: 0,
|
|
24
|
+
uploadSpeed: 0,
|
|
25
|
+
connectedPeers: 0,
|
|
26
|
+
deliveringPeers: 0,
|
|
27
|
+
knownPeers: null,
|
|
28
|
+
queuedPeers: null,
|
|
29
|
+
trackerSeeders: null,
|
|
30
|
+
trackerLeechers: null,
|
|
31
|
+
trackersAnswered: 0,
|
|
32
|
+
secondsToFirstPeer: null,
|
|
33
|
+
secondsWaitingForFirstPeer: null,
|
|
34
|
+
fileProgress: 1,
|
|
35
|
+
fileDownloaded: fileLength,
|
|
36
|
+
fileLength,
|
|
37
|
+
residence: fileLength > 0 ? [{ start: 0, end: fileLength, location: "whole-file" }] : [],
|
|
38
|
+
resumeNeededBytes: null,
|
|
39
|
+
resumeDownloadedBytes: null,
|
|
40
|
+
resumeAnchorByteStart: null,
|
|
41
|
+
headerBytes: null,
|
|
42
|
+
headerDownloadedBytes: null,
|
|
43
|
+
// Nothing is delivered by a swarm, so there is no supply to describe.
|
|
44
|
+
supply: null
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Which files and sources are held whole on disk, asked by the torrent
|
|
3
|
+
* thread before it brings a torrent back.
|
|
4
|
+
*
|
|
5
|
+
* A torrent whose every file is a file has nothing to fetch, so it is removed;
|
|
6
|
+
* anything that names the source again rebuilt it from its recipe. That is
|
|
7
|
+
* right for a read the files cannot answer, and it was wrong for everything
|
|
8
|
+
* else. The page polls the download figures and the viewers state a priority
|
|
9
|
+
* map for as long as the film plays, and each of those rebuilt the torrent
|
|
10
|
+
* only to be told nothing was missing; ten seconds later the sweep removed it
|
|
11
|
+
* again. Field 2026-10-04: 671 removals in two hours of one viewer watching one
|
|
12
|
+
* whole file, and every copy left behind in memory.
|
|
13
|
+
*
|
|
14
|
+
* So the rule is stated once, here: a file held whole does not bring its
|
|
15
|
+
* torrent back to be steered or measured, and a source held whole is described
|
|
16
|
+
* by what was written down when its torrent went. The torrent is used if it
|
|
17
|
+
* exists.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What the torrent thread says about a source when it is opened.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} SourceDescription
|
|
24
|
+
* @property {string} infoHash
|
|
25
|
+
* @property {string} name
|
|
26
|
+
* @property {number} pieceLength
|
|
27
|
+
* @property {{ index: number, name: string, path: string, length: number }[]} files
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} params
|
|
32
|
+
* @param {(infoHash: string, fileIndex: number) => ({ length: number } | null)} params.find
|
|
33
|
+
* The whole file kept for this infohash and index, or null.
|
|
34
|
+
* @returns {{
|
|
35
|
+
* fileOf: (sourceKey: string, fileIndex: number) => ({ length: number } | null),
|
|
36
|
+
* remember: (sourceKey: string, description: SourceDescription) => void,
|
|
37
|
+
* isWhole: (sourceKey: string) => boolean,
|
|
38
|
+
* describe: (sourceKey: string) => (SourceDescription | null)
|
|
39
|
+
* }}
|
|
40
|
+
*/
|
|
41
|
+
export function createWholeSources({ find }) {
|
|
42
|
+
/** @type {Map<string, SourceDescription>} */
|
|
43
|
+
const descriptions = new Map();
|
|
44
|
+
|
|
45
|
+
const infoHashOf = (sourceKey) => {
|
|
46
|
+
const key = String(sourceKey ?? "");
|
|
47
|
+
return key.startsWith("torrent:") ? key.slice("torrent:".length).toLowerCase() : null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const fileOf = (sourceKey, fileIndex) => {
|
|
51
|
+
const infoHash = infoHashOf(sourceKey);
|
|
52
|
+
if (infoHash === null || !Number.isInteger(fileIndex) || fileIndex < 0) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
return find(infoHash, fileIndex) ?? null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// Asked again each time rather than remembered as a verdict: a whole file can
|
|
59
|
+
// be removed for space, and then the torrent has work again.
|
|
60
|
+
const isWhole = (sourceKey) => {
|
|
61
|
+
const files = descriptions.get(String(sourceKey))?.files ?? [];
|
|
62
|
+
return files.length > 0 && files.every((file) => fileOf(sourceKey, file.index) !== null);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
fileOf,
|
|
67
|
+
remember(sourceKey, description) {
|
|
68
|
+
if (infoHashOf(sourceKey) !== null && Array.isArray(description?.files) && description.files.length > 0) {
|
|
69
|
+
descriptions.set(String(sourceKey), description);
|
|
70
|
+
}
|
|
71
|
+
},
|
|
72
|
+
isWhole,
|
|
73
|
+
describe(sourceKey) {
|
|
74
|
+
return isWhole(sourceKey) ? descriptions.get(String(sourceKey)) : null;
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
@@ -35,6 +35,10 @@ import { CompletedFiles, completedFilesRoot } from "../../storage/files/Complete
|
|
|
35
35
|
import { pieceFromWholeFiles, pieceIsInWholeFiles } from "../../storage/files/piece-from-whole-file.js";
|
|
36
36
|
import { Command, Event } from "./protocol.js";
|
|
37
37
|
import { filesInUse } from "./files-in-use.js";
|
|
38
|
+
import { createWholeSources } from "./whole-sources.js";
|
|
39
|
+
import { wholeFileStats } from "./whole-file-stats.js";
|
|
40
|
+
import { describeHeldObjects, readUtpOpenCount } from "./held-objects.js";
|
|
41
|
+
import { destroyedTorrents } from "../destroyed-torrents.js";
|
|
38
42
|
import { startMemoryReport, WORKER_MEMORY_SAMPLE_MS } from "../../storage/memory-report.js";
|
|
39
43
|
import { forwardLogsTo, logger } from "../../../utils/logger.js";
|
|
40
44
|
|
|
@@ -153,6 +157,22 @@ async function knownTorrent(sourceKey) {
|
|
|
153
157
|
return isUsableTorrentHandle(torrent) ? torrent : null;
|
|
154
158
|
}
|
|
155
159
|
|
|
160
|
+
/**
|
|
161
|
+
* The torrent for one file, but never one rebuilt for a file held whole.
|
|
162
|
+
*
|
|
163
|
+
* For the questions that steer or measure a download: a file held whole has no
|
|
164
|
+
* download, and rebuilding its torrent to be told so is what made the torrent
|
|
165
|
+
* come back after every removal (see `whole-sources.js`). The torrent is still
|
|
166
|
+
* used while it exists.
|
|
167
|
+
*
|
|
168
|
+
* @param {string} sourceKey
|
|
169
|
+
* @param {number} fileIndex
|
|
170
|
+
* @returns {Promise<import("webtorrent").Torrent | null>}
|
|
171
|
+
*/
|
|
172
|
+
async function torrentUnlessWhole(sourceKey, fileIndex) {
|
|
173
|
+
return wholeSources.fileOf(sourceKey, fileIndex) ? knownTorrent(sourceKey) : requireTorrent(sourceKey);
|
|
174
|
+
}
|
|
175
|
+
|
|
156
176
|
async function requireTorrent(sourceKey) {
|
|
157
177
|
const pending = torrentsByKey.get(sourceKey);
|
|
158
178
|
if (!pending) {
|
|
@@ -350,7 +370,18 @@ async function runCommand(command, params, id) {
|
|
|
350
370
|
}
|
|
351
371
|
});
|
|
352
372
|
}
|
|
353
|
-
|
|
373
|
+
// A torrent removed because it was downloaded whole is answered for by
|
|
374
|
+
// what was written down when it went: a destroyed torrent keeps its object
|
|
375
|
+
// and empties its `files`, so it would hand a viewer opening the film an
|
|
376
|
+
// empty list, and rebuilding it here would bring it back on every stream
|
|
377
|
+
// and stats request, which all come through this command. Any other
|
|
378
|
+
// destroyed torrent is rebuilt, because its files still need it.
|
|
379
|
+
const added = await pending;
|
|
380
|
+
const described = isUsableTorrentHandle(added) ? null : wholeSources.describe(params.sourceKey);
|
|
381
|
+
if (described) {
|
|
382
|
+
return described;
|
|
383
|
+
}
|
|
384
|
+
const torrent = isUsableTorrentHandle(added) ? added : await requireTorrent(params.sourceKey);
|
|
354
385
|
return {
|
|
355
386
|
infoHash: torrent.infoHash,
|
|
356
387
|
name: torrent.name,
|
|
@@ -369,6 +400,10 @@ async function runCommand(command, params, id) {
|
|
|
369
400
|
}
|
|
370
401
|
|
|
371
402
|
case Command.LIST_FILES: {
|
|
403
|
+
const described = (await knownTorrent(params.sourceKey)) ? null : wholeSources.describe(params.sourceKey);
|
|
404
|
+
if (described) {
|
|
405
|
+
return described.files;
|
|
406
|
+
}
|
|
372
407
|
const torrent = await requireTorrent(params.sourceKey);
|
|
373
408
|
return (torrent.files ?? []).map((file, index) => ({
|
|
374
409
|
index,
|
|
@@ -419,19 +454,24 @@ async function runCommand(command, params, id) {
|
|
|
419
454
|
|
|
420
455
|
|
|
421
456
|
case Command.FILL_FILE: {
|
|
422
|
-
const torrent = await
|
|
457
|
+
const torrent = await torrentUnlessWhole(params.sourceKey, params.fileIndex);
|
|
423
458
|
return {
|
|
424
|
-
started: fillFileInBackground(torrent, params.fileIndex, params.sourceKey)
|
|
459
|
+
started: torrent ? fillFileInBackground(torrent, params.fileIndex, params.sourceKey) : false
|
|
425
460
|
};
|
|
426
461
|
}
|
|
427
462
|
|
|
428
463
|
case Command.FILL_TORRENT: {
|
|
429
|
-
const torrent =
|
|
430
|
-
|
|
464
|
+
const torrent = wholeSources.isWhole(params.sourceKey)
|
|
465
|
+
? await knownTorrent(params.sourceKey)
|
|
466
|
+
: await requireTorrent(params.sourceKey);
|
|
467
|
+
return { started: torrent ? pool.fillTorrentAsCapacityAllows(torrent) : false };
|
|
431
468
|
}
|
|
432
469
|
|
|
433
470
|
case Command.WARM_POSITION: {
|
|
434
|
-
const torrent = await
|
|
471
|
+
const torrent = await torrentUnlessWhole(params.sourceKey, params.fileIndex);
|
|
472
|
+
if (!torrent) {
|
|
473
|
+
return { started: false };
|
|
474
|
+
}
|
|
435
475
|
return {
|
|
436
476
|
started: await warmResumePosition(
|
|
437
477
|
torrent,
|
|
@@ -451,7 +491,10 @@ async function runCommand(command, params, id) {
|
|
|
451
491
|
|
|
452
492
|
|
|
453
493
|
case Command.FILE_STATS: {
|
|
454
|
-
const torrent = await
|
|
494
|
+
const torrent = await torrentUnlessWhole(params.sourceKey, params.fileIndex);
|
|
495
|
+
if (!torrent) {
|
|
496
|
+
return wholeFileStats(wholeSources.fileOf(params.sourceKey, params.fileIndex)?.length ?? 0);
|
|
497
|
+
}
|
|
455
498
|
const stats = pool.getFileStats(torrent, params.fileIndex, {
|
|
456
499
|
resumeAnchorByteStart: params.resumeAnchorByteStart ?? null
|
|
457
500
|
});
|
|
@@ -475,9 +518,10 @@ async function runCommand(command, params, id) {
|
|
|
475
518
|
// handle from its recipe, so asking that way would re-add a torrent in
|
|
476
519
|
// order to be told that nothing is wanted of it.
|
|
477
520
|
const zones = Array.isArray(params.zones) ? params.zones : [];
|
|
521
|
+
// Nor may one for a file held whole, which has nothing left to fetch.
|
|
478
522
|
const torrent = zones.length === 0
|
|
479
523
|
? await knownTorrent(params.sourceKey)
|
|
480
|
-
: await
|
|
524
|
+
: await torrentUnlessWhole(params.sourceKey, params.fileIndex);
|
|
481
525
|
if (torrent) {
|
|
482
526
|
pool.applyPriorityMap(torrent, params.fileIndex, zones, params.durationSeconds);
|
|
483
527
|
}
|
|
@@ -485,16 +529,18 @@ async function runCommand(command, params, id) {
|
|
|
485
529
|
}
|
|
486
530
|
|
|
487
531
|
case Command.PRIORITIZE: {
|
|
488
|
-
const torrent = await
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
532
|
+
const torrent = await torrentUnlessWhole(params.sourceKey, params.fileIndex);
|
|
533
|
+
if (torrent) {
|
|
534
|
+
pool.prioritizeByteRange(torrent, params.fileIndex, params.byteStart, params.windowBytes, {
|
|
535
|
+
wholeFileRead: params.wholeFileRead === true
|
|
536
|
+
});
|
|
537
|
+
}
|
|
492
538
|
return true;
|
|
493
539
|
}
|
|
494
540
|
|
|
495
541
|
case Command.PREFETCH_EDGES: {
|
|
496
|
-
const torrent = await
|
|
497
|
-
return pool.prefetchFileEdges(torrent, params.fileIndex, params.options ?? {});
|
|
542
|
+
const torrent = await torrentUnlessWhole(params.sourceKey, params.fileIndex);
|
|
543
|
+
return torrent ? pool.prefetchFileEdges(torrent, params.fileIndex, params.options ?? {}) : undefined;
|
|
498
544
|
}
|
|
499
545
|
|
|
500
546
|
case Command.READ_RANGE: {
|
|
@@ -520,7 +566,11 @@ async function runCommand(command, params, id) {
|
|
|
520
566
|
}
|
|
521
567
|
|
|
522
568
|
case Command.HELD_RANGES: {
|
|
523
|
-
const torrent = await
|
|
569
|
+
const torrent = await torrentUnlessWhole(params.sourceKey, params.fileIndex);
|
|
570
|
+
if (!torrent) {
|
|
571
|
+
const length = wholeSources.fileOf(params.sourceKey, params.fileIndex)?.length ?? 0;
|
|
572
|
+
return { ranges: length > 0 ? [[0, length - 1]] : [] };
|
|
573
|
+
}
|
|
524
574
|
return { ranges: heldRangesOf(torrent, params.fileIndex) };
|
|
525
575
|
}
|
|
526
576
|
|
|
@@ -619,7 +669,14 @@ startMemoryReport({
|
|
|
619
669
|
// thread still refers to or buffers the collector has not reached yet. On
|
|
620
670
|
// separate timers the two were up to a minute apart and could not be
|
|
621
671
|
// compared at all (roadmap item 2).
|
|
622
|
-
readExtra:
|
|
672
|
+
readExtra: () => [
|
|
673
|
+
describePieceBuffers(),
|
|
674
|
+
describeHeldObjects({
|
|
675
|
+
liveTorrents: pool.client?.torrents?.length ?? 0,
|
|
676
|
+
destroyed: destroyedTorrents(),
|
|
677
|
+
utp: readUtpOpenCount()
|
|
678
|
+
})
|
|
679
|
+
].filter(Boolean).join("; "),
|
|
623
680
|
scope: "thread",
|
|
624
681
|
label: "torrent worker",
|
|
625
682
|
intervalMs: WORKER_MEMORY_SAMPLE_MS,
|
|
@@ -628,6 +685,13 @@ startMemoryReport({
|
|
|
628
685
|
snapshotDir: workerData?.stateDir || undefined,
|
|
629
686
|
snapshotFloorBytes: 400 * 1024 * 1024,
|
|
630
687
|
snapshotGrowthBytes: 400 * 1024 * 1024,
|
|
688
|
+
// CHOSEN, not derived: no healthy session has recorded this figure yet, so
|
|
689
|
+
// there is no measured normal to stand above. It is set well below the
|
|
690
|
+
// 2.1-4.3 GB the worker held in the minutes before the kills of 2026-10-01,
|
|
691
|
+
// while the machine still has room to write a snapshot. The line prints what the
|
|
692
|
+
// stores hold beside it, which is what a derived floor will come from.
|
|
693
|
+
bufferSnapshotFloorBytes: 500 * 1024 * 1024,
|
|
694
|
+
bufferSnapshotGrowthBytes: 500 * 1024 * 1024,
|
|
631
695
|
keepSnapshots: 3
|
|
632
696
|
});
|
|
633
697
|
|
|
@@ -880,6 +944,7 @@ setInterval(() => {
|
|
|
880
944
|
* thread writes them, the main thread serves them without asking anybody.
|
|
881
945
|
*/
|
|
882
946
|
const completedFiles = new CompletedFiles({ root: completedFilesRoot() });
|
|
947
|
+
const wholeSources = createWholeSources({ find: (infoHash, fileIndex) => completedFiles.find(infoHash, fileIndex) });
|
|
883
948
|
|
|
884
949
|
/**
|
|
885
950
|
* Which torrent a set of files belongs to.
|
|
@@ -1047,6 +1112,12 @@ async function keepWholeFiles() {
|
|
|
1047
1112
|
// checked against what the torrent says. Re-hashing a gigabyte and a
|
|
1048
1113
|
// half to learn what we wrote down is a minute of a viewer's time for
|
|
1049
1114
|
// nothing.
|
|
1115
|
+
wholeSources.remember(sourceKey, {
|
|
1116
|
+
infoHash: torrent.infoHash,
|
|
1117
|
+
name: torrent.name,
|
|
1118
|
+
pieceLength: Number(torrent.pieceLength) || 0,
|
|
1119
|
+
files: torrent.files.map((file, index) => ({ index, name: file.name, path: file.path, length: file.length }))
|
|
1120
|
+
});
|
|
1050
1121
|
pool.remove(torrent, "downloaded-whole");
|
|
1051
1122
|
pool.addWholeSource(sourceKey);
|
|
1052
1123
|
}
|
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
*
|
|
10
10
|
* The tunnel reconnects automatically with a fixed back-off after any
|
|
11
11
|
* unexpected close.
|
|
12
|
+
*
|
|
13
|
+
* A server being replaced by a release asks its proxies to move
|
|
14
|
+
* (`server-moving`). The proxy then opens a second connection, which reaches the
|
|
15
|
+
* new server, and keeps the old one until the old server closes it. While both
|
|
16
|
+
* are open each reply goes over the connection its request arrived on: a
|
|
17
|
+
* browser that signalled through the old server is answered there, because the
|
|
18
|
+
* new one does not know that browser.
|
|
12
19
|
*/
|
|
13
20
|
|
|
14
21
|
import { WebSocket } from "ws";
|
|
@@ -27,6 +34,11 @@ import { WebSocket } from "ws";
|
|
|
27
34
|
* Auth token sent as the `x-proxy-id` / `x-proxy-token` headers during the WS handshake.
|
|
28
35
|
* @property {number} proxyPort
|
|
29
36
|
* Local port the proxy's Fastify server is listening on.
|
|
37
|
+
* @property {string} [name]
|
|
38
|
+
* Display name, sent with every connection so that whichever server instance
|
|
39
|
+
* the connection reaches knows it without a separate registration request.
|
|
40
|
+
* @property {string} [baseUrl]
|
|
41
|
+
* Advertised direct URL, sent with every connection for the same reason.
|
|
30
42
|
* @property {(sessionId: string, signal: WebRtcSignal) => void} [onSignal]
|
|
31
43
|
* Called when the server forwards a WebRTC signal (SDP offer or ICE candidate)
|
|
32
44
|
* from a browser to this proxy. `sessionId` scopes the signal to a P2P session.
|
|
@@ -120,6 +132,8 @@ export function createTunnelClient({
|
|
|
120
132
|
proxyId,
|
|
121
133
|
token,
|
|
122
134
|
proxyPort,
|
|
135
|
+
name = "",
|
|
136
|
+
baseUrl = "",
|
|
123
137
|
onSignal,
|
|
124
138
|
onConnect,
|
|
125
139
|
onHealthRequest,
|
|
@@ -141,6 +155,21 @@ export function createTunnelClient({
|
|
|
141
155
|
*/
|
|
142
156
|
let renewalTimer = null;
|
|
143
157
|
let stopped = false;
|
|
158
|
+
/**
|
|
159
|
+
* Every connection that is open now. Usually one; two while a renewal or a
|
|
160
|
+
* move to a new server is under way.
|
|
161
|
+
*
|
|
162
|
+
* @type {Set<WebSocket>}
|
|
163
|
+
*/
|
|
164
|
+
const openConnections = new Set();
|
|
165
|
+
/**
|
|
166
|
+
* The connection each browser session signalled through. The proxy's answer
|
|
167
|
+
* and its ICE candidates go back the same way, because only the server
|
|
168
|
+
* instance that holds that browser's signalling socket can deliver them.
|
|
169
|
+
*
|
|
170
|
+
* @type {Map<string, WebSocket>}
|
|
171
|
+
*/
|
|
172
|
+
const signalRoutes = new Map();
|
|
144
173
|
|
|
145
174
|
/**
|
|
146
175
|
* Write a message to the log sink if one was provided.
|
|
@@ -174,6 +203,14 @@ export function createTunnelClient({
|
|
|
174
203
|
headers: {
|
|
175
204
|
"x-proxy-id": proxyId,
|
|
176
205
|
"x-proxy-token": token,
|
|
206
|
+
// Who this proxy is, on the connection itself: a separate registration
|
|
207
|
+
// request may reach a different server instance during a release.
|
|
208
|
+
"x-proxy-name": encodeURIComponent(name),
|
|
209
|
+
"x-proxy-base-url": baseUrl,
|
|
210
|
+
// This proxy answers on the connection a request arrived on and opens a
|
|
211
|
+
// new connection when the server says `server-moving`, so the server
|
|
212
|
+
// may wait for it to arrive at the new instance.
|
|
213
|
+
"x-proxy-follows-moves": "1",
|
|
177
214
|
"user-agent": "torrent-tv-proxy/1.0"
|
|
178
215
|
}
|
|
179
216
|
});
|
|
@@ -182,6 +219,7 @@ export function createTunnelClient({
|
|
|
182
219
|
socket = connection;
|
|
183
220
|
|
|
184
221
|
connection.addEventListener("open", () => {
|
|
222
|
+
openConnections.add(connection);
|
|
185
223
|
log("Tunnel connected.");
|
|
186
224
|
// Start keepalive pings to prevent Cloudflare's idle WebSocket timeout.
|
|
187
225
|
keepaliveTimer = setInterval(() => {
|
|
@@ -213,8 +251,19 @@ export function createTunnelClient({
|
|
|
213
251
|
return;
|
|
214
252
|
}
|
|
215
253
|
|
|
254
|
+
// The server is being replaced. A new connection reaches its successor;
|
|
255
|
+
// this one stays open until the old server has finished with the
|
|
256
|
+
// browsers it is still signalling for, and then it closes it.
|
|
257
|
+
if (message.type === "server-moving") {
|
|
258
|
+
if (connection === socket && !stopped) {
|
|
259
|
+
log("Tunnel: the server is being replaced; connecting to its successor before this connection closes.");
|
|
260
|
+
connect();
|
|
261
|
+
}
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
216
265
|
if (message.type === "request") {
|
|
217
|
-
void handleRelayRequest(message).catch((error) => {
|
|
266
|
+
void handleRelayRequest(message, connection).catch((error) => {
|
|
218
267
|
log(`Tunnel relay error: ${error?.message ?? error}`);
|
|
219
268
|
});
|
|
220
269
|
return;
|
|
@@ -223,6 +272,7 @@ export function createTunnelClient({
|
|
|
223
272
|
// WebRTC signalling: server forwards a signal from a browser session.
|
|
224
273
|
if (message.type === "signal") {
|
|
225
274
|
if (typeof message.sessionId === "string" && message.signal && typeof onSignal === "function") {
|
|
275
|
+
signalRoutes.set(message.sessionId, connection);
|
|
226
276
|
onSignal(message.sessionId, message.signal);
|
|
227
277
|
}
|
|
228
278
|
return;
|
|
@@ -244,7 +294,7 @@ export function createTunnelClient({
|
|
|
244
294
|
// silent-ok: an unanswerable question is answered "no", which is what
|
|
245
295
|
// a null offer means to the caller.
|
|
246
296
|
}
|
|
247
|
-
send({ type: "can-serve-response", requestId: message.requestId, offer });
|
|
297
|
+
send({ type: "can-serve-response", requestId: message.requestId, offer }, connection);
|
|
248
298
|
return;
|
|
249
299
|
}
|
|
250
300
|
|
|
@@ -266,7 +316,7 @@ export function createTunnelClient({
|
|
|
266
316
|
requestId: message.requestId,
|
|
267
317
|
metrics: answer?.metrics ?? answer ?? {},
|
|
268
318
|
holds: Array.isArray(answer?.holds) ? answer.holds : []
|
|
269
|
-
});
|
|
319
|
+
}, connection);
|
|
270
320
|
})();
|
|
271
321
|
return;
|
|
272
322
|
}
|
|
@@ -277,6 +327,12 @@ export function createTunnelClient({
|
|
|
277
327
|
clearInterval(keepaliveTimer);
|
|
278
328
|
keepaliveTimer = null;
|
|
279
329
|
}
|
|
330
|
+
openConnections.delete(connection);
|
|
331
|
+
for (const [sessionId, route] of signalRoutes) {
|
|
332
|
+
if (route === connection) {
|
|
333
|
+
signalRoutes.delete(sessionId);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
280
336
|
// A connection this one replaced. The server closes it as soon as the
|
|
281
337
|
// replacement registers, which is the whole point of renewing early —
|
|
282
338
|
// there is nothing to report and nothing to reconnect, because the tunnel
|
|
@@ -285,15 +341,24 @@ export function createTunnelClient({
|
|
|
285
341
|
log(`Tunnel handed over (code=${event.code}); the replacement is already carrying it.`);
|
|
286
342
|
return;
|
|
287
343
|
}
|
|
344
|
+
// The newest connection ended while an older one is still open: a move
|
|
345
|
+
// whose new connection did not get through. The older one goes on
|
|
346
|
+
// carrying the tunnel until its server closes it, and the move is tried
|
|
347
|
+
// again meanwhile.
|
|
348
|
+
const survivor = [...openConnections].at(-1);
|
|
349
|
+
if (survivor) {
|
|
350
|
+
socket = survivor;
|
|
351
|
+
log(`Tunnel connection ended (code=${event.code}) while the previous one is still open; it carries the tunnel, and a new connection is tried in ${RECONNECT_DELAY_MS}ms.`);
|
|
352
|
+
scheduleReconnect();
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
288
355
|
log(`Tunnel disconnected (code=${event.code}). Reconnecting in ${RECONNECT_DELAY_MS}ms...`);
|
|
289
356
|
socket = null;
|
|
290
357
|
if (renewalTimer !== null) {
|
|
291
358
|
clearTimeout(renewalTimer);
|
|
292
359
|
renewalTimer = null;
|
|
293
360
|
}
|
|
294
|
-
|
|
295
|
-
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
|
|
296
|
-
}
|
|
361
|
+
scheduleReconnect();
|
|
297
362
|
});
|
|
298
363
|
|
|
299
364
|
connection.addEventListener("error", (event) => {
|
|
@@ -301,14 +366,31 @@ export function createTunnelClient({
|
|
|
301
366
|
});
|
|
302
367
|
}
|
|
303
368
|
|
|
369
|
+
/**
|
|
370
|
+
* Connect again after a fixed back-off, unless a reconnect is already due.
|
|
371
|
+
*
|
|
372
|
+
* @returns {void}
|
|
373
|
+
*/
|
|
374
|
+
function scheduleReconnect() {
|
|
375
|
+
if (stopped || reconnectTimer !== null) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
reconnectTimer = setTimeout(() => {
|
|
379
|
+
reconnectTimer = null;
|
|
380
|
+
connect();
|
|
381
|
+
}, RECONNECT_DELAY_MS);
|
|
382
|
+
}
|
|
383
|
+
|
|
304
384
|
/**
|
|
305
385
|
* Fetch a resource from the local Fastify server and stream the response
|
|
306
386
|
* back to the registry server chunk-by-chunk over the WebSocket.
|
|
307
387
|
*
|
|
308
388
|
* @param {TunnelRelayRequest} relayRequest
|
|
389
|
+
* @param {WebSocket} over - The connection the request arrived on, which
|
|
390
|
+
* the response goes back over.
|
|
309
391
|
* @returns {Promise<void>}
|
|
310
392
|
*/
|
|
311
|
-
async function handleRelayRequest(relayRequest) {
|
|
393
|
+
async function handleRelayRequest(relayRequest, over) {
|
|
312
394
|
const { requestId, method, path, query, headers: forwardedHeaders, body } = relayRequest;
|
|
313
395
|
const targetUrl = `http://127.0.0.1:${proxyPort}${path}` + (query ? `?${query}` : "");
|
|
314
396
|
const requestHeaders = { ...(forwardedHeaders ?? {}), host: `127.0.0.1:${proxyPort}` };
|
|
@@ -322,7 +404,7 @@ export function createTunnelClient({
|
|
|
322
404
|
redirect: "manual"
|
|
323
405
|
});
|
|
324
406
|
} catch (fetchError) {
|
|
325
|
-
sendError(requestId, fetchError?.message ?? String(fetchError));
|
|
407
|
+
sendError(requestId, fetchError?.message ?? String(fetchError), over);
|
|
326
408
|
return;
|
|
327
409
|
}
|
|
328
410
|
|
|
@@ -332,10 +414,10 @@ export function createTunnelClient({
|
|
|
332
414
|
responseHeaders[headerName] = headerValue;
|
|
333
415
|
}
|
|
334
416
|
|
|
335
|
-
send({ type: "response-start", requestId, status: response.status, headers: responseHeaders });
|
|
417
|
+
send({ type: "response-start", requestId, status: response.status, headers: responseHeaders }, over);
|
|
336
418
|
|
|
337
419
|
if (!response.body) {
|
|
338
|
-
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
420
|
+
send({ type: "response-chunk", requestId, data: "", done: true }, over);
|
|
339
421
|
return;
|
|
340
422
|
}
|
|
341
423
|
|
|
@@ -344,7 +426,7 @@ export function createTunnelClient({
|
|
|
344
426
|
while (true) {
|
|
345
427
|
const { done, value } = await reader.read();
|
|
346
428
|
if (done) {
|
|
347
|
-
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
429
|
+
send({ type: "response-chunk", requestId, data: "", done: true }, over);
|
|
348
430
|
break;
|
|
349
431
|
}
|
|
350
432
|
send({
|
|
@@ -352,10 +434,10 @@ export function createTunnelClient({
|
|
|
352
434
|
requestId,
|
|
353
435
|
data: Buffer.from(value).toString("base64"),
|
|
354
436
|
done: false
|
|
355
|
-
});
|
|
437
|
+
}, over);
|
|
356
438
|
}
|
|
357
439
|
} catch {
|
|
358
|
-
send({ type: "response-chunk", requestId, data: "", done: true });
|
|
440
|
+
send({ type: "response-chunk", requestId, data: "", done: true }, over);
|
|
359
441
|
}
|
|
360
442
|
}
|
|
361
443
|
|
|
@@ -363,13 +445,15 @@ export function createTunnelClient({
|
|
|
363
445
|
* Serialise a message to JSON and send it through the WebSocket if open.
|
|
364
446
|
*
|
|
365
447
|
* @param {object} message
|
|
448
|
+
* @param {WebSocket | null} [over]
|
|
366
449
|
* @returns {void}
|
|
367
450
|
*/
|
|
368
451
|
function send(message, over = null) {
|
|
369
|
-
//
|
|
370
|
-
// for
|
|
371
|
-
//
|
|
372
|
-
// that
|
|
452
|
+
// What the proxy says on its own goes over the LIVE connection. `over` is
|
|
453
|
+
// for what belongs to a particular socket rather than to the tunnel: its
|
|
454
|
+
// own keepalive, and every reply, which goes back over the connection its
|
|
455
|
+
// request arrived on — during a move that is the old server, the only one
|
|
456
|
+
// still waiting for it.
|
|
373
457
|
const target = over ?? socket;
|
|
374
458
|
if (target && target.readyState === WebSocket.OPEN) {
|
|
375
459
|
target.send(JSON.stringify(message));
|
|
@@ -381,10 +465,11 @@ export function createTunnelClient({
|
|
|
381
465
|
*
|
|
382
466
|
* @param {string} requestId
|
|
383
467
|
* @param {string} errorMessage
|
|
468
|
+
* @param {WebSocket} over - The connection the request arrived on.
|
|
384
469
|
* @returns {void}
|
|
385
470
|
*/
|
|
386
|
-
function sendError(requestId, errorMessage) {
|
|
387
|
-
send({ type: "response-error", requestId, error: errorMessage });
|
|
471
|
+
function sendError(requestId, errorMessage, over) {
|
|
472
|
+
send({ type: "response-error", requestId, error: errorMessage }, over);
|
|
388
473
|
}
|
|
389
474
|
|
|
390
475
|
return {
|
|
@@ -414,6 +499,9 @@ export function createTunnelClient({
|
|
|
414
499
|
clearTimeout(reconnectTimer);
|
|
415
500
|
reconnectTimer = null;
|
|
416
501
|
}
|
|
502
|
+
for (const connection of openConnections) {
|
|
503
|
+
connection.close(1000, "shutdown");
|
|
504
|
+
}
|
|
417
505
|
if (socket) {
|
|
418
506
|
socket.close(1000, "shutdown");
|
|
419
507
|
socket = null;
|
|
@@ -429,7 +517,10 @@ export function createTunnelClient({
|
|
|
429
517
|
* @returns {void}
|
|
430
518
|
*/
|
|
431
519
|
sendSignal(sessionId, signal) {
|
|
432
|
-
|
|
520
|
+
// Back over the connection this session signalled through, while it is
|
|
521
|
+
// open; the server instance at the other end holds that browser.
|
|
522
|
+
const route = signalRoutes.get(sessionId);
|
|
523
|
+
send({ type: "signal", sessionId, signal }, route?.readyState === WebSocket.OPEN ? route : null);
|
|
433
524
|
},
|
|
434
525
|
|
|
435
526
|
/**
|