This page is about failures reported by
createApp, i.e. after app.js has parsed and run. For the one failure mode where the bundle itself cannot load — and for what a user sees on a slow cold start — see Boot Indicator.createApp returns a typed BootResult. A permanent failure is a named { ok: false, reason, detail }, and mount() renders it on the crash screen. A retryable unreadiness no longer stops boot: the app starts, and the success branch carries a notReady field so the caller can say the engine is not answering yet. A thrown failure is still caught and named too.
version_mismatch) reaches the failure branch. A retryable unreadiness — a still-starting engine, an unreachable phone-to-desktop socket — is delivered as a usable app plus an attached warning, not a refusal.
Quick Start
1
Handle a failed boot
mount() checks booted.ok and renders the crash screen with the detail. There is nothing to configure — a named failure always reaches the screen.2
Handle an engine that isn't answering yet
A retryable unreadiness — a still-starting engine, an unreachable remote — no longer reaches the
!booted.ok branch. The app booted; booted.notReady carries the reason so you can say so. Sending later retries it.3
A permanent failure still stops boot
A
version_mismatch — a build that cannot speak the server’s protocol — never fixes itself on a retry, so it stays on the failure branch and reaches the crash screen.4
Turn a throw into a reason
bootOrFail wraps createApp so an unanticipated throw — a StoragePort failure at settingsStore.load() — becomes the same typed shape rather than an uncaught rejection.What the crash screen guarantees
The fatal screen carries three properties, each pinned by a test.- It announces. The fatal element is
role="alert", notrole="status", so a screen reader speaks it the moment it mounts. - It names the real failure. The message is
booted.detail, the actual failure detail — never a generic “something went wrong”. - It replaces the chrome.
root.textContent = ""runs first, so the dead app UI is gone; the fatal element is not appended under the corpse of the send button.
Where each reason surfaces
Probe reasons split by whether a retry can help. Permanent ones refuse boot; retryable ones boot the app and attachnotReady.
Permanent boot failures — surface via !booted.ok
Retryable unreadiness — surface via booted.ok === true with booted.notReady
retryable on the failure branch is now effectively always false (or unset), because retryable outcomes take the success branch with notReady. unknown_engine and protocol_mismatch are the two failures selectEngine anticipates without any I/O; version_mismatch is the one probe reason still permanent. storage_unavailable is different in kind: it is a throw the boot never expected, turned into the same shape by bootOrFail so it renders the same way.Boot-time health probe
When anEngineChoice supplies a probe, selectEngine runs it after the cheap protocol check. A retryable probe result flows into the success branch with notReady and the engine is kept; only a permanent probe result flows into failure and disposes the engine.
The probe reasons split on whether a retry can help. unhealthy, http_status, malformed, and transport all carry retryable: true — the engine is still handed to the app so a retry costs one tap, and the app renders a warning. Only version_mismatch is permanent and refuses boot with the engine disposed.
A permanently refused probe (
version_mismatch) disposes the engine before returning, exactly as a protocol mismatch does — the probe opened a socket, and leaving the engine holding it is a leak. A retryably refused probe keeps the engine, because it is the one the app will use once the remote comes up; disposing it would leave the app holding a dead engine it can never retry through.An unreachable engine boots with a warning
Before PR #4672 a missing/health handler took the whole app down; now the app opens and points the user at Settings.
How a throw becomes a typed failure
createApp calls settingsStore.load() without a guard, so a StoragePort failure propagates as an exception. The chrome is already appended by the time boot runs, so an unhandled rejection would skip both the crash screen and the listener registrations — leaving a fully rendered app in which nothing happened. bootOrFail closes that gap.
Related
Boot Indicator
The pre-boot frame and the failure notice for a bundle that cannot load.
Architecture
Boot order and where the crash handler is installed.
Storage & Secrets
When the
StoragePort is unavailable at boot.Errors & Recovery
Failures that happen inside a turn, not at boot.
Engines
Where
unknown_engine and protocol_mismatch come from.Readiness body rules
How the probe classifies a
/health response into the reasons above.
