mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 13:53:12 -06:00
A site on a marginal link (the report came from a one-bar 5G install) could never fill its cache. Every attempt started at byte 0 and the .part was deleted on any interruption, so an asset larger than one call's worth of transfer was discarded and re-fetched forever — five minutes of progress thrown away, back off, five more minutes, thrown away. With nothing cached, the player showed the waiting state, which is what got reported as "the screens go black instead of playing cached content". The offline playback path was never the problem; the cache simply could not be filled. An interrupted download now keeps its .part and the next attempt asks for the rest with Range. Two ways that could corrupt the cache, both closed: If-Range with a stored validator makes a changed asset come back as a full 200 (restart) rather than a spliceable tail, and a partial longer than the asset gets a 416 and is discarded. Bytes are kept only when they can be built upon — with no validator there is no safe resume, so the partial is dropped and the attempt backs off as the failure it is, rather than re-fetching the same prefix forever. DownloadCoordinator now distinguishes progress from failure: attempts chain while bytes are landing (bounded, single-flight held throughout) and only a no-progress attempt escalates the exponential backoff or acks "failed" — an advancing download is not a failed one and should not be shown as such. Server side is unchanged; res.sendFile already serves Range/If-Range, and content-range-resume.test.js pins that since it is load-bearing and a future middleware could silently remove it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
125 lines
6 KiB
JavaScript
125 lines
6 KiB
JavaScript
'use strict';
|
|
|
|
// A player on a bad link cannot download a large asset in one unbroken call. It has to be able to
|
|
// come back and ask for "the rest", which means GET /api/content/:id/file must honour Range — and
|
|
// must reject a resume against an asset that changed underneath it, or the player would splice two
|
|
// different files together and cache the result as whole.
|
|
//
|
|
// Both behaviours come from res.sendFile / the `send` module rather than from code in this repo,
|
|
// which is exactly why they are worth pinning: they are load-bearing for offline resilience and a
|
|
// future middleware (compression, a custom Content-Length, a stream wrapper) would silently take
|
|
// them away. Nothing in the route would look wrong afterwards — downloads would simply start over
|
|
// from zero forever on the sites that need resume most.
|
|
|
|
const os = require('node:os');
|
|
const path = require('node:path');
|
|
const fs = require('node:fs');
|
|
const crypto = require('node:crypto');
|
|
process.env.DATA_DIR = path.join(os.tmpdir(), 'st-range-' + crypto.randomBytes(4).toString('hex'));
|
|
process.env.SELF_HOSTED = 'true';
|
|
process.env.NODE_ENV = 'test';
|
|
|
|
const { test, before, after } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const http = require('node:http');
|
|
const express = require('express');
|
|
const { db } = require('../db/database');
|
|
const config = require('../config');
|
|
|
|
const ID = 'range-fixture';
|
|
const BODY = Buffer.from(Array.from({ length: 1000 }, (_, i) => i % 251)); // 1000 known bytes
|
|
let server, base, filePath;
|
|
|
|
/** Raw request so we can see the status line and headers, not just a parsed body. */
|
|
function req(headers) {
|
|
return new Promise((resolve, reject) => {
|
|
http.get(`${base}/${ID}/file`, { headers }, (res) => {
|
|
const chunks = [];
|
|
res.on('data', (c) => chunks.push(c));
|
|
res.on('end', () => resolve({ status: res.statusCode, headers: res.headers, body: Buffer.concat(chunks) }));
|
|
}).on('error', reject);
|
|
});
|
|
}
|
|
|
|
before(async () => {
|
|
fs.mkdirSync(config.contentDir, { recursive: true });
|
|
filePath = path.join(config.contentDir, 'range-fixture.bin');
|
|
fs.writeFileSync(filePath, BODY);
|
|
// workspace_id NULL is readable by any authenticated caller (checkContentRead), which keeps the
|
|
// fixture to one row.
|
|
db.prepare('INSERT INTO content (id, filename, mime_type, file_size, filepath) VALUES (?,?,?,?,?)')
|
|
.run(ID, 'range-fixture.bin', 'application/octet-stream', BODY.length, 'range-fixture.bin');
|
|
|
|
const app = express();
|
|
app.use((r, _res, next) => { r.workspaceId = 'ws'; r.user = { id: 'u', role: 'admin' }; next(); });
|
|
app.use('/', require('../routes/content'));
|
|
server = http.createServer(app);
|
|
await new Promise((r) => server.listen(0, r));
|
|
base = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(() => new Promise((r) => server.close(r)));
|
|
|
|
test('the route advertises range support', async () => {
|
|
const r = await req({});
|
|
assert.equal(r.status, 200);
|
|
assert.equal(r.headers['accept-ranges'], 'bytes');
|
|
assert.equal(r.body.length, BODY.length);
|
|
});
|
|
|
|
test('a resume request returns 206 with exactly the remaining bytes', async () => {
|
|
// This is the player's second attempt: 400 bytes already on disk in the .part, ask for the rest.
|
|
const r = await req({ Range: 'bytes=400-' });
|
|
assert.equal(r.status, 206, 'a resume must not be answered with the whole file');
|
|
assert.equal(r.headers['content-range'], `bytes 400-999/${BODY.length}`);
|
|
assert.deepEqual(r.body, BODY.subarray(400), 'the tail must line up byte-for-byte with the head already cached');
|
|
});
|
|
|
|
test('the declared total in Content-Range is what the player validates completeness against', async () => {
|
|
// The player has no other trustworthy source for the full size: Content-Length on a 206 is the
|
|
// length of the CHUNK. Parsing the total out of Content-Range is what stops a resumed download
|
|
// being promoted while still short.
|
|
const r = await req({ Range: 'bytes=999-' });
|
|
assert.equal(r.status, 206);
|
|
assert.match(r.headers['content-range'], /\/1000$/);
|
|
assert.equal(r.body.length, 1);
|
|
});
|
|
|
|
test('a request starting past the end is refused, not answered with an empty body', async () => {
|
|
// The player treats this as "my .part is stale or longer than the asset" and starts over. If the
|
|
// server answered 200/206-with-nothing instead, the bad .part would survive every retry.
|
|
const r = await req({ Range: 'bytes=5000-' });
|
|
assert.equal(r.status, 416);
|
|
});
|
|
|
|
test('If-Range with a stale validator falls back to the WHOLE file instead of a splice', async () => {
|
|
// The one that actually protects the cache. If the asset changed since the .part was started,
|
|
// appending the tail of the new file to the head of the old one produces a corrupt asset whose
|
|
// byte count is nonetheless exactly right — it would pass the completeness check and be played.
|
|
// If-Range makes the server answer 200, and the player restarts from zero.
|
|
const r = await req({ Range: 'bytes=400-', 'If-Range': '"not-the-current-etag"' });
|
|
assert.equal(r.status, 200, 'a changed entity must yield the full body, not a 206 tail');
|
|
assert.equal(r.body.length, BODY.length);
|
|
});
|
|
|
|
test('If-Range with the CURRENT validator still resumes', async () => {
|
|
const head = await req({});
|
|
const etag = head.headers.etag;
|
|
assert.ok(etag, 'no ETag means the player has no validator to send and every resume restarts');
|
|
const r = await req({ Range: 'bytes=400-', 'If-Range': etag });
|
|
assert.equal(r.status, 206);
|
|
assert.deepEqual(r.body, BODY.subarray(400));
|
|
});
|
|
|
|
test('remote-url content still 404s rather than serving a range of nothing', async () => {
|
|
db.prepare('INSERT INTO content (id, filename, mime_type, file_size, remote_url) VALUES (?,?,?,?,?)')
|
|
.run('range-remote', 'feed', 'text/html', 0, 'https://example.com/');
|
|
const r = await new Promise((resolve, reject) => {
|
|
http.get(`${base}/range-remote/file`, { headers: { Range: 'bytes=0-' } }, (res) => {
|
|
res.resume();
|
|
res.on('end', () => resolve({ status: res.statusCode }));
|
|
}).on('error', reject);
|
|
});
|
|
assert.equal(r.status, 404);
|
|
});
|