diff --git a/src/servers/sidecar/create-proxy.ts b/src/servers/sidecar/create-proxy.ts index c83b9493..3f88a525 100644 --- a/src/servers/sidecar/create-proxy.ts +++ b/src/servers/sidecar/create-proxy.ts @@ -31,10 +31,16 @@ export type SidecarProxyParams = { /** Extra work when the sidecar reports in. Rare — most sidecars only need the port remembered. */ onRegister?: (port: number) => void; /** - * Per-request idle timeout, seconds. The main server drops a proxied connection after 60s with no bytes - * flowing, which is wrong for a sidecar route that thinks before it answers (a from-scratch music - * reindex holds the socket open for minutes). Applied to the whole prefix: the proxy must not know which - * of a sidecar's routes are slow. + * Per-request idle timeout, seconds — or omitted, which now means NO timeout and is the default. + * + * The main server drops an ordinary connection after 60s with no bytes flowing, which is wrong for + * every sidecar route that thinks before it answers. A from-scratch music reindex holds the socket for + * minutes; transcribing a 42-minute recording holds it for longer than that. + * + * The owner's call on 2026-08-24, after a transcription that SUCCEEDED was thrown away by a proxy that + * gave up on it: there should be no timeouts on these requests at all, because long ones will keep + * arriving from everywhere and every ceiling is a number somebody guessed. A plugin may still name one + * if it genuinely wants a bound; nothing does by default. */ timeoutSeconds?: number; /** @@ -127,15 +133,19 @@ export function createSidecarProxy({ const subpath = url.pathname.slice(prefix.length) || '/'; const target = `${baseUrl}${subpath}${url.search}`; - if (timeoutSeconds) { - // Bun passes the server as Hono's env. Older Bun has no per-request timeout; the request still - // completes, it just risks the default idle drop. - const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined; - try { - server?.timeout?.(ctx.req.raw, timeoutSeconds); - } catch { - /* no per-request timeout available */ - } + /* + The INBOUND leg: how long Bun holds the caller's connection. + + Applied to every proxied request now, not only ones that declared a number — `0` disables the idle + timeout, and no timeout is the default for anything going through a sidecar. Bun passes the server + as Hono's env; an older Bun without per-request timeouts still completes the request, it just risks + the 60s default drop. + */ + const server = ctx.env as { timeout?: (req: Request, seconds: number) => void } | undefined; + try { + server?.timeout?.(ctx.req.raw, timeoutSeconds ?? 0); + } catch { + /* no per-request timeout available */ } const method = ctx.req.method; @@ -198,11 +208,27 @@ export function createSidecarProxy({ : ctx.req.raw.body : undefined; - const init: RequestInit & { duplex?: 'half' } = { + /* + `timeoutSeconds` has to govern BOTH hops, and it only governed one. + + Above, it extends how long Bun holds the INBOUND connection open. This is the outbound leg, and it + had nothing — so it took Bun's built-in fetch ceiling of about 300s regardless of what a plugin + declared. transcribe declares 1800 for exactly this case and still gave up on a 42-minute recording + at four minutes, while the sidecar went on and finished it. The work was done and thrown away. + + `timeout: false`, unconditionally. An AbortSignal is specifically NOT wanted: it cannot raise Bun's + ceiling anyway (only lower it — the trap `plugins/transcribe/sidecar/transcribe.ts` documents from + hitting it first), and it aborts the response BODY as well as the request. This proxy carries video, + and jellyfin declares 3600, so a signal would have cut a film at exactly one hour of playback. A + streaming response has its headers in milliseconds and then runs for as long as somebody watches. + */ + type BunRequestInit = RequestInit & { duplex?: 'half'; timeout?: boolean | number }; + const init: BunRequestInit = { method, headers, body, duplex: 'half', + timeout: false, }; let upstream: Response; @@ -211,6 +237,15 @@ export function createSidecarProxy({ } catch (err) { // Target path only — never the body, which may carry a passphrase or a credential. console.error(`[${name}] proxy fetch failed`, { target, error: String(err) }); + /* + A timeout is not "unreachable", and saying so sent an hour of debugging at the wrong machine: the + sidecar had answered 200 and finished the job. One means the sidecar is not there; the other means + it is there and slower than the plugin said to wait. 504 rather than 502, and the number named, so + the next person reads "this took longer than 1800s" instead of "the thing I can see running is + not running". + */ + const timedOut = err instanceof Error && (err.name === 'TimeoutError' || err.name === 'AbortError'); + if (timedOut) return ctx.text(`${name} sidecar did not answer in time`, 504); return ctx.text(`${name} sidecar unreachable`, 502); }