fix(anime): cap outcome polling and upstream stalls by wall clock

- watchPlaybackOutcome now tracks a real deadline so slow property reads eat the timeout budget instead of extending it, and a zero probe interval still terminates
- stream strip proxy aborts upstream GETs that go silent for 15s instead of hanging mpv on that segment forever
- anime browser runtime drops a stream proxy that finishes starting after its bridge already died, instead of leaking a listener pointed at nothing
This commit is contained in:
2026-08-01 03:13:48 -07:00
parent dec7851a17
commit 336e9fb8a5
5 changed files with 120 additions and 58 deletions
+43 -3
View File
@@ -7,12 +7,25 @@ type Harness = {
listenerCount: () => number; listenerCount: () => number;
setProperty: (name: string, value: unknown) => void; setProperty: (name: string, value: unknown) => void;
failProperty: (name: string) => void; failProperty: (name: string) => void;
/** Virtual milliseconds burned so far, by sleeps and by property reads. */
elapsed: () => number;
}; };
function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: number }) { /**
* The clock is virtual and only moves when the code under test sleeps (or,
* with `readCostMs`, when it reads a property), so timeout behaviour is
* asserted without any real waiting.
*/
function createHarness(overrides?: {
timeoutMs?: number;
probeIntervalMs?: number;
readCostMs?: number;
}) {
const listeners = new Set<(event: PlaybackEndFileEvent) => void>(); const listeners = new Set<(event: PlaybackEndFileEvent) => void>();
const properties = new Map<string, unknown>(); const properties = new Map<string, unknown>();
const failing = new Set<string>(); const failing = new Set<string>();
const readCostMs = overrides?.readCostMs ?? 0;
let clock = 0;
const watch = watchPlaybackOutcome({ const watch = watchPlaybackOutcome({
onEndFile: (listener) => { onEndFile: (listener) => {
@@ -20,10 +33,14 @@ function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: numbe
return () => listeners.delete(listener); return () => listeners.delete(listener);
}, },
readProperty: async (name) => { readProperty: async (name) => {
clock += readCostMs;
if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`); if (failing.has(name)) throw new Error(`Failed to read MPV property '${name}'`);
return properties.get(name); return properties.get(name);
}, },
wait: async () => {}, wait: async (ms) => {
clock += ms;
},
now: () => clock,
timeoutMs: overrides?.timeoutMs ?? 1000, timeoutMs: overrides?.timeoutMs ?? 1000,
probeIntervalMs: overrides?.probeIntervalMs ?? 100, probeIntervalMs: overrides?.probeIntervalMs ?? 100,
}); });
@@ -35,6 +52,7 @@ function createHarness(overrides?: { timeoutMs?: number; probeIntervalMs?: numbe
listenerCount: () => listeners.size, listenerCount: () => listeners.size,
setProperty: (name, value) => properties.set(name, value), setProperty: (name, value) => properties.set(name, value),
failProperty: (name) => failing.add(name), failProperty: (name) => failing.add(name),
elapsed: () => clock,
}; };
return { watch, harness }; return { watch, harness };
} }
@@ -68,10 +86,32 @@ test('ignores the end-file fired for the file being replaced', async () => {
}); });
test('times out with a failure when nothing ever starts', async () => { test('times out with a failure when nothing ever starts', async () => {
const { watch } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 }); const { watch, harness } = createHarness({ timeoutMs: 300, probeIntervalMs: 100 });
const outcome = await watch.wait(); const outcome = await watch.wait();
assert.equal(outcome.ok, false); assert.equal(outcome.ok, false);
assert.ok(!outcome.ok && outcome.error.length > 0); assert.ok(!outcome.ok && outcome.error.length > 0);
assert.equal(harness.elapsed(), 300);
watch.dispose();
});
test('slow property reads eat the budget instead of extending it', async () => {
const { watch, harness } = createHarness({
timeoutMs: 300,
probeIntervalMs: 100,
readCostMs: 250,
});
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
// Two probes: 250 + 50 (the sleep clamped to what was left) then 250 again.
assert.ok(harness.elapsed() >= 300, 'gave up before the timeout');
assert.ok(harness.elapsed() < 900, 'read delays stretched the timeout');
watch.dispose();
});
test('a zero probe interval still terminates at the deadline', async () => {
const { watch } = createHarness({ timeoutMs: 200, probeIntervalMs: 0, readCostMs: 50 });
const outcome = await watch.wait();
assert.equal(outcome.ok, false);
watch.dispose(); watch.dispose();
}); });
+11 -2
View File
@@ -26,6 +26,8 @@ export interface WatchPlaybackOutcomeDeps {
/** One-shot mpv property read; may reject while the file is still loading. */ /** One-shot mpv property read; may reject while the file is still loading. */
readProperty: (name: string) => Promise<unknown>; readProperty: (name: string) => Promise<unknown>;
wait: (ms: number) => Promise<void>; wait: (ms: number) => Promise<void>;
/** Injectable clock; the timeout is wall-clock, not a probe count. */
now?: () => number;
timeoutMs?: number; timeoutMs?: number;
probeIntervalMs?: number; probeIntervalMs?: number;
} }
@@ -58,7 +60,11 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
}); });
async function wait(): Promise<PlaybackOutcome> { async function wait(): Promise<PlaybackOutcome> {
for (let elapsed = 0; elapsed < timeoutMs; elapsed += probeIntervalMs) { // Wall-clock, not a probe count: a slow `readProperty` must eat into the
// budget rather than stretch it, and a zero probe interval must still end.
const now = deps.now ?? Date.now;
const deadline = now() + timeoutMs;
while (now() < deadline) {
if (failure) return failure; if (failure) return failure;
try { try {
if ((await deps.readProperty('vo-configured')) === true) return { ok: true }; if ((await deps.readProperty('vo-configured')) === true) return { ok: true };
@@ -66,7 +72,10 @@ export function watchPlaybackOutcome(deps: WatchPlaybackOutcomeDeps): PlaybackOu
// The property is unreadable while mpv is between files; keep polling. // The property is unreadable while mpv is between files; keep polling.
} }
if (failure) return failure; if (failure) return failure;
await deps.wait(probeIntervalMs); // Sleeping past the deadline would only delay the timeout report.
const remaining = deadline - now();
if (remaining <= 0) break;
await deps.wait(Math.min(probeIntervalMs, remaining));
} }
return ( return (
failure ?? { failure ?? {
+30 -34
View File
@@ -64,11 +64,16 @@ test('rewritePlaylistOrigins swaps absolute upstream URLs and keeps relative lin
type Route = { status: number; contentType: string; body: Buffer }; type Route = { status: number; contentType: string; body: Buffer };
/** Either a static routing table or a handler, for upstreams that need one. */
async function withProxy( async function withProxy(
routes: Record<string, Route>, routes: Record<string, Route> | http.RequestListener,
run: (proxyOrigin: string, upstreamOrigin: string) => Promise<void>, run: (proxyOrigin: string, upstreamOrigin: string) => Promise<void>,
): Promise<void> { ): Promise<void> {
const upstream = http.createServer((req, res) => { const upstream = http.createServer((req, res) => {
if (typeof routes === 'function') {
routes(req, res);
return;
}
const route = routes[req.url ?? '']; const route = routes[req.url ?? ''];
if (!route) { if (!route) {
res.writeHead(404).end('missing'); res.writeHead(404).end('missing');
@@ -133,35 +138,34 @@ test('proxy leaves non-TS bodies alone', async () => {
}); });
test('proxy rewrites absolute upstream playlist entries to its own origin', async () => { test('proxy rewrites absolute upstream playlist entries to its own origin', async () => {
const upstream = http.createServer((req, res) => { await withProxy(
const origin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; (req, res) => {
if (req.url === '/video/list.m3u8') { if (req.url !== '/video/list.m3u8') {
res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' }); res.writeHead(404).end();
res.end(`#EXTM3U\n#EXTINF:6,\n${origin}/video/abs.ts\n`);
return; return;
} }
res.writeHead(404).end(); // The proxy rewrites the Host header to the upstream it dialled, so this
}); // is that origin — the one the playlist must not leak to mpv.
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve)); res.writeHead(200, { 'content-type': 'application/vnd.apple.mpegurl' });
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; res.end(`#EXTM3U\n#EXTINF:6,\nhttp://${req.headers.host}/video/abs.ts\n`);
const proxy = await startStreamStripProxy({ upstreamOrigin: () => upstreamOrigin }); },
try { async (proxyOrigin, upstreamOrigin) => {
const { body } = await fetchBytes(`${proxy.origin}/video/list.m3u8`); const { body } = await fetchBytes(`${proxyOrigin}/video/list.m3u8`);
const text = body.toString('utf8'); const text = body.toString('utf8');
assert.ok(text.includes(`${proxy.origin}/video/abs.ts`)); assert.ok(text.includes(`${proxyOrigin}/video/abs.ts`));
assert.ok(!text.includes(upstreamOrigin)); assert.ok(!text.includes(upstreamOrigin));
} finally { },
await proxy.close(); );
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
}); });
test('proxy strips even when the client asks for a byte range', async () => { test('proxy strips even when the client asks for a byte range', async () => {
// ffmpeg opens every HLS segment with `Range: bytes=0-`; the bridge answers // ffmpeg opens every HLS segment with `Range: bytes=0-`. The proxy drops the
// some of those with 206, which must not bypass the strip. // header, so the upstream answers 200 with the whole body and the strip
// applies; a 206 would have been forwarded untouched.
const ts = makeTsPackets(8); const ts = makeTsPackets(8);
const disguised = Buffer.concat([PNG_HEADER, ts]); const disguised = Buffer.concat([PNG_HEADER, ts]);
const upstream = http.createServer((req, res) => { await withProxy(
(req, res) => {
if (req.headers.range !== undefined) { if (req.headers.range !== undefined) {
res.writeHead(206, { res.writeHead(206, {
'content-type': 'image/png', 'content-type': 'image/png',
@@ -172,24 +176,16 @@ test('proxy strips even when the client asks for a byte range', async () => {
} }
res.writeHead(200, { 'content-type': 'image/png' }); res.writeHead(200, { 'content-type': 'image/png' });
res.end(disguised); res.end(disguised);
}); },
await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve)); async (proxyOrigin) => {
const upstreamOrigin = `http://127.0.0.1:${(upstream.address() as AddressInfo).port}`; const response = await fetch(`${proxyOrigin}/video/seg.ts`, {
const proxy = await startStreamStripProxy({
upstreamOrigin: () => upstreamOrigin,
retryDelayMs: 5,
});
try {
const response = await fetch(`${proxy.origin}/video/seg.ts`, {
headers: { Range: 'bytes=0-' }, headers: { Range: 'bytes=0-' },
}); });
const body = Buffer.from(await response.arrayBuffer()); const body = Buffer.from(await response.arrayBuffer());
assert.equal(response.status, 200); assert.equal(response.status, 200);
assert.deepEqual(body, ts); assert.deepEqual(body, ts);
} finally { },
await proxy.close(); );
await new Promise<void>((resolve) => upstream.close(() => resolve()));
}
}); });
test('proxy forwards error statuses without touching the body', async () => { test('proxy forwards error statuses without touching the body', async () => {
+13 -1
View File
@@ -73,6 +73,12 @@ export interface StreamStripProxyOptions {
} }
const DEFAULT_RETRY_DELAY_MS = 400; const DEFAULT_RETRY_DELAY_MS = 400;
/**
* Socket timeout on the upstream GET, cleared once its headers arrive. Node's
* http client has no deadline of its own, so a host that accepts the
* connection and then says nothing would hang mpv on that segment forever.
*/
const UPSTREAM_TIMEOUT_MS = 15_000;
export interface StreamStripProxyHandle { export interface StreamStripProxyHandle {
origin: string; origin: string;
@@ -152,8 +158,10 @@ export function startStreamStripProxy(
const upstreamRequest = http.request( const upstreamRequest = http.request(
upstreamUrl, upstreamUrl,
{ method: req.method, headers: requestHeaders }, { method: req.method, headers: requestHeaders, timeout: UPSTREAM_TIMEOUT_MS },
(upstream) => { (upstream) => {
// Body streaming has its own pace; only the wait for headers is capped.
upstreamRequest.setTimeout(0);
const status = upstream.statusCode ?? 502; const status = upstream.statusCode ?? 502;
if (status === 404 || status >= 500) { if (status === 404 || status >= 500) {
if (mayRetry) { if (mayRetry) {
@@ -167,6 +175,10 @@ export function startStreamStripProxy(
handleUpstreamResponse(req, res, upstream); handleUpstreamResponse(req, res, upstream);
}, },
); );
// Destroying with an error routes the stall through the retry/502 path.
upstreamRequest.on('timeout', () => {
upstreamRequest.destroy(new Error(`upstream silent for ${UPSTREAM_TIMEOUT_MS}ms`));
});
upstreamRequest.on('error', (error) => { upstreamRequest.on('error', (error) => {
if (mayRetry) { if (mayRetry) {
log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`); log(`[stream-proxy] upstream request failed: ${String(error)}; retrying once`);
+6 -1
View File
@@ -228,10 +228,15 @@ export function createAnimeBrowserRuntime(deps: AnimeBrowserRuntimeDeps) {
}); });
try { try {
stripProxy = await startStreamStripProxy({ const proxy = await startStreamStripProxy({
upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl, upstreamOrigin: () => sidecar?.baseUrl ?? handle.baseUrl,
log: deps.log, log: deps.log,
}); });
// The bridge can die while the proxy is coming up, in which case onExit
// already ran and cleared `stripProxy`; adopting this one would leak a
// listening server pointed at a dead upstream.
if (sidecar === handle) stripProxy = proxy;
else void proxy.close();
} catch (error) { } catch (error) {
// Playback still works for undisguised streams; log and carry on. // Playback still works for undisguised streams; log and carry on.
deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`); deps.log(`[anime-browser] stream proxy failed to start: ${describeError(error)}`);