authorclover caruso <git@paperclover.net> 2026-10-11 15:45:35-07:00
committerclover caruso <git@paperclover.net> 2026-10-11 20:05:55-07:00
log27372f562b13b96c1bf165339923d5f402753428
tree617dee5a71d5e850efd832982fbdb7e41f920112
parent4ceca6c30ef19c14cd4ba76a3cbd9e894f91fdc6
signature Signed by SSH key SHA256:cOKiuRFOeSRxne6EWgHtdQQSlBxjOXm2hOCFnCdLQbQ (~clover)

fix(file-viewer): fix all the bugs

Closes #106 - EBADF: a descriptor reused from `fdsCache` stayed in that lru, so it was closed, or its number handed to another file, under a stream still reading it. Handles are counted before the first await and leave the lru while held. - RangeError: `parseRangeHeader` never clamped or rejected ranges, and the throw leaked the entry lock. Unsatisfiable ranges are now a 416, and open and suffix ranges (`500-`, `-500`) parse instead of being ignored. - `http.serve` piped bodies without cancelling them on disconnect or handling their errors: every aborted request leaked a descriptor and a lock, and any stream error was an uncaught exception. - Every request, the first included, reads a download back from disk as it lands. This replaces the tee, where a slow first client throttled the download, and the bypass that sent pending requests upstream. - A download that is cut short, stalls, or disagrees with the expected size drops its entry instead of staying pending forever. - Uncached ranges were sent the whole file, since nothing implements `Clover-Start`. They use `Range`, sliced locally if the server ignores it. - `Lru.delete` skipped the `delete` callback, leaving dropped entries on disk, and the revive compared base names to absolute paths, wiping the cache on every restart. - Chunk reuse repeated bytes after a chunk at offset 0 and could hand out the uninitialized tail of a short read. - `Path` deduplication broke when an old copy's finalizer ran after a newer copy registered, giving the cache two entries for one file. - Source of Truth: requests sharing a descriptor shared its file offset and split the file between them. Reads are positioned and `Range` is supported. - Ranges and `If-Range` on derived assets used the original file's size and hash. Assisted-by: claude-opus-5.5

12 files changed, 1201 insertions(+), 379 deletions(-)

framework/lib/path.test.ts created+22
......@@ -0,0 +1,22 @@
1test("a path stays deduplicated after an older copy is collected", async () => {
2 const raw = "/clover/path.test/deduplicated";
3 (() => void new Path(raw))();
4 // a WeakRef keeps its target alive until the task that created it ends
5 await delay(0);
6 collectGarbage();
7
8 // the first copy is collected, but its finalizer has not yet run
9 const second = new Path(raw);
10 await delay(10);
11 assert.equal(new Path(raw), second);
12});
13
14v8.setFlagsFromString("--expose-gc");
15const collectGarbage: () => void = vm.runInNewContext("gc");
16
17import { delay } from "@clo/lib/async";
18import { strict as assert } from "node:assert";
19import * as v8 from "node:v8";
20import * as vm from "node:vm";
21import { test } from "vitest";
22import { Path } from "./path.ts";
framework/lib/path.ts+4-1
......@@ -1,5 +1,8 @@
11const unique = new Map<string, WeakRef<Path>>();
2const final = new FinalizationRegistry((raw: string) => unique.delete(raw));
2// A finalizer can run after a newer `Path` has taken over the entry.
3const final = new FinalizationRegistry((raw: string) => {
4 if (!unique.get(raw)?.deref()) unique.delete(raw);
5});
36
47export class Path {
58 private raw: string;
lib/Lru.test.ts+22
......@@ -148,6 +148,28 @@ test("revival", () => {
148148 assert.equal(lru.get("d"), 4);
149149 assert.equal(lru.get("e"), 5);
150150});
151test("delete callback sees every removal, evict only automatic ones", () => {
152 const deleted: string[] = [];
153 const evicted: string[] = [];
154 const lru = new Lru<string, number>({
155 capacity: 2,
156 delete: (_, key) => deleted.push(key),
157 evict: (_, key) => evicted.push(key),
158 });
159 lru.set("a", 1);
160 lru.set("b", 2);
161 lru.set("c", 3);
162 assert.deepEqual([deleted, evicted], [["a"], ["a"]]);
163
164 assert.equal(lru.delete("b"), true);
165 assert.equal(lru.delete("b"), false);
166 assert.deepEqual([deleted, evicted], [["a", "b"], ["a"]]);
167
168 lru.set("d", 4);
169 lru.clear();
170 assert.deepEqual([deleted, evicted], [["a", "b", "c", "d"], ["a"]]);
171 assert.equal(lru.used, 0);
172});
151173
152174import assert from "node:assert";
153175import { test } from "vitest";
lib/Lru.ts+4
......@@ -56,18 +56,22 @@ export class Lru<K, V> extends Map<K, V> {
5656 }
5757
5858 override clear() {
59 const removed = this.#deleteFn ? [...this] : [];
5960 super.clear();
6061 this.#order.clear();
6162 this.#head = none;
6263 this.#tail = none;
6364 this.#used = 0;
65 for (const [key, value] of removed) this.#deleteFn?.(value, key);
6466 }
6567 override delete(key: K): boolean {
6668 const entry = this.#evict(key);
6769 if (!entry) return false;
70 const value = super.get(key) as V;
6871 this.#used -= entry[0];
6972 ASSERT(this.#order.delete(key));
7073 ASSERT(super.delete(key));
74 this.#deleteFn?.(value, key);
7175 return true;
7276 }
7377 override get(key: K): V | undefined {
lib/http.test.ts created+133
......@@ -0,0 +1,133 @@
1describe("parseRangeHeader", () => {
2 test("closed, open, and suffix ranges", () => {
3 assert.deepEqual(http.parseRangeHeader("bytes=0-1", 100), [{ start: 0, end: 1 }]);
4 assert.deepEqual(http.parseRangeHeader("bytes=10-", 100), [{ start: 10, end: 99 }]);
5 assert.deepEqual(http.parseRangeHeader("bytes=-10", 100), [{ start: 90, end: 99 }]);
6 assert.deepEqual(http.parseRangeHeader("bytes=-500", 100), [{ start: 0, end: 99 }]);
7 assert.deepEqual(http.parseRangeHeader("bytes=50-500", 100), [{ start: 50, end: 99 }]);
8 assert.deepEqual(http.parseRangeHeader("bytes=0-9, 20-29", 100), [
9 { start: 0, end: 9 },
10 { start: 20, end: 29 },
11 ]);
12 });
13
14 test("ranges past the end are unsatisfiable", () => {
15 // https://shale.paperclover.net/sitegen/issues/106
16 assert.deepEqual(http.parseRangeHeader("bytes=46226599-", 25705639), []);
17 assert.deepEqual(http.parseRangeHeader("bytes=100-200", 100), []);
18 assert.deepEqual(http.parseRangeHeader("bytes=-0", 100), []);
19 assert.deepEqual(http.parseRangeHeader("bytes=0-", 0), []);
20 });
21
22 test("malformed headers are ignored", () => {
23 for (
24 const header of [
25 "",
26 "bytes",
27 "bytes=",
28 "bytes=-",
29 "bytes=5-2",
30 "bytes=a-b",
31 "bytes=1.5-3",
32 "bytes=0x10-",
33 "bytes=--5",
34 "bytes=1-2-3",
35 "pages=0-1",
36 "bytes=0-5,3-9",
37 ]
38 ) {
39 assert.equal(http.parseRangeHeader(header, 100), null, header);
40 }
41 });
42
43 test("fuzz: every range it returns can be read", () => {
44 const pieces = [
45 "bytes",
46 "=",
47 "-",
48 ",",
49 " ",
50 "0",
51 "1",
52 "7",
53 "99",
54 "100",
55 "101",
56 "25705639",
57 "9".repeat(30),
58 "x",
59 ".",
60 "e3",
61 "-1",
62 ];
63 let state = 106;
64 const random = (max: number) => (state = (state * 48271) % 0x7fffffff) % max;
65 for (let i = 0; i < 20_000; i++) {
66 const size = [0, 1, 100, 25705639][random(4)]!;
67 let header = random(4) === 0 ? "" : "bytes=";
68 for (let n = random(7); n > 0; n--) header += pieces[random(pieces.length)];
69
70 for (const { start, end } of http.parseRangeHeader(header, size) ?? []) {
71 assert.ok(
72 Number.isInteger(start) && Number.isInteger(end)
73 && 0 <= start && start <= end && end < size,
74 `${JSON.stringify(header)} of ${size} gave [${start}, ${end}]`,
75 );
76 }
77 }
78 });
79});
80
81describe("serve", () => {
82 test("a client that disconnects cancels the response body", async () => {
83 const cancelled = Promise.withResolvers<void>();
84 await using server = await http.serve({
85 port: 0,
86 respond: async () =>
87 new Response(
88 new ReadableStream({
89 pull: (controller) => controller.enqueue(new Uint8Array(65536)),
90 cancel: () => cancelled.resolve(),
91 }),
92 ),
93 });
94
95 const response = await new Promise<nodeHttp.IncomingMessage>((resolve) => nodeHttp.get(server.url, resolve));
96 response.once("data", () => response.destroy());
97 await cancelled.promise;
98 });
99
100 test("a failing response body closes the connection", async () => {
101 const failure = Object.assign(new Error("bad file descriptor"), { code: "EBADF" });
102 const logged = vi.spyOn(console, "error").mockImplementation(() => {});
103 let pulls = 0;
104 await using server = await http.serve({
105 port: 0,
106 respond: async () =>
107 new Response(
108 new ReadableStream({
109 pull(controller) {
110 if (pulls++ === 0) controller.enqueue(new Uint8Array(16));
111 else controller.error(failure);
112 },
113 }),
114 { headers: { "Content-Length": "65536" } },
115 ),
116 });
117
118 await assert.rejects(async () => {
119 const response = await new Promise<nodeHttp.IncomingMessage>((resolve, reject) =>
120 nodeHttp.get(server.url, resolve).on("error", reject)
121 );
122 await consumers.buffer(response);
123 }, { code: "ECONNRESET" });
124 assert.deepEqual(logged.mock.calls, [[failure]]);
125 logged.mockRestore();
126 });
127});
128
129import { strict as assert } from "node:assert";
130import * as nodeHttp from "node:http";
131import * as consumers from "node:stream/consumers";
132import { describe, test, vi } from "vitest";
133import * as http from "./http.ts";
lib/http.ts+22-10
......@@ -67,7 +67,15 @@ export async function serve(
6767 Array.from(response.headers.entries()),
6868 );
6969 if (response.body) {
70 stream.Readable.fromWeb(response.body as import("node:stream/web").ReadableStream).pipe(res);
70 // unlike `pipe`, this cancels the body when the client goes away,
71 // and a failing body closes the connection instead of the process.
72 stream.pipeline(
73 stream.Readable.fromWeb(response.body as import("node:stream/web").ReadableStream),
74 res,
75 (err) => {
76 if (err && exception.code(err) !== "ERR_STREAM_PREMATURE_CLOSE") console.error(err);
77 },
78 );
7179 } else {
7280 res.end();
7381 }
......@@ -123,7 +131,9 @@ export async function serve(
123131
124132/**
125133 * parses the [`Range`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/Range_requests)
126 * header, returning `null` if it is invalid. the `fileSize`
134 * header for a resource of `fileSize` bytes. `null` means the header is
135 * malformed and should be ignored. otherwise, the satisfiable ranges are
136 * returned clamped to the resource, so an empty array is a `416` response.
127137 */
128138export function parseRangeHeader(
129139 rangeHeader: string,
......@@ -137,14 +147,16 @@ export function parseRangeHeader(
137147 const rangeParts = ranges.split(",");
138148
139149 for (const range of rangeParts) {
140 const split = range.split("-");
141 const [start, end] = split;
142 if (!start || !end) return null;
143 if (start === "" && end === "") return null;
144 result.push({
145 start: start === "" ? fileSize - +end : +start,
146 end: end === "" ? fileSize - 1 : +end,
147 });
150 const match = /^\s*(\d*)-(\d*)\s*$/.exec(range);
151 if (!match) return null;
152 const [, first = "", last = ""] = match;
153 if (first === "" && last === "") return null;
154 if (first !== "" && last !== "" && +last < +first) return null;
155
156 // `-500` is the last 500 bytes, `500-` is everything after the first 500.
157 const start = first === "" ? Math.max(0, fileSize - +last) : +first;
158 const end = first === "" || last === "" ? fileSize - 1 : Math.min(+last, fileSize - 1);
159 if (start <= end) result.push({ start, end });
148160 }
149161
150162 // validate that ranges do not intersect
lib/readme.changes.md+9
......@@ -7,6 +7,15 @@
77- `meta`'s auto-embed feature now sets all the twitter tags, not just thumbnail.
88 sorry i might have been dumb when doing the first version of this.
99
10### fixes
11
12- `Lru`: `delete()` and `clear()` now call the `delete` callback, as documented.
13- `http.parseRangeHeader` accepts open and suffix ranges (`500-`, `-500`),
14 clamps ranges to the resource, and returns an empty array when none of them
15 can be satisfied.
16- `http.serve` cancels the response body when the client disconnects, and a
17 failing body closes the connection instead of crashing the process.
18
1019## v6
1120
1221### breaking
src/file-viewer/backend.ts+14-10
......@@ -151,7 +151,7 @@ app.get("/file/*", async (c, next) => {
151151 let status = 200;
152152 let rangeHeader = c.req.header("Range") ?? null;
153153 const ifRangeHeader = c.req.header("If-Range");
154 if (ifRangeHeader && ifRangeOutdated(file, ifRangeHeader)) {
154 if (ifRangeHeader && ifRangeOutdated(hash, date, ifRangeHeader)) {
155155 // > If the condition is not fulfilled, the full resource is
156156 // > sent back with a 200 OK status.
157157 rangeHeader = null;
......@@ -163,17 +163,21 @@ app.get("/file/*", async (c, next) => {
163163 // Range requests
164164 // https://developer.mozilla.org/en-US/docs/Web/HTTP/Range_requests
165165 if (rangeHeader) {
166 const ranges = http.parseRangeHeader(rangeHeader, file.size);
166 const ranges = http.parseRangeHeader(rangeHeader, size);
167 if (ranges?.length === 0) {
168 c.res = new Response(null, {
169 status: 416,
170 headers: { "Content-Range": `bytes */${size}` },
171 });
172 return;
173 }
167174 if (ranges?.[0] && ranges.length === 1) {
168175 start = ranges[0].start;
169176 end = ranges[0].end;
170177
171178 status = 206;
172 headers.set(
173 "Content-Range",
174 `bytes ${ranges[0].start}-${ranges[0].end}/${file.size}`,
175 );
176 headers.delete("Content-Length");
179 headers.set("Content-Range", `bytes ${start}-${end}/${size}`);
180 headers.set("Content-Length", String(end - start + 1));
177181 } else {
178182 // multi-ranges are not supported
179183 // it could be done by calling `cache.read` multiple times.
......@@ -209,13 +213,13 @@ app.get("/file/*", async (c, next) => {
209213 });
210214});
211215
212function ifRangeOutdated(file: MediaFile, ifRangeHeader: string) {
216function ifRangeOutdated(hash: string, date: Date, ifRangeHeader: string) {
213217 // etag
214218 if (ifRangeHeader[0] === "\"") {
215 return ifRangeHeader.slice(1, -1) !== file.hash;
219 return ifRangeHeader.slice(1, -1) !== hash;
216220 }
217221 // date
218 return new Date(ifRangeHeader) < file.date;
222 return new Date(ifRangeHeader) < date;
219223}
220224
221225function checkCotyledonCookie(c: Context) {
src/file-viewer/cache.test.ts created+603
......@@ -0,0 +1,603 @@
1// fuzzes `cache.read` against a fake Source of Truth and a `node:fs` that
2// tracks descriptor ownership and injects faults. every delivered byte is
3// compared against the known file contents, and at each quiescent point the
4// locks, descriptors, and cache directory must agree with the Lru.
5//
6// FUZZ_SEED, FUZZ_RUNS, and FUZZ_OPS reproduce a failure or run a soak, and
7// FUZZ_STATS prints what each run exercised.
8const h = vi.hoisted(() => ({
9 lrus: [] as unknown[],
10 fs: {
11 /** descriptors opened through the mock and not yet closed */
12 open: new Map<number, string>(),
13 violations: [] as string[],
14 random: Math.random as () => number,
15 lag: 0,
16 shortIo: 0,
17 readError: 0,
18 writeError: 0,
19 onFault: () => {},
20 },
21}));
22
23vi.mock("@clo/lib/Lru", async (importOriginal) => {
24 const real = await importOriginal<typeof import("@clo/lib/Lru")>();
25 class Tracked<K, V> extends real.Lru<K, V> {
26 constructor(opts: ConstructorParameters<typeof real.Lru<K, V>>[0]) {
27 super(opts);
28 h.lrus.push(this);
29 }
30 }
31 return { ...real, Lru: Tracked };
32});
33
34vi.mock("node:fs", async (importOriginal) => {
35 const real = await importOriginal<typeof import("node:fs")>();
36 const s = h.fs;
37 const roll = (chance: number) => chance > 0 && s.random() < chance;
38 const later = (fn: () => void) => {
39 if (roll(s.lag)) setTimeout(fn, Math.floor(s.random() * 3));
40 else fn();
41 };
42 const injected = (code: string, syscall: string) => {
43 s.onFault();
44 return Object.assign(new Error(`${code}: injected, ${syscall}`), { code, syscall });
45 };
46 type IoCallback = (err: Error | null, bytes: number, buf: Buffer) => void;
47 type Io = (
48 fd: number,
49 buf: Buffer,
50 off: number,
51 len: number,
52 pos: number,
53 cb: IoCallback,
54 ) => void;
55 const io =
56 (name: string, real: Io, errorChance: () => number, errorCode: string): Io => (fd, buf, off, len, pos, cb) => {
57 if (!s.open.has(fd)) s.violations.push(`${name} on fd ${fd} which is not open`);
58 if (roll(errorChance())) return later(() => cb(injected(errorCode, name), 0, buf));
59 const partial = len > 1 && roll(s.shortIo) ? 1 + Math.floor(s.random() * (len - 1)) : len;
60 real(fd, buf, off, partial, pos, (err, bytes) => later(() => cb(err, bytes, buf)));
61 };
62 const overrides = {
63 open(path: string, flags: string, cb: (err: Error | null, fd: number) => void) {
64 real.open(path, flags, (err, fd) => {
65 if (!err) s.open.set(fd, path);
66 cb(err, fd);
67 });
68 },
69 close(fd: number, cb: (err: Error | null) => void) {
70 if (!s.open.delete(fd)) s.violations.push(`close of fd ${fd} which is not open`);
71 real.close(fd, cb);
72 },
73 closeSync(fd: number) {
74 if (!s.open.delete(fd)) s.violations.push(`close of fd ${fd} which is not open`);
75 real.closeSync(fd);
76 },
77 read: io("read", real.read, () => s.readError, "EIO"),
78 write: io("write", real.write, () => s.writeError, "ENOSPC"),
79 };
80 return { ...real, ...overrides, default: { ...real, ...overrides } };
81});
82
83const seed = Number(process.env.FUZZ_SEED ?? 1);
84const runs = Number(process.env.FUZZ_RUNS ?? 6);
85const opCount = Number(process.env.FUZZ_OPS ?? 250);
86const token = "fuzz-token";
87
88function prng(seed: number) {
89 const next = () => {
90 let t = (seed += 0x6D2B79F5);
91 t = Math.imul(t ^ (t >>> 15), t | 1);
92 t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
93 return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
94 };
95 return Object.assign(next, {
96 int: (max: number) => Math.floor(next() * max),
97 roll: (chance: number) => chance > 0 && next() < chance,
98 pick: <T>(items: readonly T[]) => UNWRAP(items[Math.floor(next() * items.length)]),
99 });
100}
101type Random = ReturnType<typeof prng>;
102
103interface FakeFile {
104 file: MediaFile;
105 derived: string | null;
106 url: string;
107 hash: string;
108 data: Buffer;
109}
110
111function fakeFiles(random: Random): FakeFile[] {
112 const sizes = [0, 1, 2, 4096, 65535, 65536, 65537, 131072, 200_001, 300_000];
113 const files = sizes.map((size, i): FakeFile => {
114 const data = Buffer.alloc(size);
115 for (let b = 0; b < size; b++) data[b] = random.int(256);
116 const path = `/2026/friends/${i}/new home [rev ${i}].mp3`;
117 return {
118 file: { path } as MediaFile,
119 derived: null,
120 url: `/file${path}`,
121 hash: crypto.createHash("sha1").update(data).digest("hex"),
122 data,
123 };
124 });
125 // a derived asset, and a second path with the contents of another file
126 const source = UNWRAP(files[4]);
127 const twin = UNWRAP(files[7]);
128 const preview = Buffer.from(source.data.subarray(100, 30_100));
129 files.push({
130 ...source,
131 derived: `${source.hash}/preview.webp`,
132 url: `${source.url}:/preview.webp`,
133 hash: crypto.createHash("sha1").update(preview).digest("hex"),
134 data: preview,
135 }, {
136 ...twin,
137 file: { path: "/2026/copy of new home.mp3" } as MediaFile,
138 url: "/file/2026/copy of new home.mp3",
139 });
140 return files;
141}
142
143interface Chaos {
144 /** Source of Truth responds 500 */
145 status: number;
146 /** Source of Truth resets the connection mid-body */
147 abort: number;
148 /** Source of Truth serves a file shorter than the database says */
149 truncated: number;
150 /** a filesystem callback arrives a few milliseconds late */
151 lag: number;
152 shortIo: number;
153 readError: number;
154 writeError: number;
155}
156const calm: Chaos = {
157 status: 0,
158 abort: 0,
159 truncated: 0,
160 lag: 0.3,
161 shortIo: 0.1,
162 readError: 0,
163 writeError: 0,
164};
165const stormy: Chaos = {
166 ...calm,
167 status: 0.03,
168 abort: 0.05,
169 truncated: 0.02,
170 readError: 0.002,
171 writeError: 0.003,
172};
173
174interface DiskFile {
175 size: number;
176 written: { value: number };
177}
178
179class World {
180 violations: string[] = [];
181 /** the cache's own log, shown alongside a failure */
182 log: string[] = [];
183 /** what the run exercised, printed with FUZZ_STATS */
184 stats: Record<string, number> = {};
185 lastFault = -Infinity;
186 files: FakeFile[];
187 random: Random;
188 sotRandom: Random;
189 dir: string;
190 cacheDir: string;
191 rawDir: string | null;
192 server!: http.Server;
193 cache!: typeof import("./cache.ts");
194 diskCache!: Lru<Path, DiskFile>;
195 fdsCache!: Lru<Path, number>;
196
197 constructor(seed: number, public chaos: Chaos, local: boolean) {
198 this.random = prng(seed);
199 this.sotRandom = prng(seed ^ 0x5eed);
200 this.files = fakeFiles(prng(seed ^ 0xf11e));
201 this.dir = fs.mkdtempSync(path.join(os.tmpdir(), "clover-cache-fuzz-"));
202 this.cacheDir = path.join(this.dir, ".clover/file-viewer");
203 this.rawDir = local ? path.join(this.dir, "raw") : null;
204
205 const fsRandom = prng(seed ^ 0xd15c);
206 Object.assign(h.fs, chaos, { random: fsRandom, onFault: () => this.fault() });
207 h.fs.violations = this.violations;
208 }
209
210 fault() {
211 this.lastFault = performance.now();
212 }
213
214 count(what: string, by = 1) {
215 this.stats[what] = (this.stats[what] ?? 0) + by;
216 }
217
218 async start() {
219 this.server = http.createServer((req, res) => this.respond(req, res));
220 await new Promise<void>((resolve) => this.server.listen(0, "127.0.0.1", resolve));
221 const { port } = this.server.address() as net.AddressInfo;
222
223 // only some files are on the local share, the rest fall back to the network
224 if (this.rawDir) {
225 for (const [i, { file, derived, data }] of this.files.entries()) {
226 if (i % 3 === 0) continue;
227 const local = derived
228 ? path.join(this.dir, "derived", derived)
229 : path.join(this.rawDir, file.path);
230 fs.mkdirSync(path.dirname(local), { recursive: true });
231 fs.writeFileSync(local, data);
232 }
233 }
234
235 process.env.CLOVER_SOT_URL = `http://127.0.0.1:${port}`;
236 process.env.CLOVER_SOT_KEY = token;
237 process.env.CLOVER_FILE_RAW = this.rawDir ?? path.join(this.dir, "missing");
238 process.env.CLOVER_FILE_DERIVED = path.join(this.dir, "derived");
239 process.env.CLOVER_DB = path.join(this.dir, "db");
240 process.chdir(this.dir);
241 await this.boot();
242 }
243
244 /** load a fresh copy of the cache module, as a process restart would */
245 async boot() {
246 for (const fd of h.fs.open.keys()) realFs.closeSync(fd);
247 h.fs.open.clear();
248
249 vi.resetModules();
250 const log = await import("@clo/lib/log");
251 log.replaceGlobalMessageDestination((message) => {
252 this.log.push(log.formatAnsiMessage(message, false).trimEnd());
253 });
254 this.cache = await import("./cache.ts");
255 [this.diskCache, this.fdsCache] = h.lrus.slice(-2) as [Lru<Path, DiskFile>, Lru<Path, number>];
256 this.diskCache.capacity = 700_000;
257 this.fdsCache.capacity = 3;
258 }
259
260 async stop() {
261 await new Promise((resolve) => this.server.close(resolve));
262 this.server.closeAllConnections();
263 for (const fd of h.fs.open.keys()) realFs.closeSync(fd);
264 h.fs.open.clear();
265 fs.rmSync(this.dir, { recursive: true, force: true });
266 }
267
268 respond(req: http.IncomingMessage, res: http.ServerResponse) {
269 const { sotRandom: random, chaos } = this;
270 const found = this.files.find((f) => f.url === decodeURI(UNWRAP(req.url)));
271 if (!found) return void res.writeHead(404).end("not found");
272 if (req.headers.authorization !== token) {
273 this.violations.push(`request for ${found.url} did not carry the token`);
274 }
275 if (random.roll(chaos.status)) {
276 this.fault();
277 return void res.writeHead(500).end("injected");
278 }
279
280 let { data } = found;
281 let status = 200;
282 const headers: Record<string, string> = {};
283 // a server is free to ignore `Range`, so do so half of the time
284 const range = /^bytes=(\d+)-(\d+)$/.exec(req.headers.range ?? "");
285 if (range && random.roll(0.5)) {
286 status = 206;
287 headers["Content-Range"] = `bytes ${range[1]}-${range[2]}/${data.length}`;
288 data = data.subarray(Number(range[1]), Number(range[2]) + 1);
289 }
290 if (data.length > 0 && random.roll(chaos.truncated)) {
291 this.fault();
292 data = data.subarray(0, random.int(data.length));
293 }
294 const abortAt = random.roll(chaos.abort) ? random.int(data.length + 1) : Infinity;
295 headers["Content-Length"] = String(data.length);
296 res.writeHead(status, headers);
297
298 let sent = 0;
299 const pump = () => {
300 if (res.destroyed) return;
301 if (sent >= abortAt) {
302 this.fault();
303 return void res.destroy();
304 }
305 if (sent >= data.length) return void res.end();
306 const end = Math.min(data.length, abortAt, sent + 1 + random.int(90_000));
307 res.write(data.subarray(sent, sent = end));
308 if (random.roll(0.3)) setTimeout(pump, random.int(4));
309 else setImmediate(pump);
310 };
311 pump();
312 }
313
314 /** a request for the whole of `target` */
315 async request({ file, derived, hash, data }: FakeFile) {
316 const options = { file, derived, hash, size: data.length, start: 0, end: Infinity };
317 return UNWRAP(await this.cache.read(options));
318 }
319
320 async body(target: FakeFile) {
321 const { stream } = await this.request(target);
322 return Buffer.from(await new Response(stream as BodyInit).arrayBuffer());
323 }
324
325 /** one request, consumed the way `backend.ts` hands it to a `Response` */
326 async read(label: string) {
327 const { random } = this;
328 const target = random.pick(this.files);
329 const { data } = target;
330 let start = 0, end = Infinity;
331 if (data.length > 0 && random.roll(0.6)) {
332 start = random.int(data.length);
333 end = start + random.int(data.length - start);
334 }
335 const expected = data.subarray(start, end + 1);
336 const mode = random.pick(["drain", "drain", "slow", "cancel"] as const);
337 const cancelAt = mode === "cancel" ? random.int(expected.length + 1) : Infinity;
338 const what = `${label} ${target.url} [${start}, ${end}] ${mode}`;
339 const startedAt = performance.now();
340
341 let received = 0;
342 let src = "";
343 try {
344 const found = UNWRAP(
345 await this.cache.read({
346 file: target.file,
347 derived: target.derived,
348 size: data.length,
349 hash: target.hash,
350 start,
351 end,
352 noWrite: random.roll(0.1),
353 }),
354 );
355 src = found.src;
356 this.count(`src ${src}`);
357 if (found.size !== data.length) {
358 this.violations.push(`${what}: size ${found.size}, expected ${data.length}`);
359 }
360 const reader = UNWRAP(new Response(found.stream as BodyInit).body).getReader();
361 while (true) {
362 if (received >= cancelAt) {
363 this.count("cancelled");
364 return void await reader.cancel();
365 }
366 const { done, value } = await reader.read();
367 if (done) break;
368 const want = expected.subarray(received, received + value.length);
369 if (want.length !== value.length || !want.equals(value)) {
370 this.violations.push(`${what} (${src}): wrong bytes at offset ${received}`);
371 return void await reader.cancel();
372 }
373 received += value.length;
374 this.count("bytes verified", value.length);
375 if (mode === "slow") await delay(random.int(4));
376 }
377 if (received !== expected.length) {
378 this.violations.push(
379 `${what} (${src}): ended cleanly after ${received} of ${expected.length} bytes`,
380 );
381 }
382 } catch (err) {
383 // an injected fault may fail a request, but never corrupt one
384 if (this.lastFault >= startedAt - 500) return this.count("failed by a fault");
385 this.violations.push(`${what} (${src}): ${util.inspect(err)}`);
386 }
387 }
388
389 /** wait for the cache to go idle, then check it against the disk */
390 async settle(label: string) {
391 const { diskCache, fdsCache } = this;
392 const idle = () =>
393 diskCache.unlockedCapacity === diskCache.capacity
394 && h.fs.open.size === fdsCache.size
395 && [...fdsCache.values()].every((fd) => h.fs.open.has(fd));
396 for (let waited = 0; !idle() && waited < 4000; waited += 10) await delay(10);
397 if (!idle()) {
398 const locked = diskCache.capacity - diskCache.unlockedCapacity;
399 const leaked = [...h.fs.open].filter(([fd]) => !new Set(fdsCache.values()).has(fd));
400 this.violations.push(
401 `${label}: never went idle, ${locked} bytes locked, `
402 + `leaked descriptors ${util.inspect(leaked)}, `
403 + `descriptor cache ${util.inspect([...fdsCache.values()])}`,
404 );
405 return;
406 }
407
408 let used = 0;
409 const names = new Set<string>();
410 for (const [file, entry] of diskCache) {
411 used += entry.size;
412 if (names.has(file.base)) {
413 this.violations.push(`${label}: ${file.base} has two entries`);
414 }
415 names.add(file.base);
416 const known = this.files.find((f) => f.hash === file.base);
417 const onDisk = fs.existsSync(file.toString()) ? fs.readFileSync(file.toString()) : null;
418 if (entry.written.value !== entry.size) {
419 this.violations.push(`${label}: ${file.base} is idle but still pending`);
420 } else if (!onDisk) {
421 this.violations.push(`${label}: ${file.base} is cached but not on disk`);
422 } else if (!known || !known.data.equals(onDisk)) {
423 this.violations.push(`${label}: ${file.base} is corrupt on disk`);
424 }
425 }
426 if (used !== diskCache.used || used > diskCache.capacity) {
427 this.violations.push(`${label}: holds ${used} bytes, accounted ${diskCache.used}`);
428 }
429 for (const name of fs.readdirSync(this.cacheDir)) {
430 if (name !== "state.json" && !names.has(name)) {
431 this.violations.push(`${label}: ${name} is on disk but not in the cache`);
432 }
433 }
434 }
435
436 /** restart the process, optionally after damaging the cache directory */
437 async restart(label: string) {
438 const { random, cacheDir } = this;
439 const before = new Set([...this.diskCache.keys()].map((file) => file.base));
440 const damage = random.pick(["none", "none", "truncate", "stray", "state", "nostate"] as const);
441 const victim = before.size > 0 ? random.pick([...before]) : null;
442 const state = path.join(cacheDir, "state.json");
443 if (damage === "truncate" && victim) {
444 const { size } = fs.statSync(path.join(cacheDir, victim));
445 fs.truncateSync(path.join(cacheDir, victim), random.int(size));
446 } else if (damage === "stray") {
447 fs.writeFileSync(path.join(cacheDir, "0".repeat(40)), "stray");
448 } else if (damage === "state" && fs.existsSync(state)) {
449 fs.truncateSync(state, random.int(fs.statSync(state).size));
450 } else if (damage === "nostate") {
451 fs.rmSync(state, { force: true });
452 }
453
454 this.count(`restart ${damage}`);
455 await this.boot();
456 const after = new Set([...this.diskCache.keys()].map((file) => file.base));
457 if (damage === "none" && before.size > 0 && before.difference(after).size > 0) {
458 this.violations.push(
459 `${label}: restart lost ${before.difference(after).size} of ${before.size} entries`,
460 );
461 }
462 }
463
464 check() {
465 // the log module owns `console` here, so write to the descriptor
466 if (process.env.FUZZ_STATS) fs.writeSync(2, util.inspect(this.stats) + "\n");
467 if (this.violations.length === 0) return;
468 const shown = this.violations.slice(0, 8).join("\n ");
469 const log = this.log.slice(-40).join("\n ");
470 assert.fail(`${this.violations.length} violations, first:\n ${shown}\nlog:\n ${log}`);
471 }
472}
473
474async function withWorld<T>(
475 seed: number,
476 chaos: Chaos,
477 local: boolean,
478 fn: (world: World) => Promise<T>,
479) {
480 const cwd = process.cwd();
481 const world = new World(seed, chaos, local);
482 try {
483 await world.start();
484 return await fn(world);
485 } finally {
486 process.chdir(cwd);
487 await world.stop();
488 }
489}
490
491const quiet: Chaos = { ...calm, lag: 0, shortIo: 0 };
492
493describe("issue 106", () => {
494 test("a descriptor is not closed while a stream reads it", () =>
495 withWorld(106, quiet, false, async (world) => {
496 const big = UNWRAP(world.files.at(9));
497 const others = world.files.filter((f) => f !== big && f.data.length < 70_000);
498 world.diskCache.capacity = 8_000_000;
499
500 // once to fill the cache, once more to park the descriptor
501 await world.body(big);
502 await world.settle("fill");
503 await world.body(big);
504 await world.settle("park");
505
506 // a paused player holds this response open while a listing loads
507 const held = await world.request(big);
508 for (const other of others) await world.body(other);
509 for (const other of others) await world.body(other);
510
511 const body = Buffer.from(await new Response(held.stream as BodyInit).arrayBuffer());
512 assert.ok(body.equals(big.data), "held response was served the wrong bytes");
513 await world.settle("end");
514 world.check();
515 }));
516
517 test("a descriptor is not parked while another request is opening it", () =>
518 withWorld(106, quiet, false, async (world) => {
519 const big = UNWRAP(world.files.at(9));
520 const others = world.files.filter((f) => f !== big && f.data.length < 70_000);
521 world.diskCache.capacity = 8_000_000;
522 await world.body(big);
523 await world.settle("fill");
524
525 // once its buffers fill, the first stream has no read in flight, so
526 // cancelling it releases the descriptor before the second request resumes
527 const first = (await world.request(big)).stream.getReader();
528 await delay(50);
529 const second = world.request(big);
530 await first.cancel();
531
532 const held = await second;
533 for (const other of others) await world.body(other);
534 const body = Buffer.from(await new Response(held.stream as BodyInit).arrayBuffer());
535 assert.ok(body.equals(big.data), "held response was served the wrong bytes");
536 await world.settle("end");
537 world.check();
538 }));
539
540 test("a range that starts past its end leaks nothing", () =>
541 withWorld(106, quiet, false, async (world) => {
542 const { file, hash, data } = UNWRAP(world.files.at(8));
543 await world.body(UNWRAP(world.files.at(8)));
544 await world.settle("fill");
545
546 const backwards = { file, derived: null, hash, size: data.length, start: 46226599, end: 25705638 };
547 await assert.rejects(world.cache.read(backwards));
548 await world.settle("end");
549 world.check();
550 }));
551});
552
553describe("fuzz", () => {
554 const cases = Array.from({ length: runs }, (_, i) => ({
555 seed: seed + i,
556 chaos: (seed + i) % 2 === 0 ? "calm" : "stormy",
557 local: (seed + i) % 3 === 0,
558 }));
559 test.each(cases)(
560 "seed $seed, $chaos, local share $local",
561 ({ seed, chaos, local }) =>
562 withWorld(seed, chaos === "calm" ? calm : stormy, local, async (world) => {
563 const { random } = world;
564 const inflight = new Set<Promise<void>>();
565 for (let i = 0; i < opCount && world.violations.length === 0; i++) {
566 const roll = random();
567 if (roll < 0.03) {
568 await Promise.all(inflight);
569 await world.settle(`#${i} settle`);
570 if (world.violations.length === 0 && random.roll(0.5)) {
571 await world.restart(`#${i} restart`);
572 }
573 continue;
574 }
575 const op = world.read(`#${i}`).finally(() => inflight.delete(op));
576 inflight.add(op);
577 if (inflight.size >= 12 || random.roll(0.25)) await Promise.race(inflight);
578 else if (random.roll(0.4)) await delay(random.int(3));
579 }
580 await Promise.all(inflight);
581 await world.settle("end");
582 world.check();
583 }),
584 120_000,
585 );
586});
587
588const realFs = await vi.importActual<typeof import("node:fs")>("node:fs");
589
590import type { Path } from "#sitegen/path";
591import { UNWRAP } from "@clo/lib/assert";
592import { delay } from "@clo/lib/async";
593import type { Lru } from "@clo/lib/Lru";
594import { strict as assert } from "node:assert";
595import * as crypto from "node:crypto";
596import * as fs from "node:fs";
597import * as http from "node:http";
598import type * as net from "node:net";
599import * as os from "node:os";
600import * as path from "node:path";
601import * as util from "node:util";
602import { describe, test, vi } from "vitest";
603import type { MediaFile } from "./models/MediaFile.ts";
src/file-viewer/cache.ts+304-357
......@@ -53,45 +53,41 @@ const diskCache = new Lru<Path, DiskFile>({
5353// and held open in the `fdsCache` lru when no references.
5454const fdsCache = new Lru<Path, number>({
5555 capacity: 64,
56 delete: (value) => void fsCallbacks.closeSync(value),
56 evict: (fd) => fsCallbacks.closeSync(fd),
5757});
58const fds = new Map<Path, Awaitable<RefCountFile>>();
59
60const parallelRequests = new Map<string, Promise<http.IncomingMessage>>();
58const fds = new Map<Path, RefCountFile>();
6159
6260// Revive disk cache state
63revive: {
61{
62 const revived = new Set([diskCacheState.base]);
6463 try {
6564 const state = diskCacheState
66 .readIfExistsSync<Lru.Serialized<string, number>>("json");
67 if (!state) break revive;
68 const missing = new Set(diskCacheRoot.readDirSync().map((c) => c.base));
69 missing.delete(diskCacheState.base);
65 .readIfExistsSync<Lru.Serialized<string, number>>("json") ?? [];
7066 diskCache.revive(state, {
71 map(k: string, v: number) {
72 const path = new Path(k);
67 map(base: string, size: number) {
68 const path = diskCacheRoot.join(base);
7369 try {
74 const { size } = path.statSync();
75 if (size !== v) return null;
76 if (!missing.delete(k)) return null;
77 return [
78 path,
79 { size, written: new async.Watch(size), memory: new Map() },
80 ];
70 if (path.statSync().size !== size) return null;
8171 } catch {
8272 return null;
8373 }
74 revived.add(base);
75 return [
76 path,
77 { size, written: new async.Watch(size), memory: new Map() },
78 ];
8479 },
8580 });
86 // Delete unreferenced files
87 for (const missingFile of missing) {
88 try {
89 diskCacheRoot.join(missingFile).deleteSync();
90 } catch {}
91 }
9281 } catch (err) {
9382 console.warn("Failed to restore file viewer cache", err);
9483 }
84 // Delete unreferenced files
85 for (const file of diskCacheRoot.readDirSync()) {
86 if (revived.has(file.base)) continue;
87 try {
88 file.deleteSync();
89 } catch {}
90 }
9591}
9692
9793export interface Options {
......@@ -101,7 +97,7 @@ export interface Options {
10197 size: number;
10298 hash: string;
10399 start: number;
104 /** exclusive. ex: [10, 30] skips ten bytes then grabs 20 */
100 /** inclusive, as in an HTTP range. ex: [10, 30] skips ten bytes then grabs 21 */
105101 end: number;
106102 noWrite?: boolean;
107103 localOnly?: boolean;
......@@ -112,8 +108,13 @@ export interface Options {
112108 * `null` if a derived asset does not exist
113109 */
114110export async function read(
115 { file, derived, hash, size, start, end, noWrite, localOnly }: Options,
111 options: Options,
116112): Promise<{ stream: ReadableStream; src: Source; size: number } | null> {
113 const { file, derived, hash, size, start, end, noWrite, localOnly } = options;
114 ASSERT(
115 Number.isInteger(start) && start >= 0 && start <= end,
116 () => `Invalid range [${start}, ${end}]`,
117 );
117118 const rel = derived ? `${file.path}$/${derived}` : file.path;
118119
119120 // Serve from local network share if it is mounted.
......@@ -123,24 +124,19 @@ export async function read(
123124 local: {
124125 if (!localFile) break local;
125126 if (process.env.CLOVER_SOT_FORCE) break local;
126 let size: number;
127127 try {
128 ({ size } = await localFile.stat());
128 const { size } = await localFile.stat();
129 const fileStream = await readStream(localFile, { start, end });
130 console.info(`from local: ${rel} ` + localFile);
131 return {
132 stream: stream.Readable.toWeb(fileStream),
133 size,
134 src: "local",
135 };
129136 } catch (err) {
130 if (exception.code(err) === "ENOENT") {
131 console.warn(`missing local file: ${localFile}`);
132 break local;
133 }
134 throw err;
137 if (exception.code(err) !== "ENOENT") throw err;
138 console.warn(`missing local file: ${localFile}`);
135139 }
136 const fileStream = await readStream(localFile, { start, end });
137 if (!fileStream) break local;
138 console.info(`from local: ${rel} ` + localFile);
139 return {
140 stream: stream.Readable.toWeb(fileStream),
141 size,
142 src: "local",
143 };
144140 }
145141
146142 if (localOnly) return null;
......@@ -151,200 +147,206 @@ export async function read(
151147 // Disk cache
152148 const diskPath = diskCacheRoot.join(hash);
153149 let diskFile = diskCache.get(diskPath);
150 let src: Source = "hit";
154151 if (diskFile) {
155 const lock = diskCache.lock(diskPath);
156 const diskStream = await readStream(diskPath, {
157 ref: diskFile,
158 start: start,
159 end: end,
160 lock,
161 });
162 if (diskStream) {
163 const isPending = diskFile.written.value !== diskFile.size;
164
165 // TODO: fix this? i'm suspicious
166 if (isPending) {
167 diskStream.close();
168 return {
169 stream: await fetchAndSlice(rel, url, start, end),
170 size,
171 src: "contended",
172 };
173 }
174
175 console.info(`from ${isPending ? "pending " : ""}disk cache: ${rel}`);
152 if (diskFile.written.value !== diskFile.size) src = "pending";
153 console.info(`from ${src === "pending" ? "pending " : ""}disk cache: ${rel}`);
154 } else {
155 // Do not cache large assets
156 if (size > 1_000_000_000) {
157 console.info(`skipping disk cache due to large asset: ${rel}`);
176158 return {
177 stream: stream.Readable.toWeb(diskStream),
178 src: isPending ? "pending" : "hit",
179 size: diskFile.size,
159 stream: await fetchAndSlice(rel, url, size, start, end),
160 size,
161 src: "ignored",
162 };
163 }
164 // Bail out of the disk cache if it is contended
165 if (diskCache.unlockedCapacity < size) {
166 console.info(`skipping disk cache due to contention: ${rel}`);
167 return {
168 stream: await fetchAndSlice(rel, url, size, start, end),
169 size,
170 src: "contended",
171 };
172 }
173 // IPs which request too many cache misses are temporarily
174 // banned from writing into the disk cache to avoid abuse.
175 if (noWrite) {
176 console.info(`skipping disk cache due to config: ${rel}`);
177 return {
178 stream: await fetchAndSlice(rel, url, size, start, end),
179 size,
180 src: "ratelimit",
180181 };
181 } else {
182 diskCache.delete(diskPath);
183182 }
184 }
185183
186 // Do not cache large assets
187 if (size > 1_000_000_000) {
188 console.info(`skipping disk cache due to large asset: ${rel}`);
189 return {
190 stream: await fetchAndSlice(rel, url, start, end),
191 size,
192 src: "ignored",
193 };
184 // Create a disk cache entry. Every request, this one included, reads the
185 // file back as it lands so that a slow client never stalls the download.
186 diskFile = { size, written: new async.Watch(0), memory: new Map() };
187 diskCache.set(diskPath, diskFile);
188 void download(rel, url, diskPath, diskFile);
189 src = "miss";
194190 }
195 // Bail out of the disk cache if it is contended
196 if (diskCache.unlockedCapacity < size) {
197 console.info(`skipping disk cache due to contention: ${rel}`);
198 return {
199 stream: await fetchAndSlice(rel, url, start, end),
200 size,
201 src: "contended",
202 };
203 }
204 // IPs which request too many cache misses are temporarily
205 // banned from writing into the disk cache to avoid abuse.
206 if (noWrite) {
207 console.info(`skipping disk cache due to config: ${rel}`);
191
192 const lock = diskCache.lock(diskPath);
193 try {
194 const diskStream = await readStream(diskPath, {
195 ref: diskFile,
196 start,
197 end,
198 lock,
199 });
208200 return {
209 stream: await fetchAndSlice(rel, url, start, end),
210 size,
211 src: "ratelimit",
201 stream: stream.Readable.toWeb(diskStream),
202 src,
203 size: diskFile.size,
212204 };
205 } catch (err) {
206 lock();
207 if (src === "miss" || exception.code(err) !== "ENOENT") throw err;
208 // The file vanished from under its entry, so cache it again.
209 if (diskCache.get(diskPath) === diskFile) diskCache.delete(diskPath);
210 return read(options);
213211 }
214
215 // Create a disk cache entry;
216 // Tee the stream to both the request and the filesystem.
217 diskFile = { size, written: new async.Watch(0), memory: new Map() };
218 diskCache.set(diskPath, diskFile);
219 const lock = diskCache.lock(diskPath);
220 return await fetchAndStore(
221 rel,
222 url,
223 size,
224 diskPath,
225 diskFile,
226 start,
227 end,
228 lock,
229 );
230212}
231213
232async function fetchAndStore(
214/** Fill a new disk cache entry. Readers follow along through `diskFile.written`. */
215async function download(
233216 rel: string,
234217 url: string,
235 size: number,
236218 diskPath: Path,
237219 diskFile: DiskFile,
238 start: number,
239 end: number,
240 lock: ts.Dispose,
241220) {
221 const lock = diskCache.lock(diskPath);
222 // Readers wait on this download, so a stalled connection has to fail it.
223 const response = fetch(rel, url, { timeout: 30_000 });
242224 try {
243 let response = await fetch(rel, url);
244 const { statusCode } = response;
245 if (statusCode !== 200) {
246 const text = await new Response(response as unknown as Blob).text();
247 const msg = `Source of Truth responded with status ${statusCode}: ${text}`;
248 console.error(msg);
249 throw new Error("Source of Truth is unavailable");
250 }
251 const writer = await writeStream(diskPath, response, diskFile, lock);
252 writer.on("error", () => {
253 diskCache.delete(diskPath);
254 diskFile.written.value = Infinity;
225 // Opening only after the response arrives makes every reader sharing this
226 // handle wait on the Source of Truth, and fail with it.
227 const handle = await getHandle(diskPath, async () => {
228 await response;
229 return await openFile(diskPath.toString(), "w+");
255230 });
256231
257 return {
258 stream: sliceStream(response, start, end),
259 size,
260 src: "miss",
261 } as const;
262 } catch (e) {
232 const { memory, written } = diskFile;
233 const writer = fs.createWriteStream(diskPath.toString(), {
234 start: 0,
235 fd: handle.fd,
236 fs: {
237 close(_: number, cb: (er: Error | null) => void) {
238 handle.release();
239 cb(null);
240 },
241 write(
242 fd: number,
243 data: Buffer,
244 off: 0,
245 size: number,
246 pos: number | undefined,
247 cb: (er: Error | null, bytesWritten: number, buffer: Buffer) => void,
248 ) {
249 ASSERT(size === data.byteLength);
250 ASSERT(pos != null);
251 fsCallbacks.write(fd, data, off, size, pos, (er, bytes) => {
252 if (!er && bytes) {
253 remember(memory, pos, data);
254 written.value += bytes;
255 }
256 cb(er, bytes, data);
257 });
258 },
259 },
260 });
261 await streamPromises.pipeline(await response, writer);
262
263 if (written.value !== diskFile.size) {
264 throw new Error(`Expected ${diskFile.size} bytes, received ${written.value}`);
265 }
266 writeDiskCache();
267 } catch (err) {
268 console.error(`Failed to cache ${rel}:`, err);
269 response.then((opened) => opened.destroy(), () => {});
270 if (diskCache.get(diskPath) === diskFile) diskCache.delete(diskPath);
271 diskFile.written.value = Infinity;
272 } finally {
263273 lock();
264 throw e;
265274 }
266275}
267276
268async function fetch(rel: string, url: string, headers?: {}) {
269 let promise = !headers ? parallelRequests.get(url) : null;
270 if (promise) {
271 console.info(`from other response: ${rel}`);
272 return await promise;
273 } else {
274 console.info(`cache miss: ${rel}`);
275 promise = httpGet(url, {
276 headers: {
277 ...token ? { Authorization: token } : null,
278 ...headers,
279 },
280 });
281 if (!headers) parallelRequests.set(url, promise);
282 const response = await promise;
283 if (!headers) parallelRequests.delete(url);
284 return response;
277async function fetch(rel: string, url: string, options: https.RequestOptions = {}) {
278 console.info(`cache miss: ${rel}`);
279 const response = await httpGet(url, {
280 ...options,
281 headers: {
282 ...token ? { Authorization: token } : null,
283 ...options.headers,
284 },
285 });
286 const { statusCode } = response;
287 if (statusCode !== 200 && statusCode !== 206) {
288 const text = await consumers.text(response);
289 const msg = `Source of Truth responded with status ${statusCode}: ${text}`;
290 console.error(msg);
291 throw new Error("Source of Truth is unavailable");
285292 }
293 return response;
286294}
287295
296/** Stream from the Source of Truth, skipping the disk cache. */
288297async function fetchAndSlice(
289298 rel: string,
290299 url: string,
300 size: number,
291301 start: number,
292302 end: number,
293) {
294 const headers = start !== 0 || end !== Infinity
295 ? { "Clover-Start": String(start), "Clover-End": String(end) }
296 : null;
297 return stream.Readable.toWeb(await fetch(rel, url, headers));
303): Promise<ReadableStream> {
304 const ranged = start !== 0 || end !== Infinity;
305 const response = await fetch(
306 rel,
307 url,
308 ranged ? { headers: { Range: `bytes=${start}-${end}` } } : {},
309 );
310 const partial = response.statusCode === 206;
311
312 // The caller has already promised a length, which a short body would break.
313 const expected = partial ? end - start + 1 : size;
314 const length = response.headers["content-length"];
315 if (length !== undefined && Number(length) !== expected) {
316 response.destroy();
317 throw new Error(`Source of Truth has ${length} bytes of ${rel}, expected ${expected}`);
318 }
319
320 // A server is free to ignore `Range` and respond with the whole file.
321 if (ranged && !partial) {
322 return ReadableStream.from(sliceStream(response, start, end));
323 }
324 return stream.Readable.toWeb(response);
298325}
299326
300export function sliceStream(
301 readable: stream.Readable,
327/** `end` is inclusive. Returning early destroys the rest of `readable`. */
328async function* sliceStream(
329 readable: AsyncIterable<Buffer>,
302330 start: number,
303331 end: number,
304): Readable {
305 const length = end === Infinity ? Infinity : end - start;
306 let recieved = 0;
307 let sent = 0;
308
309 const transform = new stream.Transform({
310 transform(chunk: Buffer, _, callback) {
311 const chunkEnd = recieved + chunk.length;
312
313 if (chunkEnd <= start) {
314 recieved = chunkEnd;
315 callback();
316 return;
317 }
318
319 if (sent >= length) {
320 callback();
321 return;
322 }
323
324 const sliceStart = Math.max(0, start - recieved);
325 const remaining = length - sent;
326 const sliceEnd = end === Infinity
327 ? chunk.length
328 : Math.min(chunk.length, sliceStart + remaining);
329
330 const slice = chunk.slice(sliceStart, sliceEnd);
331 sent += slice.length;
332 recieved = chunkEnd;
333
334 this.push(slice);
335
336 callback();
337 },
338 });
339
340 readable.on("error", (err) => transform.destroy(err));
341
342 return readable.pipe(transform);
332) {
333 let offset = 0;
334 for await (const chunk of readable) {
335 const slice = chunk.subarray(
336 Math.max(0, start - offset),
337 Math.max(0, end + 1 - offset),
338 );
339 offset += chunk.length;
340 if (slice.length > 0) yield slice;
341 if (offset > end) return;
342 }
343343}
344344
345345interface DiskFile {
346346 size: number;
347 /** bytes on disk so far, or `Infinity` once the download has failed */
347348 written: async.Watch<number>;
349 /** chunks recently written or read, keyed by their offset in the file */
348350 memory: Map<number, WeakRef<Buffer>>;
349351}
350352
......@@ -356,28 +358,42 @@ function httpGet(url: string, options: https.RequestOptions) {
356358 );
357359 request.on("response", resolve);
358360 request.on("error", reject);
361 request.on("timeout", () => {
362 request.destroy(new Error(`Source of Truth timed out: ${url}`));
363 });
359364 });
360365}
361366
367type Memory = DiskFile["memory"];
362368const finalization = new FinalizationRegistry(
363 ([map, key]: [Map<number, unknown>, number]) => {
364 map.delete(key);
369 ([memory, offset, ref]: [Memory, number, WeakRef<Buffer>]) => {
370 if (memory.get(offset) === ref) memory.delete(offset);
365371 },
366372);
367373
374function remember(memory: Memory, offset: number, chunk: Buffer) {
375 const ref = new WeakRef(chunk);
376 memory.set(offset, ref);
377 finalization.register(chunk, [memory, offset, ref]);
378}
379
368380const openFile = util.promisify(fsCallbacks.open);
369381
370382function deleteCachedPath(path: Path) {
371 try {
383 // Streams still holding the descriptor close it instead of parking it.
384 fds.delete(path);
385 const parked = fdsCache.get(path);
386 if (parked !== undefined) {
372387 fdsCache.delete(path);
373 } catch {}
388 fsCallbacks.closeSync(parked);
389 }
374390 try {
375391 path.deleteSync();
376392 } catch {}
377393}
378394
379395interface RefCountFile {
380 fd: number;
396 fd: Promise<number>;
381397 refs: number;
382398}
383399
......@@ -394,42 +410,54 @@ function writeDiskCache() {
394410 const serialized = diskCache.serialize({
395411 map(path, { size, written }) {
396412 if (size !== written.value) return null;
397 return [path.toString(), size];
413 return [path.base, size];
398414 },
399415 });
400 diskCacheState.writeJsonFile(serialized)
401 .catch((err) => console.error(`Failed to write ${diskCacheState.relative()}:`, err));
416 // Renamed into place so a crash cannot leave half a file to revive from.
417 const partial = `${diskCacheState}.tmp`;
418 try {
419 fsCallbacks.writeFileSync(partial, JSON.stringify(serialized));
420 fsCallbacks.renameSync(partial, diskCacheState.toString());
421 } catch (err) {
422 console.error(`Failed to write ${diskCacheState.relative()}:`, err);
423 }
402424}
403425
404async function getHandle(path: Path, flag: string, unlockOnFail?: Disposable) {
405 let handle: RefCountFile | null = null;
426/** `open` only runs when `path` has no holders and no parked descriptor. */
427async function getHandle(path: Path, open: () => Promise<number>) {
428 let handle = fds.get(path);
429 if (!handle) {
430 const parked = fdsCache.get(path);
431 if (parked !== undefined) fdsCache.delete(path);
432 handle = {
433 fd: parked === undefined ? open() : Promise.resolve(parked),
434 refs: 0,
435 };
436 fds.set(path, handle);
437 }
438 const shared = handle;
439 // Counted before the first await, or a release in between could park a
440 // descriptor that is about to be used.
441 shared.refs += 1;
442
443 let fd: number;
406444 try {
407 handle = await fds.get(path) ?? null;
408 if (!handle) {
409 const cached = fdsCache.get(path);
410 if (cached) {
411 fds.delete(path);
412 fds.set(path, handle = { fd: cached, refs: 0 });
413 } else {
414 const promise = openFile(path.raw, flag)
415 .then((fd) => ({ fd, refs: 0 }))
416 .catch((err) => {
417 fds.delete(path);
418 throw err;
419 });
420 fds.set(path, promise);
421 fds.set(path, handle = await promise);
422 }
423 }
424 handle.refs += 1;
425 } catch (err: any) {
426 unlockOnFail?.[Symbol.dispose]();
427 if (err.code === "ENOENT") {
428 return null;
429 }
445 fd = await shared.fd;
446 } catch (err) {
447 shared.refs -= 1;
448 if (fds.get(path) === shared) fds.delete(path);
430449 throw err;
431450 }
432 return handle;
451
452 return {
453 fd,
454 release() {
455 if ((shared.refs -= 1) > 0) return;
456 if (fds.get(path) !== shared) return void fsCallbacks.closeSync(fd);
457 fds.delete(path);
458 fdsCache.set(path, fd);
459 },
460 };
433461}
434462
435463interface ReadStreamOptions {
......@@ -439,15 +467,19 @@ interface ReadStreamOptions {
439467 lock?: Disposable;
440468}
441469
442async function readStream(path: Path, opts: ReadStreamOptions) {
443 const handle = await getHandle(path, "r", opts?.lock);
444 if (!handle) return null;
470/** Disposes `lock` when the stream closes, but not if opening it throws. */
471async function readStream(
472 path: Path,
473 { start, end, ref, lock }: ReadStreamOptions,
474) {
475 const handle = await getHandle(path, () => openFile(path.toString(), "r"));
445476
446477 let read = fsCallbacks.read;
447 if (opts?.ref) {
448 const { memory, written, size } = opts.ref;
449 let lastRead: number | null = null;
450 read = function loop(
478 if (ref) {
479 const { memory, written, size } = ref;
480 /** offset of the chunk in `memory` that the previous read stopped inside */
481 let partial: number | null = null;
482 read = function(
451483 fd: number,
452484 buf: Buffer,
453485 offset: 0,
......@@ -459,155 +491,70 @@ async function readStream(path: Path, opts: ReadStreamOptions) {
459491 ASSERT(offset === 0);
460492 written.until((v) => v >= Math.min(size, pos + len)).then(
461493 (newLength) => {
494 if (newLength > size) return cb(new Error("Broken Pipe"), 0, buf);
495
462496 // Try to re-use uncollected buffers in memory
463 while (true) {
464 const weak = memory.get(lastRead ?? pos);
465 if (weak) {
466 let cachedBuf = weak.deref();
467 if (cachedBuf) {
468 if (lastRead) {
469 cachedBuf = cachedBuf.subarray(pos - lastRead);
470 }
471 const { byteLength } = cachedBuf;
472
473 if (byteLength === len && offset === 0) {
474 cb(null, len, cachedBuf);
475 return;
476 }
477 if (byteLength <= len) {
478 buf.set(cachedBuf, offset);
479 lastRead = null;
480 } else {
481 buf.set(cachedBuf.subarray(0, len), offset);
482 lastRead = lastRead ?? pos;
483 }
484 offset += byteLength;
485 len -= byteLength;
486 pos += byteLength;
487 if (len > 0) continue;
488 cb(null, offset + len, buf);
489 return;
490 } else {
491 memory.delete(pos);
492 }
497 let filled = 0;
498 while (filled < len) {
499 const at = pos + filled;
500 const key = partial ?? at;
501 const chunk = memory.get(key)?.deref()?.subarray(at - key);
502 partial = null;
503 if (!chunk?.byteLength) {
504 if (key === at) break;
505 continue;
506 }
507 if (filled === 0 && chunk.byteLength === len) {
508 return cb(null, len, chunk);
493509 }
494510
495 lastRead = null;
496 break;
511 const slice = chunk.subarray(0, len - filled);
512 buf.set(slice, filled);
513 filled += slice.byteLength;
514 if (slice.byteLength < chunk.byteLength) partial = key;
497515 }
516 if (filled === len) return cb(null, len, buf);
498517
499518 // Read from disk
500 if (newLength > size) cb(new Error("Broken Pipe"), 0, buf);
501 fsCallbacks.read(
502 fd,
503 buf,
504 offset,
505 len,
506 pos,
507 async (er, bytes, buf) => {
508 if (!er && bytes > 0) {
509 memory.set(pos - offset, new WeakRef(buf));
510 finalization.register(buf, [memory, pos]);
511 }
512 cb(er, offset + bytes, buf);
513 },
514 );
519 fsCallbacks.read(fd, buf, filled, len - filled, pos + filled, (er, bytes) => {
520 // A short read leaves the tail of `buf` uninitialized.
521 if (!er && filled === 0 && bytes === len) remember(memory, pos, buf);
522 cb(er, filled + bytes, buf);
523 });
515524 },
516525 );
517526 } as typeof fsCallbacks.read;
518527 }
519528
520 let closed = false;
521529 return fs.createReadStream(path.toString(), {
522530 fd: handle.fd,
523 start: opts?.start ?? 0,
524 end: opts?.end ?? Infinity,
525 fs: {
526 close: util.callbackify(async () => {
527 ASSERT(!closed);
528 if ((handle.refs -= 1) <= 0) {
529 fds.delete(path);
530 fdsCache.set(path, handle.fd);
531 }
532 closed = true;
533 opts?.lock?.[Symbol.dispose]();
534 }),
535 read,
536 },
537 });
538}
539
540async function writeStream(
541 path: Path,
542 data: stream.Readable,
543 file: DiskFile,
544 lock: Disposable,
545) {
546 const handle = UNWRAP(await getHandle(path, "w+", lock));
547 let closed = false;
548
549 const { memory, written } = file;
550 const writer = fs.createWriteStream(path.toString(), {
551 start: 0,
552 fd: handle.fd,
531 start,
532 end,
553533 fs: {
554 close: util.callbackify(async () => {
555 ASSERT(!closed);
556 if ((handle.refs -= 1) <= 0) {
557 fds.delete(path);
558 fdsCache.set(path, handle.fd);
559 }
560 closed = true;
561 lock[Symbol.dispose]();
562 writeDiskCache();
563 }),
564 write(
565 fd: number,
566 data: Buffer,
567 off: 0,
568 size: number,
569 pos: number | undefined,
570 cb: (er: Error | null, bytesWritten: number, buffer: Buffer) => void,
571 ) {
572 ASSERT(size === data.byteLength);
573 ASSERT(pos != null);
574 fsCallbacks.write(
575 fd,
576 data,
577 off,
578 size,
579 pos,
580 (er, bytes, buf) => {
581 if (!er && bytes) {
582 memory.set(pos, new WeakRef(buf));
583 finalization.register(buf, [memory, pos]);
584 written.value += bytes;
585 }
586 cb(er, bytes, buf);
587 },
588 );
534 close(_: number, cb: (er: Error | null) => void) {
535 handle.release();
536 lock?.[Symbol.dispose]();
537 cb(null);
589538 },
539 read,
590540 },
591541 });
592 data.pipe(writer);
593
594 return writer;
595542}
596543
597544import * as fs from "#sitegen/fs";
598545import { Path } from "#sitegen/path";
599import { ASSERT, UNWRAP } from "@clo/lib/assert";
546import { ASSERT } from "@clo/lib/assert";
600547import * as async from "@clo/lib/async";
601548import * as exception from "@clo/lib/exception";
602549import * as log from "@clo/lib/log";
603550import { Lru } from "@clo/lib/Lru";
604import * as ts from "@clo/lib/ts";
605import * as crypto from "node:crypto";
606551import * as fsCallbacks from "node:fs";
607552import * as http from "node:http";
608553import * as https from "node:https";
609554import * as stream from "node:stream";
610import type { ReadableStream } from "node:stream/web";
555import * as consumers from "node:stream/consumers";
556import * as streamPromises from "node:stream/promises";
557import { ReadableStream } from "node:stream/web";
611558import * as util from "node:util";
612import { MediaFile } from "./models/MediaFile.ts";
559import type { MediaFile } from "./models/MediaFile.ts";
613560import * as paths from "./paths.ts";
src/source-of-truth.test.ts created+51
......@@ -0,0 +1,51 @@
1const dir = vi.hoisted(() => {
2 const fs = process.getBuiltinModule("node:fs");
3 const os = process.getBuiltinModule("node:os");
4 const dir = fs.mkdtempSync(os.tmpdir() + "/clover-sot-test-");
5 fs.mkdirSync(dir + "/raw");
6 fs.mkdirSync(dir + "/derived");
7 Object.assign(process.env, {
8 CLOVER_DB: dir + "/db",
9 CLOVER_FILE_RAW: dir + "/raw",
10 CLOVER_FILE_DERIVED: dir + "/derived",
11 CLOVER_SOT_KEY: "test",
12 });
13 return dir;
14});
15afterAll(() => fs.rmSync(dir, { recursive: true, force: true }));
16
17const data = crypto.randomBytes(1_000_000);
18const url = "http://localhost/file/new%20home%20[rev%203].mp3";
19fs.writeFileSync(dir + "/raw/new home [rev 3].mp3", data);
20MediaFile.createFile({
21 path: "/new home [rev 3].mp3",
22 date: new Date(0),
23 hash: crypto.createHash("sha1").update(data).digest("hex"),
24 size: data.length,
25 duration: 0,
26 dimensions: "",
27 contents: "",
28});
29
30test("requests for the same file do not share a file offset", async () => {
31 // the second request arrives once the first holds the descriptor
32 const first = await app.fetch(new Request(url));
33 const second = await app.fetch(new Request(url));
34 const bodies = await Promise.all([first.arrayBuffer(), second.arrayBuffer()]);
35 for (const body of bodies) assert.ok(data.equals(Buffer.from(body)));
36});
37
38test("a range is served as partial content", async () => {
39 const response = await app.fetch(new Request(url, { headers: { Range: "bytes=70000-" } }));
40 assert.equal(response.status, 206);
41 assert.equal(response.headers.get("Content-Range"), `bytes 70000-999999/1000000`);
42 assert.equal(response.headers.get("Content-Length"), "930000");
43 assert.ok(data.subarray(70000).equals(Buffer.from(await response.arrayBuffer())));
44});
45
46import { strict as assert } from "node:assert";
47import * as crypto from "node:crypto";
48import * as fs from "node:fs";
49import { afterAll, test, vi } from "vitest";
50import { MediaFile } from "./file-viewer/models/MediaFile.ts";
51import app from "./source-of-truth.ts";
src/source-of-truth.ts+13-1
......@@ -100,9 +100,21 @@ app.get("/file/*", async (c) => {
100100 if (err.code === "ENOENT") return c.notFound();
101101 throw err;
102102 }
103 c.header("Content-Length", (await fstat(handle.fd)).size.toString());
103 const { size } = await fstat(handle.fd);
104 let start = 0;
105 let end = Infinity;
106 const ranges = http.parseRangeHeader(c.req.header("Range") ?? "", size);
107 if (ranges?.[0] && ranges.length === 1) {
108 ({ start, end } = ranges[0]);
109 c.status(206);
110 c.header("Content-Range", `bytes ${start}-${end}/${size}`);
111 }
112 c.header("Content-Length", String(Math.min(end, size - 1) - start + 1));
104113 const nodeStream = fs.createReadStream(fullPath, {
105114 fd: handle.fd,
115 // Positioned, or requests sharing this descriptor would share its offset.
116 start,
117 end,
106118 fs: {
107119 close: util.callbackify(async () => {
108120 ASSERT(handle);