Fix play proxy cancel crashing the server on client disconnect

This commit is contained in:
Jonathan Sykes
2026-08-23 22:54:31 +08:00
parent 831063125d
commit d1ba9d1e9c

View File

@@ -34,6 +34,11 @@ import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto'; import { createHash } from 'node:crypto';
import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist } from './db.js'; import { initDb, upsertUser, recordVideoAccess, getUserData, createProfile, getProfile, saveProfile, createSharedPlaylist, getSharedPlaylist } from './db.js';
// A media proxy must not die because one client's stream hit an edge case
// (see /api/play cancel()): log and keep serving instead of crash-looping.
process.on('uncaughtException', (err) => console.error('[ytplayer] uncaught exception:', err));
process.on('unhandledRejection', (err) => console.error('[ytplayer] unhandled rejection:', err));
const PORT = parseInt(process.env.PORT || '3000', 10); const PORT = parseInt(process.env.PORT || '3000', 10);
const APP_VERSION = process.env.APP_VERSION || '1.0.0'; const APP_VERSION = process.env.APP_VERSION || '1.0.0';
const YTDLP = process.env.YTDLP_PATH || 'yt-dlp'; const YTDLP = process.env.YTDLP_PATH || 'yt-dlp';
@@ -597,6 +602,13 @@ app.get('/api/play', async (c) => {
const length = end - start + 1; const length = end - start + 1;
const fmt = attempt.fmt; const fmt = attempt.fmt;
// Client-disconnect handling: the active upstream body is LOCKED by its
// reader, so `body.cancel()` on it throws ("Cannot cancel a locked
// ReadableStream") — and an exception thrown from cancel() took the whole
// Bun process down on every seek / quality switch / aborted fetch. Cancel
// through the reader instead, and stop the slice loop via a flag.
let aborted = false;
let reader = null;
const body = new ReadableStream({ const body = new ReadableStream({
async start(ctrl) { async start(ctrl) {
// Upstream body is a slice already; the slice boundaries below are // Upstream body is a slice already; the slice boundaries below are
@@ -604,15 +616,15 @@ app.get('/api/play', async (c) => {
let pos = start; let pos = start;
let res = up; let res = up;
try { try {
while (pos <= end) { while (pos <= end && !aborted) {
const sliceEnd = Math.min(end, pos + PLAY_CHUNK - 1); const sliceEnd = Math.min(end, pos + PLAY_CHUNK - 1);
if (res === null) { if (res === null) {
res = await fetch(fmt.url, { headers: { ...fmt.headers, Range: `bytes=${pos}-${sliceEnd}` }, redirect: 'follow' }); res = await fetch(fmt.url, { headers: { ...fmt.headers, Range: `bytes=${pos}-${sliceEnd}` }, redirect: 'follow' });
if (res.status !== 206 && res.status !== 200) throw new Error('upstream ' + res.status); if (res.status !== 206 && res.status !== 200) throw new Error('upstream ' + res.status);
} }
const reader = res.body.getReader(); reader = res.body.getReader();
let got = 0; let got = 0;
while (got < sliceEnd - pos + 1) { while (got < sliceEnd - pos + 1 && !aborted) {
const { value, done } = await reader.read(); const { value, done } = await reader.read();
if (done) break; if (done) break;
const room = sliceEnd - pos + 1 - got; const room = sliceEnd - pos + 1 - got;
@@ -620,17 +632,23 @@ app.get('/api/play', async (c) => {
ctrl.enqueue(chunk); ctrl.enqueue(chunk);
got += chunk.length; got += chunk.length;
} }
try { reader.cancel(); } catch {} try { await reader.cancel(); } catch {}
reader = null;
if (aborted) break;
if (got === 0) throw new Error('upstream returned no bytes'); if (got === 0) throw new Error('upstream returned no bytes');
pos += got; pos += got;
res = null; res = null;
} }
ctrl.close(); if (!aborted) ctrl.close();
} catch (err) { } catch (err) {
ctrl.error(err); if (!aborted) { try { ctrl.error(err); } catch {} }
} }
}, },
cancel() { try { up.body?.cancel(); } catch {} }, cancel() {
aborted = true;
const r = reader;
if (r) r.cancel().catch(() => {});
},
}); });
headers.set('Content-Length', String(length)); headers.set('Content-Length', String(length));