@torrent-tv/proxy 2.45.0 → 2.47.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/CHANGELOG.md +10 -0
- package/assets/calibration/NOTICE.md +40 -3
- package/assets/calibration/cal-hevc-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-hevc-480-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc-480-lo.mp4 +0 -0
- package/assets/calibration/cal-hevc10-1080-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc10-1080-lo.mp4 +0 -0
- package/assets/calibration/cal-hevc10-480-hi.mp4 +0 -0
- package/assets/calibration/cal-hevc10-480-lo.mp4 +0 -0
- package/package.json +1 -1
- package/services/decode-cost-fit.js +37 -5
- package/services/ffmpeg-banner.js +45 -0
- package/services/hls-session-manager.js +11 -3
- package/services/hwaccel.js +80 -12
- package/services/playback-planner.js +8 -1
- package/services/tunnel-client.js +90 -18
- package/test/decode-cost.test.js +65 -1
- package/test/tunnel-renewal.test.js +126 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,13 @@
|
|
|
1
|
+
## 2.47.0
|
|
2
|
+
|
|
3
|
+
- **Fix**: The tunnel is replaced before anything upstream ends it, so a viewer no longer arrives to find no proxy. Something between the proxy and the server closes the socket after exactly **100 min 15 s** — measured across a day of logs 2026-08-20, three intervals of 100:15 wherever a restart did not reset the clock, `code=1006` each time, and with the 30 s keepalive running throughout, so it is a lifetime cap and not an idle timeout. Reconnecting afterwards takes five seconds during which this proxy does not exist as far as the registry is concerned. The connection is now replaced at ninety minutes and the replacement takes over FIRST: the new socket registers itself, the server atomically supersedes the old one, and only then does the old one close — so there is no instant with nothing registered. A socket that finds itself superseded says so rather than reporting the tunnel as down, and an abrupt close nobody asked for still reconnects as before. Pinned by a test against a real WebSocket server.
|
|
4
|
+
|
|
5
|
+
## 2.46.0
|
|
6
|
+
|
|
7
|
+
- **New**: The cost of DECODING is measured per codec family, not once on H.264. A video that has to be re-encoded is by definition one the browser could not play — HEVC, 10-bit — so the one model the host had was fitted on the codec it is least often asked about, and those decode dearer per pixel on the same box. There are now sets for HEVC 8-bit and HEVC Main 10 beside the H.264 one (`assets/calibration/`, four clips each: two sizes at two bitrates, the smallest grid that keeps the axes independent and still leaves a spare), the source's own codec and bit depth choose the constants, and a family with no set of its own is priced as H.264 — said in the log rather than left to be inferred. Measured on a desktop 2026-08-20, the same 1080p picture at ~5.8 Mbit/s: 7.7x as 8-bit HEVC against 6.3x as 10-bit, which is why ten bits is its own family and not a multiplier. AV1 has no set yet; the release survey of 2026-07-10 found it rare where HEVC was 18 %.
|
|
8
|
+
- **New**: The video's bit depth is read from the probe (`parseFfmpegBitDepth`), and it travels with the source's pixel and bit rates because it decides which measurement of this host applies.
|
|
9
|
+
- **Chore**: When the H.264 clips do not fit, the line now says which families did. Measured on a fast desktop the same day, the H.264 readings stopped being ordered — 1080p at 9.35 Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is not a thing a decoder does — so a failure there is a measurement problem and not a missing file, and the two have to be tellable apart.
|
|
10
|
+
|
|
1
11
|
## 2.45.0
|
|
2
12
|
|
|
3
13
|
- **Fix**: A run is cut where the PLAYER was told the cuts are. There are two boundary tables — the live one, corrected as produced segments reveal where the file's cuts truly are, and the one the playlist text was written from — and a player places every fragment by the text it holds, which never changes. The cut list handed to ffmpeg came from the live table, so every correction moved the run away from the timeline the player is reading: measured 2026-08-20 on `Minions.and.Monsters.1080p.mkv`, 119 of 125 produced segments arrived a uniform 2.002 s before the times their playlist named, against the 0.5 s hls.js bridges, and a fragment that does not land is fetched again — on 2026-08-17 two of them 1908 times each. A seek is resolved on the same table for the same reason: the time being resolved came out of that playlist. The corrections keep their purpose, which is to describe the FILE — a variant created later inherits the corrected table and publishes it, so its own playlist and its own cuts agree from the start — but they may no longer move the cuts of a session already being read.
|
|
@@ -58,6 +58,43 @@ ffmpeg's own output rather than from this table. Two rules hold, though:
|
|
|
58
58
|
- **the axes stay independent** — every size at both bitrates. Dropping one cell
|
|
59
59
|
reintroduces the collinearity that produced the zeros above.
|
|
60
60
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
61
|
+
## One set per codec family
|
|
62
|
+
|
|
63
|
+
A video that has to be RE-ENCODED is by definition one the browser could not
|
|
64
|
+
play, which is to say HEVC, 10-bit or AV1 — so the model was fitted on the one
|
|
65
|
+
codec it is least often asked about. There is now a set per family, and the
|
|
66
|
+
source's own codec chooses the constants.
|
|
67
|
+
|
|
68
|
+
| file | codec | resolution | bitrate |
|
|
69
|
+
|---|---|---|---|
|
|
70
|
+
| `cal-hevc-1080-hi.mp4` | HEVC Main, 8-bit | 1920×1080 | 5.78 Mbit/s |
|
|
71
|
+
| `cal-hevc-1080-lo.mp4` | HEVC Main, 8-bit | 1920×1080 | 0.52 Mbit/s |
|
|
72
|
+
| `cal-hevc-480-hi.mp4` | HEVC Main, 8-bit | 854×480 | 7.76 Mbit/s |
|
|
73
|
+
| `cal-hevc-480-lo.mp4` | HEVC Main, 8-bit | 854×480 | 0.74 Mbit/s |
|
|
74
|
+
| `cal-hevc10-1080-hi.mp4` | HEVC Main 10 | 1920×1080 | 5.82 Mbit/s |
|
|
75
|
+
| `cal-hevc10-1080-lo.mp4` | HEVC Main 10 | 1920×1080 | 0.52 Mbit/s |
|
|
76
|
+
| `cal-hevc10-480-hi.mp4` | HEVC Main 10 | 854×480 | 6.95 Mbit/s |
|
|
77
|
+
| `cal-hevc10-480-lo.mp4` | HEVC Main 10 | 854×480 | 0.68 Mbit/s |
|
|
78
|
+
|
|
79
|
+
Four per family, not six: two sizes at two bitrates is the smallest grid that
|
|
80
|
+
keeps the axes independent and still leaves one spare measurement, and every
|
|
81
|
+
clip costs the startup about two seconds. They are one second long rather than
|
|
82
|
+
two — the benchmark loops the clip and measures a slope over about a second of
|
|
83
|
+
decoding, so length beyond that buys nothing and only makes the package larger.
|
|
84
|
+
|
|
85
|
+
They were cut from `cal-h264-1080-hi.mp4` rather than from the 4K master, which
|
|
86
|
+
is not in this repository. Re-encoding an already-compressed picture softens its
|
|
87
|
+
grain slightly; what is being measured is the cost of decoding the new
|
|
88
|
+
bitstream, and the grain that survives is the same material in every set, which
|
|
89
|
+
is what makes the families comparable to each other.
|
|
90
|
+
|
|
91
|
+
10-bit is its own family rather than a multiplier on the 8-bit one. Wider
|
|
92
|
+
samples mean wider arithmetic, and how much that costs is a property of the
|
|
93
|
+
machine — which is the thing being measured. Measured on a desktop 2026-08-20,
|
|
94
|
+
1080p at ~5.8 Mbit/s: 7.7x as 8-bit HEVC and 6.3x as 10-bit, so it is a real
|
|
95
|
+
difference and not a rounding.
|
|
96
|
+
|
|
97
|
+
AV1 has no set yet. The release survey of 2026-07-10 found it rare where HEVC
|
|
98
|
+
was 18 %, so it is priced as H.264 until it is worth the startup seconds — and
|
|
99
|
+
the log says which families were measured and which are falling back, rather
|
|
100
|
+
than leaving it to be inferred.
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/package.json
CHANGED
|
@@ -315,17 +315,49 @@ function invert(matrix) {
|
|
|
315
315
|
return work.map((row) => row.slice(size));
|
|
316
316
|
}
|
|
317
317
|
|
|
318
|
+
/**
|
|
319
|
+
* Which measured family prices a source of this codec and depth.
|
|
320
|
+
*
|
|
321
|
+
* The families are the ones there are clips for. Anything else is priced as
|
|
322
|
+
* H.264 — not because it decodes like H.264, but because that is the only
|
|
323
|
+
* measurement of this host there is, and saying so in one place beats each
|
|
324
|
+
* caller inventing its own fallback.
|
|
325
|
+
*
|
|
326
|
+
* 10-bit is its own family rather than a multiplier on the 8-bit one: the
|
|
327
|
+
* samples are wider, the arithmetic is wider, and how much that costs is a
|
|
328
|
+
* property of the machine, which is what is being measured.
|
|
329
|
+
*
|
|
330
|
+
* @param {{ codec?: string | null, bitDepth?: number | null }} source
|
|
331
|
+
* @returns {string}
|
|
332
|
+
*/
|
|
333
|
+
export function decodeFamilyOf(source) {
|
|
334
|
+
const codec = typeof source?.codec === "string" ? source.codec.trim().toLowerCase() : "";
|
|
335
|
+
const depth = Number(source?.bitDepth);
|
|
336
|
+
const tenBit = Number.isFinite(depth) && depth >= 10;
|
|
337
|
+
if (codec === "hevc" || codec === "h265" || codec === "x265") {
|
|
338
|
+
return tenBit ? "hevc10" : "hevc";
|
|
339
|
+
}
|
|
340
|
+
return "h264";
|
|
341
|
+
}
|
|
342
|
+
|
|
318
343
|
/**
|
|
319
344
|
* What a model prices one source at.
|
|
320
345
|
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
346
|
+
* The model may carry a set of per-family fits (`families`) alongside the H.264
|
|
347
|
+
* terms it also spreads at the top level, which is what a caller that knows
|
|
348
|
+
* nothing about codecs still reads. When the source names a codec and that
|
|
349
|
+
* family was measured, its own constants are used.
|
|
350
|
+
*
|
|
351
|
+
* @param {DecodeCostModel & { families?: Record<string, DecodeCostModel> }} model
|
|
352
|
+
* @param {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec?: string | null, bitDepth?: number | null }} source
|
|
323
353
|
* @returns {number} Seconds of work per second of content.
|
|
324
354
|
*/
|
|
325
355
|
export function decodeCostOf(model, source) {
|
|
356
|
+
const family = decodeFamilyOf(source);
|
|
357
|
+
const terms = model?.families?.[family] ?? model;
|
|
326
358
|
return (
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
359
|
+
terms.pixelTerm * source.megapixelsPerSecond +
|
|
360
|
+
terms.bitrateTerm * source.megabitsPerSecond +
|
|
361
|
+
terms.constantTerm
|
|
330
362
|
);
|
|
331
363
|
}
|
|
@@ -165,3 +165,48 @@ export function parseFfmpegHdr(stderrText) {
|
|
|
165
165
|
// the reliable HDR signal.
|
|
166
166
|
return /\b(smpte2084|arib-std-b67|arib_std_b67)\b/i.test(videoLine[0]);
|
|
167
167
|
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* How many bits per sample the video carries, from the pixel format ffmpeg
|
|
171
|
+
* prints on its `Video:` line.
|
|
172
|
+
*
|
|
173
|
+
* This is a decode-cost input, not a colour one: a 10-bit stream holds wider
|
|
174
|
+
* samples and is decoded with wider arithmetic, and how much that costs is a
|
|
175
|
+
* property of the machine. It is therefore its own calibration family rather
|
|
176
|
+
* than a multiplier on the 8-bit one.
|
|
177
|
+
*
|
|
178
|
+
* ffmpeg names the depth in the format itself — `yuv420p10le`, `yuv420p12le`,
|
|
179
|
+
* `p010le` — and omits it at 8 bits (`yuv420p`, `nv12`). An unreadable line
|
|
180
|
+
* gives null, and the caller then prices the source as 8-bit, which is what
|
|
181
|
+
* every source was priced as before this existed.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} stderrText
|
|
184
|
+
* @returns {number | null}
|
|
185
|
+
*/
|
|
186
|
+
export function parseFfmpegBitDepth(stderrText) {
|
|
187
|
+
if (typeof stderrText !== "string" || stderrText.length === 0) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const videoLine = stderrText.match(/Video:[^\n]*/i);
|
|
191
|
+
if (!videoLine) {
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
// The pixel format is the token after the codec and its tag, and the depth
|
|
195
|
+
// rides on its name. `p010`/`p016` are the two that state it without a `p`
|
|
196
|
+
// separator, and they are 10 and 16 bits.
|
|
197
|
+
const named = videoLine[0].match(/\byuv[a-z0-9]*?p(\d{1,2})(?:le|be)\b/i);
|
|
198
|
+
if (named) {
|
|
199
|
+
const depth = Number(named[1]);
|
|
200
|
+
return Number.isFinite(depth) && depth >= 8 ? depth : null;
|
|
201
|
+
}
|
|
202
|
+
if (/\bp010(?:le|be)?\b/i.test(videoLine[0])) {
|
|
203
|
+
return 10;
|
|
204
|
+
}
|
|
205
|
+
if (/\bp016(?:le|be)?\b/i.test(videoLine[0])) {
|
|
206
|
+
return 16;
|
|
207
|
+
}
|
|
208
|
+
if (/\byuv[a-z0-9]*p\b|\bnv12\b|\bnv21\b|\bgbrp\b/i.test(videoLine[0])) {
|
|
209
|
+
return 8;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
@@ -180,8 +180,13 @@ export function variantHeightsFor(sourceHeight) {
|
|
|
180
180
|
* Returns null when the probe did not report enough — the budget then prices
|
|
181
181
|
* the encoder alone rather than inventing a figure.
|
|
182
182
|
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
183
|
+
* The codec and bit depth travel with the rates because they decide WHICH
|
|
184
|
+
* measurement of this host applies: the model is fitted per codec family, and a
|
|
185
|
+
* video that has to be re-encoded is by definition one the browser could not
|
|
186
|
+
* play — HEVC, 10-bit — which is exactly where H.264 constants are wrong.
|
|
187
|
+
*
|
|
188
|
+
* @param {{ width: number | null, height: number | null, fps: number | null, bitrateKbps: number | null, codec?: string | null, bitDepth?: number | null }} mediaInfo
|
|
189
|
+
* @returns {{ megapixelsPerSecond: number, megabitsPerSecond: number, codec: string, bitDepth: number | null } | null}
|
|
185
190
|
*/
|
|
186
191
|
export function sourceDecodeCharacteristics(mediaInfo) {
|
|
187
192
|
const width = Number(mediaInfo?.width);
|
|
@@ -191,9 +196,12 @@ export function sourceDecodeCharacteristics(mediaInfo) {
|
|
|
191
196
|
if (!(width > 0) || !(height > 0) || !(fps > 0) || !(kbps > 0)) {
|
|
192
197
|
return null;
|
|
193
198
|
}
|
|
199
|
+
const depth = Number(mediaInfo?.bitDepth);
|
|
194
200
|
return {
|
|
195
201
|
megapixelsPerSecond: (width * height * fps) / 1e6,
|
|
196
|
-
megabitsPerSecond: kbps / 1000
|
|
202
|
+
megabitsPerSecond: kbps / 1000,
|
|
203
|
+
codec: typeof mediaInfo?.codec === "string" ? mediaInfo.codec : "",
|
|
204
|
+
bitDepth: Number.isFinite(depth) && depth > 0 ? depth : null
|
|
197
205
|
};
|
|
198
206
|
}
|
|
199
207
|
|
package/services/hwaccel.js
CHANGED
|
@@ -683,14 +683,41 @@ export async function detectTonemapSupport({ ffmpegBin, logger }) {
|
|
|
683
683
|
// optimistic. Six points leave three spare, so the fit has a residual, and a
|
|
684
684
|
// term the data does not determine can be refused instead of published as a
|
|
685
685
|
// zero that looks measured. See `assets/calibration/NOTICE.md`.
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
686
|
+
//
|
|
687
|
+
// One set PER CODEC FAMILY, because a family is what the model describes. The
|
|
688
|
+
// fit used to be H.264 only, while a video that has to be RE-ENCODED is by
|
|
689
|
+
// definition one the browser could not play — which is to say HEVC, 10-bit or
|
|
690
|
+
// AV1 — and those decode dearer per pixel on the same box. Pricing them with
|
|
691
|
+
// H.264 constants is the one case the model is always asked about and was never
|
|
692
|
+
// measured on.
|
|
693
|
+
//
|
|
694
|
+
// A family that has no set of its own is priced with H.264's, which is what
|
|
695
|
+
// happened to every family before this; the line says so rather than implying
|
|
696
|
+
// it. AV1 has no set yet: the survey of 2026-07-10 found it rare where HEVC was
|
|
697
|
+
// 18 % of releases, so it waits for the same treatment.
|
|
698
|
+
const CALIBRATION_SETS = {
|
|
699
|
+
h264: [
|
|
700
|
+
"cal-h264-1080-hi.mp4",
|
|
701
|
+
"cal-h264-1080-lo.mp4",
|
|
702
|
+
"cal-h264-720-hi.mp4",
|
|
703
|
+
"cal-h264-720-lo.mp4",
|
|
704
|
+
"cal-h264-480-hi.mp4",
|
|
705
|
+
"cal-h264-480-lo.mp4"
|
|
706
|
+
],
|
|
707
|
+
hevc: [
|
|
708
|
+
"cal-hevc-1080-hi.mp4",
|
|
709
|
+
"cal-hevc-1080-lo.mp4",
|
|
710
|
+
"cal-hevc-480-hi.mp4",
|
|
711
|
+
"cal-hevc-480-lo.mp4"
|
|
712
|
+
],
|
|
713
|
+
hevc10: [
|
|
714
|
+
"cal-hevc10-1080-hi.mp4",
|
|
715
|
+
"cal-hevc10-1080-lo.mp4",
|
|
716
|
+
"cal-hevc10-480-hi.mp4",
|
|
717
|
+
"cal-hevc10-480-lo.mp4"
|
|
718
|
+
]
|
|
719
|
+
};
|
|
720
|
+
const CALIBRATION_CLIPS = CALIBRATION_SETS.h264;
|
|
694
721
|
const CALIBRATION_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "assets", "calibration");
|
|
695
722
|
// How wide the measured window must be before the slope is trusted, and how
|
|
696
723
|
// long to wait for it at most. A second of decoding is thousands of frames on a
|
|
@@ -833,13 +860,54 @@ export async function benchmarkContention({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
833
860
|
|
|
834
861
|
export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBRATION_DIR }) {
|
|
835
862
|
const log = logger ?? { info: () => {}, warn: () => {} };
|
|
863
|
+
const startedAllAt = Date.now();
|
|
864
|
+
/** @type {Record<string, { pixelTerm: number, bitrateTerm: number, constantTerm: number }>} */
|
|
865
|
+
const families = {};
|
|
866
|
+
for (const [family, clips] of Object.entries(CALIBRATION_SETS)) {
|
|
867
|
+
const fitted = await fitOneFamily({ ffmpegBin, log, clipsDir, family, clips });
|
|
868
|
+
if (fitted) {
|
|
869
|
+
families[family] = fitted;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (!families.h264) {
|
|
873
|
+
// H.264 is the family every other one falls back to, so without it there is
|
|
874
|
+
// no model at all rather than a partial one. Which families DID fit is said
|
|
875
|
+
// anyway: on a fast host the H.264 clips decode at 20-80x and the readings
|
|
876
|
+
// stop being ordered — measured 2026-08-20 on a desktop, 1080p at 9.35
|
|
877
|
+
// Mbit/s costing 0.0307 s/s against 720p at 9.94 costing 0.0472, which is
|
|
878
|
+
// not a thing a decoder does — so a failure here is a measurement problem
|
|
879
|
+
// and not a missing file, and the line has to let those be told apart.
|
|
880
|
+
log.warn(
|
|
881
|
+
"hwaccel: decode cost unknown — the H.264 clips did not fit" +
|
|
882
|
+
(Object.keys(families).length > 0
|
|
883
|
+
? `, though ${Object.keys(families).join(" and ")} did`
|
|
884
|
+
: "")
|
|
885
|
+
);
|
|
886
|
+
return null;
|
|
887
|
+
}
|
|
888
|
+
const missing = Object.keys(CALIBRATION_SETS).filter((family) => !families[family]);
|
|
889
|
+
log.info(
|
|
890
|
+
`hwaccel: decode cost measured for ${Object.keys(families).join(", ")}` +
|
|
891
|
+
(missing.length > 0 ? `; ${missing.join(" and ")} priced as H.264` : "") +
|
|
892
|
+
` (in ${((Date.now() - startedAllAt) / 1000).toFixed(1)}s)`
|
|
893
|
+
);
|
|
894
|
+
return { families, ...families.h264 };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/**
|
|
898
|
+
* Fit one codec family's decode cost from its own clips.
|
|
899
|
+
*
|
|
900
|
+
* @param {{ ffmpegBin: string, log: { info: Function, warn: Function }, clipsDir: string, family: string, clips: string[] }} params
|
|
901
|
+
* @returns {Promise<{ pixelTerm: number, bitrateTerm: number, constantTerm: number } | null>}
|
|
902
|
+
*/
|
|
903
|
+
async function fitOneFamily({ ffmpegBin, log, clipsDir, family, clips }) {
|
|
836
904
|
const startedAllAt = Date.now();
|
|
837
905
|
/** @type {Array<{ megapixelsPerSecond: number, megabitsPerSecond: number, costSecondsPerSecond: number }>} */
|
|
838
906
|
const samples = [];
|
|
839
|
-
for (const clip of
|
|
907
|
+
for (const clip of clips) {
|
|
840
908
|
const measured = await measureDecodeSlope(ffmpegBin, path.join(clipsDir, clip));
|
|
841
909
|
if (!measured) {
|
|
842
|
-
log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing;
|
|
910
|
+
log.warn(`hwaccel: decode benchmark "${clip}" failed or said nothing; ${family} not measured`);
|
|
843
911
|
return null;
|
|
844
912
|
}
|
|
845
913
|
const cost = 1 / measured.speed;
|
|
@@ -856,11 +924,11 @@ export async function benchmarkDecodeCost({ ffmpegBin, logger, clipsDir = CALIBR
|
|
|
856
924
|
}
|
|
857
925
|
const fitted = fitDecodeCost(samples);
|
|
858
926
|
if (!fitted) {
|
|
859
|
-
log.warn(
|
|
927
|
+
log.warn(`hwaccel: ${family} decode cost could not be fitted to these measurements`);
|
|
860
928
|
return null;
|
|
861
929
|
}
|
|
862
930
|
log.info(
|
|
863
|
-
`hwaccel: decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
|
|
931
|
+
`hwaccel: ${family} decode cost = ${fitted.pixelTerm.toFixed(6)} × Mpx/s + ${fitted.bitrateTerm.toFixed(6)} × Mbit/s ` +
|
|
864
932
|
`+ ${fitted.constantTerm.toFixed(4)} s/s (${fitted.shape} from ${fitted.samples} clips, ` +
|
|
865
933
|
`typical disagreement ${fitted.residualRms.toFixed(4)} s/s` +
|
|
866
934
|
// Named rather than implied: a zero in the line above means "not
|
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
parseFfmpegDurationSeconds,
|
|
14
14
|
parseFfmpegStartTimeSeconds,
|
|
15
15
|
parseFfmpegVideoDimensions,
|
|
16
|
+
parseFfmpegBitDepth,
|
|
16
17
|
parseFfmpegBitrateKbps,
|
|
17
18
|
parseFfmpegVideoFps,
|
|
18
19
|
parseFfmpegHdr
|
|
@@ -534,6 +535,11 @@ export function createPlaybackPlanner({
|
|
|
534
535
|
height: videoHeight,
|
|
535
536
|
fps: parseFfmpegVideoFps(probe.stderr),
|
|
536
537
|
bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
|
|
538
|
+
// Which family of the decode measurement prices this source. A video
|
|
539
|
+
// that has to be re-encoded is one the browser could not play, so it
|
|
540
|
+
// is usually NOT H.264, and H.264 constants are wrong for it.
|
|
541
|
+
codec: videoCodec,
|
|
542
|
+
bitDepth: parseFfmpegBitDepth(probe.stderr),
|
|
537
543
|
// Which file this is, so the offer can be answered from what an
|
|
538
544
|
// encoder has already learned about THIS source rather than from the
|
|
539
545
|
// startup clips — the same correction a live session applies.
|
|
@@ -569,7 +575,8 @@ export function createPlaybackPlanner({
|
|
|
569
575
|
bitrateKbps: parseFfmpegBitrateKbps(probe.stderr),
|
|
570
576
|
fps: parseFfmpegVideoFps(probe.stderr),
|
|
571
577
|
startTime: parseFfmpegStartTimeSeconds(probe.stderr),
|
|
572
|
-
isHdr: parseFfmpegHdr(probe.stderr)
|
|
578
|
+
isHdr: parseFfmpegHdr(probe.stderr),
|
|
579
|
+
bitDepth: parseFfmpegBitDepth(probe.stderr)
|
|
573
580
|
});
|
|
574
581
|
// Warm the file-body start for the transcode session that follows.
|
|
575
582
|
// Fire-and-forget: never delays the plan response.
|
|
@@ -80,6 +80,28 @@ import { WebSocket } from "ws";
|
|
|
80
80
|
const RECONNECT_DELAY_MS = 5_000;
|
|
81
81
|
/** Send a keepalive ping every 30 s to prevent Cloudflare's idle WebSocket timeout (~100 s). */
|
|
82
82
|
const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
83
|
+
/**
|
|
84
|
+
* Replace the connection before anything upstream ends it for us.
|
|
85
|
+
*
|
|
86
|
+
* Something between this process and the server closes the socket after
|
|
87
|
+
* exactly **100 min 15 s**, whatever is flowing over it. It is not an idle
|
|
88
|
+
* timeout — the keepalive above has been running for months — it is a lifetime
|
|
89
|
+
* cap. Measured across one day of logs (2026-08-20): 01:54:12 → 03:34:27 →
|
|
90
|
+
* ... the same 100:15 apart wherever a restart did not reset the clock, and
|
|
91
|
+
* with `code=1006`, an abrupt close with no closing handshake, which is what an
|
|
92
|
+
* intermediary killing a connection looks like.
|
|
93
|
+
*
|
|
94
|
+
* Reconnecting after the fact costs 5 s during which this proxy does not exist
|
|
95
|
+
* as far as the registry is concerned, and a viewer arriving in that window is
|
|
96
|
+
* told there is no proxy. So the connection is replaced BEFORE the cap, and the
|
|
97
|
+
* replacement is seamless: the new socket registers itself with the server,
|
|
98
|
+
* which atomically supersedes the old one, and only then does the old one
|
|
99
|
+
* close. There is no moment with nothing registered.
|
|
100
|
+
*
|
|
101
|
+
* Ninety minutes leaves ten minutes of margin against a cap that has been
|
|
102
|
+
* exact, and makes the replacement a quiet event rather than a race.
|
|
103
|
+
*/
|
|
104
|
+
const CONNECTION_LIFETIME_MS = 90 * 60_000;
|
|
83
105
|
|
|
84
106
|
/**
|
|
85
107
|
* Create and manage the outbound WebSocket tunnel to the registry server.
|
|
@@ -87,15 +109,30 @@ const KEEPALIVE_INTERVAL_MS = 30_000;
|
|
|
87
109
|
* @param {TunnelClientOptions} options
|
|
88
110
|
* @returns {TunnelClient}
|
|
89
111
|
*/
|
|
90
|
-
export function createTunnelClient({
|
|
112
|
+
export function createTunnelClient({
|
|
113
|
+
serverUrl,
|
|
114
|
+
proxyId,
|
|
115
|
+
token,
|
|
116
|
+
proxyPort,
|
|
117
|
+
onSignal,
|
|
118
|
+
onConnect,
|
|
119
|
+
onHealthRequest,
|
|
120
|
+
onLog,
|
|
121
|
+
connectionLifetimeMs = CONNECTION_LIFETIME_MS
|
|
122
|
+
}) {
|
|
91
123
|
const wsUrl = serverUrl.replace(/^http/, "ws").replace(/\/+$/, "") + "/ws/proxy-tunnel";
|
|
92
124
|
|
|
93
125
|
/** @type {WebSocket | null} */
|
|
94
126
|
let socket = null;
|
|
95
127
|
/** @type {ReturnType<typeof setTimeout> | null} */
|
|
96
128
|
let reconnectTimer = null;
|
|
97
|
-
/**
|
|
98
|
-
|
|
129
|
+
/**
|
|
130
|
+
* The renewal that will replace the live connection before the upstream cap
|
|
131
|
+
* ends it. Owned by the connection it belongs to, and cancelled with it.
|
|
132
|
+
*
|
|
133
|
+
* @type {ReturnType<typeof setTimeout> | null}
|
|
134
|
+
*/
|
|
135
|
+
let renewalTimer = null;
|
|
99
136
|
let stopped = false;
|
|
100
137
|
|
|
101
138
|
/**
|
|
@@ -122,28 +159,46 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
|
|
|
122
159
|
}
|
|
123
160
|
log(`Connecting tunnel to ${wsUrl}`);
|
|
124
161
|
|
|
125
|
-
|
|
162
|
+
// The connection being opened, held separately from `socket` so that a
|
|
163
|
+
// socket which has been SUPERSEDED can still recognise itself. During a
|
|
164
|
+
// renewal two exist for a moment, and the old one's close must not be
|
|
165
|
+
// mistaken for the tunnel going down.
|
|
166
|
+
const connection = new WebSocket(wsUrl, {
|
|
126
167
|
headers: {
|
|
127
168
|
"x-proxy-id": proxyId,
|
|
128
169
|
"x-proxy-token": token,
|
|
129
170
|
"user-agent": "torrent-tv-proxy/1.0"
|
|
130
171
|
}
|
|
131
172
|
});
|
|
173
|
+
/** @type {ReturnType<typeof setInterval> | null} */
|
|
174
|
+
let keepaliveTimer = null;
|
|
175
|
+
socket = connection;
|
|
132
176
|
|
|
133
|
-
|
|
177
|
+
connection.addEventListener("open", () => {
|
|
134
178
|
log("Tunnel connected.");
|
|
135
179
|
// Start keepalive pings to prevent Cloudflare's idle WebSocket timeout.
|
|
136
180
|
keepaliveTimer = setInterval(() => {
|
|
137
|
-
if (
|
|
138
|
-
send({ type: "ping" });
|
|
181
|
+
if (connection.readyState === WebSocket.OPEN) {
|
|
182
|
+
send({ type: "ping" }, connection);
|
|
139
183
|
}
|
|
140
184
|
}, KEEPALIVE_INTERVAL_MS);
|
|
185
|
+
// And replace this connection before the upstream lifetime cap does.
|
|
186
|
+
if (renewalTimer !== null) {
|
|
187
|
+
clearTimeout(renewalTimer);
|
|
188
|
+
}
|
|
189
|
+
renewalTimer = setTimeout(() => {
|
|
190
|
+
if (stopped || socket !== connection) {
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
log("Tunnel renewing before the upstream lifetime cap; the replacement takes over first.");
|
|
194
|
+
connect();
|
|
195
|
+
}, connectionLifetimeMs);
|
|
141
196
|
if (typeof onConnect === "function") {
|
|
142
197
|
onConnect();
|
|
143
198
|
}
|
|
144
199
|
});
|
|
145
200
|
|
|
146
|
-
|
|
201
|
+
connection.addEventListener("message", (event) => {
|
|
147
202
|
let message;
|
|
148
203
|
try {
|
|
149
204
|
message = JSON.parse(event.data);
|
|
@@ -174,19 +229,31 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
|
|
|
174
229
|
}
|
|
175
230
|
});
|
|
176
231
|
|
|
177
|
-
|
|
178
|
-
log(`Tunnel disconnected (code=${event.code}). Reconnecting in ${RECONNECT_DELAY_MS}ms...`);
|
|
179
|
-
socket = null;
|
|
232
|
+
connection.addEventListener("close", (event) => {
|
|
180
233
|
if (keepaliveTimer !== null) {
|
|
181
234
|
clearInterval(keepaliveTimer);
|
|
182
235
|
keepaliveTimer = null;
|
|
183
236
|
}
|
|
237
|
+
// A connection this one replaced. The server closes it as soon as the
|
|
238
|
+
// replacement registers, which is the whole point of renewing early —
|
|
239
|
+
// there is nothing to report and nothing to reconnect, because the tunnel
|
|
240
|
+
// never went down.
|
|
241
|
+
if (socket !== connection) {
|
|
242
|
+
log(`Tunnel handed over (code=${event.code}); the replacement is already carrying it.`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
log(`Tunnel disconnected (code=${event.code}). Reconnecting in ${RECONNECT_DELAY_MS}ms...`);
|
|
246
|
+
socket = null;
|
|
247
|
+
if (renewalTimer !== null) {
|
|
248
|
+
clearTimeout(renewalTimer);
|
|
249
|
+
renewalTimer = null;
|
|
250
|
+
}
|
|
184
251
|
if (!stopped) {
|
|
185
252
|
reconnectTimer = setTimeout(connect, RECONNECT_DELAY_MS);
|
|
186
253
|
}
|
|
187
254
|
});
|
|
188
255
|
|
|
189
|
-
|
|
256
|
+
connection.addEventListener("error", (event) => {
|
|
190
257
|
log(`Tunnel WebSocket error: ${event.message ?? "unknown"}`);
|
|
191
258
|
});
|
|
192
259
|
}
|
|
@@ -255,9 +322,14 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
|
|
|
255
322
|
* @param {object} message
|
|
256
323
|
* @returns {void}
|
|
257
324
|
*/
|
|
258
|
-
function send(message) {
|
|
259
|
-
|
|
260
|
-
|
|
325
|
+
function send(message, over = null) {
|
|
326
|
+
// Everything the proxy has to say goes over the LIVE connection. `over` is
|
|
327
|
+
// for the one thing that belongs to a particular socket rather than to the
|
|
328
|
+
// tunnel — its own keepalive — which must not be sent over a replacement
|
|
329
|
+
// that has already taken over.
|
|
330
|
+
const target = over ?? socket;
|
|
331
|
+
if (target && target.readyState === WebSocket.OPEN) {
|
|
332
|
+
target.send(JSON.stringify(message));
|
|
261
333
|
}
|
|
262
334
|
}
|
|
263
335
|
|
|
@@ -291,9 +363,9 @@ export function createTunnelClient({ serverUrl, proxyId, token, proxyPort, onSig
|
|
|
291
363
|
*/
|
|
292
364
|
disconnect() {
|
|
293
365
|
stopped = true;
|
|
294
|
-
if (
|
|
295
|
-
|
|
296
|
-
|
|
366
|
+
if (renewalTimer !== null) {
|
|
367
|
+
clearTimeout(renewalTimer);
|
|
368
|
+
renewalTimer = null;
|
|
297
369
|
}
|
|
298
370
|
if (reconnectTimer != null) {
|
|
299
371
|
clearTimeout(reconnectTimer);
|
package/test/decode-cost.test.js
CHANGED
|
@@ -17,6 +17,7 @@ import assert from "node:assert/strict";
|
|
|
17
17
|
import { createRequire } from "node:module";
|
|
18
18
|
import { spawn } from "node:child_process";
|
|
19
19
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
20
|
+
import { decodeCostOf, decodeFamilyOf } from "../services/decode-cost-fit.js";
|
|
20
21
|
import os from "node:os";
|
|
21
22
|
import path from "node:path";
|
|
22
23
|
import {
|
|
@@ -282,8 +283,22 @@ test("a banner with no bitrate reads as no bitrate, not as zero", () => {
|
|
|
282
283
|
test("the source's decode figures come off the probe, or not at all", () => {
|
|
283
284
|
assert.deepEqual(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000 }), {
|
|
284
285
|
megapixelsPerSecond: (1920 * 1080 * 24) / 1e6,
|
|
285
|
-
megabitsPerSecond: 8
|
|
286
|
+
megabitsPerSecond: 8,
|
|
287
|
+
// Which measurement of this host applies. Absent on the probe means absent
|
|
288
|
+
// here — the caller then prices the source as H.264 8-bit, which is what
|
|
289
|
+
// every source was priced as before the model was fitted per family.
|
|
290
|
+
codec: "",
|
|
291
|
+
bitDepth: null
|
|
286
292
|
});
|
|
293
|
+
assert.deepEqual(
|
|
294
|
+
sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: 8000, codec: "hevc", bitDepth: 10 }),
|
|
295
|
+
{
|
|
296
|
+
megapixelsPerSecond: (1920 * 1080 * 24) / 1e6,
|
|
297
|
+
megabitsPerSecond: 8,
|
|
298
|
+
codec: "hevc",
|
|
299
|
+
bitDepth: 10
|
|
300
|
+
}
|
|
301
|
+
);
|
|
287
302
|
assert.equal(sourceDecodeCharacteristics({ width: 1920, height: 1080, fps: 24, bitrateKbps: null }), null);
|
|
288
303
|
assert.equal(sourceDecodeCharacteristics(null), null);
|
|
289
304
|
});
|
|
@@ -391,3 +406,52 @@ test("the OFFER drops the rungs the host cannot hold, and the master keeps addre
|
|
|
391
406
|
"the copied height costs no encoder and stays; nothing re-encoded survives that supply"
|
|
392
407
|
);
|
|
393
408
|
});
|
|
409
|
+
|
|
410
|
+
test("a source is priced by its own codec family when that family was measured", () => {
|
|
411
|
+
// A model as the startup benchmark now returns it: H.264 terms at the top
|
|
412
|
+
// level, for a caller that knows nothing about codecs, and the measured
|
|
413
|
+
// families beside them.
|
|
414
|
+
const model = {
|
|
415
|
+
pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0,
|
|
416
|
+
families: {
|
|
417
|
+
h264: { pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0 },
|
|
418
|
+
hevc: { pixelTerm: 0.011, bitrateTerm: 0.020, constantTerm: 0 },
|
|
419
|
+
hevc10: { pixelTerm: 0.017, bitrateTerm: 0.026, constantTerm: 0 }
|
|
420
|
+
}
|
|
421
|
+
};
|
|
422
|
+
const rates = { megapixelsPerSecond: 50, megabitsPerSecond: 9 };
|
|
423
|
+
const asH264 = decodeCostOf(model, { ...rates, codec: "h264", bitDepth: 8 });
|
|
424
|
+
const asHevc = decodeCostOf(model, { ...rates, codec: "hevc", bitDepth: 8 });
|
|
425
|
+
const asHevc10 = decodeCostOf(model, { ...rates, codec: "hevc", bitDepth: 10 });
|
|
426
|
+
assert.equal(Number(asH264.toFixed(4)), 0.408);
|
|
427
|
+
assert.equal(Number(asHevc.toFixed(4)), 0.73);
|
|
428
|
+
assert.equal(Number(asHevc10.toFixed(4)), 1.084);
|
|
429
|
+
// The whole point: the same file costs more as HEVC than as H.264, and more
|
|
430
|
+
// again at ten bits. A single fit could not say that.
|
|
431
|
+
assert.ok(asHevc > asH264 && asHevc10 > asHevc);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
test("a family with no clips is priced as H.264, and a model with no families still works", () => {
|
|
435
|
+
const withFamilies = {
|
|
436
|
+
pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0,
|
|
437
|
+
families: { h264: { pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0 } }
|
|
438
|
+
};
|
|
439
|
+
const rates = { megapixelsPerSecond: 50, megabitsPerSecond: 9 };
|
|
440
|
+
// AV1 has no set of its own yet.
|
|
441
|
+
assert.equal(
|
|
442
|
+
decodeCostOf(withFamilies, { ...rates, codec: "av1", bitDepth: 8 }),
|
|
443
|
+
decodeCostOf(withFamilies, { ...rates, codec: "h264", bitDepth: 8 })
|
|
444
|
+
);
|
|
445
|
+
// And a flat model — every model before this release — is unchanged.
|
|
446
|
+
const flat = { pixelTerm: 0.006, bitrateTerm: 0.012, constantTerm: 0 };
|
|
447
|
+
assert.equal(decodeCostOf(flat, { ...rates, codec: "hevc", bitDepth: 10 }), 0.408);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
test("the family is chosen by codec and depth, and unknown names fall to H.264", () => {
|
|
451
|
+
assert.equal(decodeFamilyOf({ codec: "hevc", bitDepth: 8 }), "hevc");
|
|
452
|
+
assert.equal(decodeFamilyOf({ codec: "HEVC", bitDepth: 10 }), "hevc10");
|
|
453
|
+
assert.equal(decodeFamilyOf({ codec: "h265", bitDepth: 12 }), "hevc10");
|
|
454
|
+
assert.equal(decodeFamilyOf({ codec: "h264", bitDepth: 10 }), "h264");
|
|
455
|
+
assert.equal(decodeFamilyOf({ codec: "vc1", bitDepth: null }), "h264");
|
|
456
|
+
assert.equal(decodeFamilyOf({}), "h264");
|
|
457
|
+
});
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file The tunnel is replaced before anything upstream ends it, and the
|
|
3
|
+
* replacement takes over first.
|
|
4
|
+
*
|
|
5
|
+
* Something between the proxy and the server closes the socket after exactly
|
|
6
|
+
* 100 min 15 s whatever is flowing over it — measured across a day of logs on
|
|
7
|
+
* 2026-08-20, `code=1006` each time, with a 30 s keepalive running throughout,
|
|
8
|
+
* so it is a lifetime cap and not an idle timeout. Reconnecting afterwards
|
|
9
|
+
* costs five seconds in which the proxy does not exist as far as the registry
|
|
10
|
+
* is concerned, and a viewer arriving then is told there is no proxy.
|
|
11
|
+
*
|
|
12
|
+
* What is pinned here is the property that removes that window: at no instant
|
|
13
|
+
* is the server without a registered connection for this proxy.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import assert from "node:assert/strict";
|
|
17
|
+
import test from "node:test";
|
|
18
|
+
import { WebSocketServer } from "ws";
|
|
19
|
+
|
|
20
|
+
import { createTunnelClient } from "../services/tunnel-client.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A stand-in for the registry's tunnel endpoint, with the one behaviour that
|
|
24
|
+
* matters here: a new connection for a proxy REPLACES the previous one, which
|
|
25
|
+
* is what `registerConnection` does in `server/services/proxy-tunnel-server.js`.
|
|
26
|
+
*
|
|
27
|
+
* @returns {Promise<{ url: string, close: () => Promise<void>, registered: () => number, opened: () => number, everEmpty: () => boolean }>}
|
|
28
|
+
*/
|
|
29
|
+
async function startRegistry() {
|
|
30
|
+
const server = new WebSocketServer({ port: 0 });
|
|
31
|
+
await new Promise((resolve) => { server.on("listening", resolve); });
|
|
32
|
+
/** @type {import("ws").WebSocket | null} */
|
|
33
|
+
let current = null;
|
|
34
|
+
let opened = 0;
|
|
35
|
+
let everEmpty = false;
|
|
36
|
+
server.on("connection", (socket) => {
|
|
37
|
+
opened += 1;
|
|
38
|
+
const previous = current;
|
|
39
|
+
current = socket;
|
|
40
|
+
// The replacement is registered BEFORE the old one is closed, so a reader
|
|
41
|
+
// of `current` never sees nothing.
|
|
42
|
+
if (previous && previous.readyState < 2) {
|
|
43
|
+
previous.close(1000, "replaced");
|
|
44
|
+
}
|
|
45
|
+
socket.on("close", () => {
|
|
46
|
+
if (current === socket) {
|
|
47
|
+
current = null;
|
|
48
|
+
everEmpty = true;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
const { port } = server.address();
|
|
53
|
+
return {
|
|
54
|
+
url: `http://127.0.0.1:${port}`,
|
|
55
|
+
close: () => new Promise((resolve) => { server.close(resolve); }),
|
|
56
|
+
registered: () => (current && current.readyState === 1 ? 1 : 0),
|
|
57
|
+
killCurrent: () => { current?.terminate(); },
|
|
58
|
+
opened: () => opened,
|
|
59
|
+
everEmpty: () => everEmpty
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
test("the connection is replaced before its lifetime runs out, without a gap", async (t) => {
|
|
64
|
+
const registry = await startRegistry();
|
|
65
|
+
/** @type {string[]} */
|
|
66
|
+
const lines = [];
|
|
67
|
+
const client = createTunnelClient({
|
|
68
|
+
serverUrl: registry.url,
|
|
69
|
+
proxyId: "p1",
|
|
70
|
+
token: "t",
|
|
71
|
+
proxyPort: 9090,
|
|
72
|
+
onLog: (line) => lines.push(line),
|
|
73
|
+
// The real cap is 100 min 15 s and the real renewal is at 90 min; the
|
|
74
|
+
// ratio is what matters, not the magnitude.
|
|
75
|
+
connectionLifetimeMs: 150
|
|
76
|
+
});
|
|
77
|
+
t.after(async () => {
|
|
78
|
+
client.disconnect();
|
|
79
|
+
await registry.close();
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
client.connect();
|
|
83
|
+
// Long enough for several renewals at 150 ms each.
|
|
84
|
+
await new Promise((resolve) => { setTimeout(resolve, 700); });
|
|
85
|
+
|
|
86
|
+
assert.ok(registry.opened() >= 3, `expected several renewals, saw ${registry.opened()}`);
|
|
87
|
+
// The property this exists for: the registry was never left with nothing.
|
|
88
|
+
assert.equal(registry.everEmpty(), false, "the registry lost its connection at some point");
|
|
89
|
+
assert.equal(registry.registered(), 1);
|
|
90
|
+
// And the proxy knows the difference between a handover and going down. A
|
|
91
|
+
// "Reconnecting in" line here would mean it had treated its own renewal as a
|
|
92
|
+
// failure and waited five seconds before coming back.
|
|
93
|
+
assert.ok(lines.some((line) => line.includes("Tunnel renewing")), lines.join("\n"));
|
|
94
|
+
assert.ok(lines.some((line) => line.includes("Tunnel handed over")), lines.join("\n"));
|
|
95
|
+
assert.equal(lines.filter((line) => line.includes("Reconnecting in")).length, 0, lines.join("\n"));
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
test("a connection killed from outside is still reconnected", async (t) => {
|
|
99
|
+
const registry = await startRegistry();
|
|
100
|
+
/** @type {string[]} */
|
|
101
|
+
const lines = [];
|
|
102
|
+
const client = createTunnelClient({
|
|
103
|
+
serverUrl: registry.url,
|
|
104
|
+
proxyId: "p2",
|
|
105
|
+
token: "t",
|
|
106
|
+
proxyPort: 9090,
|
|
107
|
+
onLog: (line) => lines.push(line),
|
|
108
|
+
// Far longer than this test runs, so nothing renews and the only close is
|
|
109
|
+
// the one forced below — which is what the upstream cap looks like from
|
|
110
|
+
// here: an abrupt end nobody asked for.
|
|
111
|
+
connectionLifetimeMs: 60_000
|
|
112
|
+
});
|
|
113
|
+
t.after(async () => {
|
|
114
|
+
client.disconnect();
|
|
115
|
+
await registry.close();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
client.connect();
|
|
119
|
+
await new Promise((resolve) => { setTimeout(resolve, 200); });
|
|
120
|
+
assert.equal(registry.opened(), 1);
|
|
121
|
+
|
|
122
|
+
registry.killCurrent();
|
|
123
|
+
await new Promise((resolve) => { setTimeout(resolve, 300); });
|
|
124
|
+
// The renewal must not have taken the ordinary reconnect away with it.
|
|
125
|
+
assert.ok(lines.some((line) => line.includes("Reconnecting in")), lines.join("\n"));
|
|
126
|
+
});
|