@torrent-tv/proxy 2.72.0 → 2.72.2

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.
@@ -5,8 +5,17 @@
5
5
  * ASS/SSA (.ass/.ssa) to WebVTT so the browser can attach them to a `<track>`
6
6
  * without any client-side conversion. The proxy owns subtitle conversion so it
7
7
  * can also run language detection where the full text is available.
8
+ *
9
+ * Reading a format's own framing is NOT here — it is `SubtitleFileContainer`
10
+ * for a file beside the film, and `MatroskaContainer` / `Mp4Container` for a
11
+ * track inside it. What is here is everything after that: a cue's missing end
12
+ * time, its codec's markup, and writing the WebVTT document. One writer, so a
13
+ * pushed cue and a pulled one cannot read differently.
8
14
  */
9
15
 
16
+ import { SubtitleFileContainer } from "./container/SubtitleFileContainer.js";
17
+ import { plainCueText } from "./tracks/subtitle-markup.js";
18
+
10
19
  /**
11
20
  * Decode subtitle bytes to text. Prefers UTF-8 (honouring a BOM); if the UTF-8
12
21
  * decode yields many replacement characters the bytes are re-decoded as
@@ -41,110 +50,95 @@ function stripBom(text) {
41
50
  return typeof text === "string" && text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
42
51
  }
43
52
 
44
- function srtTsToVtt(ts) {
45
- return ts.replace(",", ".");
46
- }
47
-
48
53
  /**
49
- * Convert SubRip (.srt) text to WebVTT.
54
+ * One cue's start or end as WebVTT writes it: `hh:mm:ss.mmm`.
50
55
  *
51
- * @param {string} text
56
+ * @param {number} seconds
52
57
  * @returns {string}
53
58
  */
54
- function srtToVtt(text) {
55
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
56
- const out = ["WEBVTT", ""];
57
- for (const line of lines) {
58
- const m = line.match(/^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})(.*)?$/);
59
- out.push(m ? `${srtTsToVtt(m[1])} --> ${srtTsToVtt(m[2])}${m[3] ?? ""}` : line);
60
- }
61
- return out.join("\n");
59
+ export function vttTime(seconds) {
60
+ const safe = Math.max(0, Number(seconds) || 0);
61
+ const hours = Math.floor(safe / 3600);
62
+ const minutes = Math.floor((safe % 3600) / 60);
63
+ const rest = safe % 60;
64
+ return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${rest.toFixed(3).padStart(6, "0")}`;
62
65
  }
63
66
 
64
- function assTsToVtt(ts) {
65
- const m = ts.match(/^(\d+):(\d{2}):(\d{2})\.(\d{2})$/);
66
- if (!m) {
67
- return "00:00:00.000";
68
- }
69
- const ms = (parseInt(m[4], 10) * 10).toString().padStart(3, "0");
70
- return `${m[1].padStart(2, "0")}:${m[2]}:${m[3]}.${ms}`;
71
- }
72
-
73
- function stripAssTags(text) {
74
- return text
75
- .replace(/\{[^}]*\}/g, "")
76
- .replace(/\\N/g, "\n")
77
- .replace(/\\n/g, "\n")
78
- .replace(/\\h/g, " ")
79
- .trim();
67
+ /**
68
+ * Resolve what a cue is missing and take its codec's markup off, so what is
69
+ * left is what a player shows.
70
+ *
71
+ * The container's framing is NOT undone here — it is undone where the cue is
72
+ * read, by the container that framed it, which is the only place the framing is
73
+ * known. Until 2.72.1 this function tried to do both by counting commas, and
74
+ * on an embedded ASS track it showed every field of the dialogue row to the
75
+ * viewer.
76
+ *
77
+ * A cue with no duration — a Matroska SimpleBlock, which subtitles rarely use —
78
+ * is given the time until the next one IN THIS LIST, and the last such cue a
79
+ * few seconds. Not an invention about the film: it is what a player does with
80
+ * an open-ended cue, made explicit so every consumer agrees on it.
81
+ *
82
+ * @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
83
+ * @param {string} codecId
84
+ * @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
85
+ */
86
+ export function finalizeCues(cues, codecId) {
87
+ const result = [];
88
+ (Array.isArray(cues) ? cues : []).forEach((cue, index) => {
89
+ const next = cues[index + 1];
90
+ const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
91
+ const text = plainCueText(cue.text, codecId);
92
+ if (!text) {
93
+ return;
94
+ }
95
+ result.push({ startSeconds: cue.startSeconds, endSeconds, text });
96
+ });
97
+ return result;
80
98
  }
81
99
 
82
100
  /**
83
- * Convert ASS/SSA text to WebVTT (only the [Events] section; styling dropped).
101
+ * A WebVTT document from a list of cues — the one writer, used by every path
102
+ * that produces subtitles: a file beside the film, a track inside it, a pull
103
+ * and a push.
84
104
  *
85
- * @param {string} text
105
+ * @param {{ startSeconds: number, endSeconds?: number | null, text: string }[]} cues
106
+ * @param {string} codecId
86
107
  * @returns {string}
87
108
  */
88
- function assToVtt(text) {
89
- const lines = text.replace(/\r\n/g, "\n").replace(/\r/g, "\n").split("\n");
90
- let inEvents = false;
91
- let formatCols = null;
92
- const cues = [];
93
- for (const line of lines) {
94
- const trimmed = line.trim();
95
- if (trimmed === "[Events]") {
96
- inEvents = true;
97
- continue;
98
- }
99
- if (trimmed.startsWith("[") && trimmed.endsWith("]") && inEvents) {
100
- inEvents = false;
101
- continue;
102
- }
103
- if (!inEvents) {
104
- continue;
105
- }
106
- if (trimmed.startsWith("Format:")) {
107
- formatCols = trimmed.slice("Format:".length).split(",").map((c) => c.trim().toLowerCase());
108
- continue;
109
- }
110
- if (trimmed.startsWith("Dialogue:") && formatCols) {
111
- const parts = trimmed.slice("Dialogue:".length).split(",");
112
- const startIdx = formatCols.indexOf("start");
113
- const endIdx = formatCols.indexOf("end");
114
- const textIdx = formatCols.indexOf("text");
115
- if (startIdx < 0 || endIdx < 0 || textIdx < 0) {
116
- continue;
117
- }
118
- const cueText = stripAssTags(parts.slice(textIdx).join(","));
119
- if (!cueText) {
120
- continue;
121
- }
122
- cues.push(`${assTsToVtt((parts[startIdx] ?? "").trim())} --> ${assTsToVtt((parts[endIdx] ?? "").trim())}\n${cueText}`);
123
- }
109
+ export function cuesToVtt(cues, codecId) {
110
+ const lines = ["WEBVTT", ""];
111
+ for (const cue of finalizeCues(cues, codecId)) {
112
+ lines.push(`${vttTime(cue.startSeconds)} --> ${vttTime(cue.endSeconds)}`);
113
+ lines.push(cue.text);
114
+ lines.push("");
124
115
  }
125
- return cues.length === 0 ? "WEBVTT\n" : `WEBVTT\n\n${cues.join("\n\n")}`;
116
+ return lines.join("\n");
126
117
  }
127
118
 
128
119
  /**
129
120
  * Convert subtitle text to WebVTT by file extension. Returns null for formats
130
121
  * that cannot be converted in-place (image-based .sup, ambiguous .sub, .ttml).
131
122
  *
123
+ * The reading is `SubtitleFileContainer`'s: the file states how its own cues
124
+ * are framed — SubRip by position, ASS by the `Format:` line of `[Events]` —
125
+ * and that is a fact about the file, not about this conversion.
126
+ *
132
127
  * @param {string} text
133
128
  * @param {string} ext - Lowercase extension including the dot, e.g. ".srt".
134
129
  * @returns {string | null}
135
130
  */
136
131
  export function convertSubtitleToVtt(text, ext) {
137
132
  const clean = stripBom(text);
138
- switch (ext) {
139
- case ".vtt":
140
- case ".webvtt":
141
- return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
142
- case ".srt":
143
- return srtToVtt(clean);
144
- case ".ass":
145
- case ".ssa":
146
- return assToVtt(clean);
147
- default:
148
- return null;
133
+ const extension = String(ext ?? "").toLowerCase();
134
+ if (extension === ".vtt" || extension === ".webvtt") {
135
+ // Already what a browser reads. Parsing it to write it back would drop its
136
+ // styles, its regions and its cue identifiers for nothing.
137
+ return clean.trimStart().startsWith("WEBVTT") ? clean : `WEBVTT\n\n${clean}`;
138
+ }
139
+ if (!SubtitleFileContainer.detect(extension)) {
140
+ return null;
149
141
  }
142
+ const cues = new SubtitleFileContainer({ extension }).readCues(clean);
143
+ return cues === null ? null : cuesToVtt(cues, extension);
150
144
  }
@@ -18,8 +18,11 @@
18
18
  */
19
19
 
20
20
  import { readSubtitlePlan, harvestCluster } from "../container-index/matroska-subtitles.js";
21
- import { decodeSubtitleSample, readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
21
+ import { readMp4SubtitlePlan } from "../container-index/mp4-subtitles.js";
22
22
  import { iterateElements } from "../container-index/ebml-reader.js";
23
+ import { MatroskaContainer } from "../container/MatroskaContainer.js";
24
+ import { Mp4Container } from "../container/Mp4Container.js";
25
+ import { finalizeCues } from "../subtitle-convert.js";
23
26
  import { detectLanguage } from "../language-detect.js";
24
27
  import { logger } from "../../utils/logger.js";
25
28
 
@@ -358,7 +361,8 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
358
361
  continue;
359
362
  }
360
363
  harvested.add(sample.offset);
361
- const text = decodeSubtitleSample(bytes, track.codecId);
364
+ // The MP4 has framed this cue and is the one that unframes it.
365
+ const text = Mp4Container.cueTextOf(bytes, track.codecId);
362
366
  if (text) {
363
367
  cues.push({
364
368
  startSeconds: sample.startSeconds,
@@ -424,9 +428,18 @@ async function walkFor(torrent, fileIndex, state, plan, track, trackNumber) {
424
428
  state.cues.set(candidate.trackNumber, into);
425
429
  }
426
430
  let found = false;
427
- for (const cue of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
428
- cue.seq = nextSeq(state, candidate.trackNumber);
429
- into.push(cue);
431
+ for (const block of harvestCluster(bytes, candidate.trackNumber, plan.secondsPerTick)) {
432
+ // The block's bytes become this track's text HERE, where the container
433
+ // that framed them is known. A cue kept in its framed form and unframed
434
+ // later cannot be unframed at all: nothing downstream knows which
435
+ // container it came out of, and guessing from the field count is what
436
+ // showed the dialogue row's own fields to the viewer.
437
+ into.push({
438
+ startSeconds: block.startSeconds,
439
+ endSeconds: block.endSeconds,
440
+ text: MatroskaContainer.cueTextOf(block.payload, candidate.codecId),
441
+ seq: nextSeq(state, candidate.trackNumber)
442
+ });
430
443
  found = true;
431
444
  }
432
445
  if (found) {
@@ -515,54 +528,6 @@ export async function warmSubtitleCues(torrent, fileIndex, sourceKey) {
515
528
  return fresh;
516
529
  }
517
530
 
518
- /**
519
- * Resolve a cue's end time and strip codec-specific formatting, turning a raw
520
- * block-derived cue into text a player can show directly. The same step
521
- * `routes/api/subtitles/get.js` applies when building WebVTT for a pull —
522
- * factored out here so a pushed cue and a pulled one read identically.
523
- *
524
- * A cue with no duration — a SimpleBlock, which subtitles rarely use — is
525
- * given the time until the next one IN THIS LIST, and the last such cue a few
526
- * seconds. Not an invention about the film: it is what a player does with an
527
- * open-ended cue, made explicit so every consumer agrees on it.
528
- *
529
- * @param {{ startSeconds: number, endSeconds: number | null, text: string }[]} cues
530
- * @param {string} codecId
531
- * @returns {{ startSeconds: number, endSeconds: number, text: string }[]}
532
- */
533
- export function finalizeCues(cues, codecId) {
534
- const isAss = codecId === "S_TEXT/ASS" || codecId === "S_TEXT/SSA";
535
- const result = [];
536
- cues.forEach((cue, index) => {
537
- const next = cues[index + 1];
538
- const endSeconds = cue.endSeconds ?? (next ? next.startSeconds : cue.startSeconds + 4);
539
- const text = isAss ? assDialogueToText(cue.text) : cue.text.trim();
540
- if (!text) {
541
- return;
542
- }
543
- result.push({ startSeconds: cue.startSeconds, endSeconds, text });
544
- });
545
- return result;
546
- }
547
-
548
- /**
549
- * The visible text of an ASS dialogue row.
550
- *
551
- * A block carries the fields after `Dialogue:` without their header — nine of
552
- * them, then the text, which itself holds override groups in braces.
553
- *
554
- * @param {string} raw
555
- * @returns {string}
556
- */
557
- function assDialogueToText(raw) {
558
- const fields = raw.split(",");
559
- const text = fields.length > 9 ? fields.slice(9).join(",") : raw;
560
- return text
561
- .replace(/\{[^}]*\}/g, "")
562
- .replace(/\\N/gi, "\n")
563
- .trim();
564
- }
565
-
566
531
  /**
567
532
  * The text subtitle tracks of a file, for the menu the viewer sees.
568
533
  *
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { SubtitleTrack } from "./SubtitleTrack.js";
10
+ import { markupKindOf, plainCueText } from "./subtitle-markup.js";
10
11
 
11
12
  const TEXT_CODECS_MATROSKA = new Set(["S_TEXT/UTF8", "S_TEXT/ASS", "S_TEXT/SSA", "S_TEXT/WEBVTT"]);
12
13
  const TEXT_FORMATS_MP4 = new Set(["tx3g", "text", "wvtt"]);
@@ -21,6 +22,23 @@ export class TextSubtitleTrack extends SubtitleTrack {
21
22
  return true;
22
23
  }
23
24
 
25
+ /** Which markup this track's cue text carries — see `subtitle-markup.js`. */
26
+ get markupKind() {
27
+ return markupKindOf(this.textCodec);
28
+ }
29
+
30
+ /**
31
+ * The visible text of one of this track's cues.
32
+ *
33
+ * @param {string} textField - The cue's text field, already out of its
34
+ * container's framing. This method knows the codec and not the container,
35
+ * which is why it cannot be handed a whole dialogue row.
36
+ * @returns {string}
37
+ */
38
+ plainText(textField) {
39
+ return plainCueText(textField, this.textCodec);
40
+ }
41
+
24
42
  static isTextCodec(codecId) {
25
43
  return TEXT_CODECS_MATROSKA.has(codecId) || TEXT_FORMATS_MP4.has(codecId);
26
44
  }
@@ -4,6 +4,7 @@ export { AudioTrack } from "./AudioTrack.js";
4
4
  export { SubtitleTrack } from "./SubtitleTrack.js";
5
5
  export { TextSubtitleTrack, TEXT_CODECS_MATROSKA, TEXT_FORMATS_MP4 } from "./TextSubtitleTrack.js";
6
6
  export { ImageSubtitleTrack } from "./ImageSubtitleTrack.js";
7
+ export { MarkupKind, markupKindOf, plainCueText } from "./subtitle-markup.js";
7
8
  // There is deliberately no class for a track that lives in a file of its own.
8
9
  // `<name>.mka` is a Matroska container holding an `AudioTrack`, and
9
10
  // `MatroskaContainer` reads it exactly as it reads the picture's — so "external"
@@ -0,0 +1,104 @@
1
+ /**
2
+ * @file The markup a subtitle codec puts INSIDE the text of one cue, and how to
3
+ * take it off. One axis of variation, and only one.
4
+ *
5
+ * Nothing here knows which container the text came out of. That is the other
6
+ * axis and it belongs to `container/`: how a cue's bytes are framed is stated by
7
+ * the CONTAINER's specification, not by the subtitle format's. Matroska
8
+ * reorders an ASS dialogue row and drops its two timing fields
9
+ * (`matroska.org/technical/subtitles.html`); a `.ass` file states its own field
10
+ * order in the `Format:` line of `[Events]`; an MP4 carries each cue as a sample
11
+ * with a length prefix. Every one of those is a fact about the container.
12
+ *
13
+ * What IS a fact about ASS, wherever it is stored: override groups in braces,
14
+ * `\N` and `\n` for a line break, `\h` for a hard space. That is this file.
15
+ *
16
+ * The two were mixed in one function until 2.72.1, which is what broke English
17
+ * subtitles on an embedded ASS track: the function counted commas to guess
18
+ * which framing it had been handed, expected ten fields — the shape of a row in
19
+ * a FILE — and a Matroska block carries nine. Every field of the row was then
20
+ * shown to the viewer as if it were dialogue. A function that has to guess the
21
+ * shape of its input is being called by someone who knew and did not say.
22
+ */
23
+
24
+ /** How a cue's text is marked up, once the container's framing is off. */
25
+ export const MarkupKind = {
26
+ /** Sub Station Alpha and its advanced form: `{\pos(…)}`, `\N`, `\h`. */
27
+ ASS: "ass",
28
+ /** Nothing to strip: the text is what is shown. */
29
+ NONE: "none"
30
+ };
31
+
32
+ /**
33
+ * Which markup a codec's cue text carries.
34
+ *
35
+ * The keys are every name this proxy has for a text subtitle codec: Matroska
36
+ * CodecIDs (RFC 9559 §5.1.4.1.28 and the codec mappings beside it), MP4 sample
37
+ * entry types (ISO/IEC 14496-12 §12.6, plus Apple's `tx3g`), and the file
38
+ * extensions a subtitle shipped beside the film uses. They are listed together
39
+ * because the ANSWER is the same for all of them — ASS is ASS whether it sits
40
+ * in a Matroska block, in a file, or nowhere yet — and keeping three tables
41
+ * would mean three places to forget.
42
+ *
43
+ * @type {Map<string, string>}
44
+ */
45
+ const MARKUP_BY_CODEC = new Map([
46
+ ["S_TEXT/ASS", MarkupKind.ASS],
47
+ ["S_TEXT/SSA", MarkupKind.ASS],
48
+ [".ass", MarkupKind.ASS],
49
+ [".ssa", MarkupKind.ASS],
50
+ ["S_TEXT/UTF8", MarkupKind.NONE],
51
+ ["S_TEXT/WEBVTT", MarkupKind.NONE],
52
+ [".srt", MarkupKind.NONE],
53
+ [".vtt", MarkupKind.NONE],
54
+ [".webvtt", MarkupKind.NONE],
55
+ ["tx3g", MarkupKind.NONE],
56
+ ["text", MarkupKind.NONE],
57
+ ["wvtt", MarkupKind.NONE]
58
+ ]);
59
+
60
+ /**
61
+ * The markup kind of a codec, by any of its names.
62
+ *
63
+ * An unknown codec is answered `NONE` rather than refused: the text is then
64
+ * shown as it is, which is wrong only in so far as some markup stays visible,
65
+ * where a refusal would show nothing at all.
66
+ *
67
+ * @param {string} codecId - Matroska CodecID, MP4 sample entry type, or a file
68
+ * extension including the dot. Case is ignored for extensions, which arrive
69
+ * from file names, and kept for the others, which are spelled by a spec.
70
+ * @returns {string} One of {@link MarkupKind}.
71
+ */
72
+ export function markupKindOf(codecId) {
73
+ const name = String(codecId ?? "");
74
+ return MARKUP_BY_CODEC.get(name) ?? MARKUP_BY_CODEC.get(name.toLowerCase()) ?? MarkupKind.NONE;
75
+ }
76
+
77
+ /**
78
+ * The visible text of one cue: its markup taken off, nothing else touched.
79
+ *
80
+ * @param {string} text - The cue's text FIELD, already out of the container's
81
+ * framing. Handing a whole dialogue row to this is the mistake described at
82
+ * the top of this file.
83
+ * @param {string} codecId
84
+ * @returns {string} Possibly empty — a cue whose text is only a drawing command
85
+ * or a positioning group has nothing to show, and the caller drops it.
86
+ */
87
+ export function plainCueText(text, codecId) {
88
+ const raw = String(text ?? "");
89
+ if (markupKindOf(codecId) !== MarkupKind.ASS) {
90
+ return raw.trim();
91
+ }
92
+ return raw
93
+ // An override group. Any brace content is a directive, never dialogue:
94
+ // drawing commands, karaoke timing, positioning, font changes.
95
+ .replace(/\{[^}]*\}/g, "")
96
+ // Both breaks reach a player as a break. ASS distinguishes them — `\N` is
97
+ // always a break, `\n` only where the style does not wrap — and a WebVTT
98
+ // cue has no way to express the difference, so it takes the break.
99
+ .replace(/\\N/g, "\n")
100
+ .replace(/\\n/g, "\n")
101
+ // A space the renderer may not collapse.
102
+ .replace(/\\h/g, " ")
103
+ .trim();
104
+ }
@@ -156,7 +156,12 @@ test("a spill that fails gives back the slot the eviction claimed", async () =>
156
156
  }
157
157
  });
158
158
 
159
- test("a claim that cannot be met ends in an error, not in waiting for ever", async () => {
159
+ // A bound, because the failure this catches is a claim that never ends: without
160
+ // it the check does not fail, it HANGS, and `node --test` then cannot finish at
161
+ // all — which is what happened between 2026-08-31 and 2026-09-03 (roadmap item
162
+ // 54). The store gives up after PINNED_WAIT_MS, so 30 s is ample for the answer
163
+ // and short enough to be a failure rather than a stoppage.
164
+ test("a claim that cannot be met ends in an error, not in waiting for ever", { timeout: 30_000 }, async () => {
160
165
  const disk = makeDisk({ holdWrites: true });
161
166
  const { store } = makeStore({ pieces: 2, disk });
162
167
  try {
@@ -183,7 +188,7 @@ test("a claim that cannot be met ends in an error, not in waiting for ever", asy
183
188
  }
184
189
  });
185
190
 
186
- test("closing the store fails whoever is waiting for room", async () => {
191
+ test("closing the store fails whoever is waiting for room", { timeout: 30_000 }, async () => {
187
192
  const disk = makeDisk({ holdWrites: true });
188
193
  const { store } = makeStore({ pieces: 2, disk });
189
194
  try {
@@ -194,7 +199,15 @@ test("closing the store fails whoever is waiting for room", async () => {
194
199
  store.pin(1);
195
200
 
196
201
  const waiting = put(store, 3);
197
- await until(() => store.stats().waitedForPins > 0, "the claim is waiting");
202
+ // Either counter: the claim waits for the disk first — a block is in flight
203
+ // and the store is full, so evicting another piece would only raise the
204
+ // memory in use — and reaches the pinned wait five seconds later. Asking for
205
+ // `waitedForPins` alone named one of the two ways of waiting and timed out
206
+ // while the store was demonstrably doing the other.
207
+ await until(
208
+ () => store.stats().waitedForPins + store.stats().waitedForDisk > 0,
209
+ "the claim is waiting"
210
+ );
198
211
 
199
212
  await new Promise((resolve) => store.close(resolve));
200
213
  await assert.rejects(() => waiting, /closed/);
@@ -207,20 +220,30 @@ test("closing the store fails whoever is waiting for room", async () => {
207
220
  }
208
221
  });
209
222
 
210
- test("a piece written back to memory is not resurrected on disk by its own spill", async () => {
223
+ test("a piece written back to memory is not resurrected on disk by its own spill", { timeout: 30_000 }, async () => {
211
224
  const disk = makeDisk({ holdWrites: true });
212
- const { store } = makeStore({ pieces: 3, disk });
225
+ const { store } = makeStore({ pieces: 4, disk });
213
226
  try {
214
227
  await put(store, 0);
215
228
  await put(store, 1);
216
229
  await put(store, 2);
217
230
 
218
231
  // Piece 0 leaves memory because the machine's allowance fell; its write is
219
- // still in flight. The allowance then recovers, so there is room again
220
- // without waiting for that write.
232
+ // still in flight. The allowance then recovers to MORE than it was, so the
233
+ // piece coming back needs no eviction and no wait for that write.
234
+ //
235
+ // Four blocks, not three, and the reason is what this check is about. A
236
+ // block being written out is still memory in use, so with a ceiling of three
237
+ // and one block in flight the store is full, and the claim correctly waits
238
+ // for the disk — which the earlier setup did not allow for, so the check
239
+ // timed out on an admission policy it never meant to measure. Whether a
240
+ // piece arriving while its OWN spill is in flight should be admitted without
241
+ // taking a second block is a real question about the accounting and is
242
+ // roadmap item 9, not this check's subject: the subject is that the spill,
243
+ // when it completes, must not put the stale copy back on disk.
221
244
  store.reviseGrowthCeiling(CHUNK * 2);
222
245
  await until(() => disk.heldCount > 0, "the spill of piece 0 is in flight");
223
- store.reviseGrowthCeiling(CHUNK * 3);
246
+ store.reviseGrowthCeiling(CHUNK * 4);
224
247
 
225
248
  // The swarm hands piece 0 back while that write is still going. The store
226
249
  // must drop the disk copy AFTER the write has recorded it, not before —