Boot Failures

What each BootResult.reason renders on the crash screen, and when it fires

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.

ts
import { createApp, type BootResult } from "praisonai-mobile/app/boot";

const booted: BootResult = await createApp(deps);
if (!booted.ok) {
  renderFatal(root, strings.bootFailed(booted.detail)); // permanent failure
} else if (booted.notReady) {
  render(strings.engineNotReady(booted.notReady.detail)); // booted, but warn
}

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.

ts
export type BootResult =
  | {
      readonly ok: true;
      readonly app: App;
      /** The app started, but the engine is not answering yet. A caller must
       *  SAY so -- silently booting into an engine that cannot reply is how a
       *  first message fails with no explanation. */
      readonly notReady?: { readonly reason: string; readonly detail: string };
    }
  | {
      readonly ok: false;
      readonly reason: string;
      readonly detail: string;
      /** Set when the failure is a health probe that might succeed on a retry.
       *  In normal flow this is effectively always unset now -- retryable
       *  outcomes take the SUCCESS branch with `notReady` instead. */
      readonly retryable?: boolean;
    };
Diagram

true, no notReady

true, notReady set

false

๐Ÿญ createApp

ok?

๐Ÿ–ผ๏ธ mount clean

โš ๏ธ mount + warning notice

๐Ÿ’ฅ crash screen

๐Ÿ’ฃ throw

๐Ÿ›ก๏ธ bootOrFail

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.

    ts
    if (!booted.ok) {
      renderFatal(root, strings.bootFailed(booted.detail));
      return null;
    }
  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.

    ts
    if (booted.ok && booted.notReady) {
      // The app is up. Say the engine is not ready; sending will retry.
      render(strings.engineNotReady(booted.notReady.detail));
    }
  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.

    ts
    if (!booted.ok && booted.reason === "version_mismatch") {
      renderFatal(root, strings.bootFailed(booted.detail)); // waiting cannot help
    }
  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.

    ts
    try {
      return await createApp(options);
    } catch (error) {
      return { ok: false, reason: "storage_unavailable", detail: String(error) };
    }

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

reasonSourceWhen it fires
unknown_engineselectEngine โ€” no engine with that id, or an engine reporting a different id than it registered underA settings engineId naming an engine that is not built
protocol_mismatchselectEngine โ€” the engine speaks a different PROTOCOL_VERSION than this buildAn engine adapter upgraded out of step with the app
storage_unavailablebootOrFail โ€” a throw from createApp, caught and namedSecurityError (site data blocked) or QuotaExceededError at settingsStore.load()
version_mismatchselectEngine โ€” probe response reported a version this build cannot speakServer upgraded incompatibly โ€” waiting cannot fix it

Retryable unreadiness โ€” surface via booted.ok === true with booted.notReady

booted.notReady.reasonSourceWhen it fires
unhealthyselectEngine โ€” probe classified a 200 {"ok": false} responseRemote engine is up but reporting itself not ready (still binding, dependency down)
http_statusselectEngine โ€” probe got a non-200 (including 404, 5xx, and any sub-200)Wrong baseUrl, endpoint not routed, proxy misconfigured
malformedselectEngine โ€” probe reached something whose body is not our engine’s shapeA captive portal, a proxy error page, a different service on the port
transportselectEngine โ€” probe request or body read threw, aborted, or timed out after PROBE_TIMEOUT_MS (5s)Engine unreachable, still binding its socket, or a hanging endpoint / black-hole proxy

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.

Diagram

No

Yes

No

Yes

No

Yes

Yes

No, retryable

No, permanent

๐Ÿญ selectEngine

๐Ÿ” id known?

โŒ unknown_engine

๐Ÿ” protocol matches?

โŒ protocol_mismatch

๐Ÿ” probe supplied?

โœ… engine offered

๐Ÿ” probe ready?

โš ๏ธ ok:true + notReady โ€” engine KEPT

โŒ version_mismatch

๐Ÿงน engine.dispose (no leaked socket)


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.

before:  no /health handler  โ†’  "PraisonAI could not start: 404"   (dead app, no way in)
after:   no /health handler  โ†’  App opens; a warning row says the engine is not
                                answering yet; the user can reach Settings.
Diagram
EngineHealth ProbeMobile AppUserEngineHealth ProbeMobile AppUserLaunch appprobeHealth(baseUrl)(unreachable){ ready: false, reason: “transport”, retryable: true }mount() + render “engine not answering” noticeApp opens with warning bannerOpen Settings, fix baseUrlType message, tap Sendretry with new baseUrlstreams answeranswer in transcript

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.

Diagram
sequenceDiagram
    participant Mount as mount
    participant BootOrFail as bootOrFail
    participant Create as createApp
    Mount->>BootOrFail: bootOrFail(options)
    BootOrFail->>Create: createApp(options)
    Create--xBootOrFail: throw (StoragePort failed)
    BootOrFail-->>Mount: { ok: false, reason: "storage_unavailable", detail }
    Mount->>Mount: renderFatal(detail)

Related