Skip to content

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

Open
Matobi98 wants to merge 6 commits into
MostroP2P:mainfrom
Matobi98:fix/389-startup-guard
Open

Matobi98 wants to merge 6 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.

The problem

main.dart is Future<void> main() => bootstrapAndRun(); with no guard, and the stretch of bootstrapAndRun before runApp runs a dozen 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. Everything after runApp already degrades; this stretch was the outlier.

#227 is the precedent that motivated this, not a case it covers: that crash fires inside the engine's own CanvasKitRenderer.initialize, before main() runs, which is why #370 fixed it in web/index.html and stated plainly that no app-level try/catch could reach it.

The fix

lib/core/startup_sequence.dart holds the sequence: currentStep, plus two ways to run a step.

  • optional(name, body) — a step the app can open without. A failure is logged under a shared [startup] prefix and startup continues, degraded.
  • required(name, body) — a step it cannot. The failure propagates to the guard, runGuarded (same file), which paints StartupFailureApp naming the step and reports the cause to the web smoke test.

Both set the label on entry, and that is the reason there are two: with only optional, the mandatory stretches in between carry no label of their own and run under whichever optional step finished last — so a failure there names a step that succeeded. That was the shape of this PR after round 1, caught in review.

Every step, classified by what the app can still do without it:

Step Without it
registering font licences Flutter's licence page is missing the bundled fonts' notices optional
setting up notifications no push notifications optional
loading the engine no protocol, keys, relays or chat required
reading your settings no language, walkthrough state or wallet required
applying your log settings default log verbosity optional
opening the local database memory-only session, no persistence optional
selecting the Mostro node the compiled-in default node labelled
mirroring trade key indices the secure-storage copy is absent labelled
loading your identity secure storage unavailable labelled
subscribing to bond notices no in-app notice for a slash optional
subscribing to bond claim updates no live update of a claim; My Trades and the trade detail still list claims optional
subscribing to trade updates no in-app cards for trade updates; trade screens open their own subscription optional
subscribing to chat messages no in-app cards for chat messages; chat opens its own per-trade subscription optional
connecting to the network opens, but nothing that needs relays works optional
reading relay status one diagnostic line missing optional
building the interface nothing to show required

The three "labelled" ones keep their own bespoke handlers — one of them reports to the web bridge probe — so they announce their name and handle their own failure rather than being forced through a helper.

The two card subscriptions come from #474 (merged into main while this PR was open). They feed only EventCards; trade_state_provider.dart and chat_providers.dart subscribe on their own, so the app works without them.

The web smoke test now sees late failures. mostroBridgeReady used to be set at the first Rust call (selecting the Mostro node), and smoke.mjs stopped watching there, so anything failing afterwards passed CI. That gap predates this PR: measured on 86b74a7 with a throw before the interface is built, the smoke test passed 3/3, finishing at ~1.2 s while the failure arrived at ~1.6 s. The guard made it worse, turning the throw into a debugPrint. markBridgeReady() now runs at the very end of startup, after runApp; any failure before it leaves only mostroBridgeError, which the smoke test already waits for. smoke.mjs reports startup finished / startup failed: ….

reading your settings is a deliberate required. It could degrade to defaults, but then the app would open looking freshly installed: the walkthrough again, the device language instead of the one the user picked, and no attempt to reconnect a saved wallet (NWC, not available on web). Showing that as if nothing were wrong misleads about data loss in an app people trade money through. All three measured by forcing the step to read an empty store.

opening the local database runs on the web too. Since #408 that is where web persistence lives: with this step broken, a trade taken in Chrome is gone after a reload. startup_guard_test.dart fails if it ends up back inside a !kIsWeb block.

connecting to the network stays optional, measured with it forced to fail. The app opens, and the secret words can still be viewed and backed up — they come from secure storage, not from relays. Nothing that needs relays works, though: adding a relay in Settings fails, because the relay API goes through a pool that never started; the order book spins with no message, and My Trades shows empty although the trades are stored. Those screens have no "disconnected" state yet — that is what makes this degradation confusing, not the decision to open.

The failure surface

lib/core/startup_failure.dart. "Mostro could not start", the step that failed, and the cause underneath it — selectable, capped at 300 characters (counted in characters, so an emoji is never cut in half). Each copy button sits to the right of what it copies, in one aligned column:

  • Copy details puts the step and the cause on the clipboard. On Android, iOS and Linux there is no console for the person hitting this to read, and dragging to select inside a scrolling view is awkward on a phone.
  • Copy link copies https://github.com/MostroP2P/app/issues, where to report it.

The write is awaited: when the clipboard refuses it (web without permission, non-secure context) the screen says "Could not copy" instead of a false "Copied".

A footer spells out the cost of the obvious way out: without the secret words, uninstalling or clearing app data loses access to open trades, disputes and reputation. Checked against the code — trade keys derive from the mnemonic (m/44'/1237'/38383'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/0/N), the daemon restores sessions from the identity key, and ratings sit on the master pubkey — so the screen warns rather than recommending it.

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. Colours are hard-coded for the same reason (the cause is #8C94A8, 5.3:1 on the background), and CLAUDE.md records the l10n exception so nobody "fixes" it. The content scrolls, so a long cause on a short screen moves out of the way rather than painting Flutter's striped overflow warning.

It answers any initial route with exactly one page. On the web the initial route is the browser's URL, and Flutter's default handling pushes one route per path segment: /orders/abc/detail would otherwise stack four identical copies, with back popping to a clone.

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

Platform-independent, as the issue asks — no kIsWeb in the guard itself. Web is where it bites hardest, because the in-app log viewer lives inside the app that did not start.

Automated tests

  • flutter analyze — clean
  • flutter test1344 passed after merging main (fd94e2b), including 27 from this PR: 8 over StartupSequence, 14 over the failure surface, 3 over the guard and 2 over openDatabase
  • cargo test — 661 passed (pre-commit hook)
  • Web smoke test on the merge, release bundle: passes with SMOKE_BOND_STORE=1 SMOKE_PUSH_WORKER=1; with a throw injected before the interface is built it fails with startup failed: Bad state: …

What they pin, each mutation-checked (the fix reverted, the test seen red):

  • the guard is executed through runGuarded with fakes for runApp and the bridge report: the failure surface is painted with the failing step and error, before CI is told. Moving the call out of the catch, swallowing the error earlier, flipping the order or not awaiting the body all fail it;
  • the step reported is the one that failed, not the last that succeeded;
  • a programming Error in an optional step is reported through FlutterError in debug while startup continues; an environment failure is not;
  • openDatabase opens mostro on the web without asking for a directory;
  • the clipboard failure message, the link button, character truncation, the aligned copy column, the footer and the title spacing.

One static grep remains, deliberately: that _startup calls openDatabase without a !kIsWeb around it. Executing that line would mean faking ~20 Rust calls to protect one line, and the test says so.

Manual Testing

Round 3, by hand:

Commit What Result
fd94e2b (merge with main) full sell trade — mostro-cli sells, the app buys — local regtest on Linux completed
913c44f3 failure screen on Linux with an injected throw: text, layout, both copy buttons, footer as described above; copied text checked by pasting
86b74a7 and this branch web smoke test with the same injected throw: before the fix it passes, after it fails with the cause confirmed

Agent-run: the smoke test repeated on 913c44f3 and on the merge; an isolated Linux debug run, before the merge, to check that no optional step raised an Error (the two card subscriptions from #474 were not part of it).

Not tested by hand in round 3: the failure screen on web and Android, and whether #474's in-app cards appear during the trade.

Round 2, breaking one step at a time, on the code merged then:

Broken Observed
nothing order book, and a trade survives a page reload
nothing a full buy and a full sell against mostro-cli, both completed
font licences and bond claim updates app opens, one [startup] line each
RustLib.init "Mostro could not start: loading the engine", cause below it, copy button works
the container assembly "building the interface"
RustLib.init, from a deep URL one page; back does not land on a clone
relay pool init app opens; secret words visible; adding a relay fails; order book spins
settings read as empty walkthrough, device language, no wallet restore attempted

From the earlier round, before the merge:

Broken Observed
the database app opens, [startup] line, persistence gone until restored
SharedPreferences "It failed while reading your settings" — a different screen

Reloading the page 30 times on a release build gave no blank page. In a long-lived flutter run session one reload came up blank with a flutter_rust_bridge panic in TradeKeyIndexStream; a fresh dev server, on main and on this branch alike, gave none in 18 reloads each.

Screenshots

Failure screen, rendered from the widget with the real fonts (phone 400×800 and desktop 1000×700):

pr405-screenshot-desktop pr405-screenshot-phone

Known, not addressed here

  • With relay init broken the app opens but nothing that needs relays works, and the order book spins forever rather than saying it is offline. That surface has no "disconnected" state of its own.
  • [startup] degradations reach debugPrint only. The in-app log viewer shows Rust's logs, and Dart has no way to write into it, so "grep [startup] to see what a run gave up on" holds only with a dev console attached.
  • Follow-ups from review round 3 (issues to be linked): a failure while painting the first frame bypasses the guard and the ready flag; listeners started during startup outlive a failure; the step label can still go stale through the public setter.

@coderabbitai

coderabbitai Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: bc3bb29f-1cbe-4e65-bd5f-d428c60eef77


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.

@Matobi98

Copy link
Copy Markdown
Contributor Author

Merged main and re-ran the manual round on the result. Three corrections to what I wrote earlier:

  • The Firebase comment was wrong. It said currentPlatform throws UnsupportedError on every run. Only Linux throws; web, Android, iOS, macOS and Windows return options. Fixed in the code comment.
  • "An app that opens offline can be fixed from inside" is false. Measured with relay init forced to fail: Settings opens, but adding a relay fails, because the relay API goes through a pool that never started. It stays optional — the app opens and the secret words can still be backed up — but the comment now says what actually works and what does not.
  • "Before this round the container failure said 'reading relay status'" I confirmed by reading the round-1 code (the label only changed after the container was built), not by running it.
  • "A page reload produced AnyhowException(AlreadyInitialized)" — it was a hot restart, not a reload. A hot restart keeps the Rust side alive, so the relay pool is already initialised; a real reload starts it fresh. Dropped from the description.

The merge also brought two steps, both optional: registering font licences (it was left outside the guard by the merge, moved inside) and subscribing to bond claim updates. Description updated.

@Matobi98
Matobi98 marked this pull request as ready for review September 14, 2026 17:52

@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.

Review (strict pass, round 3)

The round-2 findings are resolved:

  • every stretch now runs under its own label (required + optional, locked in by startup_sequence_test.dart);
  • the deep-route test really sets defaultRouteNameTestValue and checks canPop();
  • the !kIsWeb grep is scoped to the database step;
  • #227 is cited as a precedent;
  • markBridgeFailed is called from the guard.

The step classification is careful. What remains is mostly around the guard: how CI observes it, how the failure screen behaves when copying fails, and whether the guard's own logic is really tested.

Findings

# Severity Where Summary
1 🟠 Major app_bootstrap.dart guard A fatal failure after markBridgeReady() now passes the web smoke test. It used to be an uncaught page error; now it is a debugPrint plus a flag the smoke test no longer reads
2 🟠 Major startup_guard_test.dart The guard itself (catch → runApp(StartupFailureApp)markBridgeFailed) is only checked by a substring grep, so a dead or misplaced call still passes. It can be extracted and tested without Rust
3 🟡 Minor app_bootstrap.dart building the interface The comment says runApp is "already covered", but only a synchronous throw is: a MostroApp build error happens in the first frame, outside the try
4 🟡 Minor app_bootstrap.dart building the interface A throw after AppLifecycleService.attach() or after the bond consumers start leaves them running on a container that is never mounted, and ResumeResync runs behind the failure screen
5 🟡 Minor startup_failure.dart Copy details Clipboard.setData is not awaited. "Copied" shows even when the write is rejected (web without clipboard permission, non-secure context), and the rejection goes unhandled
6 🟡 Minor startup_failure.dart No next step on the screen: nowhere to report, no hint about clearing app data. With reading your settings required, a corrupt preference is a permanent dead end
7 🔵 Nit startup_sequence.dart optional also swallows Errors (TypeError, LateInitializationError, failed asserts), so programming bugs degrade silently during development
8 🔵 Nit startup_sequence.dart / app_bootstrap.dart currentStep is public and mutable, and three steps set it by hand. The first new step with its own handler that forgets the line brings back the round-1 bug
9 🔵 Nit startup_failure.dart substring(0, 300) can split a surrogate pair, and the cause text (#7A8296 on #1D212C, 12 px) is ≈4.2:1, below WCAG AA 4.5:1

Checks on 54bf16f:

  • flutter analyze reports no issues on startup_failure.dart, startup_sequence.dart and the three new test files.
  • The 17 new tests pass.
  • A full-tree analyze in a fresh worktree fails only because the FRB bindings aren't generated there, which is unrelated to this PR.
  • I did not run the smoke test. Finding 1 comes from reading smoke.mjs: stage 3 and its console / pageerror collectors.

Verdict: request changes. 1 and 2 should be fixed before merge. The rest can go in this PR or a follow-up issue.

Comment thread lib/core/app_bootstrap.dart Outdated
// Then CI. No-op off web; on web it hands test/web/smoke/smoke.mjs the
// cause, so the run stops with a reason instead of timing out waiting for
// a bridge that is never coming.
markBridgeFailed(e);

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.

🧪 CI Observability | 🟠 Major

A fatal startup failure after markBridgeReady() now passes the web smoke test.

markBridgeReady() fires inside selecting the Mostro node (line 206). After it, loading your identity, connecting to the network and the required step building the interface still run. Following test/web/smoke/smoke.mjs for a throw in building the interface:

  • Stage 3 (lines 358–373) resolves waitForFunction as soon as mostroBridgeReady === true, then reads mostroBridgeError once. Identity init and relay-pool init sit between those two points and take far longer than that read. The error flag this guard sets later is never seen. Only stage 3b's post-reload check would see it, and 3b is opt-in (SMOKE_BOND_STORE=1).
  • Final checks (lines 261–264, 443) fail on console messages of type error and on pageerror. Before this PR the throw escaped main() and reached them. Now it is caught, printed with debugPrint (a console.log, not an error) and turned into StartupFailureApp.

The result is a green smoke run on a bundle that opens on "Mostro could not start: building the interface". The PR fixes this failure for users, but it also hides it from CI, which used to catch it.

Two small options:

} catch (e, st) {
  runApp(StartupFailureApp(step: startup.currentStep, error: e));
  markBridgeFailed(e);
  // Keep it observable as an error: console.error on web, error log elsewhere.
  FlutterError.reportError(FlutterErrorDetails(
    exception: e, stack: st, library: 'startup',
    context: ErrorDescription('while ${startup.currentStep}'),
  ));
}

or publish a separate mostroStartupFailed global, and have the smoke test wait for MostroApp's first frame (e.g. a known semantics label) before it declares success. The first keeps the change local; the second closes the gap for good.

🤖 Prompt for AI Agents
In lib/core/app_bootstrap.dart bootstrapAndRun catch block, after runApp(StartupFailureApp(...)) and markBridgeFailed(e), report the error through FlutterError.reportError (or console.error on web) so test/web/smoke/smoke.mjs's console/pageerror collectors fail the run. Optionally extend smoke.mjs stage 3 to re-read mostroBridgeError after the app has rendered.

Comment thread test/core/startup_guard_test.dart Outdated
// broke the first time an argument was added to it, which is the failure
// mode of a grep and the reason only two are left in this file.
expect(
source.contains('runApp(StartupFailureApp('),

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.

🧪 Test Quality | 🟠 Major

The guard is the fix, and it is the one part without a behavioural test.

source.contains('runApp(StartupFailureApp(') and contains('step: startup.currentStep') still pass when:

  • the call moves out of the catch (into an unreachable branch, or a helper that is never called);
  • a second catch above it swallows the error first;
  • the order flips so markBridgeFailed runs first and throws;
  • someone wraps _startup in unawaited(...), so the try never catches anything.

Reaching bootstrapAndRun needs Rust, preferences and relays, but the guard doesn't. Extracting it takes a few lines:

@visibleForTesting
Future<void> runGuarded(
  Future<void> Function(StartupSequence) body, {
  void Function(Widget) run = runApp,
  void Function(Object) onFailed = markBridgeFailed,
}) async {
  final startup = StartupSequence();
  try {
    await body(startup);
  } catch (e, st) { /* same as today */ }
}

bootstrapAndRun becomes runGuarded((s) => _startup(s, seedRelays: seedRelays)). A test can then use a fake body that throws inside required('building the interface', …) and check that run received a StartupFailureApp with that step and error, and that onFailed ran after it. That covers all four cases above. The kIsWeb grep can stay.

// interface' when it runs, so it is already covered, and the guard sits above
// both either way. Purely so this reads as one statement rather than a
// closure with the whole tail inside it.
final container = await startup.required('building the interface', () 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.

🎯 Correctness of the claim | 🟡 Minor

runApp is only partly covered.

The comment above says runApp "is already covered" because the label is still set. But the try in bootstrapAndRun only catches what runApp throws synchronously (binding and attach errors). A throw in MostroApp.build, in a provider read during the first build, or in GoRouter's initial redirect happens later, in the frame pipeline. It goes to FlutterError.onError (red screen in debug, grey box in release) and never reaches this catch.

That scope is acceptable, but the comment and the table row ("building the interface → nothing to show") should say so, so nobody reads the guard as covering first-frame errors. If you want it covered: install a FlutterError.onError for the first frame only. It calls runApp(StartupFailureApp(step: 'showing the first screen', …)), and you remove it in addPostFrameCallback.

// Resume = resync in Rust, then re-hydrate every notifier from the bridge
// (issue #308, docs/PUSH_NOTIFICATIONS.md §10). Attached before runApp so
// the first suspension is observed too.
AppLifecycleService(

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.

♻️ Resource lifecycle | 🟡 Minor

After a failure past this point, listeners outlive the failed startup.

Today only the return follows attach(), but this stretch is the one likely to grow. If anything after attach() throws, or runApp below throws synchronously, the guard paints StartupFailureApp while these keep running:

  • AppLifecycleService, whose onResume runs ResumeResync against a container that is never mounted;
  • the _consumeBondSlashed / _consumeBondClaims loops;
  • _restoreNwcConnection's microtask.

The user sees "could not start", yet a background resume still calls into Rust and the notifiers. Any resulting async errors land unhandled in the log and bury the real cause.

Either attach the lifecycle service and start the consumers as the last thing before runApp, once the container is fully built, or keep handles and call detach() / container.dispose() in the guard's catch.

Comment thread lib/core/startup_failure.dart Outdated
// whole report over in a form that can be pasted into a message.
TextButton.icon(
onPressed: () {
Clipboard.setData(ClipboardData(text: _report()));

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.

🎯 Robustness | 🟡 Minor

"Copied" is shown even when nothing was copied.

Clipboard.setData returns a Future that is not awaited. On the web it goes through navigator.clipboard.writeText, which rejects outside a secure context, when permission is denied, and in some embedded webviews. When it rejects:

  • the snackbar still says Copied, and the person pastes an empty report;
  • the rejection becomes an unhandled async error on the one screen that should stay quiet.

The screen's only action should report what actually happened:

onPressed: () async {
  final messenger = ScaffoldMessenger.of(context);
  try {
    await Clipboard.setData(ClipboardData(text: _report()));
    messenger.showSnackBar(const SnackBar(content: Text('Copied')));
  } catch (_) {
    messenger.showSnackBar(const SnackBar(
      content: Text('Could not copy — select the text above instead'),
    ));
  }
},

Please add a test where the mock handler throws on Clipboard.setData.

Comment thread lib/core/startup_failure.dart Outdated
),
const SizedBox(height: 12),
Text(
'It failed while $step.',

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.

🎨 UX | 🟡 Minor

The screen says what failed, but not what to do.

The text is actionable for a maintainer, but the person who sees it only gets "Copy details" and nowhere to paste them. The two required steps that can fail from local state fail on every launch:

  • reading your settings: a corrupt or incompatible preference;
  • loading the engine: a mismatched wasm bundle.

There is no retry (fine, for the RustLib.init reason in the description), so the only way out is clearing site or app data, and nothing says so.

One hard-coded line keeps the no-dependency rule, for example: "Please report this at github.com/MostroP2P/app/issues. If it keeps happening, clearing the app's data may help — you will need your secret words to restore your account." Adjust the wording to what is safe to recommend. A dead end in an app that holds money should point to the way out.

currentStep = name;
try {
await body();
} catch (e, st) {

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.

🎯 Diagnostics | 🔵 Nit

catch (e, st) also swallows Error.

That includes TypeError, LateInitializationError, RangeError and, in debug, AssertionError. These are programming bugs, not an environment problem, but today each becomes one [startup] line and the app opens half-initialised. Keeping release behaviour unchanged:

} catch (e, st) {
  debugPrint('[startup] $name failed — continuing without it: $e\n$st');
  assert(e is! Error, 'optional step "$name" hit a programming error: $e');
}

(or rethrow Error when kDebugMode). A bug in an optional step then fails loudly during development instead of degrading silently.

/// there would name a step that succeeded (#405 review).
class StartupSequence {
/// The step in progress. Read by the failure surface.
String currentStep = 'starting up';

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.

♻️ Maintainability | 🔵 Nit

The label can still go stale through the public setter.

The round-1 bug was a stretch running under the previous step's name. required and optional fix it structurally, but the three steps with their own handlers (selecting the Mostro node, mirroring trade key indices, loading your identity) set currentStep by hand. The next step added with its own catch that forgets that line brings the bug back, and no test catches it.

A third helper sets the label and leaves error handling to the caller:

Future<void> handled(
  String name,
  Future<void> Function() body, {
  required void Function(Object e, StackTrace st) onError,
}) async {
  currentStep = name;
  try { await body(); } catch (e, st) { onError(e, st); }
}

Then make the field read-only (String get currentStep). The bridge-probe step becomes startup.handled('selecting the Mostro node', …, onError: (e, _) { debugPrint(…); markBridgeFailed(e); }).

Comment thread lib/core/startup_failure.dart Outdated
final text = error.toString();
return text.length <= _maxErrorChars
? text
: '${text.substring(0, _maxErrorChars)}…';

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.

🎯 Text handling / a11y | 🔵 Nit

Truncation and contrast.

  • text.substring(0, 300) counts UTF-16 code units, so cutting at an emoji or other astral character leaves a lone surrogate. It renders as a replacement glyph, and on the web it can throw when passed to JS. Use text.characters.take(_maxErrorChars).toString() instead; package:characters already comes with Flutter.
  • The cause is 12 px #7A8296 on #1D212C, which I compute at about 4.2:1. That is below the WCAG AA minimum of 4.5:1 for small text, on the line people will be asked to read out. #8C94A8 or lighter clears 4.5:1 without a visible change in tone.

@Matobi98

Copy link
Copy Markdown
Contributor Author

Thanks for the round-3 review. Fixed here: 1, 2, 5, 6, 7, 9. Follow-up issues: #489 (3), #510 (4), #490 (8). Each point quotes your finding, then the change, then how I checked it: by hand in the app, or with a script I ran that breaks one line at a time and runs the tests (for what can't be done by hand, like a race).

1. Smoke test after markBridgeReady()

A fatal failure after markBridgeReady() passes the web smoke test.

Change: markBridgeReady() now runs at the very end of startup, after runApp. A failure anywhere before it leaves only mostroBridgeError, which the smoke test already waits for.

Checked by hand, with a throw injected before the interface is built:

  • pre-PR (86b74a7): BUNDLE_DIR=… node smoke.mjsweb bundle smoke test passed. — the gap was older than this PR;
  • this branch: same command → ✗ web bundle smoke test failed: startup failed: Bad state: ….

2. The guard is only grepped

The guard itself is only checked by a substring grep.

Change: the try/catch moved into runGuarded(body, run:, onFailed:); tests execute it with a body that throws. openDatabase is executed in a test too; one grep stays on its call site, since running it means faking ~20 Rust calls.

Checked with the script, against startup_guard_test.dart: each of your four cases — run(...) removed from the catch, try { await body(startup); } catch (_) {}, run/onFailed swapped, body(startup) without await — gives [E] a failure reaches runApp, naming the step that failed.

3. runApp "already covered"

Only a synchronous throw is covered; a first-frame error happens outside the try.

Change: the comment now says so. Covering the first frame: #489.

4. Listeners outlive a failed startup

A throw after AppLifecycleService.attach() leaves ResumeResync running behind the failure screen.

Follow-up: #510 — some of those starts depend on their order, so moving them needs its own verification.

Checked by hand, with a throw right after .attach(): flutter run -d chrome --web-port=5555 …, then switch tab and back → [lifecycle] resync: online=true flushed=0 coalesced=false behind the failure screen.

5. Clipboard not awaited

"Copied" shows even when the write is rejected.

Change: the write is awaited; on failure the screen shows "Could not copy. Select the text instead."

Checked by hand: tapping copy → "Copied", and the pasted text holds the step and the cause. With the script, dropping the await gives [E] says so when the copy fails instead of claiming it worked.

6. No next step

Nowhere to report, no hint about clearing app data.

Change: a second copy button for https://github.com/MostroP2P/app/issues, and a footer: without the secret words, uninstalling or clearing app data loses access to open trades, disputes and reputation (trade keys derive from the mnemonic, m/44'/1237'/38383'/0/N; restore_session.rs:15: let master_key = event.identity.to_string();; rate_user.rs:115: the rating goes to normal_seller_idkey / normal_buyer_idkey, the identity keys).

Checked by hand: "Copy link" pastes the URL; the footer sits at the bottom.

7. optional swallows Errors

Programming bugs degrade silently.

Change: in debug an Error is reported through FlutterError.reportError and startup continues, so a step already failing on some platform doesn't block debug runs. Release unchanged.

Checked with the script: replacing the condition with if (false) gives [E] optional reports a programming error loudly in debug.

8. Stale step label

currentStep is public and mutable; three steps set it by hand.

Follow-up: #490. Nothing is broken today; it prevents a regression.

9. Truncation and contrast

substring can split a surrogate pair; the cause text is ≈4.2:1.

Change: truncation counts characters (characters.take); the colour is #8C94A8, 5.3:1 on #1D212C.

Checked with the script: going back to substring gives [E] truncates by character, never inside an emoji; the WCAG formula on #8C94A8 / #1D212C prints 5.3.

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