Files
open-design/packages
lefarcenandGitHub d4762a9522 fix(desktop): break renderer crash-reload loops with a circuit breaker (#5322)
* fix(desktop): break renderer crash-reload loops with a circuit breaker

A renderer that crashes deterministically — a GPU/V8 CHECK failure, a
corrupt profile that aborts on every load — is reloaded forever by the
desktop poll loop: load → crash → reload → crash. One 0.14.0 device did
exactly this, emitting 26,011 desktop_renderer_crash events in a single
day (96% of all renderer crashes on the release) while its window stayed
blank the whole time.

Add RendererCrashLoopBreaker (pure, unit-tested): after 5 crashes inside
a 60s rolling window it opens. The poll loop then parks on a static,
dependency-free error screen that explains recovery is automatic, rather
than cycling the crash; markRendererFailed no-ops while open. Per-crash
telemetry is suppressed once open — the tripping crash is still reported,
flagged loop_tripped — so one wedged device can't emit tens of thousands
of identical events. The breaker re-arms after a 5-minute quiet cooldown
so a transient fault still self-heals without a reinstall.

* feat(desktop): make crash-loop recovery active + observable

Review feedback: the breaker only "waited" — its cooldown re-arm reloaded
the same URL from the same cache, and the whole flow was near-invisible.

On re-arm it now actively recovers: clears the HTTP + V8 code caches
(non-destructive — no cookies or storage) before the retry reload,
targeting the most common recoverable cause (a corrupt cached bundle or
shader) instead of hoping conditions changed on their own.

And it's now observable: a console.warn when the breaker opens (parking),
a console.info per recovery attempt, and a bounded recovery-attempt
analytics signal (reason:"recovery-attempt", recovery_attempt: N, at most
one per cooldown). Triage can tell a chronic looper (index keeps
climbing) from a device that recovered (events simply stop) without
re-introducing the per-crash spam the breaker was built to kill.

* revert(desktop): drop active cache-clear recovery, keep it passive + observable

Reviewer worry: clearing caches on every cooldown could amplify churn on a
wedged device without helping the dominant crash cause (a GPU/V8 CHECK,
exit 0x80000003), i.e. pour oil on the fire. Back the breaker off to
"stop the loop + passive retry": on re-arm it reloads without mutating any
device state and lets a transient fault clear on its own.

The observability added alongside stays: a warn on breaker-open, an info
per recovery attempt, and the bounded recovery-attempt analytics signal
(recovery_attempt: N) so chronic loopers are still visible.

* feat(desktop): add report + save-logs actions to the renderer crash screen

The recovery screen was informational only. On a renderer crash the daemon
is still alive, so give the wedged user an actionable exit — reusing IPC the
preload already exposes, no new surface:

- "Report a problem" → window.__od__.openExternal() opens a prefilled GitHub
  new-issue whose title + body carry the app version, OS, and the renderer
  exit code (e.g. 0x80000003) a triager needs, and ask the user to attach
  the logs.
- "Save logs…" → window.openDesignDesktop.exportDiagnostics() runs the
  existing diagnostics-bundle export (save dialog → zip).

Neither an issue URL nor mailto can carry a file attachment, so the flow is
save-then-attach. Each button hides itself if its bridge is missing (preload
failed to load) to avoid a dead control.

* feat(desktop): add "Email us" (support@open-design.ai) to the crash screen

Alongside "Report a problem" / "Save logs…", the recovery screen now offers a
prefilled mailto to our support address for users who'd rather email than open
a GitHub account. It reuses the existing openExternal bridge; the
shell:open-external handler now also allows a mailto addressed strictly to the
support address (isSupportMailtoUrl, unit-tested) — no other scheme or address
opens, so widening past http can't be abused into arbitrary-scheme launches.
The link hides itself if the bridge is absent, like the other actions.

* style(desktop): match crash screen to the app's monochrome brand palette

The crash screen used a stray blue accent that didn't match the app. Repaint
it with the neutral design tokens from apps/web (tokens.css): warm off-white
#faf9f7 + near-black #1a1916, a monochrome near-black primary button, and a
plain underlined (not blue) email link. Dark mode inverts the pair to a light
button on the dark bg (a near-black button would vanish), mirroring the
black/white onboarding look.

* fix(desktop): address review — tighten support mailto + reveal crash screen

Two Looper findings on the crash-screen actions:

- Security: isSupportMailtoUrl validated only protocol + pathname, so
  `mailto:support@open-design.ai?bcc=attacker@example.com` (or ?to=/?cc=)
  still passed and shell.openExternal would launch the mail client with
  extra recipients/headers through the renderer-exposed bridge. Now reject
  any query key other than the crash screen's own subject/body.

- Startup lifecycle: parking on the crash screen left `revealed` false and
  the splash open when the loop tripped before revealWhenReady(), so the
  runtime's show() kept focusing the splash — a user re-focusing during a
  startup crash loop was sent back to the boot splash. Route the park
  through revealMainWindow() so the recovery screen becomes the revealed,
  focused window and the splash is torn down (no-op after normal reveal).

* feat(diagnostics): make the support bundle root-cause native renderer crashes

Audit of "Save logs…" for the crash-recovery screen: on a renderer crash the
bundle collected text logs but NOT the one artifact that can explain an opaque
native abort like 0x80000003 (a V8/Chromium CHECK) — a minidump. crashReporter
was never started (no dumps existed anywhere), the crash-report collector was
macOS-only, and there was no GPU info (the usual suspect for such crashes).

Close the gap end to end:
- apps/desktop: start crashReporter (uploadToServer:false — on-device only) and
  relocate Electron's crashDumps into <logs/desktop>/crashes, inside the log
  tree the export already bundles; snapshot GPU + feature-status +
  runtime info to <logs/desktop>/gpu-info.json at startup.
- packages/diagnostics: add a `binary` LogSource kind (copied verbatim, no text
  decode/redaction so a minidump isn't corrupted) and findCrashDumps() to
  collect .dmp files; buildDiagnosticsZip gains a `crashDumps` input.
- apps/daemon: bundle logs/desktop/gpu-info.json and the relocated crash
  minidumps (cross-platform, capped, within 14 days).

The renderer crash context (reason/exit code/url) is already captured — the
main-process console.error lands in logs/desktop/latest.log — so a bundle now
carries stack (minidump) + GPU state + context: enough to root-cause.

* fix(desktop): mkdir crashDumps dir before setPath so first-crash dumps land in the log tree

Review (PerishCode): app.setPath('crashDumps', dir) throws when the dir does
not exist; on a fresh runtime logs/desktop/crashes won't exist yet, so setPath
threw, we fell back to Electron's default crashDumps location, and the daemon
export (which only scans <logs/desktop>/crashes) missed the minidump for
exactly the first crash loop this is meant to root-cause. mkdirSync the dir
(recursive) before setPath.

* fix(desktop): reject CR/LF in the support mailto subject/body

Review (PerishCode): the key allowlist rejected to/cc/bcc keys, but a decoded
CRLF in an allowed value (subject=ok%0D%0ABcc:attacker@…) could still inject a
mail header once the mailto reaches the mail client. Reject any subject/body
value containing CR or LF.
2026-07-13 04:58:16 +00:00
..
…