Skip to main content
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.
Only a permanent unreadiness (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", not role="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 attach notReady.

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 an EngineChoice 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.
The crash handler is installed in mount() before detectPlatform(), because detectPlatform reads window.localStorage and can itself throw SecurityError. See Architecture → Boot Order for the full ordering.

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.