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
Describe the bug
The Studio preview server shares the one-shot CLI's process-wide
uncaughtExceptionhandler, which calls
process.exit(1). For arenderinvocation that is correct — thecommand 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:
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:2. The only escape hatch is never set by the server.
markRenderSucceeded()is calledfrom exactly two places, both inside the one-shot render command —
packages/cli/src/commands/render.ts:748and:913. Thepreviewsubcommand that hostsStudio never calls it, so
isRenderSucceeded()is permanentlyfalsethere and everyuncaught error takes the
exitAfterCliFailurebranch.3. Studio reports the resulting socket drop as a connectivity problem —
packages/studio/src/components/renders/useRenderQueue.ts:259setserror: "Connection lost. Is the render server running?"when theEventSourceerrors on ajob 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.tsand delivered as a gracefulstatus: "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-modewith default workers, and--workers 4, on both macOS and Linux. Byrule 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:
the global handler, or
isRenderSucceeded()alreadyexempts 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