@torrent-tv/proxy 2.9.111 → 2.9.113
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
CHANGED
|
@@ -1,3 +1,11 @@
|
|
|
1
|
+
## 2.9.113
|
|
2
|
+
|
|
3
|
+
- **New**: The transport's own counters are written to the log for as long as a channel is open, not only when its send queue backs up. The queue was the wrong thing to watch: field 2026-08-06, a 9.26 MB segment was accepted by the transport with `maxBuffered=0 bufferedAtEnd=0` and reported as sent at 274 Mbit/s, and it never arrived — after which everything the proxy sent vanished the same way while requests kept arriving in the other direction. With nothing ever queued the existing watcher never woke, so the one question that decides the cause — did those bytes leave the machine — had no answer in the log. Every five seconds it now records bytes sent and received by the transport itself, the queue depth, the round-trip time, and the path in use. The browser records the matching figures on the same cadence (server 0.8.110), so a recurrence is settled by subtracting one line from the other rather than by reasoning.
|
|
4
|
+
|
|
5
|
+
## 2.9.112
|
|
6
|
+
|
|
7
|
+
- **New**: A session whose data went away now waits for it to come back instead of dying. Losing the input is not the session failing — the torrent can be added again and the pieces downloaded again — but a run that died that way marked the session terminal, and every request for the playlist answered 500 from then on, although the swarm was right there and the data would have returned in seconds. Such a run is now retried at the position the viewer is waiting at, backing off from 2 s to at most 15 s so a source that is genuinely unavailable costs a process every few seconds rather than continuously, and the requests being held are simply held: nothing is broken and there is nothing for the viewer to retry. The circuit breaker stays for what it was built for — a target that truly cannot be encoded — and no longer condemns a session that merely lost its data. Which of the two happened is decided by the message, tested against the exact ones the field produced.
|
|
8
|
+
|
|
1
9
|
## 2.9.111
|
|
2
10
|
|
|
3
11
|
- **Fix**: The film being watched could be deleted mid-seek. A torrent's data is protected only by a claim that READS take, and a seek leaves a gap with no read at all — the old encoder is dead, the new one has not started. The thirty-second disk sweep met that gap on 2026-08-06: with the cap exceeded it evicted "the idle torrent" that a viewer was in the middle of, deleted six gigabytes, and the new encoder found nothing to read. The session's own thirty minutes never governed the data underneath it, because the pool was never told a session existed. A session now holds its source for as long as it lives, and lets go when it is disposed.
|
package/package.json
CHANGED
|
@@ -107,8 +107,42 @@ function makeSendQueueWatcher({ log, getTransportSnapshot }) {
|
|
|
107
107
|
let lowestSinceDrain = Number.POSITIVE_INFINITY;
|
|
108
108
|
let stuckSince = 0;
|
|
109
109
|
let previous = null;
|
|
110
|
+
// Independent of the queue: the transport's own counters, sampled for as
|
|
111
|
+
// long as the channel is open. The queue was the wrong thing to watch —
|
|
112
|
+
// field 2026-08-06, a 9.26 MB segment was accepted by the transport with
|
|
113
|
+
// `maxBuffered=0 bufferedAtEnd=0`, reported as sent at 274 Mbit/s, and
|
|
114
|
+
// never arrived; everything the proxy sent from that moment on was lost the
|
|
115
|
+
// same way while requests kept coming the other direction. With nothing
|
|
116
|
+
// queued this watcher never woke, so the one question that matters — did
|
|
117
|
+
// those bytes leave the machine — has no answer in the log. It does now.
|
|
118
|
+
let heartbeatPrevious = null;
|
|
119
|
+
let heartbeatAt = 0;
|
|
110
120
|
|
|
111
121
|
const timer = setInterval(() => {
|
|
122
|
+
const sampledAt = Date.now();
|
|
123
|
+
if (sampledAt - heartbeatAt >= TRANSPORT_HEARTBEAT_MS) {
|
|
124
|
+
heartbeatAt = sampledAt;
|
|
125
|
+
const snapshot = getTransportSnapshot?.(sessionId) ?? null;
|
|
126
|
+
if (snapshot) {
|
|
127
|
+
const sent = heartbeatPrevious ? snapshot.bytesSent - heartbeatPrevious.bytesSent : null;
|
|
128
|
+
const received = heartbeatPrevious
|
|
129
|
+
? snapshot.bytesReceived - heartbeatPrevious.bytesReceived
|
|
130
|
+
: null;
|
|
131
|
+
heartbeatPrevious = snapshot;
|
|
132
|
+
let depth = 0;
|
|
133
|
+
try {
|
|
134
|
+
depth = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
|
|
135
|
+
} catch {
|
|
136
|
+
depth = -1;
|
|
137
|
+
}
|
|
138
|
+
log(
|
|
139
|
+
`[dc-transport] ${tag} "${label}" sent=${snapshot.bytesSent}` +
|
|
140
|
+
`${sent === null ? "" : ` (+${sent})`} received=${snapshot.bytesReceived}` +
|
|
141
|
+
`${received === null ? "" : ` (+${received})`} queued=${depth}B ` +
|
|
142
|
+
`rtt=${snapshot.rtt}ms pc=${snapshot.state} ice=${snapshot.iceState} pair=${snapshot.pair}`
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
112
146
|
let queued = 0;
|
|
113
147
|
try {
|
|
114
148
|
queued = typeof channel.bufferedAmount === "function" ? channel.bufferedAmount() : 0;
|
|
@@ -630,6 +664,10 @@ const PARTIAL_REQUEST_TTL_MS = 60_000;
|
|
|
630
664
|
// well under a second on the LAN — and short enough that a stuck channel is
|
|
631
665
|
// named while the viewer is still looking at it.
|
|
632
666
|
const SEND_QUEUE_SAMPLE_MS = 1_000;
|
|
667
|
+
// How often the transport's own counters are written to the log, whatever the
|
|
668
|
+
// send queue is doing. Frequent enough to place a loss within a few seconds,
|
|
669
|
+
// sparse enough that a two-hour film costs a few hundred lines.
|
|
670
|
+
const TRANSPORT_HEARTBEAT_MS = 5_000;
|
|
633
671
|
const SEND_QUEUE_STUCK_MS = 5_000;
|
|
634
672
|
const DC_BUFFER_HIGH_WATER = 8 * 1024 * 1024;
|
|
635
673
|
/** Resume sending once the channel buffer drains to this many bytes. */
|
|
@@ -36,6 +36,33 @@ import {
|
|
|
36
36
|
} from "./ffmpeg-banner.js";
|
|
37
37
|
import { resolveSegmentFormat, SEGMENT_FORMAT_IDS } from "./segment-formats/index.js";
|
|
38
38
|
|
|
39
|
+
/**
|
|
40
|
+
* Whether an encoder run died because its INPUT went away, rather than because
|
|
41
|
+
* of anything about the encode itself.
|
|
42
|
+
*
|
|
43
|
+
* These are the messages the read path and ffmpeg's HTTP client produce when
|
|
44
|
+
* the torrent is gone, being re-added, or has no data for the range yet — all
|
|
45
|
+
* of them temporary by nature: the source can be added again and the pieces
|
|
46
|
+
* fetched again.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} message
|
|
49
|
+
* @returns {boolean}
|
|
50
|
+
*/
|
|
51
|
+
export function isInputUnavailable(message) {
|
|
52
|
+
const text = typeof message === "string" ? message : "";
|
|
53
|
+
return (
|
|
54
|
+
/Error reading HTTP response/i.test(text) ||
|
|
55
|
+
/not found in (?:magnet|torrent):/i.test(text) ||
|
|
56
|
+
/Unknown source/i.test(text) ||
|
|
57
|
+
/is gone and cannot be re-added/i.test(text) ||
|
|
58
|
+
/Read error at pos/i.test(text) ||
|
|
59
|
+
/Server returned 5\d\d/i.test(text) ||
|
|
60
|
+
/Input\/output error/i.test(text) ||
|
|
61
|
+
/Connection reset by peer/i.test(text) ||
|
|
62
|
+
/End of file/i.test(text)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
39
66
|
const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
40
67
|
const CLEANUP_INTERVAL_MS = 30_000;
|
|
41
68
|
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
@@ -130,6 +157,13 @@ const SEEK_FAST_FAIL_MS = 2_000;
|
|
|
130
157
|
// for whatever residual case still fails — not a second competing "fix" that
|
|
131
158
|
// blindly retries the identical command hoping for a different result.
|
|
132
159
|
const MAX_SEEK_FAILURES = 3;
|
|
160
|
+
// A run that lost its INPUT is retried rather than condemned: the torrent can
|
|
161
|
+
// be added again and the pieces downloaded again, so the data being gone is a
|
|
162
|
+
// wait, not a verdict. Backed off so a source that is truly unavailable costs a
|
|
163
|
+
// process every few seconds rather than continuously, and never given up on —
|
|
164
|
+
// the session's own idle TTL is what ends it if the viewer leaves.
|
|
165
|
+
const INPUT_RETRY_BASE_MS = 2_000;
|
|
166
|
+
const INPUT_RETRY_MAX_MS = 15_000;
|
|
133
167
|
// Idle TTL: a session is disposed this long after the last segment/playlist
|
|
134
168
|
// access. Long enough that a viewer who pauses, backgrounds the tab, or briefly
|
|
135
169
|
// turns the phone off can resume WITHOUT a cold ffmpeg restart (the warm session
|
|
@@ -2506,6 +2540,45 @@ export class HlsSessionManager {
|
|
|
2506
2540
|
session.seekFailureTarget = -1;
|
|
2507
2541
|
session.seekFailureCount = 0;
|
|
2508
2542
|
}
|
|
2543
|
+
// Losing the INPUT is not the session failing — it is the data not being
|
|
2544
|
+
// there YET. The torrent can be re-added and re-downloaded, so the
|
|
2545
|
+
// honest answer to the viewer is "still working", not an error screen.
|
|
2546
|
+
// Field 2026-08-06: a torrent evicted mid-seek took the film with it, the
|
|
2547
|
+
// run died on `File 0 not found`, the session went terminal and answered
|
|
2548
|
+
// 500 to every request from then on — although the swarm was there and
|
|
2549
|
+
// the data would have come back in seconds. The circuit breaker below
|
|
2550
|
+
// stays for what it was built for, a target that genuinely cannot be
|
|
2551
|
+
// encoded; it must not condemn a session whose data merely went away.
|
|
2552
|
+
if (isInputUnavailable(session.lastError)) {
|
|
2553
|
+
session.state = "recovering";
|
|
2554
|
+
// On the wire it is simply "not ready yet" — a state the browser has
|
|
2555
|
+
// always known how to wait through. Only the proxy needs the
|
|
2556
|
+
// distinction between waiting for data and having given up.
|
|
2557
|
+
session.progress.state = "starting";
|
|
2558
|
+
session.progress.updatedAt = Date.now();
|
|
2559
|
+
session.inputRetryCount = (session.inputRetryCount ?? 0) + 1;
|
|
2560
|
+
const delayMs = Math.min(
|
|
2561
|
+
INPUT_RETRY_MAX_MS,
|
|
2562
|
+
INPUT_RETRY_BASE_MS * 2 ** Math.min(session.inputRetryCount - 1, 6)
|
|
2563
|
+
);
|
|
2564
|
+
logger.warn(
|
|
2565
|
+
`transcode ${session.id} ${session.runLabel ?? "run#?"} lost its input ` +
|
|
2566
|
+
`(${session.lastError}); retrying in ${Math.round(delayMs / 1000)}s ` +
|
|
2567
|
+
`(attempt ${session.inputRetryCount})`
|
|
2568
|
+
);
|
|
2569
|
+
session.inputRetryTimer = setTimeout(() => {
|
|
2570
|
+
session.inputRetryTimer = null;
|
|
2571
|
+
if (session.state !== "recovering") {
|
|
2572
|
+
return;
|
|
2573
|
+
}
|
|
2574
|
+
const at = Number.isInteger(session.lastRequestedSegment)
|
|
2575
|
+
? session.lastRequestedSegment
|
|
2576
|
+
: (session.encodeStartIndex ?? 0);
|
|
2577
|
+
this.#startEncodeRun(session, at).catch(() => {});
|
|
2578
|
+
}, delayMs);
|
|
2579
|
+
session.inputRetryTimer.unref?.();
|
|
2580
|
+
return;
|
|
2581
|
+
}
|
|
2509
2582
|
session.state = "failed";
|
|
2510
2583
|
session.progress.state = "failed";
|
|
2511
2584
|
session.progress.updatedAt = Date.now();
|
|
@@ -2949,6 +3022,12 @@ export class HlsSessionManager {
|
|
|
2949
3022
|
if (!session || !isSafeFileName(fileName, session.segmentFormat)) {
|
|
2950
3023
|
return { kind: "not-found" };
|
|
2951
3024
|
}
|
|
3025
|
+
if (session.state === "recovering") {
|
|
3026
|
+
// The data went away and is being fetched again. Holding the request is
|
|
3027
|
+
// the truthful answer: nothing is broken and there is nothing for the
|
|
3028
|
+
// viewer to retry.
|
|
3029
|
+
return { kind: "warming-up" };
|
|
3030
|
+
}
|
|
2952
3031
|
if (session.state === "failed") {
|
|
2953
3032
|
return {
|
|
2954
3033
|
kind: "failed",
|
|
@@ -3064,6 +3143,9 @@ export class HlsSessionManager {
|
|
|
3064
3143
|
// — the time from session-create entry to a playable first segment.
|
|
3065
3144
|
if (!isPlaylist && !session.firstSegmentLogged) {
|
|
3066
3145
|
session.firstSegmentLogged = true;
|
|
3146
|
+
// Data is flowing again, so the next loss starts its backoff afresh
|
|
3147
|
+
// rather than inheriting the delay of the last one.
|
|
3148
|
+
session.inputRetryCount = 0;
|
|
3067
3149
|
this.#rememberFirstSegmentLatency(Date.now() - session.createEntryMs);
|
|
3068
3150
|
logger.info(
|
|
3069
3151
|
`cold-start ${sessionId.slice(0, 8)}: first-segment ready +${Date.now() - session.createEntryMs}ms`
|
|
@@ -3262,6 +3344,10 @@ export class HlsSessionManager {
|
|
|
3262
3344
|
clearTimeout(session.seekSettleTimer);
|
|
3263
3345
|
session.seekSettleTimer = null;
|
|
3264
3346
|
}
|
|
3347
|
+
if (session.inputRetryTimer) {
|
|
3348
|
+
clearTimeout(session.inputRetryTimer);
|
|
3349
|
+
session.inputRetryTimer = null;
|
|
3350
|
+
}
|
|
3265
3351
|
|
|
3266
3352
|
this.#resumeEncoder(session, "session disposed");
|
|
3267
3353
|
if (session.ffmpeg && !session.ffmpeg.killed) {
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file Telling "the data is not here yet" from "this cannot be encoded".
|
|
3
|
+
*
|
|
4
|
+
* A run that dies because its input went away used to condemn the whole
|
|
5
|
+
* session: state `failed`, and every request for the playlist answered 500
|
|
6
|
+
* from then on. But the torrent can be added again and the pieces downloaded
|
|
7
|
+
* again, so the data being gone is a wait, not a verdict — measured 2026-08-06,
|
|
8
|
+
* a torrent evicted mid-seek killed a session whose swarm was right there and
|
|
9
|
+
* whose data would have come back in seconds.
|
|
10
|
+
*
|
|
11
|
+
* The classification is what decides which of the two happened, so it is tested
|
|
12
|
+
* on the exact messages the field produced.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import test from "node:test";
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import { isInputUnavailable } from "../services/hls-session-manager.js";
|
|
18
|
+
|
|
19
|
+
test("the messages the field produced when data went away are all temporary", () => {
|
|
20
|
+
for (const message of [
|
|
21
|
+
"[http @ 0x7f99b19f00] Error reading HTTP response: End of file",
|
|
22
|
+
"File 0 not found in torrent:17c46f5c36b94865858bfeaa412693c097328a50.",
|
|
23
|
+
"File 5 not found in magnet:8d3b6c2e74df473e3649521f927ae33b20cd9e67.",
|
|
24
|
+
"Unknown source 9711bbde2debdcd0d1fbd8cf88d68fd9612e5d31.",
|
|
25
|
+
"[in#0/matroska,webm @ 0x7f9005f340] Read error at pos. 138556 (0x21d3c)",
|
|
26
|
+
"Server returned 503 Service Unavailable",
|
|
27
|
+
"Input/output error"
|
|
28
|
+
]) {
|
|
29
|
+
assert.equal(isInputUnavailable(message), true, `should be retried: ${message}`);
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test("a real encoding failure is not mistaken for one", () => {
|
|
34
|
+
for (const message of [
|
|
35
|
+
"Cannot write moov atom before AC3 packets. Set the delay_moov flag to fix this.",
|
|
36
|
+
"Could not write header (incorrect codec parameters ?): Invalid argument",
|
|
37
|
+
"Unknown encoder 'h264_v4l2m2m'",
|
|
38
|
+
"ffmpeg exited with code 1"
|
|
39
|
+
]) {
|
|
40
|
+
assert.equal(isInputUnavailable(message), false, `should stay terminal: ${message}`);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
test("nothing at all is not a reason to retry for ever", () => {
|
|
45
|
+
assert.equal(isInputUnavailable(""), false);
|
|
46
|
+
assert.equal(isInputUnavailable(undefined), false);
|
|
47
|
+
assert.equal(isInputUnavailable(null), false);
|
|
48
|
+
});
|