Repository navigation
Description
Describe the bug
The Studio preview server shares the one-shot CLI's process-wide uncaughtException
handler, which calls process.exit(1). For a render invocation that is correct — the
command is over anyway. For a long-running server it means any uncaught error anywhere
in the process tears down the server, dropping every open SSE connection at once.
Every Studio client watching a render then shows:
Connection lost. Is the render server running?
which blames the user's setup for something that was a crash inside our own process.
The chain, verified in source
1. The handler is process-wide and fatal — packages/cli/src/cli.ts:393:
process.on("uncaughtException", (error) => {
if ((error as NodeJS.ErrnoException).code === "EPIPE") { ... }
if (isRenderSucceeded()) {
exitAfterPostRenderTermination("uncaughtException", "uncaught_exception", error);
}
exitAfterCliFailure("uncaught_exception", error); // -> process.exit(1)
});2. The only escape hatch is never set by the server. markRenderSucceeded() is called
from exactly two places, both inside the one-shot render command —
packages/cli/src/commands/render.ts:748 and :913. The preview subcommand that hosts
Studio never calls it, so isRenderSucceeded() is permanently false there and every
uncaught error takes the exitAfterCliFailure branch.
3. Studio reports the resulting socket drop as a connectivity problem —
packages/studio/src/components/renders/useRenderQueue.ts:259 sets
error: "Connection lost. Is the render server running?" when the EventSource errors on a
job still in rendering.
Note the asymmetry with the normal failure path: a render job that fails cleanly is caught
in packages/cli/src/server/studioServer.ts and delivered as a graceful status: "failed"
SSE event carrying the real error text. Only a process death produces the generic
"Connection lost" wording. So the message a user sees tells you which of the two happened,
and this one means we exited.
Why this matters beyond the wording
A one-shot command exiting on an uncaught exception loses nothing. A server doing it loses
every concurrent job and every connected client, and the users who hit it are told to check
whether they started the server — which they did.
It also makes the failure look reproducible from the user's side ("happens every time")
while being invisible from ours, because the thing that died left no per-job error behind.
What is verified and what is not
Verified: the handler, the missing exemption for the preview server, the process.exit(1),
and the Studio error string. Those are facts about the code as it stands.
Not verified: that this is what produced any particular field report. Chasing the
suspected trigger — a browser target dying mid-capture — did not reproduce: 0 failures in
48 trials of a 1050-frame composition across --workers 1 --low-memory-mode,
--no-low-memory-mode with default workers, and --workers 4, on both macOS and Linux. By
rule of three that puts the true crash rate under roughly 6%, consistent with "rare" and not
with "reliably reproducible". So no live escape-path evidence was captured, and no specific
unawaited listener has been identified.
That does not change the recommendation, because the defect here is structural rather than
dependent on any one trigger.
Suggested fix
Do not let a render job's uncaught error kill the process that is also serving the SSE
connection reporting on it. Either:
- isolate the render-job promise chain in the preview server so its failures cannot reach
the global handler, or - exempt the preview server from the fatal branch the same way
isRenderSucceeded()already
exempts a completed CLI render, logging and failing the affected job instead of exiting.
The second is closer to the existing design and reuses machinery that is already there.
Either way the outcome should be that a crashed render fails that job with its real error,
and every other client stays connected.
Environment
hyperframes 0.8.4
Affected packages/cli/src/cli.ts:393
packages/cli/src/commands/render.ts:748,913 (only markRenderSucceeded callers)
packages/studio/src/components/renders/useRenderQueue.ts:259
Activity
miga-heygen commentedon Aug 23, 2026
Closing after deep investigation — no reproduction found.
The structural observation (preview server inherits the CLI's fatal
uncaughtExceptionhandler) is real architecture debt, but every async path in the preview server is properly guarded: render execution has a top-level try/catch, the parallel coordinator catches per-worker errors, browser disconnect is handled gracefully, SSE writes have.catch(), and the thumbnail browser returns null on failure. The issue author ran 48 trials with 0 crashes, and no stack trace or field report was attached.If this does surface in the field, the
_trackCliErrortelemetry emitsuncaught_exceptionwith the full stack — that data would point to the specific error to catch at its source.— Miga