@torrent-tv/proxy 2.89.16 → 2.89.18
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/README.md +14 -3
- package/package.json +10 -2
- package/routes/api/client-logs/post.js +9 -2
- package/server.js +0 -11
- package/.claude/commands/opsx/apply.md +0 -155
- package/.claude/commands/opsx/archive.md +0 -160
- package/.claude/commands/opsx/explore.md +0 -174
- package/.claude/commands/opsx/propose.md +0 -109
- package/.claude/commands/opsx/sync.md +0 -143
- package/.claude/skills/openspec-apply-change/SKILL.md +0 -159
- package/.claude/skills/openspec-archive-change/SKILL.md +0 -117
- package/.claude/skills/openspec-explore/SKILL.md +0 -289
- package/.claude/skills/openspec-propose/SKILL.md +0 -113
- package/.claude/skills/openspec-sync-specs/SKILL.md +0 -147
- package/.dockerignore +0 -5
- package/AGENTS.md +0 -363
- package/CHANGELOG.md +0 -2373
- package/Dockerfile +0 -30
- package/biome.json +0 -412
- package/corpus.tmp.json +0 -1
- package/docs/browser-proxy-contract.md +0 -155
- package/docs/container-architecture.md +0 -273
- package/docs/download-architecture.md +0 -261
- package/docs/encode-architecture.md +0 -765
- package/docs/encode-run-state.md +0 -96
- package/docs/logs.md +0 -138
- package/docs/playback-start-forecast.md +0 -113
- package/docs/socks5-egress-research.md +0 -114
- package/docs/storage-architecture.md +0 -273
- package/knip.json +0 -14
- package/main2.cpuprofile +0 -1
- package/openspec/changes/adaptive-bitrate/.openspec.yaml +0 -0
- package/openspec/changes/adaptive-bitrate/design.md +0 -92
- package/openspec/changes/adaptive-bitrate/proposal.md +0 -56
- package/openspec/changes/adaptive-bitrate/specs/adaptive-bitrate/spec.md +0 -51
- package/openspec/changes/adaptive-bitrate/tasks.md +0 -40
- package/openspec/changes/binary-distribution/.openspec.yaml +0 -2
- package/openspec/changes/binary-distribution/proposal.md +0 -53
- package/openspec/changes/chunked-request-bodies/design.md +0 -81
- package/openspec/changes/chunked-request-bodies/proposal.md +0 -53
- package/openspec/changes/chunked-request-bodies/specs/chunked-request-bodies/spec.md +0 -30
- package/openspec/changes/chunked-request-bodies/tasks.md +0 -40
- package/openspec/changes/cold-start/design.md +0 -98
- package/openspec/changes/cold-start/proposal.md +0 -62
- package/openspec/changes/cold-start/specs/cold-start/spec.md +0 -47
- package/openspec/changes/cold-start/tasks.md +0 -48
- package/openspec/changes/disk-cap/.openspec.yaml +0 -2
- package/openspec/changes/disk-cap/proposal.md +0 -37
- package/openspec/changes/disk-cap/specs/disk-cap/spec.md +0 -25
- package/openspec/changes/disk-cap/tasks.md +0 -19
- package/openspec/changes/proxy-observability/.openspec.yaml +0 -2
- package/openspec/changes/proxy-observability/design.md +0 -38
- package/openspec/changes/proxy-observability/proposal.md +0 -49
- package/openspec/changes/proxy-observability/specs/observability/spec.md +0 -33
- package/openspec/changes/proxy-observability/tasks.md +0 -21
- package/openspec/changes/seek-debounce/.openspec.yaml +0 -0
- package/openspec/changes/seek-debounce/design.md +0 -118
- package/openspec/changes/seek-debounce/proposal.md +0 -59
- package/openspec/changes/seek-debounce/specs/seek-debounce/spec.md +0 -41
- package/openspec/changes/seek-debounce/tasks.md +0 -33
- package/openspec/changes/subtitle-language/.openspec.yaml +0 -2
- package/openspec/changes/subtitle-language/proposal.md +0 -50
- package/openspec/changes/subtitle-language/specs/subtitle-language/spec.md +0 -34
- package/openspec/changes/subtitle-language/tasks.md +0 -26
- package/openspec/changes/track-selection/.openspec.yaml +0 -2
- package/openspec/changes/track-selection/design.md +0 -47
- package/openspec/changes/track-selection/proposal.md +0 -45
- package/openspec/changes/track-selection/specs/track-selection/spec.md +0 -41
- package/openspec/changes/track-selection/tasks.md +0 -20
- package/openspec/changes/transcode-quality/.openspec.yaml +0 -2
- package/openspec/changes/transcode-quality/proposal.md +0 -51
- package/openspec/changes/transcode-quality/specs/transcode-quality/spec.md +0 -139
- package/openspec/changes/transcode-quality/tasks.md +0 -63
- package/openspec/config.yaml +0 -38
- package/public/.well-known/appspecific/com.chrome.devtools.json +0 -1
- package/research/delivery-wedge-stand-ladder-2026-09-13.md +0 -329
- package/research/double-spawn-2026-09-10.md +0 -171
- package/research/handover-2026-09-13.md +0 -209
- package/research/handover-reader-claims-removal-2026-09-12.md +0 -166
- package/research/piece-withdrawn-but-still-claimed-2026-09-12.md +0 -175
- package/research/preset-benchmark-size-scaling-2026-08-22.md +0 -55
- package/research/priority-map-is-the-truth-2026-09-12.md +0 -225
- package/research/valgrind-replica-2026-08-27/logs.tar.gz +0 -0
- package/scripts/build-audio-samples.mjs +0 -31
- package/scripts/render-run-graph.js +0 -173
- package/test/asset-paths.test.js +0 -28
- package/test/audio-buffer-continuity.test.js +0 -77
- package/test/audio-calibration.test.js +0 -23
- package/test/audio-inventory.test.js +0 -212
- package/test/audio-track-bitrate.test.js +0 -40
- package/test/audio-warm-recorded.test.js +0 -63
- package/test/auto-quality-step.test.js +0 -762
- package/test/available-share.test.js +0 -76
- package/test/background-fill.test.js +0 -190
- package/test/client-log-file.test.js +0 -126
- package/test/coalesce.test.js +0 -39
- package/test/completed-files.test.js +0 -115
- package/test/concurrent-cost.test.js +0 -148
- package/test/container-media-info.test.js +0 -228
- package/test/contention.test.js +0 -76
- package/test/core-dumps.test.js +0 -57
- package/test/coverage-follows-the-disk.test.js +0 -233
- package/test/coverage-map.test.js +0 -195
- package/test/cue-push-carries-what-the-walk-found.test.js +0 -58
- package/test/cut-times-timeline.test.js +0 -65
- package/test/cuts-follow-published-grid.test.js +0 -109
- package/test/data-channel-frame.test.js +0 -63
- package/test/data-channel-viewer-lifecycle.test.js +0 -193
- package/test/dead-torrent-handle.test.js +0 -52
- package/test/declared-rates.test.js +0 -116
- package/test/declared-tracks.test.js +0 -43
- package/test/decode-cost-fit.test.js +0 -202
- package/test/decode-cost.test.js +0 -475
- package/test/delivery-probe-field.test.js +0 -173
- package/test/delivery-probe.test.js +0 -382
- package/test/demand-register.test.js +0 -206
- package/test/dht-bootstrap.test.js +0 -72
- package/test/drain-threat.test.js +0 -65
- package/test/duplicate-add.test.js +0 -48
- package/test/encode-admission.test.js +0 -170
- package/test/encode-cost.test.js +0 -275
- package/test/encode-exit.test.js +0 -111
- package/test/encode-orchestrator.test.js +0 -426
- package/test/encode-plan-viewers.test.js +0 -725
- package/test/encode-plan.test.js +0 -545
- package/test/encode-run-state.test.js +0 -314
- package/test/encode-run.test.js +0 -248
- package/test/encode-slope.test.js +0 -46
- package/test/encoded-output.test.js +0 -39
- package/test/encoder-kinds.test.js +0 -122
- package/test/encoder-readings.test.js +0 -68
- package/test/episode-naming.test.js +0 -131
- package/test/every-claimant-is-told-its-share.test.js +0 -152
- package/test/failed-encoder-still-serves-what-exists.test.js +0 -354
- package/test/failed-start-breaker.test.js +0 -202
- package/test/fastest-wires.test.js +0 -143
- package/test/ffmpeg-stream-counts.test.js +0 -101
- package/test/file-edges.test.js +0 -191
- package/test/files-in-use.test.js +0 -34
- package/test/fixtures/audio-frame-boundary.json +0 -1
- package/test/fixtures/refused-audio-20261002.json +0 -10
- package/test/fmp4-presentation-clock.test.js +0 -148
- package/test/fmp4-self-contained.test.js +0 -80
- package/test/fmp4-timestamps.test.js +0 -140
- package/test/fmp4-track-completeness.test.js +0 -41
- package/test/format-keyed-outputs.test.js +0 -78
- package/test/health-metrics.test.js +0 -36
- package/test/held-read-ownership.test.js +0 -77
- package/test/held-request-width.test.js +0 -157
- package/test/helpers/encode-run.js +0 -248
- package/test/helpers/held-file.js +0 -77
- package/test/helpers/manager.js +0 -62
- package/test/helpers/matroska-file.js +0 -296
- package/test/helpers/output-spec.js +0 -63
- package/test/host-load.test.js +0 -68
- package/test/index-accuracy-is-about-the-file.test.js +0 -128
- package/test/init-compat.test.js +0 -219
- package/test/input-lost-quiets-the-plan.test.js +0 -261
- package/test/input-unavailable.test.js +0 -48
- package/test/installation-id.test.js +0 -39
- package/test/keeping-period.test.js +0 -83
- package/test/keyframe-index-accuracy.test.js +0 -188
- package/test/keyframe-table-budget.test.js +0 -123
- package/test/keyframes-belong-to-the-file.test.js +0 -249
- package/test/learned-median.test.js +0 -37
- package/test/letting-go-asks-who-is-watching.test.js +0 -191
- package/test/limit-row-by-area.test.js +0 -60
- package/test/link-budget.test.js +0 -111
- package/test/link-probe.test.js +0 -56
- package/test/local-encoding-data.test.js +0 -273
- package/test/logger-repeats.test.js +0 -134
- package/test/look-ahead-coverage.test.js +0 -56
- package/test/manager-surface.test.js +0 -291
- package/test/matroska-blocks.test.js +0 -0
- package/test/matroska-cluster-discovery.test.js +0 -323
- package/test/matroska-cues-track.test.js +0 -192
- package/test/media-time.test.js +0 -62
- package/test/memory-budget.test.js +0 -390
- package/test/move-cost.test.js +0 -216
- package/test/mp4-composition-times.test.js +0 -0
- package/test/mp4-subtitles.test.js +0 -173
- package/test/one-authority.test.js +0 -285
- package/test/one-budget.test.js +0 -149
- package/test/one-output-one-session.test.js +0 -273
- package/test/one-resource-divided.test.js +0 -185
- package/test/one-serving-output-per-height.test.js +0 -458
- package/test/orchestrator-wired.test.js +0 -207
- package/test/output-catalog.test.js +0 -39
- package/test/output-shape.test.js +0 -21
- package/test/output-spec.test.js +0 -251
- package/test/output-speed.test.js +0 -92
- package/test/outputs-being-prepared.test.js +0 -40
- package/test/packet-witness-ring.test.js +0 -236
- package/test/packet-witness.test.js +0 -148
- package/test/piece-completeness.test.js +0 -78
- package/test/piece-disk-store.test.js +0 -401
- package/test/piece-from-whole-file.test.js +0 -129
- package/test/piece-lru.test.js +0 -319
- package/test/piece-reader.test.js +0 -162
- package/test/piece-selection-offset.test.js +0 -85
- package/test/piece-store-eviction.test.js +0 -595
- package/test/piece-store-never-refuses.test.js +0 -153
- package/test/piece-store-reservations.test.js +0 -403
- package/test/piece-store-slow-disk.test.js +0 -159
- package/test/piece-tail.test.js +0 -97
- package/test/plan-host-timings.test.js +0 -68
- package/test/plan-is-steady.test.js +0 -225
- package/test/playback-readiness.test.js +0 -518
- package/test/playback-start-regressions.test.js +0 -101
- package/test/priority-map-download.test.js +0 -174
- package/test/priority-map-emptied.test.js +0 -204
- package/test/priority-map-per-output.test.js +0 -215
- package/test/priority-map.test.js +0 -163
- package/test/probe-wedge-certainty.test.js +0 -60
- package/test/produced-copy-choice.test.js +0 -213
- package/test/published-timeline.test.js +0 -83
- package/test/quality-offer.test.js +0 -160
- package/test/quality-variants.test.js +0 -1274
- package/test/rate-control-in-the-output.test.js +0 -181
- package/test/read-survives-withdrawal.test.js +0 -164
- package/test/read-window.test.js +0 -365
- package/test/reads-not-yet-arrived.test.js +0 -218
- package/test/resume-warm.test.js +0 -39
- package/test/run-graph-drift.test.js +0 -37
- package/test/run-inputs.test.js +0 -80
- package/test/run-intervals.test.js +0 -100
- package/test/run-position-follows-published-grid.test.js +0 -89
- package/test/run-progress.test.js +0 -64
- package/test/same-height-switch.test.js +0 -663
- package/test/seek-frees-the-viewers-requests.test.js +0 -217
- package/test/seek-landing.test.js +0 -109
- package/test/seek-target-not-superseded.test.js +0 -195
- package/test/segment-closed-on-both-branches.test.js +0 -114
- package/test/segment-cut-times.test.js +0 -63
- package/test/segment-demand.test.js +0 -80
- package/test/segment-hold-supersede.test.js +0 -91
- package/test/segment-judged-by-its-own-header.test.js +0 -222
- package/test/segment-serve-wiring.test.js +0 -481
- package/test/segment-store-eviction.test.js +0 -247
- package/test/segment-store.test.js +0 -352
- package/test/segment-true-start.test.js +0 -86
- package/test/segments-are-shared.test.js +0 -176
- package/test/send-chunk-bytes.test.js +0 -86
- package/test/serving-existing-output.test.js +0 -111
- package/test/serving-output.test.js +0 -102
- package/test/shared-piece-store.test.js +0 -310
- package/test/sidecar-naming.test.js +0 -142
- package/test/soundtrack-rate-bound.test.js +0 -289
- package/test/source-file.test.js +0 -133
- package/test/source-files-route.test.js +0 -113
- package/test/source-registry.test.js +0 -18
- package/test/split-a-stretch.test.js +0 -139
- package/test/stale-request-after-seek.test.js +0 -158
- package/test/startup-readings.test.js +0 -124
- package/test/stream-route.test.js +0 -237
- package/test/subtitle-cue-framing.test.js +0 -200
- package/test/subtitle-cue-source.test.js +0 -102
- package/test/subtitle-cue-walk.test.js +0 -385
- package/test/subtitle-cursor.test.js +0 -63
- package/test/subtitle-defaults.test.js +0 -118
- package/test/subtitle-language.test.js +0 -252
- package/test/subtitle-metadata.test.js +0 -15
- package/test/subtitle-plan-waits.test.js +0 -125
- package/test/subtitle-track-numbering.test.js +0 -391
- package/test/supply-margin.test.js +0 -190
- package/test/swarm-follows-readers.test.js +0 -174
- package/test/swarm-reach.test.js +0 -130
- package/test/swarm-selection.test.js +0 -220
- package/test/tail-duplication.test.js +0 -167
- package/test/the-channel-has-both-ends.test.js +0 -91
- package/test/the-torrent-thread-serves-bytes.test.js +0 -107
- package/test/thread-pool.test.js +0 -53
- package/test/timeline.test.js +0 -84
- package/test/torrent-contents.test.js +0 -235
- package/test/torrent-cost.test.js +0 -71
- package/test/torrent-files.test.js +0 -221
- package/test/torrent-naming.test.js +0 -255
- package/test/tracks-begin-together.test.js +0 -209
- package/test/tunnel-renewal.test.js +0 -167
- package/test/two-viewers-one-picture.test.js +0 -508
- package/test/upload-hurry.test.js +0 -186
- package/test/usrsctp-state.test.js +0 -131
- package/test/video-facts.test.js +0 -102
- package/test/viewer-assignments.test.js +0 -143
- package/test/viewer-outputs.test.js +0 -285
- package/test/viewer-presence.test.js +0 -116
- package/test/viewer-requests-admission.test.js +0 -92
- package/test/viewer-starts-on-map.test.js +0 -27
- package/test/viewer-states.test.js +0 -192
- package/test/viewer-subtitle-subscription.test.js +0 -96
- package/test/viewer.test.js +0 -139
- package/test/viewing-generations.test.js +0 -390
- package/test/visible-rung.test.js +0 -37
- package/test/wait-ledger.test.js +0 -100
- package/test/webrtc-shim.test.js +0 -149
- package/test/wedge-certainty.test.js +0 -131
- package/test/whole-file-held-read.test.js +0 -30
- package/test/withdraw-piece-claim.test.js +0 -212
- package/test/worker-channel.test.js +0 -158
|
@@ -1,153 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file A store that is short of memory must inconvenience a read, never end a
|
|
3
|
-
* torrent.
|
|
4
|
-
*
|
|
5
|
-
* The field failure of 2026-09-11, in one sentence: the store could not hand
|
|
6
|
-
* out a block, threw, the throw travelled out through the torrent client's own
|
|
7
|
-
* write callback, and the client destroyed the torrent. For the rest of the
|
|
8
|
-
* process every read of that film answered `File 1 not found in
|
|
9
|
-
* torrent:d4022ff4…`, `/stats` reported `peers=0 connected of 1186 known`, and
|
|
10
|
-
* the viewer could not open anything until the addon was restarted.
|
|
11
|
-
*
|
|
12
|
-
* Three properties hold it shut, and each is checked here on its own:
|
|
13
|
-
*
|
|
14
|
-
* 1. an arriving piece is never refused — it has the disk;
|
|
15
|
-
* 2. a read on the torrent client's own path takes no block at all, so an
|
|
16
|
-
* upload can neither wait for memory nor be refused it;
|
|
17
|
-
* 3. a store between readers keeps room for one window, because that is what
|
|
18
|
-
* the next read asks for and the allowance is otherwise re-derived a
|
|
19
|
-
* minute later — twelve times slower than a claim gives up.
|
|
20
|
-
*/
|
|
21
|
-
|
|
22
|
-
import test from "node:test";
|
|
23
|
-
import assert from "node:assert/strict";
|
|
24
|
-
import fs from "node:fs/promises";
|
|
25
|
-
import os from "node:os";
|
|
26
|
-
import path from "node:path";
|
|
27
|
-
import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
|
|
28
|
-
|
|
29
|
-
const PIECE = 1024;
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* @param {SharedPieceStore} store
|
|
33
|
-
* @param {number} index
|
|
34
|
-
* @returns {Promise<void>}
|
|
35
|
-
*/
|
|
36
|
-
const put = (store, index) =>
|
|
37
|
-
new Promise((resolve, reject) => {
|
|
38
|
-
store.put(index, Buffer.alloc(PIECE, index % 251), (error) => (error ? reject(error) : resolve()));
|
|
39
|
-
});
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* @param {SharedPieceStore} store
|
|
43
|
-
* @param {number} index
|
|
44
|
-
* @param {{ offset: number, length: number }} range
|
|
45
|
-
* @returns {Promise<Buffer>}
|
|
46
|
-
*/
|
|
47
|
-
const get = (store, index, range) =>
|
|
48
|
-
new Promise((resolve, reject) => {
|
|
49
|
-
store.get(index, range, (error, bytes) => (error ? reject(error) : resolve(bytes)));
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* @returns {Promise<string>}
|
|
54
|
-
*/
|
|
55
|
-
const directory = () => fs.mkdtemp(path.join(os.tmpdir(), "never-refuses-"));
|
|
56
|
-
|
|
57
|
-
test("a piece is never refused for want of memory", async () => {
|
|
58
|
-
const root = await directory();
|
|
59
|
-
// Every block held by a piece that may not leave. The ceiling never falls
|
|
60
|
-
// below two, so this is how a store with nothing to give is reached: what
|
|
61
|
-
// held the blocks in the field was two writes on their way to disk, and a pin
|
|
62
|
-
// reaches the same state deterministically.
|
|
63
|
-
//
|
|
64
|
-
// This one takes the store's own patience to run — it must be seen to give
|
|
65
|
-
// up and keep the piece anyway.
|
|
66
|
-
const store = new SharedPieceStore(PIECE, {
|
|
67
|
-
length: PIECE * 8,
|
|
68
|
-
memoryBytes: PIECE * 2,
|
|
69
|
-
path: root,
|
|
70
|
-
name: "no-block-to-be-had"
|
|
71
|
-
});
|
|
72
|
-
try {
|
|
73
|
-
await put(store, 0);
|
|
74
|
-
await put(store, 1);
|
|
75
|
-
store.pin(0);
|
|
76
|
-
store.pin(1);
|
|
77
|
-
|
|
78
|
-
await put(store, 3);
|
|
79
|
-
const stats = store.stats();
|
|
80
|
-
assert.equal(stats.spilled, 1, "the piece is on disk");
|
|
81
|
-
assert.equal(store.locate(3), null, "and not in memory, since no block could be had for it");
|
|
82
|
-
assert.equal(stats.resident, 2, "the pinned pieces were not taken from under their reader");
|
|
83
|
-
assert.ok(
|
|
84
|
-
stats.admittedWithoutSlot >= 1,
|
|
85
|
-
"and the store says so, rather than the torrent client saying it with a destroyed torrent"
|
|
86
|
-
);
|
|
87
|
-
const bytes = await get(store, 3, { offset: 0, length: PIECE });
|
|
88
|
-
assert.equal(bytes.length, PIECE, "and it reads back");
|
|
89
|
-
assert.equal(bytes[0], 3 % 251, "with its own contents");
|
|
90
|
-
} finally {
|
|
91
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
92
|
-
await fs.rm(root, { recursive: true, force: true });
|
|
93
|
-
}
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
test("a read for the torrent client takes no block and only its own range", async () => {
|
|
97
|
-
const root = await directory();
|
|
98
|
-
const store = new SharedPieceStore(PIECE, {
|
|
99
|
-
length: PIECE * 8,
|
|
100
|
-
memoryBytes: PIECE,
|
|
101
|
-
path: root,
|
|
102
|
-
name: "ranged-read"
|
|
103
|
-
});
|
|
104
|
-
try {
|
|
105
|
-
// Two fit; the third displaces the oldest, which goes to disk.
|
|
106
|
-
await put(store, 0);
|
|
107
|
-
await put(store, 1);
|
|
108
|
-
await put(store, 2);
|
|
109
|
-
const before = store.stats();
|
|
110
|
-
assert.equal(before.spilled, 1, "the first piece is on disk");
|
|
111
|
-
|
|
112
|
-
const wanted = 16;
|
|
113
|
-
const bytes = await get(store, 0, { offset: PIECE - wanted, length: wanted });
|
|
114
|
-
assert.equal(bytes.length, wanted, "only what was asked for comes back");
|
|
115
|
-
assert.equal(bytes[0], 0, "and it is that piece's own bytes");
|
|
116
|
-
|
|
117
|
-
const after = store.stats();
|
|
118
|
-
assert.equal(
|
|
119
|
-
after.blocksAllocated,
|
|
120
|
-
before.blocksAllocated,
|
|
121
|
-
"and answering it took no block: a peer asks for kilobytes and a piece here is megabytes"
|
|
122
|
-
);
|
|
123
|
-
assert.equal(after.resident, before.resident, "nothing was revived for it");
|
|
124
|
-
assert.equal(after.fromDisk, before.fromDisk + 1, "and the read is counted as coming from disk");
|
|
125
|
-
} finally {
|
|
126
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
127
|
-
await fs.rm(root, { recursive: true, force: true });
|
|
128
|
-
}
|
|
129
|
-
});
|
|
130
|
-
|
|
131
|
-
test("a store between readers keeps room for one window", async () => {
|
|
132
|
-
const root = await directory();
|
|
133
|
-
const store = new SharedPieceStore(PIECE, {
|
|
134
|
-
length: PIECE * 200,
|
|
135
|
-
memoryBytes: PIECE * 64,
|
|
136
|
-
path: root,
|
|
137
|
-
name: "between-readers"
|
|
138
|
-
});
|
|
139
|
-
try {
|
|
140
|
-
store.protectRange("read-1", 10, 21, 100);
|
|
141
|
-
const asked = store.wantedBytes;
|
|
142
|
-
assert.ok(asked >= PIECE * 12, "with a reader, the window it declared is asked for");
|
|
143
|
-
|
|
144
|
-
store.releaseProtection("read-1");
|
|
145
|
-
assert.ok(
|
|
146
|
-
store.wantedBytes >= PIECE * 12,
|
|
147
|
-
"and with the reader gone the floor is still one window — the next read asks for the same again"
|
|
148
|
-
);
|
|
149
|
-
} finally {
|
|
150
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
151
|
-
await fs.rm(root, { recursive: true, force: true });
|
|
152
|
-
}
|
|
153
|
-
});
|
|
@@ -1,403 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @file Room for a piece is an owned reservation, and it comes back every time.
|
|
3
|
-
*
|
|
4
|
-
* Each case here is a defect read out of the field failure of 2026-08-31
|
|
5
|
-
* (`research/worker-heap-oom-2026-08-31.md`, §5), where the torrent worker was
|
|
6
|
-
* found holding reservations nobody could return. They all need the disk tier
|
|
7
|
-
* to be slow, or to fail, at a moment the caller chooses — which is what
|
|
8
|
-
* `options.disk` is for.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
|
-
import test from "node:test";
|
|
12
|
-
import assert from "node:assert/strict";
|
|
13
|
-
import { SharedPieceStore } from "../services/storage/piece-store/shared-piece-store.js";
|
|
14
|
-
|
|
15
|
-
const CHUNK = 1024;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* A disk tier the test drives: writes can be held open and either side can be
|
|
19
|
-
* made to fail.
|
|
20
|
-
*
|
|
21
|
-
* @param {{ failWrite?: boolean, failRead?: boolean, holdWrites?: boolean }} [behaviour]
|
|
22
|
-
*/
|
|
23
|
-
function makeDisk({ failWrite = false, failRead = false, holdWrites = false } = {}) {
|
|
24
|
-
const stored = new Map();
|
|
25
|
-
const reading = new Map();
|
|
26
|
-
/** @type {Array<() => void>} */
|
|
27
|
-
const held = [];
|
|
28
|
-
return {
|
|
29
|
-
stored,
|
|
30
|
-
/** Let every held write finish. */
|
|
31
|
-
releaseWrites() {
|
|
32
|
-
const waiting = held.splice(0, held.length);
|
|
33
|
-
for (const resume of waiting) {
|
|
34
|
-
resume();
|
|
35
|
-
}
|
|
36
|
-
},
|
|
37
|
-
finishWrites() {
|
|
38
|
-
holdWrites = false;
|
|
39
|
-
this.releaseWrites();
|
|
40
|
-
},
|
|
41
|
-
get heldCount() {
|
|
42
|
-
return held.length;
|
|
43
|
-
},
|
|
44
|
-
get size() {
|
|
45
|
-
return stored.size;
|
|
46
|
-
},
|
|
47
|
-
get bytes() {
|
|
48
|
-
let total = 0;
|
|
49
|
-
for (const value of stored.values()) {
|
|
50
|
-
total += value.length;
|
|
51
|
-
}
|
|
52
|
-
return total;
|
|
53
|
-
},
|
|
54
|
-
get path() {
|
|
55
|
-
return ".";
|
|
56
|
-
},
|
|
57
|
-
get allowanceBytes() {
|
|
58
|
-
return null;
|
|
59
|
-
},
|
|
60
|
-
reviseAllowance() {
|
|
61
|
-
return null;
|
|
62
|
-
},
|
|
63
|
-
stats() {
|
|
64
|
-
return { pieces: stored.size, bytes: this.bytes, allowanceBytes: null, evictions: 0 };
|
|
65
|
-
},
|
|
66
|
-
has(index) {
|
|
67
|
-
return stored.has(index);
|
|
68
|
-
},
|
|
69
|
-
hold(index) {
|
|
70
|
-
if (!stored.has(index)) return null;
|
|
71
|
-
reading.set(index, (reading.get(index) ?? 0) + 1);
|
|
72
|
-
return () => {
|
|
73
|
-
const count = reading.get(index) - 1;
|
|
74
|
-
if (count > 0) reading.set(index, count);
|
|
75
|
-
else reading.delete(index);
|
|
76
|
-
};
|
|
77
|
-
},
|
|
78
|
-
async write(index, bytes) {
|
|
79
|
-
if (holdWrites) {
|
|
80
|
-
await new Promise((resolve) => held.push(resolve));
|
|
81
|
-
}
|
|
82
|
-
if (failWrite) {
|
|
83
|
-
throw new Error("the disk refused the write");
|
|
84
|
-
}
|
|
85
|
-
stored.set(index, Buffer.from(bytes));
|
|
86
|
-
},
|
|
87
|
-
async read(index, target) {
|
|
88
|
-
if (failRead) {
|
|
89
|
-
throw new Error("the disk refused the read");
|
|
90
|
-
}
|
|
91
|
-
const bytes = stored.get(index);
|
|
92
|
-
if (!bytes) {
|
|
93
|
-
throw new Error(`Piece ${index} is not on disk.`);
|
|
94
|
-
}
|
|
95
|
-
bytes.copy(target);
|
|
96
|
-
return bytes.length;
|
|
97
|
-
},
|
|
98
|
-
forget(index) {
|
|
99
|
-
if (!reading.has(index)) stored.delete(index);
|
|
100
|
-
},
|
|
101
|
-
async close() {},
|
|
102
|
-
async destroy() {
|
|
103
|
-
stored.clear();
|
|
104
|
-
}
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
* @param {object} [options]
|
|
110
|
-
* @returns {{ store: SharedPieceStore, disk: ReturnType<typeof makeDisk> }}
|
|
111
|
-
*/
|
|
112
|
-
function makeStore({ pieces = 2, totalPieces = 16, disk = makeDisk() } = {}) {
|
|
113
|
-
const store = new SharedPieceStore(CHUNK, {
|
|
114
|
-
length: CHUNK * totalPieces,
|
|
115
|
-
memoryBytes: CHUNK * pieces,
|
|
116
|
-
disk,
|
|
117
|
-
name: "test"
|
|
118
|
-
});
|
|
119
|
-
return { store, disk };
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* @param {number} index
|
|
124
|
-
* @returns {Buffer}
|
|
125
|
-
*/
|
|
126
|
-
function piece(index) {
|
|
127
|
-
const bytes = Buffer.allocUnsafeSlow(CHUNK);
|
|
128
|
-
bytes.fill(index % 256);
|
|
129
|
-
bytes.writeUInt32BE(index, 0);
|
|
130
|
-
return bytes;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const put = (store, index) =>
|
|
134
|
-
new Promise((resolve, reject) => store.put(index, piece(index), (error) => (error ? reject(error) : resolve())));
|
|
135
|
-
const get = (store, index) =>
|
|
136
|
-
new Promise((resolve, reject) =>
|
|
137
|
-
store.get(index, undefined, (error, bytes) => (error ? reject(error) : resolve(bytes)))
|
|
138
|
-
);
|
|
139
|
-
|
|
140
|
-
test("a revival holds disk bytes across the asynchronous memory reservation", async () => {
|
|
141
|
-
const { store, disk } = makeStore();
|
|
142
|
-
disk.stored.set(0, piece(0));
|
|
143
|
-
try {
|
|
144
|
-
const revival = store.reside(0);
|
|
145
|
-
disk.forget(0);
|
|
146
|
-
const located = await revival;
|
|
147
|
-
assert.deepEqual(Buffer.from(located.buffer, located.offset, located.length), piece(0));
|
|
148
|
-
disk.forget(0);
|
|
149
|
-
assert.equal(disk.has(0), false);
|
|
150
|
-
} finally {
|
|
151
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
152
|
-
}
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
test("an assembled-file revival returns shared bytes and preserves a concurrent pinned reader", async () => {
|
|
156
|
-
const disk = makeDisk();
|
|
157
|
-
let reads = 0;
|
|
158
|
-
const store = new SharedPieceStore(CHUNK, {
|
|
159
|
-
length: CHUNK * 16, memoryBytes: CHUNK * 4, disk,
|
|
160
|
-
readPieceElsewhere: async ({ index }) => {
|
|
161
|
-
reads += 1;
|
|
162
|
-
return piece(index);
|
|
163
|
-
}, name: "whole-file-read"
|
|
164
|
-
});
|
|
165
|
-
try {
|
|
166
|
-
store.pin(0);
|
|
167
|
-
const [first, second] = await Promise.all([store.reside(0), store.reside(0)]);
|
|
168
|
-
assert.ok(first.buffer instanceof SharedArrayBuffer);
|
|
169
|
-
assert.equal(first.buffer, second.buffer);
|
|
170
|
-
assert.equal(reads, 1, "concurrent readers repeated the whole-file allocation and read");
|
|
171
|
-
await put(store, 1);
|
|
172
|
-
await put(store, 2);
|
|
173
|
-
assert.deepEqual(Buffer.from(first.buffer, first.offset, first.length), piece(0));
|
|
174
|
-
assert.equal(store.locate(0).buffer, first.buffer);
|
|
175
|
-
assert.equal(store.stats().outstanding, 0);
|
|
176
|
-
store.unpin(0);
|
|
177
|
-
} finally {
|
|
178
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
179
|
-
}
|
|
180
|
-
});
|
|
181
|
-
|
|
182
|
-
test("a waiting source reader receives a released slot before queued arrivals", async () => {
|
|
183
|
-
const disk = makeDisk({ holdWrites: true });
|
|
184
|
-
const { store } = makeStore({ pieces: 2, disk });
|
|
185
|
-
let arrived = false;
|
|
186
|
-
try {
|
|
187
|
-
await put(store, 0);
|
|
188
|
-
await put(store, 1);
|
|
189
|
-
store.pin(1);
|
|
190
|
-
disk.stored.set(4, piece(4));
|
|
191
|
-
const spilling = put(store, 2);
|
|
192
|
-
await until(() => disk.heldCount > 0, "an arriving piece occupies the spill slot");
|
|
193
|
-
const arrival = put(store, 3).then(() => { arrived = true; });
|
|
194
|
-
store.pin(4);
|
|
195
|
-
const reading = store.reside(4);
|
|
196
|
-
await until(() => store.stats().waitedForDisk >= 2, "both claims wait behind the spill");
|
|
197
|
-
disk.finishWrites();
|
|
198
|
-
const located = await reading;
|
|
199
|
-
assert.deepEqual(Buffer.from(located.buffer, located.offset, located.length), piece(4));
|
|
200
|
-
assert.equal(arrived, false, "an arriving piece overtook an already waiting reader");
|
|
201
|
-
store.unpin(1);
|
|
202
|
-
store.unpin(4);
|
|
203
|
-
await Promise.all([spilling, arrival]);
|
|
204
|
-
assert.equal(store.stats().outstanding, 0);
|
|
205
|
-
} finally {
|
|
206
|
-
disk.finishWrites();
|
|
207
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
208
|
-
}
|
|
209
|
-
});
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* Wait until a condition holds, rather than for a chosen interval — a test that
|
|
213
|
-
* sleeps samples, it does not check (roadmap item 54).
|
|
214
|
-
*
|
|
215
|
-
* @param {() => boolean} holds
|
|
216
|
-
* @param {string} what
|
|
217
|
-
* @param {number} [limit] - A backstop, never the measurement: one of the
|
|
218
|
-
* conditions here is the store's own patience, which is itself five seconds.
|
|
219
|
-
* @returns {Promise<void>}
|
|
220
|
-
*/
|
|
221
|
-
async function until(holds, what, limit = 5_000) {
|
|
222
|
-
const deadline = Date.now() + limit;
|
|
223
|
-
while (!holds()) {
|
|
224
|
-
if (Date.now() > deadline) {
|
|
225
|
-
throw new Error(`timed out waiting until ${what}`);
|
|
226
|
-
}
|
|
227
|
-
await new Promise((resolve) => setImmediate(resolve));
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
test("a revival that fails on the disk gives its slot back", async () => {
|
|
232
|
-
const disk = makeDisk({ failRead: true });
|
|
233
|
-
const { store } = makeStore({ pieces: 2, disk });
|
|
234
|
-
try {
|
|
235
|
-
await put(store, 0);
|
|
236
|
-
await put(store, 1);
|
|
237
|
-
await put(store, 2); // piece 0 is the least recently used, so it spills
|
|
238
|
-
|
|
239
|
-
await assert.rejects(() => get(store, 0), /refused the read/);
|
|
240
|
-
|
|
241
|
-
assert.equal(
|
|
242
|
-
store.stats().outstanding,
|
|
243
|
-
0,
|
|
244
|
-
"the slot claimed for the revival was never returned"
|
|
245
|
-
);
|
|
246
|
-
} finally {
|
|
247
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
248
|
-
}
|
|
249
|
-
});
|
|
250
|
-
|
|
251
|
-
test("a spill that fails gives back the slot the eviction claimed", async () => {
|
|
252
|
-
const disk = makeDisk({ failWrite: true });
|
|
253
|
-
const { store } = makeStore({ pieces: 2, disk });
|
|
254
|
-
try {
|
|
255
|
-
await put(store, 0);
|
|
256
|
-
await put(store, 1);
|
|
257
|
-
await assert.rejects(() => put(store, 2), /refused the write/);
|
|
258
|
-
|
|
259
|
-
assert.equal(store.stats().outstanding, 0, "the eviction kept its reservation");
|
|
260
|
-
} finally {
|
|
261
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
262
|
-
}
|
|
263
|
-
});
|
|
264
|
-
|
|
265
|
-
// A bound, because the failure this catches is a claim that never ends: without
|
|
266
|
-
// it the check does not fail, it HANGS, and `node --test` then cannot finish at
|
|
267
|
-
// all — which is what happened between 2026-08-31 and 2026-09-03 (roadmap item
|
|
268
|
-
// 54). The store gives up after PINNED_WAIT_MS, so 30 s is ample for the answer
|
|
269
|
-
// and short enough to be a failure rather than a stoppage.
|
|
270
|
-
test("a claim that cannot be met ends in an error, not in waiting for ever", { timeout: 30_000 }, async () => {
|
|
271
|
-
const disk = makeDisk({ holdWrites: true });
|
|
272
|
-
const { store } = makeStore({ pieces: 2, disk });
|
|
273
|
-
try {
|
|
274
|
-
await put(store, 0);
|
|
275
|
-
await put(store, 1);
|
|
276
|
-
|
|
277
|
-
// Evicts one piece; its write is held, so the store has a spill in flight
|
|
278
|
-
// for as long as this test wants.
|
|
279
|
-
const spilling = put(store, 2);
|
|
280
|
-
await until(() => disk.heldCount > 0, "a write is in flight");
|
|
281
|
-
|
|
282
|
-
// Nothing left that may be evicted: one piece is being written out, the
|
|
283
|
-
// other is pinned. The old rule waited while anything was nominally in
|
|
284
|
-
// flight, which here is for ever.
|
|
285
|
-
store.pin(1);
|
|
286
|
-
// The claim gives up within its own patience — that is the property. What
|
|
287
|
-
// the caller does with the refusal is its own business, and since
|
|
288
|
-
// 2026-09-11 an arriving piece answers it by taking the disk instead of
|
|
289
|
-
// failing the torrent client's write.
|
|
290
|
-
const arriving = put(store, 3);
|
|
291
|
-
await until(
|
|
292
|
-
() => store.stats().blockedByPins > 0,
|
|
293
|
-
"the claim gave up rather than waiting for ever",
|
|
294
|
-
20_000
|
|
295
|
-
);
|
|
296
|
-
|
|
297
|
-
store.unpin(1);
|
|
298
|
-
disk.releaseWrites();
|
|
299
|
-
await arriving;
|
|
300
|
-
disk.releaseWrites();
|
|
301
|
-
await spilling;
|
|
302
|
-
} finally {
|
|
303
|
-
disk.releaseWrites();
|
|
304
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
305
|
-
}
|
|
306
|
-
});
|
|
307
|
-
|
|
308
|
-
test("closing the store fails whoever is waiting for room", { timeout: 30_000 }, async () => {
|
|
309
|
-
const disk = makeDisk({ holdWrites: true });
|
|
310
|
-
const { store } = makeStore({ pieces: 2, disk });
|
|
311
|
-
try {
|
|
312
|
-
await put(store, 0);
|
|
313
|
-
await put(store, 1);
|
|
314
|
-
const spilling = put(store, 2);
|
|
315
|
-
await until(() => disk.heldCount > 0, "a write is in flight");
|
|
316
|
-
store.pin(1);
|
|
317
|
-
|
|
318
|
-
const waiting = put(store, 3);
|
|
319
|
-
// Either counter: the claim waits for the disk first — a block is in flight
|
|
320
|
-
// and the store is full, so evicting another piece would only raise the
|
|
321
|
-
// memory in use — and reaches the pinned wait five seconds later. Asking for
|
|
322
|
-
// `waitedForPins` alone named one of the two ways of waiting and timed out
|
|
323
|
-
// while the store was demonstrably doing the other.
|
|
324
|
-
await until(
|
|
325
|
-
() => store.stats().waitedForPins + store.stats().waitedForDisk > 0,
|
|
326
|
-
"the claim is waiting"
|
|
327
|
-
);
|
|
328
|
-
|
|
329
|
-
await new Promise((resolve) => store.close(resolve));
|
|
330
|
-
await assert.rejects(() => waiting, /closed/);
|
|
331
|
-
|
|
332
|
-
store.unpin(1);
|
|
333
|
-
disk.releaseWrites();
|
|
334
|
-
await spilling.catch(() => undefined);
|
|
335
|
-
} finally {
|
|
336
|
-
disk.releaseWrites();
|
|
337
|
-
}
|
|
338
|
-
});
|
|
339
|
-
|
|
340
|
-
test("a piece written back to memory is not resurrected on disk by its own spill", { timeout: 30_000 }, async () => {
|
|
341
|
-
const disk = makeDisk({ holdWrites: true });
|
|
342
|
-
const { store } = makeStore({ pieces: 4, disk });
|
|
343
|
-
try {
|
|
344
|
-
await put(store, 0);
|
|
345
|
-
await put(store, 1);
|
|
346
|
-
await put(store, 2);
|
|
347
|
-
|
|
348
|
-
// Piece 0 leaves memory because the machine's allowance fell; its write is
|
|
349
|
-
// still in flight. The allowance then recovers to MORE than it was, so the
|
|
350
|
-
// piece coming back needs no eviction and no wait for that write.
|
|
351
|
-
//
|
|
352
|
-
// Four blocks, not three, and the reason is what this check is about. A
|
|
353
|
-
// block being written out is still memory in use, so with a ceiling of three
|
|
354
|
-
// and one block in flight the store is full, and the claim correctly waits
|
|
355
|
-
// for the disk — which the earlier setup did not allow for, so the check
|
|
356
|
-
// timed out on an admission policy it never meant to measure. Whether a
|
|
357
|
-
// piece arriving while its OWN spill is in flight should be admitted without
|
|
358
|
-
// taking a second block is a real question about the accounting and is
|
|
359
|
-
// roadmap item 9, not this check's subject: the subject is that the spill,
|
|
360
|
-
// when it completes, must not put the stale copy back on disk.
|
|
361
|
-
store.reviseGrowthCeiling(CHUNK * 2);
|
|
362
|
-
await until(() => disk.heldCount > 0, "the spill of piece 0 is in flight");
|
|
363
|
-
store.reviseGrowthCeiling(CHUNK * 4);
|
|
364
|
-
|
|
365
|
-
// The swarm hands piece 0 back while that write is still going. The store
|
|
366
|
-
// must drop the disk copy AFTER the write has recorded it, not before —
|
|
367
|
-
// `PieceDiskStore.write` adds the index on completion, so an early forget is
|
|
368
|
-
// undone and the next read of piece 0 comes back from before the rewrite.
|
|
369
|
-
let settled = false;
|
|
370
|
-
const rewritten = put(store, 0);
|
|
371
|
-
void rewritten.then(() => {
|
|
372
|
-
settled = true;
|
|
373
|
-
});
|
|
374
|
-
await until(() => store.stats().resident === 3, "piece 0 is back in memory");
|
|
375
|
-
assert.equal(settled, false, "the write finished without waiting for the piece's own spill");
|
|
376
|
-
|
|
377
|
-
disk.releaseWrites();
|
|
378
|
-
await rewritten;
|
|
379
|
-
|
|
380
|
-
assert.equal(disk.has(0), false, "the completing spill put the stale copy back");
|
|
381
|
-
} finally {
|
|
382
|
-
disk.releaseWrites();
|
|
383
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
384
|
-
}
|
|
385
|
-
});
|
|
386
|
-
|
|
387
|
-
test("a spill that fails while the allowance is lowered is counted, not thrown", async () => {
|
|
388
|
-
const disk = makeDisk({ failWrite: true });
|
|
389
|
-
const { store } = makeStore({ pieces: 4, disk });
|
|
390
|
-
try {
|
|
391
|
-
await put(store, 0);
|
|
392
|
-
await put(store, 1);
|
|
393
|
-
await put(store, 2);
|
|
394
|
-
await put(store, 3);
|
|
395
|
-
|
|
396
|
-
store.reviseGrowthCeiling(CHUNK * 2);
|
|
397
|
-
await until(() => store.stats().spillFailures > 0, "the failed spills are counted");
|
|
398
|
-
|
|
399
|
-
assert.equal(store.stats().outstanding, 0, "lowering the allowance held a reservation");
|
|
400
|
-
} finally {
|
|
401
|
-
await new Promise((resolve) => store.destroy(resolve));
|
|
402
|
-
}
|
|
403
|
-
});
|