Skip to content

Persist Electron logs and recover from renderer death across all window views#2428

Merged
joshalbrecht merged 17 commits into
mainfrom
gabriel/archetypal-mouse
Jul 14, 2026
Merged

Persist Electron logs and recover from renderer death across all window views#2428
joshalbrecht merged 17 commits into
mainfrom
gabriel/archetypal-mouse

Conversation

@gnguralnick

@gnguralnick gnguralnick commented Jul 10, 2026

Copy link
Copy Markdown
Contributor

What & why

From kanjun's blank-screen-after-sleep incident (Sentry bc6d78e9): on wake, a workspace window showed a pinned-white screen with no recovery, and the Electron main process logged nothing durable, so the failure was undiagnosable from uploaded logs. The root cause is that a window's WebContentsView renderers can be reaped by the OS over a long sleep, and nothing handled render-process-gone. Each window has three such renderers, in separate processes, that can die independently: the workspace content view, the chrome (titlebar) view, and the overlay (sidebar/inbox/help) view.

This implements blueprint/electron-log-and-crash-page/: durable main-process logging, recovery UI for all three views, and automatic Sentry reporting so these deaths are visible without waiting for a manual report.

Changes

Logging & rotation

  • New electron/logger.js — tees the main process's console.log/warn/error (timestamped) into a new ~/.minds/logs/electron.log, and records uncaught exceptions (via uncaughtExceptionMonitor, chosen so Sentry capture / process-exit behavior is unchanged) and unhandled rejections. Reentrancy-guarded so a rotation-failure console.warn can't recurse into the tee. Initialized first in main.js.
  • New electron/log-rotation.js — shared 100 MB / keep-10 size rotation, gzipping each rotated file in the background. Used by electron.log and (via backend.js) minds.log, which was previously an unbounded append stream (the source of ~600 MB log files). Async fs.WriteStream (never blocks the main thread); on hitting the threshold it ends/flushes the stream before renaming so the background gzip never reads a partial file, replays any lines that arrive mid-rotation, and gzips via stream.pipeline so a compression failure can't leak fds.

Renderer-crash recovery (all three views)

  • Content viewrender-process-gone shows a local crashed.html ("Aw, Snap!"-style: crash reason/exit code, a manual Reload, and Report a bug). No auto-reload, by design (crash-loop safety, matching Chrome/Firefox/VS Code). content-relay-preload.js relays the allowlisted minds:reload-crashed-view; isCrashPageUrl guards onContentNavigate so the crash page doesn't clobber the pre-crash URL that Reload re-loads.
  • Chrome (titlebar) view — because the chrome view is a full-window underlay with only its top ~38 px visible, a dead titlebar has no recovery affordance at all. It now shows a compact local chrome-crashed.html strip anchored to that band, with a manual Reload (reload-chrome IPC → reloads /_chrome, re-primed from the cached chrome state). The workspace content view keeps running. Drag region is scoped to just the 38 px strip: macOS unions -webkit-app-region across all views in a window, so a full-window drag surface was swallowing clicks on the content view's Reload button when both crashed at once.
  • Overlay/menu view — silently reloaded warm (loadOverlayHost) on death; it's hidden when idle, so there's no visible error state, just a fresh host for the next sidebar/inbox/help open.
  • All handlers ignore clean-exit / shutdown / error-takeover teardown and defer navigation via setImmediate (electron#19887 — never loadURL synchronously in that handler).

Sentry reporting of renderer deaths

  • sentry.js broadens the @sentry/electron childProcess integration to capture oom renderer deaths as events (its default only events abnormal-exit/launch-failed/integrity-failure), and sets getRendererName so events are labeled by which view died (chrome/content/modal). crashed is deliberately not added — a native crash already produces a minidump via SentryMinidump, so adding it would double-report. killed stays breadcrumb-only (OS/sleep reaping is unactionable and noisy); it remains in electron.log. Auto-gated by the existing report_unexpected_errors opt-in.

Upload plumbing

  • sentry.js — backend-down reports now attach both minds.log and electron.log (plus the newest jsonl), so a report filed while the backend is dead shows both why it died and what the shell did.
  • imbue/minds/utils/sentry/core.py — splits the previously-mislabeled *.log attachment group into accurately-named backend_logs (minds.log) and electron_logs (electron.log), each with a gzipped rotated group; core_test.py updated.

Screenshots

image

This shows the result of killing both the chrome and content view renderer processes. Each state can show independently.

Testing

Automated

  • JS unit tests: node --test apps/minds/test/unit/*.test.js → 16 passed (adds log-rotation.test.js)
  • Python: just test-quick apps/minds/imbue/minds/utils/sentry/core_test.py
  • /autofix and /verify-conversation gates clean

Real-app verification (done on a staging build)

  • Killing the content, chrome, and overlay renderers each shows the right recovery UI; Reload restores the view; both crash pages' Reload buttons are clickable independently when all three die at once (the drag-region fix).
  • oom/killed-path events reach Sentry labeled by view — three distinct issues ('chrome'/'content'/'modal' process exited), carrying git_sha/release/environment tags and structured {exitCode, reason} breadcrumbs. (killed was temporarily un-exempted only to drive this test, then reverted — it does not ship as a reported reason.)
  • A manual /help bug report uploads both minds.log and electron.log to S3 under their accurate uploaded_files_backend_logs / uploaded_files_electron_logs context keys. (The *_rotated_logs gzip groups are correctly absent until a log crosses 100 MB.)

Gabriel Guralnick and others added 3 commits July 10, 2026 10:12
…h page

Plans two improvements from the blank-screen-after-sleep diagnosis (Sentry
bc6d78e9): a new rotated/gzipped electron.log capturing Electron main-process
console output and uncaught exceptions (uploaded with bug reports, alongside
rotation for minds.log), and a render-process-gone handler that shows a
Chrome-style crash page with manual Reload and Report-a-bug buttons.

Co-authored-by: Sculptor <sculptor@imbue.com>
…page

Implements blueprint/electron-log-and-crash-page.

Logging & rotation:
- New electron/logger.js tees the Electron main process's console.log/warn/error
  into ~/.minds/logs/electron.log (timestamped) and records uncaught exceptions
  / unhandled rejections, without altering Sentry/exit behavior
  (uncaughtExceptionMonitor). Initialized first in main.js.
- New electron/log-rotation.js: shared 100MB/keep-10 rotation that gzips each
  rotated file; used by both electron.log and (via backend.js) minds.log, which
  was previously an unbounded append stream.

Crash page:
- main.js handles content-view render-process-gone by showing a local
  crashed.html ("Aw, Snap!"-style) with the crash reason, a manual Reload
  button (no auto-reload; crash-loop-safe), and a Report-a-bug button. Navigation
  is deferred (electron#19887); clean-exit and error-takeover teardown are ignored.
- content-relay-preload.js relays the new minds:reload-crashed-view message.

Upload plumbing:
- sentry.js attaches both minds.log and electron.log (plus newest jsonl) to
  backend-down reports.
- utils/sentry/core.py splits the mislabeled *.log group into backend_logs /
  electron_logs (+ gzipped rotated groups) with accurate names.

Co-authored-by: Sculptor <sculptor@imbue.com>
Problem: apps/minds/electron/logger.js wraps console.log/warn/error to tee into
electron.log via writeLine -> logStream.write -> log-rotation.rotateIfNeeded.
When rotation fails, rotateIfNeeded does not throw; it calls console.warn, which
is the wrapped console, re-entering writeLine -> logStream.write ->
rotateIfNeeded (size is never reset on failure, so it stays over threshold) ->
console.warn -> ... unbounded recursion -> stack overflow crashing the Electron
main process. The existing try/catch could not stop it because rotateIfNeeded
swallows its own error instead of throwing.

Fix: add a module-level reentrancy guard (isWritingLine) so a console.* call
emitted while a log line is being written short-circuits writeLine instead of
re-entering the rotating stream. The nested console.* still reaches stdout/stderr
via the wrapper's original(...), so the rotation warning is not lost.
Gabriel Guralnick and others added 2 commits July 10, 2026 11:10
Match the codebase's existing log-writing precedent: the minds.log sink (the
only streaming-log sink in electron/) used an async fs.createWriteStream, and the
only *Sync file write is the tiny one-shot window-state.json snapshot. Convert
createRotatingLogStream from synchronous fd writes back to an async WriteStream
so writes never block the Electron main thread. Rotation stays correct: the
stream is end()ed (flushing all buffered writes + closing the fd) BEFORE the
rename, so the background gzip can't read a partially-flushed file; lines that
arrive mid-rotation are buffered and replayed into the fresh stream. Also resets
tracked size after a failed rename (bounded retry, no tight rotation loop) and
swallows async stream 'error' events to stderr so a write error can't crash the
main process. Unit tests updated to poll for the async flush.

Document in logger.js why we hand-roll rather than use the electron-log library:
we need on-disk gzipped rotations under fixed exact names so the Sentry
LogAttachmentGroup globs upload the current file plus newest rotation with no
extra transform, which the shared log-rotation.js provides while mirroring the
Python jsonl sink's 100MB/keep-10 scheme.

Co-authored-by: Sculptor <sculptor@imbue.com>
Problem: logger.js's isWritingLine guard comment referenced rotateIfNeeded
(renamed to rotate in the same commit) and described a synchronous
writeLine->logStream.write->console.warn->writeLine recursion that can no
longer occur -- log-rotation.js now defers its rename-failure console.warn
into an async WriteStream end() callback, where isRotating buffers it instead
of re-entering writeLine on the same tick.
Fix: rewrite the comment to state the guard's remaining defensive purpose
(preventing a synchronous re-entry into writeLine) without naming the removed
function or the now-impossible rotation example. Comment-only; no behavior change.
Problem: compressRotatedFile in apps/minds/electron/log-rotation.js piped the
raw rotated file through gzip to a .gz destination with `source.pipe(gzip).pipe(dest)`
and per-stream error handlers that only logged. Node's .pipe() does not destroy
the other streams when one errors, so any read/gzip/write failure mid-compression
leaked the still-open read and write file descriptors -- unbounded over the
long-lived Electron main process's many rotations.
Fix: use stream.pipeline, which destroys every stream (freeing all fds) on both
success and error. The success path still unlinks the raw file and prunes old
rotations; the error path still logs the same best-effort warning and leaves the
raw rotation on disk.
Gabriel Guralnick and others added 2 commits July 13, 2026 10:41
…y reporting

Broaden the blueprint plan beyond the content-view crash page to cover the
window's other two renderers -- a miniaturized in-titlebar error strip with a
manual Reload for the chrome view, and a silent warm reload for the overlay
view -- and to close the observability gap by reporting abnormal renderer
deaths (crashed/oom, labeled by view) to Sentry while deliberately not
reporting sleep/external kills (killed). Updates the dev changelog to match.

Co-authored-by: Sculptor <sculptor@imbue.com>
…to Sentry

Extend the content-view crash-page recovery to the window's other two
per-window views, which run in separate renderer processes and can die
independently over a long sleep:

- chrome (titlebar) view: on render-process-gone (non-clean-exit) show a
  compact local chrome-crashed.html strip anchored to the ~38px visible
  titlebar band, with a manual Reload button (reload-chrome IPC -> reload
  /_chrome, re-primed from the cached chrome state). The workspace content
  view is left running. Manual reload for loop-safety, matching the content
  crash page.

- overlay/menu host view: on render-process-gone silently reload the warm
  host (it's hidden when idle), so the next sidebar/inbox/help open is fresh.

Also close the observability gap that let the original blank-screen-after-
sleep incident go unreported: broaden the @sentry/electron childProcess
integration to capture 'oom' renderer deaths as events (labeled by view via
getRendererName). 'crashed' is intentionally left out -- it already produces
a minidump via SentryMinidump, so adding it would double-report -- and
'killed' stays breadcrumb-only (OS/sleep reaping is unactionable; it remains
in electron.log).

Co-authored-by: Sculptor <sculptor@imbue.com>
Gabriel Guralnick and others added 2 commits July 13, 2026 11:02
Replace the emoji glyph on the content-view crash page with the distressed
Minds head (X-eye) logo, switch from the maroon takeover surface to a white
background with black text, and update the copy to a 'Bummer' heading.

Co-authored-by: Sculptor <sculptor@imbue.com>
Drop the filled brand-color background rect (and the rounded-tile framing) and
recolor the head logo to a black-on-white negative so it sits cleanly on the
white crash surface.

Co-authored-by: Sculptor <sculptor@imbue.com>
chrome-crashed.html set -webkit-app-region: drag on the whole <body>, but the
chrome view is a full-window underlay. macOS unions drag regions across all
views in a window, so a body-wide drag region turned the entire window into a
drag surface -- including the content view's crash-page Reload button when both
renderers died at once, swallowing its clicks until the titlebar was reloaded
(which dropped the region). Scope drag to the 38px .bar strip only, which never
overlaps the content view, so both crash pages' Reload buttons are clickable
independently.

Co-authored-by: Sculptor <sculptor@imbue.com>
@gnguralnick gnguralnick changed the title Persist Electron logs and add a content-view crash page Persist Electron logs and recover from renderer death across all window views Jul 13, 2026
@gnguralnick gnguralnick marked this pull request as ready for review July 13, 2026 18:56
// open-modal state and silently reload the warm host, so the next sidebar / inbox /
// help open lands on a fresh page instead of a dead one. Defer the reload a tick
// (never navigate synchronously in this handler -- electron#19887).
modal.webContents.on('render-process-gone', (_e, details) => {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think it's reasonable for the modal webcontentsview to auto-reload since it's generally not even visible to the user but lmk if i should change that

@joshalbrecht

Copy link
Copy Markdown
Contributor

lgtm, but would love a post-hoc review from @hynek-urban as well in case I'm missing something

@joshalbrecht joshalbrecht merged commit 446b8b6 into main Jul 14, 2026
14 checks passed
@joshalbrecht joshalbrecht deleted the gabriel/archetypal-mouse branch July 14, 2026 19:23
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants