Skip to content

fix(#389): guard startup so a failure names the step instead of a blank page - #405

Draft
Matobi98 wants to merge 3 commits into
MostroP2P:mainfrom
Matobi98:fix/389-startup-guard
Draft

fix(#389): guard startup so a failure names the step instead of a blank page#405
Matobi98 wants to merge 3 commits into
MostroP2P:mainfrom
Matobi98:fix/389-startup-guard

Conversation

@Matobi98

@Matobi98 Matobi98 commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Closes #389. Carries the unticked half of #227: #370 fixed the specific cause (an unparseable locale), this makes startup survivable regardless of the cause.

The problem

main.dart is Future<void> main() => bootstrapAndRun(); with no guard, and the stretch of bootstrapAndRun before runApp runs seven steps. Anything that throws there means runApp never runs: Flutter paints nothing, and the page is not broken — it is absent, with no message anywhere. #227 was exactly this, and finding a one-line cause took a full stack-trace hunt.

Everything after runApp already degrades — 12 catch blocks. This stretch was the outlier.

The fix

Each of the seven steps was classified by what the app can still do without it.

Step Without it Result
Firebase.initializeApp no push notifications continues
RustLib.init no protocol, no keys, no relays, no chat failure surface
SharedPreferences no language, no walkthrough state, no NWC wallet failure surface
setLoggingEnabled default log verbosity continues
onBondSlashed no in-app notice for a slash continues
nostr_api.initialize opens offline continues
relay status log one diagnostic line missing continues

Five of seven are optional and now log and continue through one _optional helper, so a run's degradations share a [startup] prefix and read in order — which matters when one failure causes the next.

The two that are not optional reach a last-resort catch that calls runApp with StartupFailureApp. That is the change in one sentence: runApp now always runs — with the app, or with a screen saying which step failed.

SharedPreferences is a deliberate call. It could degrade to defaults, but then the app opens looking freshly installed: walkthrough again, wrong language, wallet gone. That lies about data loss in an app holding money. A screen saying "it failed while reading your settings" is better than a convincing impostor.

Firebase keeps two arms rather than using the helper: UnsupportedError is the placeholder config, an expected state, not a failure. Collapsing both into one message would make every run log a "failed" nobody reads by the time it means something. The broad catch below it is what satisfies the second acceptance criterion — a call into a third-party JS SDK can throw FirebaseException, a network error, or anything the SDK likes, and all of those escaped before.

The failure surface

lib/core/startup_failure.dart. One screen: "Mostro could not start" and "It failed while <step>."

No localization, no app theme, no Rust, no SharedPreferences. Any of those can be what failed, and a rescue surface that needs what broke is a second blank page. Colors are hard-coded for the same reason.

No retry button: RustLib.init throws when called twice, so retrying after a failure past that point would fail differently and confuse the report. Worth adding later as a real reload.

The step name is the whole point. "Mostro won't open" is unactionable; "it failed loading the engine" is where to look — for the person reporting it and for whoever reads the report. Naming the step is also what the issue asks for: "a minimal error scaffold that names the failing step beats a blank page".

Platform-independent, as the issue asks: no kIsWeb anywhere in the change. Web is where it bites hardest — the in-app log viewer lives inside the app that did not start — but the guard is not web-specific.

Test plan

  • flutter analyze — clean

  • flutter test — 332 passed, including 3 new

  • cargo test / clippy / cargo check --target wasm32-unknown-unknown — clean (pre-commit hook)

  • Manual, against the local regtest stack in Chrome, breaking each step in turn:

    Broken Observed
    nothing order book, unchanged
    RustLib.init "It failed while loading the engine."
    SharedPreferences "It failed while reading your settings."
    all five optional at once app opens, five [startup] lines in order

    The two failure screens saying different things is the assertion that matters — a screen that named the same step regardless would pass a careless look and be worthless.

The three new tests cover the failure screen: that it renders the step it was given, that a different step renders differently (so it cannot be ignoring the value), and that it pumps with no ProviderScope, no localization and no theme. Mutation-checked: replacing the step line with a generic message fails two of the three.

bootstrapAndRun itself is not unit-tested — reaching it needs Rust, preferences and relays, i.e. the whole app assembled to watch it not assemble. That seam is covered by the manual runs above and nothing pretends otherwise.

One thing noticed while testing

Probably already known, flagging it only to confirm: with relay init broken, the app opens but the order book spins forever rather than saying it is offline. The startup guard did its job — the app opened, and Settings is reachable to switch node or edit relays — but that surface has no "disconnected" state of its own. Out of scope here; happy to open an issue if there isn't one.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@grunch

grunch commented Sep 9, 2026

Copy link
Copy Markdown
Member

This branch conflicts with main since #408 (d49f3c1) merged; both edit lib/core/app_bootstrap.dart.

What to do on rebase:

  • feat: Linux accessibility contract and Web persistence for Mortsom #408 replaced the if (!kIsWeb) { … initDb(path: p.join(dataDir, 'mostro.db')) … } block with an unconditional initDb call whose argument comes from databaseLocation(isWeb: kIsWeb, dataDir: …) in lib/core/storage/db_location.dart: on the web the store is now opened under a fixed IndexedDB name, off the web under the data directory as before. It also dropped the then-unused package:path/path.dart import. Keep that shape when you fold the store initialisation into your guarded startup steps: it should be one named step like the others, still non-fatal (the app can browse without persistence), and it must run on the web too, because every other web feature that persists anything depends on it now.
  • test/core/storage/db_location_test.dart covers the location helper; a startup-guard test that names the failing step would be the natural companion.
  • Re-run flutter analyze && flutter test after resolving. CI pins Flutter 3.38.2, so avoid matchers newer than that (isSemantics is one; containsSemantics works on both).

@Matobi98
Matobi98 force-pushed the fix/389-startup-guard branch from 38643a2 to d89fb01 Compare September 9, 2026 19:52
@Matobi98

Matobi98 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto d49f3c1, pushed as d89fb01.

The three conflicts were formatting — #408 rewrapped lines this branch had also edited. The database step didn't conflict at all, which is why the warning was worth giving: it merged in silently and would have shipped as the only step outside the sequence. It's now _optional('opening the local database', …), unconditional, with a comment saying why the !kIsWeb guard is gone.

test/core/startup_guard_test.dart pins that statically — it fails if initDb ends up back inside a !kIsWeb block, mutation-checked.

Flutter version: fixed at the root rather than remembered — the other SDK is gone from this machine, so flutter is now whatever .fvmrc pins.

Verified after the rebase: analyze clean, 351 Flutter tests, 438 Rust tests, clippy and wasm clean. Manually in Chrome against the local regtest: app opens, a trade survives a reload, and breaking the database leaves the app up with its log line.

One aside: .githooks/pre-commit calls bare flutter, so on a machine using fvm it fails analyze on deprecations the pinned version doesn't have and blocks every commit. Worked around locally — say the word and I'll send a one-line fix.

@grunch grunch left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Strict review. Verified on the branch (d89fb01): flutter analyze adds no new diagnostics, the 8 new tests pass. The direction is right and the change is small and well reasoned, but two findings contradict the PR's own bar ("a screen that named the same step regardless would pass a careless look and be worthless"), so requesting changes.

HIGH — the step label goes stale after a successful optional step. _optional sets _currentStep = name and never restores it. The node rehydration block, the trade-key mirror and IdentityService.initialize all run labelled opening the local database; ProviderContainer(...), the NWC URI read and _watchConnectionState() run labelled reading relay status — a step that already finished fine. A throw there names a step that did not fail. Structural fix: a _required(name, body) twin for the mandatory stretches, so every stretch carries its own label and there is no code "between labels". (inline)

HIGH — the "deep initial route" test sets no deep route, and the real behaviour on one is wrong. The test is identical to the fourth one (defaultRouteNameTestValue is / by default). I ran it for real with /orders/abc/detail: the Navigator stacks 4 copies of the failure page and canPop() is true, so browser/Android back "navigates" to a clone. Also the "Could not navigate to initial route" message that motivates the change lives inside an assert, so it only ever exists in debug builds. Fix with onGenerateInitialRoutes and a test that actually sets the route. (inline)

MEDIUM — #227 is cited as a case this guard catches. It is not. #370 pinned it inside CanvasKitRenderer.initialize, before main() runs, and says so: "no app-level try/catch around main() can guard it". #227 is the precedent that motivates the guard, not a case it covers. Reword the comment in bootstrapAndRun and the PR body.

MEDIUM — the guard logic has zero behavioural coverage. All four tests in startup_guard_test.dart are greps over the source. Nothing checks that a failed optional step continues, that a failed required step reaches the screen, or that the reported step is the right one (exactly the bug above). Extract a small class with optional, required and currentStep, call it from bootstrapAndRun, and test it with fake bodies. The kIsWeb grep is also not an "enclosing" check (inline).

MEDIUM — the screen shows the step but not the error. On Android/iOS/Linux the user has no console. A secondary, selectable line with error.toString() makes the report actionable without adding a dependency. Suggestion, not blocking.

LOW

  • If WidgetsFlutterBinding.ensureInitialized() throws, the rescue runApp throws too. Acceptable edge case, but worth a comment or moving that call outside the try.
  • The description drifted after round 1: "3 new tests" (there are 8), the seven-step table omits the database step, and "no kIsWeb anywhere in the change" is no longer true. It becomes the merge reference, so fix it before merging.
  • Hard-coded English is justified here but contradicts CLAUDE.md ("all user-facing strings are Dart-level l10n"). Add one line under Translations recording the exception so nobody "fixes" it.
  • The last-resort catch does not call markBridgeFailed(e); with it the web smoke test fails fast with the cause instead of timing out. (inline)

Comment thread lib/core/app_bootstrap.dart Outdated
/// One helper rather than a try/catch per step, so every degradation prints the
/// same prefix — grepping `[startup]` lists everything a run gave up on, in
/// order, which matters when one failure is the cause of the next.
Future<void> _optional(String name, Future<void> Function() body) async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

_currentStep = name is never restored. After this helper returns successfully, everything up to the next explicit assignment runs under the label of a step that already finished: the rehydrate/identity block as opening the local database, and ProviderContainer(...), the NWC URI read and _watchConnectionState() as reading relay status. A throw there produces exactly the screen the PR calls worthless — one naming a step that did not fail.

Suggest a _required(String name, Future<T> Function() body) twin (sets the label, awaits, rethrows) and using it for RustLib.init, the preferences read and the container build, so no code runs "between labels". Making both helpers methods of a tiny StartupSequence class would also let the guard be unit-tested with fake bodies instead of source greps.

Comment thread lib/core/app_bootstrap.dart Outdated
} catch (e, st) {
// The failure surface calls runApp too, and that is the whole fix: today an
// exception in here means runApp never runs and Flutter paints nothing —
// the page is not broken, it is absent, with no message anywhere (#227).

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

#227 is not an example of what this catch handles. #370 pinned it inside the engine's CanvasKitRenderer.initialize, before main() runs, and states that no app-level try/catch around main() can guard it. Cite it as the motivating precedent, not as a case covered here (same wording in the PR body and in startup_guard_test.dart:18).

Comment thread lib/core/app_bootstrap.dart Outdated
// exception in here means runApp never runs and Flutter paints nothing —
// the page is not broken, it is absent, with no message anywhere (#227).
debugPrint('[startup] fatal while $_currentStep: $e\n$st');
runApp(StartupFailureApp(step: _currentStep));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Consider markBridgeFailed(e); before runApp here. It is a no-op off web, and on web it lets test/web/smoke/smoke.mjs fail immediately with the cause instead of waiting for the bridge-ready timeout.

// before falling back. Harmless, but this screen exists to make a failed
// startup legible; it should not add noise of its own to the one log the
// person reporting it is about to read.
onGenerateRoute: (_) => MaterialPageRoute<void>(builder: _buildBody),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified with defaultRouteNameTestValue = '/orders/abc/detail': Navigator.defaultGenerateInitialRoutes resolves /, /orders, /orders/abc, /orders/abc/detail through this callback and pushes all four, so the failure page is stacked 4 deep and canPop() is true — browser/Android back pops to an identical page.

Also, the "Could not navigate to initial route" report this comment cites is inside an assert(...) in navigator.dart, so it never appears in a release build; the motivation only holds for debug.

onGenerateInitialRoutes: (_) => [MaterialPageRoute<void>(builder: _buildBody)],

yields exactly one route for any initial URL.

Comment thread test/core/startup_failure_test.dart Outdated
expect(find.textContaining('loading the engine'), findsNothing);
});

testWidgets('answers a deep initial route, not just "/"', (tester) async {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This test never sets a deep route — defaultRouteNameTestValue is / by default, and wrapping in MediaQuery does not change it — so it is the fourth test again and passes with home: too. To test what the name says:

tester.binding.platformDispatcher.defaultRouteNameTestValue = '/orders/abc/detail';
addTearDown(tester.binding.platformDispatcher.clearDefaultRouteNameTestValue);
await tester.pumpWidget(const StartupFailureApp(step: 'loading the engine'));
expect(tester.takeException(), isNull);
expect(tester.state<NavigatorState>(find.byType(Navigator)).canPop(), isFalse);

With the current onGenerateRoute the last expectation fails (4 stacked routes); with onGenerateInitialRoutes it passes.

Comment thread test/core/startup_guard_test.dart Outdated
);

final before = source.substring(0, initDbAt);
final enclosingWebGuard =

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Named enclosingWebGuard but it does not check enclosure: it matches any if (!kIsWeb) { anywhere before initDb, so a legitimate native-only step added earlier in the file fails this test with a message about the database. Either scope the search to the _optional('opening the local database' block, or drop this in favour of a behavioural test on an extracted startup sequence.

@Matobi98
Matobi98 marked this pull request as draft September 10, 2026 21:02
@Matobi98

Copy link
Copy Markdown
Contributor Author

Round 2 — pushed in 159af5a. All five findings and the four low ones taken; three more turned up on the way.

HIGH — the stale label. Correct, and the fix is the _required twin you suggested, as methods on a small StartupSequence (lib/core/startup_sequence.dart) so the guard is unit-testable. Every stretch now announces its own name; the three blocks that keep bespoke handlers — one reports to the bridge probe — set it directly, with a line at each site saying why they don't go through a helper.

Verified in the app, not only in tests: breaking the container assembly used to produce "It failed while reading relay status". It now says "building the interface".

HIGH — the deep initial route. Reproduced your measurement before changing anything: /orders/abc/detail stacked 4 copies, canPop() true. onGenerateInitialRoutes gives 1 and canPop() false. onGenerateRoute has to stay — MaterialApp refuses to construct without one — so it's an addition, not a replacement, and there's a comment saying so. The test now sets defaultRouteNameTestValue and asserts on the navigator stack; the old one was indeed the fourth test again.

You were also right that the console message lived in an assert. The comment justified the change with debug-only noise; it now states the real reason.

MEDIUM — #227 miscited. Ours to have caught: #370 is where we established the crash fires before main(). Reworded in app_bootstrap.dart and the test, and in the PR body.

MEDIUM — no behavioural coverage. Six tests now drive StartupSequence with fake bodies, including the one that would have caught the stale label: an optional step succeeds, a required one throws, and the reported step must be the second. Mutation-checked — dropping the label from required reddens two of them.

Two greps remain, and only because running is not available: that the guard reaches runApp with the current step, and the !kIsWeb check, now scoped to the opening the local database block as you asked rather than searching the whole file.

MEDIUM — the cause on screen. Added, selectable, capped at 300 characters. Plus a "Copy details" button, since on a phone dragging to select inside a scrolling view is awkward: it puts the step and the cause on the clipboard, because the cause alone loses half of what makes a report actionable.

LOW. ensureInitialized moved outside the guard, with a comment saying it is honestly unguarded — the rescue paints through runApp, which needs it too. markBridgeFailed(e) added. Description rewritten: the counts were stale, the table was missing the database step, and "no kIsWeb anywhere" had stopped being true. CLAUDE.md records the hard-coded English as a deliberate exception.

Three things found while doing this

1. A long cause on a short screen overflowed — the striped warning painted over the one screen that has to stay readable. My own addition, an hour old. The content scrolls now, with a test at 320×400.

2. markBridgeFailed was sitting in front of runApp in the catch, unguarded. If it threw there would be no rescue at all. The screen goes first now; CI gets told after.

3. [startup] degradations are not visible anywhere a user can reach. The design leans on "grep [startup] to see what a run gave up on", but those lines are debugPrint; the in-app log viewer shows Rust's logs, and Dart has no way to write into it. So the promise only holds with a dev console attached. Not this PR's to fix, and not a regression — but worth knowing before someone relies on it in a bug report.

Two more I did not act on, deliberately:

  • optional catches everything, so a null-check or type error in an optional step is swallowed and startup continues in an unknown state. Narrowing it to exceptions would turn those into hard failures — a behaviour change with real risk, and one that predates this PR. Worth a decision by someone who knows the project better than I do.
  • If runApp throws, the guard calls runApp again. Nearly unreachable, since build errors surface in the next frame and never reach this catch, but the path exists and nothing exercises it.

Verified

flutter analyze clean, flutter test 360 passed, cargo test / clippy / cargo check --target wasm32-unknown-unknown clean.

Manually against the local regtest in Chrome, seven cases: the app opens; a trade survives a reload; the database broken leaves the app up with its log line and persistence gone; RustLib.init and SharedPreferences broken produce two different screens; the container assembly broken says "building the interface"; and a deep initial route leaves one page with back not landing on a clone.

One of those runs turned up something unplanned — a page reload produced AnyhowException(AlreadyInitialized) from the relay pool, which the guard logged and continued past. Before this PR that line was unwrapped.

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.

Web: startup has no top-level guard — any failure before runApp is a silent blank page

2 participants