@torrent-tv/proxy 2.48.0 → 2.50.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 CHANGED
@@ -1,3 +1,14 @@
1
+ ## 2.50.0
2
+
3
+ - **Fix**: An AVI seek is asked for late enough to survive the container's own arithmetic. AVI names a keyframe by its frame NUMBER, and `services/container-index/avi.js` turned that into a time by multiplying by the frame duration the header declares — which lands 10-44 ms from the presentation time the demuxer computes, always under one frame (measured 2026-08-21 against the files themselves: 1196 index entries against 1196 real keyframes and 901 against 901, the frames exactly right and only their names off). A name sitting just BELOW its real keyframe seeks to before it and lands on the one before that, which is the same fault the landing offset already exists for. The reader now declares how far its times may be, and the request carries that on top. Matroska and MP4 declare nothing, because they state instants outright — nine files and 11 665 keyframes with not one disagreement.
4
+ - **Fix**: A container with no keyframe index is re-encoded rather than copied against a grid nobody knows. MPEG-TS carries no index of any kind — measured the same day, 669 real keyframes and nothing to read them from without walking the file — and a copied picture can only be cut at the source's own keyframes, so declaring an even grid is a falsehood the player punishes: it walks the whole file to rebuild the timeline, or presents audio with no picture because a segment begins with nothing decodable, both field-observed 2026-08-02. Re-encoding PLACES keyframes on our own cuts, so the grid is right by construction whatever the container. A container whose index could not be read inside the budget lands here too, for the same reason. It costs an encoder, and the alternative was a broken playlist.
5
+
6
+ ## 2.49.0
7
+
8
+ - **Fix**: The torrent worker is allowed to END rather than being torn down under itself. A core dump read on 2026-08-21 named the fault the proxy has been dying of: `SIGSEGV` in `v8::Value::IsArrayBufferView` reached through `napi_get_buffer_info` from utp-native's `on_utp_accept`, called from its UDP read — all of it inside `node::Environment::CleanupHandles`, under `FreeEnvironment`, on `Worker::Run`. That is a teardown race, not a data fault, which is why neither patch our forked library already carries touched it: a datagram arriving while the environment is being freed walks into an isolate that no longer exists. `destroyAll` called `Worker.terminate()` immediately after destroying the client inside, and `terminate()` frees the environment with libuv's handle callbacks still queued. It now waits for the thread to exit by itself — once the client is destroyed nothing holds its loop open — with `terminate()` kept as a five-second fallback, because a shutdown that hangs is worse than one that is forced.
9
+ - **New**: A worker thread that ends is noticed. Only `message` and `error` were listened for, so when the thread went away the proxy simply stopped and the log ended mid-sentence — five times in three days with not one line to say so, and no way to tell our own shutdown from the thread dying. An `exit` handler now says which of the two it was, and fails everything waiting rather than leaving it hanging.
10
+ - **New**: Core dumps are capped at the newest two at startup. Each is the worker's whole address space — 4.18 GB on the field host — and four of them had nearly filled a 235 GB disk. The newest stay because they are the evidence for the fault still open. `dumpsToRemove` is pure and tested.
11
+
1
12
  ## 2.48.0
2
13
 
3
14
  - **Fix**: A copied picture now begins where it was asked to, so its cuts land on the times its playlist names. ffmpeg's own CLI moves an input seek back by `3*AV_TIME_BASE / 23` — **130.435 ms** — whenever the container does not declare `AVFMT_SEEK_TO_PTS` (Matroska does not) and a stream carries B-frames, which is sound in itself: such containers seek in decode order while the caller asks in presentation order. The consequence for a copy is that asking for a keyframe lands on the one BEFORE it, deterministically; and since `-segment_times` is measured from where the run really began while this code computed those offsets from the time it asked for, every cut of the run inherited one whole keyframe interval. Field 2026-08-20: 119 of 125 segments arriving a uniform 2.002 s early against the 0.5 s hls.js bridges, so every fragment was refused and re-fetched — on 2026-08-17 two of them 1908 times each. The request is now made that much later, bounded by half the distance to the next keyframe. Measured 2026-08-21 on Matroska with keyframes every 2 s: `-ss 10` produced a first segment starting at 8.000, `-ss 10.130435` one starting at 10.000; on MP4, where the heuristic does not fire, 10, 10.130435 and 10.2 all produced 10.000 — right in one case and harmless in the other. Not applied when the picture is re-encoded: a re-encode discards frames up to the requested time and already begins exactly there (`-ss 11` copied starts at 10.000, re-encoded at 11.000).
package/bin/cli.js CHANGED
@@ -23,6 +23,7 @@ import { registerClient } from "../services/registry-api.js";
23
23
  import { createTunnelClient } from "../services/tunnel-client.js";
24
24
  import { createWebRtcManager } from "../services/webrtc-manager.js";
25
25
  import { createDataChannelHandler } from "../services/data-channel-handler.js";
26
+ import { pruneCoreDumps } from "../services/core-dumps.js";
26
27
  import { collectHealthMetrics } from "../services/health-collector.js";
27
28
  import { createPortMapper } from "../services/port-mapper.js";
28
29
  import { classifyNat } from "../services/nat-classifier.js";
@@ -294,6 +295,11 @@ try {
294
295
  actualPort = started.port;
295
296
  const directBaseUrl = explicitBaseUrl || `http://${bindHost}:${actualPort}`;
296
297
 
298
+ // A native fault writes the whole address space out — 4.18 GB each on the
299
+ // field host, and four of them nearly filled a 235 GB disk. Keep the newest
300
+ // two, which are the evidence for the fault still open, and drop the rest.
301
+ void pruneCoreDumps(options.stateDir);
302
+
297
303
  logger.info(`Starting @torrent-tv/proxy v${PROXY_VERSION}`);
298
304
  logger.info(`Local stream endpoint: http://${bindHost}:${actualPort}/stream`);
299
305
  logger.info(`Advertised direct URL: ${directBaseUrl}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@torrent-tv/proxy",
3
- "version": "2.48.0",
3
+ "version": "2.50.0",
4
4
  "description": "Torrent proxy client that exposes webseed-like HTTP stream endpoint.",
5
5
  "license": "GPL-3.0-or-later",
6
6
  "publishConfig": {
@@ -152,5 +152,16 @@ export async function readAviKeyframeTimes(readRange, fileSize) {
152
152
  }
153
153
  videoFrame += 1;
154
154
  }
155
- return times.length > 0 ? times : null;
155
+ if (times.length === 0) {
156
+ return null;
157
+ }
158
+ // AVI names a keyframe by its FRAME NUMBER, and the time above is that number
159
+ // multiplied by the frame duration the header declares. The frames are the
160
+ // right ones — measured 2026-08-21 against the files themselves, 1196 index
161
+ // entries against 1196 real keyframes and 901 against 901, exactly — but the
162
+ // names are 10-44 ms away from the presentation times the demuxer computes,
163
+ // always under one frame. So the caller is told how far a time here may be
164
+ // from the instant it refers to, and can ask for a seek late enough that it
165
+ // still lands on the frame rather than on the one before it.
166
+ return { times, tolerance: secondsPerFrame };
156
167
  }
@@ -62,7 +62,7 @@ const READERS = [
62
62
  * @param {ReadRange} params.readRange
63
63
  * @param {number} params.fileSize
64
64
  * @param {string} [params.label] - For logging only.
65
- * @returns {Promise<{ times: number[] | null, format: string }>} Ascending
65
+ * @returns {Promise<{ times: number[] | null, format: string, tolerance: number }>} Ascending
66
66
  * seconds, or null times when this file has no readable index — the caller
67
67
  * must then not claim to know the grid. The format is which reader matched,
68
68
  * reported whether or not it produced anything: how often an index disagrees
@@ -71,27 +71,39 @@ const READERS = [
71
71
  */
72
72
  export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
73
73
  if (typeof readRange !== "function" || !Number.isFinite(fileSize) || fileSize <= 0) {
74
- return { times: null, format: "unknown" };
74
+ return { times: null, format: "unknown", tolerance: 0 };
75
75
  }
76
76
 
77
77
  const startedAt = Date.now();
78
78
  let times = null;
79
79
  let format = "unrecognised";
80
+ // How far a time in `times` may be from the instant it names. Zero wherever
81
+ // the container states instants outright, which Matroska and MP4 both do.
82
+ let tolerance = 0;
80
83
  try {
81
84
  const sniff = await readRange(0, Math.min(SNIFF_BYTES - 1, fileSize - 1));
82
85
  if (!sniff) {
83
- return { times: null, format: "unread" };
86
+ return { times: null, format: "unread", tolerance: 0 };
84
87
  }
85
88
  const reader = READERS.find((candidate) => candidate.matches(sniff));
86
89
  if (reader) {
87
90
  format = reader.name;
88
- times = await reader.read(readRange, fileSize);
91
+ const read = await reader.read(readRange, fileSize);
92
+ // A reader may answer with the times alone, or with how far those times
93
+ // may sit from the instants they name — which only AVI has to say,
94
+ // because only AVI computes them from frame numbers.
95
+ if (Array.isArray(read)) {
96
+ times = read;
97
+ } else if (read && Array.isArray(read.times)) {
98
+ times = read.times;
99
+ tolerance = Number.isFinite(read.tolerance) ? read.tolerance : 0;
100
+ }
89
101
  }
90
102
  } catch (error) {
91
103
  // A malformed or partially-downloaded index must never take playback down —
92
104
  // it only means the grid is unknown, which the caller already handles.
93
105
  logger.warn(`container-index: failed to read index for "${label}": ${error?.message ?? error}`);
94
- return { times: null, format };
106
+ return { times: null, format, tolerance: 0 };
95
107
  }
96
108
 
97
109
  const elapsedMs = Date.now() - startedAt;
@@ -102,5 +114,5 @@ export async function readKeyframeIndex({ readRange, fileSize, label = "" }) {
102
114
  } else {
103
115
  logger.info(`container-index: no usable index for "${label}" (${format}, ${elapsedMs}ms)`);
104
116
  }
105
- return { times, format };
117
+ return { times, format, tolerance };
106
118
  }
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @file Keep the last few core dumps and no more.
3
+ *
4
+ * A native fault on the torrent worker writes the whole address space out: on
5
+ * the field host that is **4.18 GB each**, and four of them had nearly filled a
6
+ * 235 GB disk by 2026-08-21. The dumps are worth having — the one read that day
7
+ * named a fault three days of reasoning had not — but only the recent ones are,
8
+ * and a full disk costs more than an old dump is worth.
9
+ *
10
+ * Deliberately not "delete them all": the newest are the evidence for the fault
11
+ * that is still open (roadmap item 7).
12
+ */
13
+
14
+ import { readdir, rm, stat } from "node:fs/promises";
15
+ import path from "node:path";
16
+
17
+ import { logger } from "../utils/logger.js";
18
+
19
+ /** How many to keep, newest first. */
20
+ export const CORE_DUMPS_KEPT = 2;
21
+
22
+ /**
23
+ * Which dumps to remove, given what is there.
24
+ *
25
+ * Pure, so the rule can be tested without a filesystem: newest first by the
26
+ * time they were written, keep `keep`, name the rest.
27
+ *
28
+ * @param {Array<{ name: string, writtenAt: number }>} dumps
29
+ * @param {number} [keep]
30
+ * @returns {string[]} Names to delete, oldest first.
31
+ */
32
+ export function dumpsToRemove(dumps, keep = CORE_DUMPS_KEPT) {
33
+ const sorted = [...(Array.isArray(dumps) ? dumps : [])]
34
+ .filter((dump) => typeof dump?.name === "string" && Number.isFinite(dump?.writtenAt))
35
+ .sort((left, right) => right.writtenAt - left.writtenAt);
36
+ return sorted.slice(Math.max(0, keep)).map((dump) => dump.name).reverse();
37
+ }
38
+
39
+ /**
40
+ * Whether a file name is a core dump this host wrote.
41
+ *
42
+ * The kernel's pattern on the addon host produces `core.<thread>.<pid>.<epoch>`
43
+ * — every one seen so far is `core.WorkerThread.81.…`, the thread the torrent
44
+ * client runs on. Matched loosely on the `core.` prefix so a differently
45
+ * configured host is still swept.
46
+ *
47
+ * @param {string} name
48
+ * @returns {boolean}
49
+ */
50
+ export function isCoreDump(name) {
51
+ return typeof name === "string" && /^core\.[^/\\]+$/.test(name);
52
+ }
53
+
54
+ /**
55
+ * Delete all but the newest few core dumps in `dir`.
56
+ *
57
+ * Best-effort and never fatal: a proxy that cannot tidy its dumps still has to
58
+ * serve video.
59
+ *
60
+ * @param {string} dir
61
+ * @param {number} [keep]
62
+ * @returns {Promise<void>}
63
+ */
64
+ export async function pruneCoreDumps(dir, keep = CORE_DUMPS_KEPT) {
65
+ if (typeof dir !== "string" || dir.length === 0) {
66
+ return;
67
+ }
68
+ /** @type {Array<{ name: string, writtenAt: number, bytes: number }>} */
69
+ const dumps = [];
70
+ try {
71
+ for (const name of await readdir(dir)) {
72
+ if (!isCoreDump(name)) {
73
+ continue;
74
+ }
75
+ try {
76
+ const info = await stat(path.join(dir, name));
77
+ if (info.isFile()) {
78
+ dumps.push({ name, writtenAt: info.mtimeMs, bytes: info.size });
79
+ }
80
+ } catch {
81
+ // silent-ok: it went away between listing and reading, which is the
82
+ // outcome this function wanted anyway.
83
+ }
84
+ }
85
+ } catch {
86
+ return; // No such directory, or unreadable. Nothing to tidy.
87
+ }
88
+ if (dumps.length === 0) {
89
+ return;
90
+ }
91
+ const doomed = dumpsToRemove(dumps, keep);
92
+ const freed = dumps
93
+ .filter((dump) => doomed.includes(dump.name))
94
+ .reduce((total, dump) => total + dump.bytes, 0);
95
+ for (const name of doomed) {
96
+ try {
97
+ await rm(path.join(dir, name), { force: true });
98
+ } catch {
99
+ // silent-ok: best effort, and the next start tries again.
100
+ }
101
+ }
102
+ logger.info(
103
+ `core dumps: ${dumps.length} present, keeping the newest ${Math.min(keep, dumps.length)}` +
104
+ (doomed.length > 0 ? `, removed ${doomed.length} (${(freed / 1e9).toFixed(2)} GB)` : "")
105
+ );
106
+ }
@@ -1196,12 +1196,17 @@ export function seekLandingOffsetFor(session, keyframe) {
1196
1196
  if (session?.transcodeVideo === true) {
1197
1197
  return 0;
1198
1198
  }
1199
+ // A grid whose times are approximate needs that error added on top, or a name
1200
+ // sitting just below its real keyframe seeks to before it and lands on the
1201
+ // one before that. Only AVI declares one.
1202
+ const tolerance = Number.isFinite(session?.keyframeTolerance) ? Math.max(0, session.keyframeTolerance) : 0;
1203
+ const wanted = SEEK_LANDING_OFFSET_SEC + tolerance;
1199
1204
  const times = Array.isArray(session?.keyframeTimes) ? session.keyframeTimes : [];
1200
1205
  const next = times.find((time) => time > keyframe + 0.001);
1201
1206
  if (next === undefined) {
1202
- return SEEK_LANDING_OFFSET_SEC;
1207
+ return wanted;
1203
1208
  }
1204
- return Math.min(SEEK_LANDING_OFFSET_SEC, (next - keyframe) / 2);
1209
+ return Math.min(wanted, (next - keyframe) / 2);
1205
1210
  }
1206
1211
 
1207
1212
  /**
@@ -1746,6 +1751,13 @@ export class HlsSessionManager {
1746
1751
  // branches; on failure both fall back to their current behaviour (uniform
1747
1752
  // grid for boundaries, raw target for seeking) — no regression.
1748
1753
  let keyframeTimes = null;
1754
+ // How far a time in `keyframeTimes` may sit from the instant it names. Only
1755
+ // AVI has anything to declare here: it stores frame NUMBERS and the time is
1756
+ // that number times the frame duration, which lands 10-44 ms from the
1757
+ // presentation time the demuxer computes (measured 2026-08-21). A seek made
1758
+ // at such a name can fall just BELOW the real keyframe and land on the one
1759
+ // before it, which is the same fault the landing offset exists for.
1760
+ let keyframeTolerance = 0;
1749
1761
  let keyframeMs = -1; // -1 = not run (skipped), -2 = running in the background
1750
1762
  // Which container supplied the index, carried so the accuracy summary can
1751
1763
  // say what it is a summary OF.
@@ -1758,6 +1770,9 @@ export class HlsSessionManager {
1758
1770
  if (inheritedGrid) {
1759
1771
  keyframeTimes = inheritedGrid.keyframeTimes;
1760
1772
  containerFormat = inheritedGrid.containerFormat ?? "";
1773
+ keyframeTolerance = Number.isFinite(inheritedGrid.keyframeTolerance)
1774
+ ? inheritedGrid.keyframeTolerance
1775
+ : 0;
1761
1776
  } else if (hasDuration && !transcodeVideo && !audioOnly) {
1762
1777
  // Video-COPY path: keyframeTimes are REQUIRED to build correct segment
1763
1778
  // boundaries (the playlist itself), so this MUST block session creation —
@@ -1779,11 +1794,28 @@ export class HlsSessionManager {
1779
1794
  const index = await this.#readContainerKeyframes({ sourceKey, fileIndex, inputUrl, logName });
1780
1795
  keyframeTimes = index.times;
1781
1796
  containerFormat = index.format;
1797
+ keyframeTolerance = Number.isFinite(index.tolerance) ? index.tolerance : 0;
1782
1798
  keyframeMs = Date.now() - keyframeStartMs;
1783
1799
  if (!keyframeTimes) {
1800
+ // No index, so there is no honest grid for a COPY: a copied picture can
1801
+ // only be cut at the source's own keyframes, and we do not know where
1802
+ // they are. Declaring an even grid instead is a falsehood the player
1803
+ // punishes — it walks the whole file to rebuild the timeline, or shows
1804
+ // audio with no picture because a segment begins with nothing
1805
+ // decodable (both field-observed 2026-08-02).
1806
+ //
1807
+ // Re-encoding is the honest answer and costs an encoder: keyframes are
1808
+ // then PLACED at our own cut times rather than found, so the grid is
1809
+ // correct by construction whatever the container. MPEG-TS is the case
1810
+ // this exists for — measured 2026-08-21, 669 real keyframes and no
1811
+ // index of any kind to read them from — and a container whose index
1812
+ // could not be read in the budget lands here too, which is right for
1813
+ // the same reason.
1814
+ transcodeVideo = true;
1784
1815
  logger.warn(
1785
- `transcode ${sessionId}: no container keyframe index for "${logName}"; ` +
1786
- `falling back to a uniform grid segment boundaries will not match the media`
1816
+ `transcode ${sessionId}: no keyframe index in the ${containerFormat} container for ` +
1817
+ `"${logName}" a copied picture has no honest grid without one, so the video is ` +
1818
+ "re-encoded instead and its keyframes are placed on our own cuts"
1787
1819
  );
1788
1820
  }
1789
1821
  } else if (hasDuration && transcodeVideo) {
@@ -2035,6 +2067,9 @@ export class HlsSessionManager {
2035
2067
  // KNOWN valid position instead of trusting the container's own on-the-fly
2036
2068
  // seek at an arbitrary target — see the probe call above for why.
2037
2069
  keyframeTimes,
2070
+ // How far those times may sit from the instants they name — nonzero only
2071
+ // for AVI, which computes them from frame numbers.
2072
+ keyframeTolerance,
2038
2073
  // Which container the index came from, and how well it has held up. The
2039
2074
  // cut times of a copied video ARE its index, and an index can be wrong —
2040
2075
  // measured 2026-08-06, one claimed a keyframe four seconds from where the
@@ -6848,6 +6883,7 @@ export class HlsSessionManager {
6848
6883
  // creations (field 2026-08-17, corrections of 0.6-2.9 s).
6849
6884
  published: base.publishedBoundaries,
6850
6885
  keyframeTimes: base.keyframeTimes,
6886
+ keyframeTolerance: base.keyframeTolerance,
6851
6887
  containerFormat: base.containerFormat
6852
6888
  }
6853
6889
  : null,
@@ -22,6 +22,15 @@ import { createCaller, createReceiveStream } from "./channel.js";
22
22
  import { Command, Event } from "./protocol.js";
23
23
 
24
24
  const WORKER_URL = new URL("./worker.js", import.meta.url);
25
+ /**
26
+ * How long a shutdown waits for the thread to end by itself before forcing it.
27
+ *
28
+ * Ending by itself is what lets libuv drain the handle callbacks it is holding;
29
+ * forcing it is what ran one of those against a freed isolate. Five seconds is
30
+ * long enough for a destroyed torrent client to release its sockets and short
31
+ * enough that a shutdown never appears to hang.
32
+ */
33
+ const WORKER_EXIT_GRACE_MS = 5_000;
25
34
 
26
35
  /**
27
36
  * Runs the torrent client on its own thread and exposes it to the main thread.
@@ -34,6 +43,12 @@ const WORKER_URL = new URL("./worker.js", import.meta.url);
34
43
  */
35
44
  export class TorrentWorkerClient {
36
45
  #worker;
46
+ /**
47
+ * Whether this shutdown was asked for, so the thread ending can be told from
48
+ * the thread dying. Without it both look identical from outside, which is how
49
+ * five crashes produced no line in the log.
50
+ */
51
+ #stopping = false;
37
52
  #caller;
38
53
  /** Receive-side handles for in-flight reads, keyed by request id. */
39
54
  #reads = new Map();
@@ -186,6 +201,30 @@ export class TorrentWorkerClient {
186
201
  }
187
202
  this.#reads.clear();
188
203
  });
204
+
205
+ // A worker that ENDS was, until now, not noticed at all: only `message` and
206
+ // `error` were listened for. So when the thread went away the proxy simply
207
+ // stopped, the log ended mid-sentence, and nothing said whether we had
208
+ // asked for it — which is precisely the reading that was missing on
209
+ // 2026-08-21, when a core dump showed the thread faulting inside
210
+ // `Environment::CleanupHandles` and there was no way to tell our own
211
+ // shutdown from the thread ending on its own.
212
+ this.#worker.on("exit", (code) => {
213
+ if (this.#stopping) {
214
+ logger.info(`torrent-worker: thread ended as asked (code ${code})`);
215
+ return;
216
+ }
217
+ logger.error(
218
+ `torrent-worker: thread ended on its own with code ${code} — nobody asked it to. ` +
219
+ "Everything waiting on it is failed; the proxy has no torrent client until it is rebuilt."
220
+ );
221
+ const reason = new Error("Torrent worker ended unexpectedly.");
222
+ this.#caller.rejectAll(reason);
223
+ for (const [, read] of this.#reads) {
224
+ read.fail(reason);
225
+ }
226
+ this.#reads.clear();
227
+ });
189
228
  }
190
229
 
191
230
  /**
@@ -520,11 +559,33 @@ export class TorrentWorkerClient {
520
559
  * @returns {Promise<void>}
521
560
  */
522
561
  async destroyAll() {
562
+ this.#stopping = true;
523
563
  try {
524
564
  await this.#caller.call(Command.DESTROY_ALL, {});
525
565
  } catch {
526
- // Already gone — termination below is what matters.
566
+ // Already gone — the wait below settles either way.
527
567
  }
528
- await this.#worker.terminate();
568
+ // Let the thread END rather than tearing it down under itself.
569
+ //
570
+ // `terminate()` frees the environment immediately, with the handle
571
+ // callbacks libuv still holds queued. One of those is utp-native's UDP
572
+ // read, and running it against a freed isolate is what the core dump of
573
+ // 2026-08-21 caught: `on_utp_accept` → `napi_get_buffer_info` →
574
+ // `v8::Value::IsArrayBufferView` inside `Environment::CleanupHandles`.
575
+ // Once the client inside is destroyed the thread has nothing left holding
576
+ // its loop open, so it exits by itself and the callbacks drain first.
577
+ //
578
+ // `terminate()` stays as the bounded fallback, because a shutdown that
579
+ // hangs is worse than one that is forced.
580
+ await new Promise((resolve) => {
581
+ const timer = setTimeout(() => {
582
+ logger.warn("torrent-worker: thread did not end in 5s; terminating it");
583
+ void this.#worker.terminate().finally(resolve);
584
+ }, WORKER_EXIT_GRACE_MS);
585
+ this.#worker.once("exit", () => {
586
+ clearTimeout(timer);
587
+ resolve();
588
+ });
589
+ });
529
590
  }
530
591
  }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * @file Keep the last few core dumps and no more.
3
+ *
4
+ * Each is the worker thread's whole address space — 4.18 GB on the field host —
5
+ * and four of them had nearly filled a 235 GB disk by 2026-08-21. The newest
6
+ * are evidence for a fault that is still open, so they stay; the rest go.
7
+ */
8
+
9
+ import assert from "node:assert/strict";
10
+ import test from "node:test";
11
+
12
+ import { dumpsToRemove, isCoreDump } from "../services/core-dumps.js";
13
+
14
+ const dumps = [
15
+ { name: "core.WorkerThread.81.1787176646", writtenAt: 1787176646000 },
16
+ { name: "core.WorkerThread.81.1787224562", writtenAt: 1787224562000 },
17
+ { name: "core.WorkerThread.81.1787237468", writtenAt: 1787237468000 },
18
+ { name: "core.WorkerThread.81.1787243278", writtenAt: 1787243278000 },
19
+ { name: "core.WorkerThread.81.1787292750", writtenAt: 1787292750000 }
20
+ ];
21
+
22
+ test("the newest two stay and the rest go, oldest first", () => {
23
+ assert.deepEqual(dumpsToRemove(dumps), [
24
+ "core.WorkerThread.81.1787176646",
25
+ "core.WorkerThread.81.1787224562",
26
+ "core.WorkerThread.81.1787237468"
27
+ ]);
28
+ });
29
+
30
+ test("fewer than the limit leaves everything alone", () => {
31
+ assert.deepEqual(dumpsToRemove(dumps.slice(0, 2)), []);
32
+ assert.deepEqual(dumpsToRemove([]), []);
33
+ });
34
+
35
+ test("order on disk does not decide; the time written does", () => {
36
+ const shuffled = [dumps[3], dumps[0], dumps[4], dumps[2], dumps[1]];
37
+ assert.deepEqual(dumpsToRemove(shuffled, 1), [
38
+ "core.WorkerThread.81.1787176646",
39
+ "core.WorkerThread.81.1787224562",
40
+ "core.WorkerThread.81.1787237468",
41
+ "core.WorkerThread.81.1787243278"
42
+ ]);
43
+ });
44
+
45
+ test("only core dumps are considered", () => {
46
+ assert.equal(isCoreDump("core.WorkerThread.81.1787292750"), true);
47
+ assert.equal(isCoreDump("proxy.log"), false);
48
+ assert.equal(isCoreDump("host-timings.json"), false);
49
+ // Not a path, and not a directory called core.
50
+ assert.equal(isCoreDump("core.foo/bar"), false);
51
+ assert.equal(isCoreDump(""), false);
52
+ });
53
+
54
+ test("nothing readable is not a reason to delete anything", () => {
55
+ assert.deepEqual(dumpsToRemove(null), []);
56
+ assert.deepEqual(dumpsToRemove([{ name: 5, writtenAt: "x" }]), []);
57
+ });
@@ -48,3 +48,29 @@ test("no keyframe list is still answered", () => {
48
48
  assert.equal(seekLandingOffsetFor({ transcodeVideo: false }, 5), OFFSET);
49
49
  assert.equal(seekLandingOffsetFor(null, 5), OFFSET);
50
50
  });
51
+
52
+ test("a grid whose times are approximate is asked for that much later again", () => {
53
+ // AVI names a keyframe by its frame NUMBER and the time is that number times
54
+ // the frame duration, so a name can sit just BELOW the keyframe it refers to
55
+ // — measured 2026-08-21, 10-44 ms out on two files, always under one frame.
56
+ // Asking at the name alone would seek to before the real keyframe and land on
57
+ // the one before that, which is the fault this offset exists for.
58
+ const session = {
59
+ transcodeVideo: false,
60
+ keyframeTolerance: 0.04,
61
+ keyframeTimes: [0, 4.004, 8.008, 12.012]
62
+ };
63
+ assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET + 0.04);
64
+ });
65
+
66
+ test("an exact grid claims no tolerance", () => {
67
+ // Matroska and MP4 state instants outright — measured the same day, nine
68
+ // files and 11 665 keyframes with not one disagreement.
69
+ const session = { transcodeVideo: false, keyframeTolerance: 0, keyframeTimes: [0, 4.004, 8.008] };
70
+ assert.equal(seekLandingOffsetFor(session, 4.004), OFFSET);
71
+ });
72
+
73
+ test("the bound still holds once a tolerance is added", () => {
74
+ const session = { transcodeVideo: false, keyframeTolerance: 1, keyframeTimes: [0, 0.1, 0.2] };
75
+ assert.equal(seekLandingOffsetFor(session, 0.1), 0.05);
76
+ });