@torrent-tv/proxy 2.9.116 → 2.9.118
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 +9 -0
- package/package.json +1 -1
- package/services/hls-session-manager.js +237 -27
- package/test/look-ahead-coverage.test.js +56 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 2.9.118
|
|
2
|
+
|
|
3
|
+
- **New**: Suspending the encoder says what the decision was taken on — where the viewer is, how far the unbroken run of segments reaches from there, and how many segment files the session directory holds. Suspending stops the only thing that reads the input, so a wrong reading here stops the download as well: measured 2026-08-06, the log announced "135s ahead of the viewer" while three segments totalling 31 s lay on disk, and neither figure could be checked against the other because the line carried no evidence. The directory keeps the segments of every run a session has had, so which of them were counted is the whole question.
|
|
4
|
+
|
|
5
|
+
## 2.9.117
|
|
6
|
+
|
|
7
|
+
- **Fix**: A seek backwards no longer kills playback. How far the encoder is ahead of the viewer was measured as the highest segment number lying in the session's directory, which equals the look-ahead only while a viewer moves forward through one run. Measured 2026-08-06: a seek forward left segments 662-665 on disk, the viewer seeked BACK to 646, and the limiter compared 6950 s of output against a viewer at 6700 s, called it "250s ahead" and suspended the new run **136 ms after it started**, before it had produced anything. With the encoder stopped nothing read the input, so no pieces were requested — `0 selection(s)` with 33 peers connected — and segment 646 was never made; the viewer sat on a spinner for four minutes while a stopped ffmpeg was held back for being too far ahead. The measure is now the unbroken run of segments starting where the viewer is, and a viewer whose own segment is missing is not "zero ahead" but waiting, which resumes the encoder instead of pausing it. Covered by tests, including the field case.
|
|
8
|
+
- **New**: Groundwork for switching quality without interrupting playback: the heights a source can be served at, the master playlist that offers them as HLS variants, and creation of a variant's encoder on first request. Not yet reachable — the routes come with the browser side. Reasoning in `research/seamless-switching-2026-08-06.md`.
|
|
9
|
+
|
|
1
10
|
## 2.9.116
|
|
2
11
|
|
|
3
12
|
- **New**: A progress report says which height is being produced right now. Under automatic quality the proxy steps the resolution down when the host cannot encode in realtime or the viewer's link cannot carry the stream, and nothing said so — the menu read "Auto" whatever it had settled on. Zero when the video is copied, because then nothing is being chosen and the source's own height is what plays.
|
package/package.json
CHANGED
|
@@ -64,6 +64,60 @@ export function isInputUnavailable(message) {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
const PLAYLIST_FILE_NAME = "index.m3u8";
|
|
67
|
+
// The resolutions a viewer may choose between. Only rungs at or below the
|
|
68
|
+
// source are offered: upscaling invents detail and costs the encoder more than
|
|
69
|
+
// the source itself.
|
|
70
|
+
const VARIANT_LADDER = [2160, 1440, 1080, 720, 540, 480, 360, 240];
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The last index of the unbroken run of segments starting at `from`.
|
|
74
|
+
*
|
|
75
|
+
* Null when `from` itself is absent. A hole matters: segments beyond one are
|
|
76
|
+
* not look-ahead, because the viewer cannot reach them until it is filled.
|
|
77
|
+
*
|
|
78
|
+
* @param {Set<number>} present
|
|
79
|
+
* @param {number} from
|
|
80
|
+
* @returns {number | null}
|
|
81
|
+
*/
|
|
82
|
+
export function contiguousEnd(present, from) {
|
|
83
|
+
if (!present.has(from)) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
let last = from;
|
|
87
|
+
while (present.has(last + 1)) {
|
|
88
|
+
last += 1;
|
|
89
|
+
}
|
|
90
|
+
return last;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The heights offered for a source of this height, largest first.
|
|
95
|
+
*
|
|
96
|
+
* @param {number} sourceHeight
|
|
97
|
+
* @returns {number[]}
|
|
98
|
+
*/
|
|
99
|
+
export function variantHeightsFor(sourceHeight) {
|
|
100
|
+
if (!Number.isFinite(sourceHeight) || sourceHeight <= 0) {
|
|
101
|
+
return [];
|
|
102
|
+
}
|
|
103
|
+
const rungs = VARIANT_LADDER.filter((height) => height < sourceHeight);
|
|
104
|
+
return [Math.round(sourceHeight), ...rungs];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A rough bitrate for a height, in bits per second.
|
|
109
|
+
*
|
|
110
|
+
* `BANDWIDTH` is required on every variant by the HLS specification, and the
|
|
111
|
+
* player uses it to order them. It does not have to be exact — nothing here
|
|
112
|
+
* adapts on it, because the viewer chooses — so it is the usual H.264 rule of
|
|
113
|
+
* thumb rather than a measurement we do not have before encoding starts.
|
|
114
|
+
*
|
|
115
|
+
* @param {number} height
|
|
116
|
+
* @returns {number}
|
|
117
|
+
*/
|
|
118
|
+
export function estimatedBitrateFor(height) {
|
|
119
|
+
return Math.max(400_000, Math.round(height * height * 3.2));
|
|
120
|
+
}
|
|
67
121
|
const CLEANUP_INTERVAL_MS = 30_000;
|
|
68
122
|
const DEFAULT_SEGMENT_DURATION_SEC = 4;
|
|
69
123
|
// How many segments ahead of the current encode head a missing-segment request
|
|
@@ -1357,6 +1411,10 @@ export class HlsSessionManager {
|
|
|
1357
1411
|
lastLoggedAt: 0
|
|
1358
1412
|
}
|
|
1359
1413
|
};
|
|
1414
|
+
// Kept so a variant of this session can take its own hold on the same
|
|
1415
|
+
// source: a variant is another encode of the same file and must keep the
|
|
1416
|
+
// torrent's data alive exactly as this one does.
|
|
1417
|
+
session.acquireSource = typeof acquireSource === "function" ? acquireSource : null;
|
|
1360
1418
|
if (typeof acquireSource === "function") {
|
|
1361
1419
|
try {
|
|
1362
1420
|
session.releaseSource = acquireSource();
|
|
@@ -1834,53 +1892,114 @@ export class HlsSessionManager {
|
|
|
1834
1892
|
// which nobody was now making — was held for 45.7 s until the viewer gave
|
|
1835
1893
|
// up and seeked. A segment on disk is something the viewer can be served;
|
|
1836
1894
|
// a number from ffmpeg is not.
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1895
|
+
// Where the viewer is. Before the first segment request, the position the
|
|
1896
|
+
// run started at — so a session nobody has read from yet is bounded too.
|
|
1897
|
+
const viewerSegment = Number.isInteger(session.lastRequestedSegment)
|
|
1898
|
+
? session.lastRequestedSegment
|
|
1899
|
+
: (session.encodeStartIndex ?? 0);
|
|
1900
|
+
|
|
1901
|
+
// How much is ready CONTIGUOUSLY FROM WHERE THE VIEWER IS — not the highest
|
|
1902
|
+
// segment number lying in the directory. The two are the same only while a
|
|
1903
|
+
// viewer moves forward through one run, and the difference destroyed a
|
|
1904
|
+
// session on 2026-08-06: a seek forward left segments 662-665 on disk, the
|
|
1905
|
+
// viewer then seeked BACK to 646, and the limiter measured 6950 s of output
|
|
1906
|
+
// against a viewer at 6700 s, called it "250s ahead" and suspended a run
|
|
1907
|
+
// 136 ms after it started, before it had produced anything at all. Nothing
|
|
1908
|
+
// was then encoding, so nothing read the input, so no pieces were asked for
|
|
1909
|
+
// — `0 selection(s)` with 33 peers connected — and segment 646 was never
|
|
1910
|
+
// made. Segments beyond a hole are not look-ahead: the viewer cannot reach
|
|
1911
|
+
// them without the hole being filled first.
|
|
1912
|
+
const reading = this.#contiguousAheadSeconds(session, viewerSegment);
|
|
1913
|
+
const aheadSeconds = reading === null ? null : reading.seconds;
|
|
1914
|
+
if (aheadSeconds === null) {
|
|
1915
|
+
// The segment the viewer needs does not exist. Whatever else is on disk,
|
|
1916
|
+
// this encoder has work to do right now.
|
|
1917
|
+
if (session.encoderPaused) {
|
|
1918
|
+
this.#resumeEncoder(session, "the viewer needs a segment nobody has made");
|
|
1919
|
+
}
|
|
1843
1920
|
return;
|
|
1844
1921
|
}
|
|
1845
|
-
|
|
1846
|
-
//
|
|
1847
|
-
//
|
|
1848
|
-
//
|
|
1849
|
-
// often this function happens to run. Printing it per call meant printing
|
|
1850
|
-
// it at the rate of segment requests — three hundred times a minute after
|
|
1851
|
-
// one seek — and a limit of one a minute would only have hidden that the
|
|
1852
|
-
// line was in the wrong place. Two lines per occurrence also say how long
|
|
1853
|
-
// it lasted, which no sampled version could.
|
|
1922
|
+
|
|
1923
|
+
// Worth knowing when ffmpeg's own report and what exists disagree wildly —
|
|
1924
|
+
// it is the only trace of whatever made it claim a position it had not
|
|
1925
|
+
// reached. Reported on its EDGES, because it is a state and not a stream.
|
|
1854
1926
|
const claimed = Number(session.progress?.processedSeconds);
|
|
1927
|
+
const encodedTo = this.#segmentStartTime(session, viewerSegment) + aheadSeconds;
|
|
1855
1928
|
const disagrees =
|
|
1856
1929
|
Number.isFinite(claimed) && Math.abs(claimed - encodedTo) > LOOKAHEAD_PAUSE_SECONDS;
|
|
1857
1930
|
if (disagrees && !session.lookAheadDisagreementSince) {
|
|
1858
1931
|
session.lookAheadDisagreementSince = Date.now();
|
|
1859
1932
|
logger.info(
|
|
1860
1933
|
`transcode ${session.id} ffmpeg claims ${Math.round(claimed)}s processed ` +
|
|
1861
|
-
`but
|
|
1934
|
+
`but the viewer's own run of segments ends at ${Math.round(encodedTo)}s`
|
|
1862
1935
|
);
|
|
1863
1936
|
} else if (!disagrees && session.lookAheadDisagreementSince) {
|
|
1864
1937
|
const lastedMs = Date.now() - session.lookAheadDisagreementSince;
|
|
1865
1938
|
session.lookAheadDisagreementSince = 0;
|
|
1866
1939
|
logger.info(
|
|
1867
1940
|
`transcode ${session.id} ffmpeg's position and the segments on disk agree again ` +
|
|
1868
|
-
`after ${(lastedMs / 1000).toFixed(1)}s (
|
|
1941
|
+
`after ${(lastedMs / 1000).toFixed(1)}s (ready through ${Math.round(encodedTo)}s)`
|
|
1869
1942
|
);
|
|
1870
1943
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
:
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
this
|
|
1879
|
-
|
|
1880
|
-
this.#
|
|
1944
|
+
|
|
1945
|
+
if (!session.encoderPaused && aheadSeconds > LOOKAHEAD_PAUSE_SECONDS) {
|
|
1946
|
+
// The decision names what it was taken on. Suspending the encoder stops
|
|
1947
|
+
// the only thing that reads the input, so a wrong reading here stops the
|
|
1948
|
+
// download too — measured 2026-08-06: the log said "135s ahead" while
|
|
1949
|
+
// three segments totalling 31 s lay on disk, and neither figure could be
|
|
1950
|
+
// checked against the other because the line carried no evidence. The
|
|
1951
|
+
// directory holds segments from every run this session has had, so which
|
|
1952
|
+
// ones were counted is the whole question.
|
|
1953
|
+
this.#pauseEncoder(
|
|
1954
|
+
session,
|
|
1955
|
+
`${Math.round(aheadSeconds)}s ahead of the viewer ` +
|
|
1956
|
+
`(viewer at #${viewerSegment}, unbroken through #${reading.lastCovered}, ` +
|
|
1957
|
+
`${reading.total} segment file(s) present)`
|
|
1958
|
+
);
|
|
1959
|
+
} else if (session.encoderPaused && aheadSeconds <= LOOKAHEAD_RESUME_SECONDS) {
|
|
1960
|
+
this.#resumeEncoder(session, `${Math.round(aheadSeconds)}s ahead of the viewer`);
|
|
1881
1961
|
}
|
|
1882
1962
|
}
|
|
1883
1963
|
|
|
1964
|
+
/**
|
|
1965
|
+
* Seconds of playback ready without a gap, starting at the segment the viewer
|
|
1966
|
+
* is on.
|
|
1967
|
+
*
|
|
1968
|
+
* Null when that very segment is missing — which is not "zero ahead" but
|
|
1969
|
+
* "the viewer is waiting", and the two call for opposite decisions.
|
|
1970
|
+
*
|
|
1971
|
+
* @param {HlsSession} session
|
|
1972
|
+
* @param {number} viewerSegment
|
|
1973
|
+
* @returns {{ seconds: number, lastCovered: number, total: number } | null}
|
|
1974
|
+
*/
|
|
1975
|
+
#contiguousAheadSeconds(session, viewerSegment) {
|
|
1976
|
+
let present;
|
|
1977
|
+
try {
|
|
1978
|
+
present = new Set();
|
|
1979
|
+
for (const name of readdirSync(session.dirPath, { withFileTypes: false })) {
|
|
1980
|
+
if (!this.segmentFormat.isSegmentFileName(name)) {
|
|
1981
|
+
continue;
|
|
1982
|
+
}
|
|
1983
|
+
const index = this.segmentFormat.segmentIndexFromName(name);
|
|
1984
|
+
if (index >= 0) {
|
|
1985
|
+
present.add(index);
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
} catch {
|
|
1989
|
+
return null;
|
|
1990
|
+
}
|
|
1991
|
+
const lastCovered = contiguousEnd(present, viewerSegment);
|
|
1992
|
+
if (lastCovered === null) {
|
|
1993
|
+
return null;
|
|
1994
|
+
}
|
|
1995
|
+
const from = this.#segmentStartTime(session, viewerSegment);
|
|
1996
|
+
const to = this.#segmentStartTime(session, lastCovered + 1);
|
|
1997
|
+
if (!Number.isFinite(from) || !Number.isFinite(to)) {
|
|
1998
|
+
return null;
|
|
1999
|
+
}
|
|
2000
|
+
return { seconds: Math.max(0, to - from), lastCovered, total: present.size };
|
|
2001
|
+
}
|
|
2002
|
+
|
|
1884
2003
|
/**
|
|
1885
2004
|
* Suspend a session's encoder. No-op when already paused or unsupported here.
|
|
1886
2005
|
*
|
|
@@ -3000,6 +3119,97 @@ export class HlsSessionManager {
|
|
|
3000
3119
|
return highest;
|
|
3001
3120
|
}
|
|
3002
3121
|
|
|
3122
|
+
/**
|
|
3123
|
+
* The session that produces a given height for the same file, created on
|
|
3124
|
+
* first request.
|
|
3125
|
+
*
|
|
3126
|
+
* A variant IS a session — same source, same file, a different encode — so
|
|
3127
|
+
* this makes one rather than inventing a parallel object. It is created only
|
|
3128
|
+
* when its playlist is actually asked for, which is what keeps a weak host
|
|
3129
|
+
* running one encoder: with the player's own bitrate adaptation off, no
|
|
3130
|
+
* variant is ever requested unless the viewer picked it.
|
|
3131
|
+
*
|
|
3132
|
+
* @param {string} baseSessionId
|
|
3133
|
+
* @param {number} height - Encode height; must be one of the offered rungs.
|
|
3134
|
+
* @returns {Promise<HlsSession | null>} Null when the base session is unknown.
|
|
3135
|
+
*/
|
|
3136
|
+
async getVariantSession(baseSessionId, height) {
|
|
3137
|
+
const base = this.sessionsById.get(baseSessionId);
|
|
3138
|
+
if (!base || base.state === "disposed") {
|
|
3139
|
+
return null;
|
|
3140
|
+
}
|
|
3141
|
+
if (!Number.isInteger(height) || height <= 0) {
|
|
3142
|
+
return null;
|
|
3143
|
+
}
|
|
3144
|
+
// Where the viewer is now, so a variant created mid-film starts there
|
|
3145
|
+
// rather than at the beginning.
|
|
3146
|
+
const processed = Number.isFinite(base.progress?.processedSeconds)
|
|
3147
|
+
? base.progress.processedSeconds
|
|
3148
|
+
: 0;
|
|
3149
|
+
return this.createOrGetSession({
|
|
3150
|
+
sourceKey: base.sourceKey,
|
|
3151
|
+
fileIndex: base.fileIndex,
|
|
3152
|
+
transcodeVideo: true,
|
|
3153
|
+
transcodeAudio: base.transcodeAudio,
|
|
3154
|
+
fileName: base.fileName,
|
|
3155
|
+
targetWidth: 0,
|
|
3156
|
+
targetHeight: height,
|
|
3157
|
+
startPositionSeconds: processed,
|
|
3158
|
+
audioTrackIndex: base.audioTrackIndex,
|
|
3159
|
+
// A variant is a resolution the viewer chose, so it is encoded at exactly
|
|
3160
|
+
// that size and the realtime budget does not move it — otherwise two
|
|
3161
|
+
// variants could drift onto the same height and the choice would mean
|
|
3162
|
+
// nothing.
|
|
3163
|
+
manualQuality: true,
|
|
3164
|
+
segmentFormatId: base.segmentFormat?.id ?? "",
|
|
3165
|
+
acquireSource: base.acquireSource
|
|
3166
|
+
});
|
|
3167
|
+
}
|
|
3168
|
+
|
|
3169
|
+
/**
|
|
3170
|
+
* The master playlist: every resolution this file can be served at, as HLS
|
|
3171
|
+
* variants.
|
|
3172
|
+
*
|
|
3173
|
+
* This is what makes a change of quality seamless. Our media playlist is VOD
|
|
3174
|
+
* and terminated with `#EXT-X-ENDLIST`, and hls.js only re-reads a playlist
|
|
3175
|
+
* that is live — so rewriting it underneath the player achieves nothing, and
|
|
3176
|
+
* a switch had to tear the player down and build a new session. Offered as
|
|
3177
|
+
* variants instead, the switch is the player's own: it fetches the other
|
|
3178
|
+
* variant, appends it after what is already buffered, and changes the
|
|
3179
|
+
* decoder's type if the codec parameters differ.
|
|
3180
|
+
*
|
|
3181
|
+
* @param {string} sessionId
|
|
3182
|
+
* @returns {string | null} The playlist text, or null when there is nothing
|
|
3183
|
+
* to choose between — a copied video, or a source too small to step down.
|
|
3184
|
+
*/
|
|
3185
|
+
buildMasterPlaylist(sessionId) {
|
|
3186
|
+
const session = this.sessionsById.get(sessionId);
|
|
3187
|
+
if (!session || session.state === "disposed" || !session.transcodeVideo) {
|
|
3188
|
+
return null;
|
|
3189
|
+
}
|
|
3190
|
+
const sourceHeight = Number(session.sourceHeight) || 0;
|
|
3191
|
+
const rungs = variantHeightsFor(sourceHeight);
|
|
3192
|
+
if (rungs.length < 2) {
|
|
3193
|
+
return null;
|
|
3194
|
+
}
|
|
3195
|
+
const sourceWidth = Number(session.sourceWidth) || 0;
|
|
3196
|
+
const lines = ["#EXTM3U", "#EXT-X-VERSION:7"];
|
|
3197
|
+
for (const height of rungs) {
|
|
3198
|
+
const width = sourceHeight > 0 && sourceWidth > 0
|
|
3199
|
+
? Math.round((sourceWidth / sourceHeight) * height / 2) * 2
|
|
3200
|
+
: 0;
|
|
3201
|
+
lines.push(
|
|
3202
|
+
`#EXT-X-STREAM-INF:BANDWIDTH=${estimatedBitrateFor(height)}` +
|
|
3203
|
+
(width > 0 ? `,RESOLUTION=${width}x${height}` : "")
|
|
3204
|
+
);
|
|
3205
|
+
// A subdirectory, so every relative name inside the variant's own
|
|
3206
|
+
// playlist — its segments and its init — resolves to that variant
|
|
3207
|
+
// without any of them having to change.
|
|
3208
|
+
lines.push(`v/${height}/${PLAYLIST_FILE_NAME}`);
|
|
3209
|
+
}
|
|
3210
|
+
return `${lines.join("\n")}\n`;
|
|
3211
|
+
}
|
|
3212
|
+
|
|
3003
3213
|
/**
|
|
3004
3214
|
* How many times the viewer has moved since this session started.
|
|
3005
3215
|
*
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @file What "the encoder is ahead of the viewer" actually means.
|
|
3
|
+
*
|
|
4
|
+
* It was measured as the highest segment number lying in the session's
|
|
5
|
+
* directory. That equals the look-ahead only while a viewer moves forward
|
|
6
|
+
* through one encoder run, and the difference destroyed a session on
|
|
7
|
+
* 2026-08-06: a seek forward left segments 662-665 on disk, the viewer then
|
|
8
|
+
* seeked BACK to 646, and the limiter compared 6950 s of output against a
|
|
9
|
+
* viewer at 6700 s, called it "250s ahead", and suspended a run 136 ms after it
|
|
10
|
+
* started — before it had produced anything. With the encoder stopped nothing
|
|
11
|
+
* read the input, so no pieces were requested (`0 selection(s)` with 33 peers
|
|
12
|
+
* connected) and segment 646 was never made. The viewer sat on a spinner while
|
|
13
|
+
* a stopped ffmpeg was held for being "too far ahead".
|
|
14
|
+
*
|
|
15
|
+
* The measure is the unbroken run of segments starting where the viewer is.
|
|
16
|
+
* Segments beyond a hole are not look-ahead: the viewer cannot reach them until
|
|
17
|
+
* the hole is filled.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import test from "node:test";
|
|
21
|
+
import assert from "node:assert/strict";
|
|
22
|
+
import { contiguousEnd } from "../services/hls-session-manager.js";
|
|
23
|
+
|
|
24
|
+
test("an unbroken run reports its last segment", () => {
|
|
25
|
+
assert.equal(contiguousEnd(new Set([10, 11, 12, 13]), 10), 13);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("a hole stops the count, whatever lies beyond it", () => {
|
|
29
|
+
assert.equal(
|
|
30
|
+
contiguousEnd(new Set([10, 11, 662, 663, 664, 665]), 10),
|
|
31
|
+
11,
|
|
32
|
+
"segments past a gap are unreachable and must not count as ready"
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("the field case: a backward seek onto a segment nobody has made", () => {
|
|
37
|
+
// What the directory held after the forward seek, and where the viewer went.
|
|
38
|
+
const onDisk = new Set([661, 662, 663, 664, 665]);
|
|
39
|
+
assert.equal(
|
|
40
|
+
contiguousEnd(onDisk, 646),
|
|
41
|
+
null,
|
|
42
|
+
"the viewer's own segment is missing — that is a wait, not a look-ahead"
|
|
43
|
+
);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("a single segment counts as itself", () => {
|
|
47
|
+
assert.equal(contiguousEnd(new Set([5]), 5), 5);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("nothing on disk is not zero ahead — it is unknown", () => {
|
|
51
|
+
assert.equal(
|
|
52
|
+
contiguousEnd(new Set(), 0),
|
|
53
|
+
null,
|
|
54
|
+
"zero and null lead to opposite decisions: pause versus encode now"
|
|
55
|
+
);
|
|
56
|
+
});
|