Skip to content

Commit 40f02e8

Browse files
committed
Stop reporting clean sidecar shutdowns as desktop crashes
A post-boot sidecar exit was only treated as expected when the desktop itself called stopSidecar (which sends SIGTERM). A SIGINT reaching the child any other way — most often a process-group signal racing ahead of teardown — exited the bun/Effect daemon with code 130 and fell through to reportSidecarCrash plus the in-window crash screen, despite an empty stderr tail. That surfaced as a spurious 'Sidecar exited unexpectedly (code=130 signal=null)' crash report on a healthy shutdown. Guard the classification two ways: - Latch quit intent in before-quit (markAppQuitting), synchronously and before stopConnection's await, so any sidecar exit during teardown is treated as shutdown even if a signal beats the SIGTERM. - Treat a clean signal-driven exit (code 130/143 with empty stderr) as a shutdown, not a crash: still surface the recovery screen, but don't report it. Exits carrying stderr, non-signal codes, or a null code (hard kill) still report as before. The decision lives in a pure, electron-free helper so it is unit-tested in isolation.
1 parent dcb121d commit 40f02e8

4 files changed

Lines changed: 183 additions & 4 deletions

File tree

apps/desktop/src/main/index.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import {
2323
startSidecar,
2424
stopSidecar,
2525
onUnexpectedSidecarExit,
26+
markAppQuitting,
2627
SidecarPortInUseError,
2728
type SidecarConnection,
2829
} from "./sidecar";
@@ -888,6 +889,11 @@ if (ensureSingleInstance()) {
888889
});
889890

890891
app.on("before-quit", async (event) => {
892+
// Latch the quit intent first, synchronously: from here on, a sidecar exit
893+
// is part of shutdown. A process-group SIGINT can race ahead of the SIGTERM
894+
// stopConnection sends below, and without this it would be misreported as a
895+
// crash (the Sentry "Sidecar exited unexpectedly (code=130)" false positive).
896+
markAppQuitting();
891897
if (!connection) return;
892898
// A supervised daemon must keep serving after the app quits — don't stop it,
893899
// and don't block the quit on teardown we don't need to do.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
/**
2+
* Pure classification of a sidecar's *post-boot* exit (the child died after it
3+
* had already reported ready). Kept free of electron/log imports so the rule is
4+
* unit-testable in isolation — `sidecar.ts` owns the I/O around it.
5+
*
6+
* The trap this guards against: the desktop only marks an exit "expected" when
7+
* it stopped the child itself via `stopSidecar` (which sends SIGTERM). A signal
8+
* that arrives any other way — most commonly a process-group SIGINT racing
9+
* ahead of teardown — would otherwise be filed as a crash, painting the crash
10+
* screen over (and Sentry-reporting) what was really a clean shutdown.
11+
*/
12+
13+
// 128 + SIGINT(2) and 128 + SIGTERM(15): the exit codes a process reports when
14+
// it shuts down through its own signal handler rather than dying on the raw
15+
// signal. An Effect `runMain` program interrupted by SIGINT exits 130.
16+
const SHUTDOWN_SIGNAL_EXIT_CODES: ReadonlySet<number> = new Set([130, 143]);
17+
18+
export type PostBootSidecarExitDecision =
19+
/** We asked it to stop, or the app is quitting — say nothing, do nothing. */
20+
| { readonly kind: "expected" }
21+
/**
22+
* A clean signal-driven exit (130/143, no stderr) that bypassed `stopSidecar`.
23+
* The web UI is dead so the recovery screen still has to show, but this is a
24+
* healthy shutdown and must not be reported as a crash.
25+
*/
26+
| { readonly kind: "recover" }
27+
/** A genuine fault: surface recovery UI *and* report upstream. */
28+
| { readonly kind: "crash" };
29+
30+
export const classifyPostBootSidecarExit = (input: {
31+
/** The child was passed to `stopSidecar` (we initiated this stop). */
32+
readonly expected: boolean;
33+
/** `before-quit` has fired — the whole app is tearing down. */
34+
readonly appQuitting: boolean;
35+
readonly code: number | null;
36+
readonly stderrTail: string;
37+
}): PostBootSidecarExitDecision => {
38+
if (input.expected || input.appQuitting) return { kind: "expected" };
39+
const cleanShutdownSignal =
40+
input.code !== null &&
41+
SHUTDOWN_SIGNAL_EXIT_CODES.has(input.code) &&
42+
input.stderrTail.trim().length === 0;
43+
return cleanShutdownSignal ? { kind: "recover" } : { kind: "crash" };
44+
};

apps/desktop/src/main/sidecar.ts

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
import { loadOrMintLocalAuthToken } from "./local-auth";
2525
import { getServerSettings } from "./settings";
2626
import { reportSidecarCrash, sidecarCrashReportingEnv } from "./diagnostics";
27+
import { classifyPostBootSidecarExit } from "./sidecar-exit";
2728
import { SERVER_SETTINGS_USERNAME, type DesktopServerSettings } from "../shared/server-settings";
2829

2930
// Sidecar output is echoed to the terminal (visible when Electron is run
@@ -39,6 +40,15 @@ const STDERR_TAIL_LIMIT = 8 * 1024;
3940
// their exits are expected and must not be reported as crashes.
4041
const expectedExits = new WeakSet<ChildProcess>();
4142

43+
// Flipped by main/index.ts the moment the app begins quitting. A sidecar exit
44+
// during teardown is part of shutdown even when a process-group signal (SIGINT)
45+
// races ahead of stopSidecar's SIGTERM — so once this is set, no post-boot exit
46+
// is reported as a crash. One-way latch: the process is on its way out.
47+
let appQuitting = false;
48+
export const markAppQuitting = (): void => {
49+
appQuitting = true;
50+
};
51+
4252
// Main/index.ts subscribes to swap the dead web UI for the in-window crash
4353
// screen. A callback (not an import) keeps this module free of window
4454
// concerns.
@@ -409,13 +419,29 @@ export async function startSidecar(options: StartOptions = {}): Promise<SidecarC
409419

410420
const onExit = (code: number | null, signal: NodeJS.Signals | null) => {
411421
if (resolved) {
412-
// Post-boot exit: expected when we stopped it ourselves (quit,
413-
// restart, update); anything else is a sidecar crash under a live
414-
// window — log it and report upstream with the stderr tail.
415-
if (expectedExits.has(child)) {
422+
// Post-boot exit. Expected when we stopped it ourselves (quit, restart,
423+
// update) or the app is mid-quit; a clean signal-driven exit that raced
424+
// past stopSidecar still needs the recovery screen but isn't a crash;
425+
// anything else is a real fault — report it with the stderr tail.
426+
const decision = classifyPostBootSidecarExit({
427+
expected: expectedExits.has(child),
428+
appQuitting,
429+
code,
430+
stderrTail: stderrBuffer,
431+
});
432+
if (decision.kind === "expected") {
416433
sidecarLog.info(`exited (code=${code} signal=${signal})`);
417434
return;
418435
}
436+
if (decision.kind === "recover") {
437+
// A SIGINT/SIGTERM-driven shutdown (code 130/143, no stderr) that
438+
// bypassed stopSidecar — e.g. a process-group signal during teardown.
439+
// The web UI is dead, so surface the recovery screen, but don't file
440+
// a healthy shutdown as a crash.
441+
sidecarLog.warn(`exited via shutdown signal (code=${code} signal=${signal})`);
442+
unexpectedExitListener?.();
443+
return;
444+
}
419445
const message = `Sidecar exited unexpectedly (code=${code} signal=${signal})`;
420446
sidecarLog.error(message);
421447
reportSidecarCrash(message, stderrBuffer);

tests/desktop-sidecar-exit.test.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, expect, it } from "@effect/vitest";
2+
import { classifyPostBootSidecarExit } from "../apps/desktop/src/main/sidecar-exit";
3+
4+
// Guards the desktop's post-boot sidecar exit classification — the source of the
5+
// Sentry "Sidecar exited unexpectedly (code=130 signal=null)" false positive,
6+
// where a clean SIGINT-driven shutdown that raced past stopSidecar was filed as
7+
// a crash and painted the crash screen over a healthy teardown.
8+
describe("classifyPostBootSidecarExit", () => {
9+
it("treats a stop we initiated (stopSidecar) as expected", () => {
10+
expect(
11+
classifyPostBootSidecarExit({
12+
expected: true,
13+
appQuitting: false,
14+
code: 143,
15+
stderrTail: "",
16+
}),
17+
).toEqual({ kind: "expected" });
18+
});
19+
20+
it("treats any exit during app quit as expected, even a racing SIGINT", () => {
21+
// The reported event: code=130 (SIGINT), empty stderr, child not yet handed
22+
// to stopSidecar — but the app is quitting, so this is shutdown, not a crash.
23+
expect(
24+
classifyPostBootSidecarExit({
25+
expected: false,
26+
appQuitting: true,
27+
code: 130,
28+
stderrTail: "",
29+
}),
30+
).toEqual({ kind: "expected" });
31+
});
32+
33+
it("does NOT report a clean SIGINT exit (130, no stderr) as a crash", () => {
34+
// A signal that bypassed stopSidecar while the app is live: recover the UI,
35+
// but never report a healthy shutdown to Sentry.
36+
expect(
37+
classifyPostBootSidecarExit({
38+
expected: false,
39+
appQuitting: false,
40+
code: 130,
41+
stderrTail: "",
42+
}),
43+
).toEqual({ kind: "recover" });
44+
});
45+
46+
it("does NOT report a clean SIGTERM exit (143, no stderr) as a crash", () => {
47+
expect(
48+
classifyPostBootSidecarExit({
49+
expected: false,
50+
appQuitting: false,
51+
code: 143,
52+
stderrTail: "",
53+
}),
54+
).toEqual({ kind: "recover" });
55+
});
56+
57+
it("still reports a real fault: signal exit code carrying stderr", () => {
58+
// 130 with a stack trace is an interrupt that caught the daemon mid-error —
59+
// that is worth reporting, so it must not be swallowed as a clean shutdown.
60+
expect(
61+
classifyPostBootSidecarExit({
62+
expected: false,
63+
appQuitting: false,
64+
code: 130,
65+
stderrTail: "Error: boom\n at thing (x.ts:1:1)",
66+
}),
67+
).toEqual({ kind: "crash" });
68+
});
69+
70+
it("still reports a non-signal crash (e.g. exit 1)", () => {
71+
expect(
72+
classifyPostBootSidecarExit({
73+
expected: false,
74+
appQuitting: false,
75+
code: 1,
76+
stderrTail: "",
77+
}),
78+
).toEqual({ kind: "crash" });
79+
});
80+
81+
it("reports a hard kill (null code) as a crash", () => {
82+
// SIGKILL / abnormal death surfaces as code=null — not a clean shutdown.
83+
expect(
84+
classifyPostBootSidecarExit({
85+
expected: false,
86+
appQuitting: false,
87+
code: null,
88+
stderrTail: "",
89+
}),
90+
).toEqual({ kind: "crash" });
91+
});
92+
93+
it("ignores whitespace-only stderr when judging a clean shutdown", () => {
94+
expect(
95+
classifyPostBootSidecarExit({
96+
expected: false,
97+
appQuitting: false,
98+
code: 130,
99+
stderrTail: " \n\t ",
100+
}),
101+
).toEqual({ kind: "recover" });
102+
});
103+
});

0 commit comments

Comments
 (0)