@torrent-tv/proxy 2.83.5 → 2.83.7
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 +1874 -1860
- package/bin/cli.js +14 -5
- package/package.json +1 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +329 -0
- package/research/handover-2026-09-13.md +209 -0
- package/routes/api/subtitles/get.js +26 -1
- package/server.js +2 -1
- package/services/data-channel-handler.js +120 -83
- package/services/orchestrators/EncodeOrchestrator.js +42 -12
- package/services/torrent-pool.js +3 -3
- package/services/torrent-worker/client.js +5 -2
- package/services/torrent-worker/worker.js +21 -2
- package/services/viewer/Viewer.js +359 -345
- package/services/viewer/Viewers.js +44 -0
- package/test/logger-repeats.test.js +15 -1
- package/test/send-chunk-bytes.test.js +86 -0
- package/test/viewer-subtitle-subscription.test.js +96 -0
- package/utils/logger.js +44 -14
package/server.js
CHANGED
|
@@ -354,7 +354,8 @@ export async function startProxyServer({
|
|
|
354
354
|
sourceRegistry,
|
|
355
355
|
torrentPool,
|
|
356
356
|
ffmpegBin,
|
|
357
|
-
localBaseUrl: hlsSessionManager.localBaseUrl
|
|
357
|
+
localBaseUrl: hlsSessionManager.localBaseUrl,
|
|
358
|
+
viewers: hlsSessionManager.viewers
|
|
358
359
|
})
|
|
359
360
|
);
|
|
360
361
|
app.get("/stream", async (req, reply) =>
|
|
@@ -45,7 +45,6 @@
|
|
|
45
45
|
|
|
46
46
|
/** @import { DataChannel } from 'node-datachannel' */
|
|
47
47
|
|
|
48
|
-
import { deriveSourceKey } from "./torrent-source-key.js";
|
|
49
48
|
import { createDeliveryProbe, PROBE_INTERVAL_MS } from "./delivery-probe.js";
|
|
50
49
|
|
|
51
50
|
/**
|
|
@@ -582,11 +581,79 @@ export function encodeFrame(idBytes, bytes, done) {
|
|
|
582
581
|
return frame;
|
|
583
582
|
}
|
|
584
583
|
|
|
584
|
+
/**
|
|
585
|
+
* Reshapes a body into messages of `sizeBytes` each, handing every finished one
|
|
586
|
+
* to `sendOne`. A size of zero passes each body read through untouched, which
|
|
587
|
+
* is what the field does today — 10 657 210 bytes over 163 messages, so 65 382
|
|
588
|
+
* each, measured 2026-09-12.
|
|
589
|
+
*
|
|
590
|
+
* Only the SIZE changes: the same bytes reach the far end in the same order,
|
|
591
|
+
* over the same channel. That is what makes a comparison between two sizes a
|
|
592
|
+
* comparison of one thing, and it is why the wedge's dependence on the size can
|
|
593
|
+
* be measured at all — the wedge of 2026-09-12 arrived in the middle of one
|
|
594
|
+
* message, with 63 % of a segment on the wire and then nothing.
|
|
595
|
+
*
|
|
596
|
+
* @param {(bytes: Uint8Array) => void} sendOne - Given each finished message.
|
|
597
|
+
* @param {number} sizeBytes - 0 to leave reads as they are.
|
|
598
|
+
* @returns {{ push: (bytes: Uint8Array) => number, flush: () => number }}
|
|
599
|
+
* Both answer how many messages they sent, so a caller counting messages
|
|
600
|
+
* counts messages rather than body reads.
|
|
601
|
+
*/
|
|
602
|
+
export function bodySender(sendOne, sizeBytes) {
|
|
603
|
+
if (!(sizeBytes > 0)) {
|
|
604
|
+
return {
|
|
605
|
+
push(bytes) {
|
|
606
|
+
sendOne(bytes);
|
|
607
|
+
return 1;
|
|
608
|
+
},
|
|
609
|
+
flush() {
|
|
610
|
+
return 0;
|
|
611
|
+
}
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
/** @type {Uint8Array[]} */
|
|
615
|
+
let held = [];
|
|
616
|
+
let heldBytes = 0;
|
|
617
|
+
const release = () => {
|
|
618
|
+
const message = held.length === 1 ? held[0] : Buffer.concat(held);
|
|
619
|
+
held = [];
|
|
620
|
+
heldBytes = 0;
|
|
621
|
+
sendOne(message);
|
|
622
|
+
};
|
|
623
|
+
return {
|
|
624
|
+
push(bytes) {
|
|
625
|
+
let sent = 0;
|
|
626
|
+
let offset = 0;
|
|
627
|
+
while (offset < bytes.length) {
|
|
628
|
+
const take = Math.min(sizeBytes - heldBytes, bytes.length - offset);
|
|
629
|
+
held.push(bytes.subarray(offset, offset + take));
|
|
630
|
+
heldBytes += take;
|
|
631
|
+
offset += take;
|
|
632
|
+
if (heldBytes === sizeBytes) {
|
|
633
|
+
release();
|
|
634
|
+
sent += 1;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return sent;
|
|
638
|
+
},
|
|
639
|
+
flush() {
|
|
640
|
+
if (heldBytes === 0) return 0;
|
|
641
|
+
release();
|
|
642
|
+
return 1;
|
|
643
|
+
}
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
585
647
|
export function createDataChannelHandler({
|
|
586
648
|
proxyPort,
|
|
587
649
|
onLog,
|
|
588
650
|
getTransportSnapshot,
|
|
589
|
-
|
|
651
|
+
// Who wants pushed subtitle cues for a file, BY NAME. A plain function
|
|
652
|
+
// returning opaque ids: this layer never learns what a viewer is, and the
|
|
653
|
+
// subscription it used to hold against a channel — and had to sniff
|
|
654
|
+
// `/api/subtitles` to build — now belongs to the person and outlives the
|
|
655
|
+
// channel, a reconnect and a deliberate rotation alike.
|
|
656
|
+
viewersWantingCues = () => [],
|
|
590
657
|
witness,
|
|
591
658
|
// Reads usrsctp's own association state (services/usrsctp-state.js) the
|
|
592
659
|
// moment a wedge is declared, from either detector below. Optional: a host
|
|
@@ -599,7 +666,14 @@ export function createDataChannelHandler({
|
|
|
599
666
|
// released a viewer when their connection closed: the only exits were the
|
|
600
667
|
// browser's own `release` and a silence long enough to be called an absence.
|
|
601
668
|
onViewerPresent = () => {},
|
|
602
|
-
onViewerGone = () => {}
|
|
669
|
+
onViewerGone = () => {},
|
|
670
|
+
// The size of one data-channel message carrying body bytes. Zero keeps
|
|
671
|
+
// whatever the body read hands over, which in the field is 65 382 bytes —
|
|
672
|
+
// 10 657 210 over 163 chunks, measured 2026-09-12. It is settable because
|
|
673
|
+
// that day's wedge arrived in the MIDDLE of one message (63 % of a segment
|
|
674
|
+
// on the wire, then nothing), and whether the fault depends on the size is
|
|
675
|
+
// the one branch no capture has been able to close.
|
|
676
|
+
sendChunkBytes = 0
|
|
603
677
|
}) {
|
|
604
678
|
/**
|
|
605
679
|
* Who is on each connection, by WebRTC session id.
|
|
@@ -639,40 +713,30 @@ export function createDataChannelHandler({
|
|
|
639
713
|
}
|
|
640
714
|
}
|
|
641
715
|
/**
|
|
642
|
-
*
|
|
643
|
-
*
|
|
644
|
-
* embedded track — there is no separate subscribe message on the wire, the
|
|
645
|
-
* existing `/api/subtitles` request already says which file a viewer opened
|
|
646
|
-
* subtitles for. Pruned on channel close and, defensively, on a failed send.
|
|
716
|
+
* The channel each viewer is reachable on right now, by the name the far end
|
|
717
|
+
* presented when it opened.
|
|
647
718
|
*
|
|
648
|
-
*
|
|
719
|
+
* The transport knows a NAME and nothing else about a viewer: what it means,
|
|
720
|
+
* who holds it and what they are watching are somebody else's facts. Keeping
|
|
721
|
+
* only "this name is reachable here" is what lets a subscription made once
|
|
722
|
+
* outlive the channel it was made on — a reconnect, and a rotation done on
|
|
723
|
+
* purpose, both just rewrite this entry.
|
|
724
|
+
*
|
|
725
|
+
* @type {Map<string, DataChannel>}
|
|
649
726
|
*/
|
|
650
|
-
const
|
|
727
|
+
const channelOfViewer = new Map();
|
|
651
728
|
|
|
652
729
|
/**
|
|
653
|
-
*
|
|
654
|
-
*
|
|
730
|
+
* Forget every name that was reachable on this channel.
|
|
731
|
+
*
|
|
655
732
|
* @param {DataChannel} channel
|
|
656
733
|
* @returns {void}
|
|
657
734
|
*/
|
|
658
|
-
function
|
|
659
|
-
const
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
subtitleSubscribers.set(key, set);
|
|
664
|
-
}
|
|
665
|
-
const isNew = !set.has(channel);
|
|
666
|
-
set.add(channel);
|
|
667
|
-
if (isNew) {
|
|
668
|
-
log(`[dc] subtitle push: channel subscribed to ${key} (${set.size} channel(s) now)`);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
/** @param {DataChannel} channel */
|
|
673
|
-
function unsubscribeSubtitlesAll(channel) {
|
|
674
|
-
for (const set of subtitleSubscribers.values()) {
|
|
675
|
-
set.delete(channel);
|
|
735
|
+
function forgetChannel(channel) {
|
|
736
|
+
for (const [consumerId, on] of channelOfViewer) {
|
|
737
|
+
if (on === channel) {
|
|
738
|
+
channelOfViewer.delete(consumerId);
|
|
739
|
+
}
|
|
676
740
|
}
|
|
677
741
|
}
|
|
678
742
|
|
|
@@ -688,30 +752,37 @@ export function createDataChannelHandler({
|
|
|
688
752
|
* @returns {void}
|
|
689
753
|
*/
|
|
690
754
|
function publishSubtitleCues({ sourceKey, fileIndex, trackIndex, cues, language, detectedLanguage, cursor }) {
|
|
691
|
-
const
|
|
692
|
-
if (
|
|
755
|
+
const names = viewersWantingCues(sourceKey, fileIndex);
|
|
756
|
+
if (names.length === 0) {
|
|
693
757
|
log(
|
|
694
758
|
`[dc] subtitle push: ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
|
|
695
|
-
"found
|
|
759
|
+
"found nobody who wants them"
|
|
696
760
|
);
|
|
697
761
|
return;
|
|
698
762
|
}
|
|
699
|
-
const message = {
|
|
700
|
-
|
|
763
|
+
const message = JSON.stringify({
|
|
764
|
+
type: "subtitle-cues", fileIndex, trackIndex, cues, language, detectedLanguage, cursor
|
|
765
|
+
});
|
|
701
766
|
let sent = 0;
|
|
702
|
-
for (const
|
|
767
|
+
for (const consumerId of names) {
|
|
768
|
+
const channel = channelOfViewer.get(consumerId);
|
|
769
|
+
if (!channel) {
|
|
770
|
+
// Subscribed but not reachable this instant — between connections, or
|
|
771
|
+
// rotating. Nothing is dropped: they are still subscribed, and the walk
|
|
772
|
+
// keeps a per-viewer cursor, so what they missed comes on the next ask.
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
703
775
|
try {
|
|
704
|
-
channel.sendMessage(
|
|
776
|
+
channel.sendMessage(message);
|
|
705
777
|
sent += 1;
|
|
706
778
|
} catch {
|
|
707
|
-
// Closed between the
|
|
708
|
-
//
|
|
709
|
-
set.delete(channel);
|
|
779
|
+
// Closed between the lookup and the send. The subscription belongs to
|
|
780
|
+
// the person and stays; only this delivery is lost.
|
|
710
781
|
}
|
|
711
782
|
}
|
|
712
783
|
log(
|
|
713
784
|
`[dc] subtitle push: sent ${cues.length} cue(s) for ${sourceKey.slice(0, 8)}:${fileIndex} track ${trackIndex} ` +
|
|
714
|
-
`to ${sent}/${
|
|
785
|
+
`to ${sent}/${names.length} viewer(s)`
|
|
715
786
|
);
|
|
716
787
|
}
|
|
717
788
|
|
|
@@ -914,6 +985,7 @@ export function createDataChannelHandler({
|
|
|
914
985
|
const isNew = !known.has(message.consumerId);
|
|
915
986
|
known.add(message.consumerId);
|
|
916
987
|
viewersOnConnection.set(sessionId, known);
|
|
988
|
+
channelOfViewer.set(message.consumerId, channel);
|
|
917
989
|
if (isNew) {
|
|
918
990
|
log(`[dc] Session ${tag}: viewer ${message.consumerId} is on this connection`);
|
|
919
991
|
}
|
|
@@ -952,7 +1024,7 @@ export function createDataChannelHandler({
|
|
|
952
1024
|
clearTimeout(entry.timer);
|
|
953
1025
|
}
|
|
954
1026
|
partials.clear();
|
|
955
|
-
|
|
1027
|
+
forgetChannel(channel);
|
|
956
1028
|
log(`[dc] Session ${tag}: channel closed`);
|
|
957
1029
|
// The ordinary way a viewer leaves, and the only one that is immediate.
|
|
958
1030
|
// Everything else — a silence long enough to be called an absence, the
|
|
@@ -988,45 +1060,6 @@ export function createDataChannelHandler({
|
|
|
988
1060
|
return;
|
|
989
1061
|
}
|
|
990
1062
|
|
|
991
|
-
// Piggy-backs on the browser's own request for an EMBEDDED track — no
|
|
992
|
-
// separate subscribe message. `trackIndex` is what tells the two request
|
|
993
|
-
// shapes apart: an external subtitle FILE (no trackIndex) names a
|
|
994
|
-
// different file's own index in `fileIndex` — the subtitle file's, not the
|
|
995
|
-
// video's — and subscribing under that would just be a key nothing ever
|
|
996
|
-
// publishes to (an external file is one whole-file read, not something
|
|
997
|
-
// this walks incrementally). `fileIndex` alone would also scope this to
|
|
998
|
-
// the wrong grain for the real case — a torrent can carry several playable
|
|
999
|
-
// files — so the pair is what a push is ever addressed to.
|
|
1000
|
-
//
|
|
1001
|
-
// The browser's `sourceKey` is a REGISTRY key — a hash of the raw request
|
|
1002
|
-
// bytes, one per (magnet-or-.torrent, this API session). The torrent pool
|
|
1003
|
-
// publishes under its OWN key — the content's infohash, deliberately the
|
|
1004
|
-
// SAME for a magnet and a `.torrent` naming the same film, so the two
|
|
1005
|
-
// share one swarm (item 10). The two are different strings for the same
|
|
1006
|
-
// torrent whenever a source was added by its `.torrent` file (a `.torrent`
|
|
1007
|
-
// and a magnet are different request bytes, same infohash) — subscribing
|
|
1008
|
-
// under the registry key found no publisher for that reason, not because
|
|
1009
|
-
// nothing was ever read: field case 2026-08-22, cues were found and
|
|
1010
|
-
// logged, every push answered "found no subscribed channel". Resolved to
|
|
1011
|
-
// the pool's key here, the one place both are in hand.
|
|
1012
|
-
if (path === "/api/subtitles" && typeof query === "string") {
|
|
1013
|
-
const params = new URLSearchParams(query);
|
|
1014
|
-
const registrySourceKey = params.get("sourceKey");
|
|
1015
|
-
const fileIndex = Number(params.get("fileIndex"));
|
|
1016
|
-
const hasTrackIndex = params.get("trackIndex") !== null && params.get("trackIndex") !== "";
|
|
1017
|
-
if (registrySourceKey && Number.isInteger(fileIndex) && hasTrackIndex) {
|
|
1018
|
-
const record = sourceRegistry?.get(registrySourceKey);
|
|
1019
|
-
if (record) {
|
|
1020
|
-
try {
|
|
1021
|
-
const poolSourceKey = await deriveSourceKey(record.sourceType, record.source);
|
|
1022
|
-
subscribeSubtitles(poolSourceKey, fileIndex, channel);
|
|
1023
|
-
} catch (error) {
|
|
1024
|
-
log(`[dc] subtitle push: could not resolve ${registrySourceKey.slice(0, 8)} to a pool key: ` +
|
|
1025
|
-
`${error instanceof Error ? error.message : error}`);
|
|
1026
|
-
}
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
}
|
|
1030
1063
|
|
|
1031
1064
|
const queryInfo = query ? `?${query}` : "";
|
|
1032
1065
|
const bodyInfo =
|
|
@@ -1092,12 +1125,14 @@ export function createDataChannelHandler({
|
|
|
1092
1125
|
let readMs = 0;
|
|
1093
1126
|
let sendMs2 = 0;
|
|
1094
1127
|
let drainMs = 0;
|
|
1128
|
+
const body = bodySender((bytes) => sendChunk(channel, requestId, bytes, false), sendChunkBytes);
|
|
1095
1129
|
resetEventLoopDelay();
|
|
1096
1130
|
while (true) {
|
|
1097
1131
|
const readStartedAt = performance.now();
|
|
1098
1132
|
const { done, value } = await reader.read();
|
|
1099
1133
|
readMs += performance.now() - readStartedAt;
|
|
1100
1134
|
if (done) {
|
|
1135
|
+
chunks += body.flush();
|
|
1101
1136
|
sendChunk(channel, requestId, null, true);
|
|
1102
1137
|
const elapsedMs = Date.now() - sendStartedAt;
|
|
1103
1138
|
let bufferedNow = 0;
|
|
@@ -1107,6 +1142,9 @@ export function createDataChannelHandler({
|
|
|
1107
1142
|
log(
|
|
1108
1143
|
`[net-debug] sent ${path}${queryInfo} bytes=${totalBytes} fetchMs=${fetchMs} ` +
|
|
1109
1144
|
`ttfbMs=${firstByteMs} sendMs=${elapsedMs} chunks=${chunks} ` +
|
|
1145
|
+
// The size of one message, so a reading can be attributed to the
|
|
1146
|
+
// size it was taken at rather than to the run it came from.
|
|
1147
|
+
`msgBytes=${sendChunkBytes > 0 ? sendChunkBytes : "asread"} ` +
|
|
1110
1148
|
`maxBuffered=${maxBuffered} bufferedAtEnd=${bufferedNow} ` +
|
|
1111
1149
|
// Where the time went: reading the body from the local route,
|
|
1112
1150
|
// handing chunks to the channel, or waiting for its queue. Plus
|
|
@@ -1120,14 +1158,13 @@ export function createDataChannelHandler({
|
|
|
1120
1158
|
break;
|
|
1121
1159
|
}
|
|
1122
1160
|
if (firstByteMs < 0) firstByteMs = Date.now() - sendStartedAt;
|
|
1123
|
-
chunks += 1;
|
|
1124
1161
|
totalBytes += value.length;
|
|
1125
1162
|
try {
|
|
1126
1163
|
const b = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
|
|
1127
1164
|
if (b > maxBuffered) maxBuffered = b;
|
|
1128
1165
|
} catch { /* ignore */ }
|
|
1129
1166
|
const sendStepAt = performance.now();
|
|
1130
|
-
|
|
1167
|
+
chunks += body.push(value);
|
|
1131
1168
|
sendMs2 += performance.now() - sendStepAt;
|
|
1132
1169
|
// Backpressure: do not keep queuing chunks once the channel's outgoing
|
|
1133
1170
|
// buffer is large — wait for it to drain. Prevents the SCTP send buffer
|
|
@@ -63,7 +63,8 @@ export class EncodeOrchestrator {
|
|
|
63
63
|
|
|
64
64
|
/**
|
|
65
65
|
* How many times in a row an output's run has died because its input was not
|
|
66
|
-
* there. Cleared by
|
|
66
|
+
* there. Cleared only by a run that PRODUCED something, which is the only
|
|
67
|
+
* proof that the input can be read — see `#noteInputAvailability`.
|
|
67
68
|
*
|
|
68
69
|
* @type {Map<string, number>}
|
|
69
70
|
*/
|
|
@@ -307,6 +308,14 @@ export class EncodeOrchestrator {
|
|
|
307
308
|
*/
|
|
308
309
|
notePriorityMap(address, zones) {
|
|
309
310
|
this.demand.state(address, zones);
|
|
311
|
+
// NOBODY IS COMING HERE, so what was remembered about this output's input
|
|
312
|
+
// is about nobody. Kept, those three entries would stay for the life of the
|
|
313
|
+
// process, and the wait they describe would greet whoever opens this output
|
|
314
|
+
// next — a viewer arriving is new information, and one attempt for them is
|
|
315
|
+
// right whatever the last one met.
|
|
316
|
+
if (!Array.isArray(zones) || zones.length === 0) {
|
|
317
|
+
this.#forgetInputState(address);
|
|
318
|
+
}
|
|
310
319
|
}
|
|
311
320
|
|
|
312
321
|
/**
|
|
@@ -408,8 +417,7 @@ export class EncodeOrchestrator {
|
|
|
408
417
|
// AND ONLY WHILE NOTHING IS PRODUCING THERE. A run still alive on this
|
|
409
418
|
// output is proof the input can be read, whatever a run beside it met, so
|
|
410
419
|
// the wait must not silence an output that is working.
|
|
411
|
-
|
|
412
|
-
if (quietMs > 0 && this.runsOn(address).every((run) => !run.isAlive)) {
|
|
420
|
+
if (this.#isQuiet(address) && this.runsOn(address).every((run) => !run.isAlive)) {
|
|
413
421
|
return;
|
|
414
422
|
}
|
|
415
423
|
// ONE MAP, NOT ONE WINDOW PER VIEWER PER ZONE.
|
|
@@ -812,23 +820,45 @@ export class EncodeOrchestrator {
|
|
|
812
820
|
}
|
|
813
821
|
|
|
814
822
|
/**
|
|
815
|
-
*
|
|
816
|
-
*
|
|
823
|
+
* Whether this output is still waiting before anything may be placed on it.
|
|
824
|
+
*
|
|
825
|
+
* A boolean rather than the milliseconds left: the figure had one reader and
|
|
826
|
+
* that reader only asked whether it was above zero, so it was a number
|
|
827
|
+
* computed and thrown away. How long the wait is was said when it began.
|
|
817
828
|
*
|
|
818
829
|
* @param {string} address
|
|
819
|
-
* @returns {
|
|
830
|
+
* @returns {boolean}
|
|
820
831
|
*/
|
|
821
|
-
#
|
|
832
|
+
#isQuiet(address) {
|
|
822
833
|
const until = this.#quietUntil.get(address);
|
|
823
834
|
if (!Number.isFinite(until)) {
|
|
824
|
-
return
|
|
835
|
+
return false;
|
|
825
836
|
}
|
|
826
|
-
|
|
827
|
-
if (left <= 0) {
|
|
837
|
+
if (until - this.now() <= 0) {
|
|
828
838
|
this.#quietUntil.delete(address);
|
|
829
|
-
return
|
|
839
|
+
return false;
|
|
840
|
+
}
|
|
841
|
+
return true;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/**
|
|
845
|
+
* Forget everything remembered about an output nobody is producing for.
|
|
846
|
+
*
|
|
847
|
+
* Three maps are keyed by address, and an output that goes away leaves an
|
|
848
|
+
* entry in each for the life of the process. Small, and exactly the shape of
|
|
849
|
+
* accumulation this layer was built to remove from the session.
|
|
850
|
+
*
|
|
851
|
+
* @param {string} address
|
|
852
|
+
* @returns {void}
|
|
853
|
+
*/
|
|
854
|
+
#forgetInputState(address) {
|
|
855
|
+
this.#inputLostAttempts.delete(address);
|
|
856
|
+
this.#quietUntil.delete(address);
|
|
857
|
+
const timer = this.#quietTimers.get(address);
|
|
858
|
+
if (timer) {
|
|
859
|
+
clearTimeout(timer);
|
|
860
|
+
this.#quietTimers.delete(address);
|
|
830
861
|
}
|
|
831
|
-
return left;
|
|
832
862
|
}
|
|
833
863
|
|
|
834
864
|
/**
|
package/services/torrent-pool.js
CHANGED
|
@@ -1067,6 +1067,9 @@ export class TorrentPool {
|
|
|
1067
1067
|
*/
|
|
1068
1068
|
#wholeSources = new Set();
|
|
1069
1069
|
|
|
1070
|
+
/** How many claims this pool has withdrawn, over its whole life. */
|
|
1071
|
+
#claimsWithdrawn = 0;
|
|
1072
|
+
|
|
1070
1073
|
/**
|
|
1071
1074
|
* Say that every file of a source is held whole.
|
|
1072
1075
|
*
|
|
@@ -1165,9 +1168,6 @@ export class TorrentPool {
|
|
|
1165
1168
|
}
|
|
1166
1169
|
}
|
|
1167
1170
|
|
|
1168
|
-
/** How many claims this pool has withdrawn, over its whole life. */
|
|
1169
|
-
#claimsWithdrawn = 0;
|
|
1170
|
-
|
|
1171
1171
|
get claimsWithdrawn() {
|
|
1172
1172
|
return this.#claimsWithdrawn;
|
|
1173
1173
|
}
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
import { Worker } from "node:worker_threads";
|
|
18
18
|
import { Readable } from "node:stream";
|
|
19
19
|
import { fileURLToPath } from "node:url";
|
|
20
|
-
import { logger } from "../../utils/logger.js";
|
|
20
|
+
import { logger, writeAlreadyDecided } from "../../utils/logger.js";
|
|
21
21
|
import { createCaller, createReceiveStream } from "./channel.js";
|
|
22
22
|
import { Command, Event } from "./protocol.js";
|
|
23
23
|
|
|
@@ -218,7 +218,10 @@ export class TorrentWorkerClient {
|
|
|
218
218
|
this.#fragmentReaders.delete(message.id);
|
|
219
219
|
break;
|
|
220
220
|
case Event.LOG:
|
|
221
|
-
|
|
221
|
+
// Written as it is: the worker holds the same repeat rule and has
|
|
222
|
+
// already applied it, and deciding again here would be one decision
|
|
223
|
+
// taken twice on two different histories.
|
|
224
|
+
writeAlreadyDecided(message.level ?? "info", `torrent-worker: ${message.message}`);
|
|
222
225
|
break;
|
|
223
226
|
case Event.FILE_COMPLETE:
|
|
224
227
|
// Recorded here so the stream route can answer from the file without
|
|
@@ -56,8 +56,12 @@ import { forwardLogsTo, logger } from "../../utils/logger.js";
|
|
|
56
56
|
* copy of the logger that had no file, and every one of their lines was lost:
|
|
57
57
|
* measured over a whole 49 938-line file, not one of them was in it.
|
|
58
58
|
*/
|
|
59
|
-
forwardLogsTo((
|
|
60
|
-
|
|
59
|
+
forwardLogsTo((level, message) => {
|
|
60
|
+
// THE LEVEL TRAVELS TOO. It was dropped here, so every line this thread wrote
|
|
61
|
+
// — a warning about a spill that failed, an error about a torrent that went
|
|
62
|
+
// away — arrived on the other side as information and was coloured and
|
|
63
|
+
// recorded as such.
|
|
64
|
+
parentPort.postMessage({ type: Event.LOG, level, message });
|
|
61
65
|
});
|
|
62
66
|
|
|
63
67
|
// Imported dynamically, and that is load-bearing: static imports are RESOLVED
|
|
@@ -658,6 +662,7 @@ startMemoryReport({
|
|
|
658
662
|
const STORE_REPORT_INTERVAL_MS = 60_000;
|
|
659
663
|
|
|
660
664
|
/** Last reported reserve, so an unchanged one stays silent. */
|
|
665
|
+
let lastClaimsWithdrawn = 0;
|
|
661
666
|
let lastReserveBytes = 0;
|
|
662
667
|
|
|
663
668
|
/** Last reported figures per store, so unchanged ones stay silent. */
|
|
@@ -695,6 +700,20 @@ setInterval(() => {
|
|
|
695
700
|
);
|
|
696
701
|
}
|
|
697
702
|
}
|
|
703
|
+
// WHAT THE ANNOUNCEMENTS ACTUALLY DID. The stores count every piece they
|
|
704
|
+
// stopped being able to produce; this counts the ones where the library did
|
|
705
|
+
// still hold a claim and it was taken back. The GAP between the two is the
|
|
706
|
+
// reading: announcements far above withdrawals mean the stores are mostly
|
|
707
|
+
// dropping pieces that were never completed, and a withdrawal count stuck at
|
|
708
|
+
// zero while announcements climb means the mechanism is not reaching the
|
|
709
|
+
// torrent at all.
|
|
710
|
+
if (pool.claimsWithdrawn !== lastClaimsWithdrawn) {
|
|
711
|
+
lastClaimsWithdrawn = pool.claimsWithdrawn;
|
|
712
|
+
log(
|
|
713
|
+
`torrent-pool: ${pool.claimsWithdrawn} piece claim(s) withdrawn — pieces this proxy ` +
|
|
714
|
+
"had dropped and has now told the swarm it needs again"
|
|
715
|
+
);
|
|
716
|
+
}
|
|
698
717
|
const reserveNow = machineReserveBytes();
|
|
699
718
|
if (reserveNow !== reserveBefore || reserveNow !== lastReserveBytes) {
|
|
700
719
|
lastReserveBytes = reserveNow;
|