Conversation
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Advanced Run ID: 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. Comment |
|
This branch conflicts with What to do on rebase:
|
…ailure surface answers any route
38643a2 to
d89fb01
Compare
|
Rebased onto 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
Flutter version: fixed at the root rather than remembered — the other SDK is gone from this machine, so 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: |
grunch
left a comment
There was a problem hiding this comment.
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 rescuerunAppthrows too. Acceptable edge case, but worth a comment or moving that call outside thetry. - The description drifted after round 1: "3 new tests" (there are 8), the seven-step table omits the database step, and "no
kIsWebanywhere 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)
| /// 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 { |
There was a problem hiding this comment.
_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.
| } 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). |
There was a problem hiding this comment.
#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).
| // 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)); |
There was a problem hiding this comment.
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), |
There was a problem hiding this comment.
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.
| expect(find.textContaining('loading the engine'), findsNothing); | ||
| }); | ||
|
|
||
| testWidgets('answers a deep initial route, not just "/"', (tester) async { |
There was a problem hiding this comment.
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.
| ); | ||
|
|
||
| final before = source.substring(0, initDbAt); | ||
| final enclosingWebGuard = |
There was a problem hiding this comment.
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.
…r URL, the cause on screen
|
Round 2 — pushed in HIGH — the stale label. Correct, and the fix is the 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: You were also right that the console message lived in an MEDIUM — #227 miscited. Ours to have caught: #370 is where we established the crash fires before MEDIUM — no behavioural coverage. Six tests now drive Two greps remain, and only because running is not available: that the guard reaches 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. Three things found while doing this1. 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. 3. Two more I did not act on, deliberately:
Verified
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; One of those runs turned up something unplanned — a page reload produced |
|
Merged
The merge also brought two steps, both optional: |
grunch
left a comment
There was a problem hiding this comment.
Review (strict pass, round 3)
The round-2 findings are resolved:
- every stretch now runs under its own label (
required+optional, locked in bystartup_sequence_test.dart); - the deep-route test really sets
defaultRouteNameTestValueand checkscanPop(); - the
!kIsWebgrep is scoped to the database step; - #227 is cited as a precedent;
markBridgeFailedis 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 analyzereports no issues onstartup_failure.dart,startup_sequence.dartand 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 itsconsole/pageerrorcollectors.
Verdict: request changes. 1 and 2 should be fixed before merge. The rest can go in this PR or a follow-up issue.
| // 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); |
There was a problem hiding this comment.
🧪 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
waitForFunctionas soon asmostroBridgeReady === true, then readsmostroBridgeErroronce. 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
consolemessages of typeerrorand onpageerror. Before this PR the throw escapedmain()and reached them. Now it is caught, printed withdebugPrint(aconsole.log, not an error) and turned intoStartupFailureApp.
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.
| // 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('), |
There was a problem hiding this comment.
🧪 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
catchabove it swallows the error first; - the order flips so
markBridgeFailedruns first and throws; - someone wraps
_startupinunawaited(...), so thetrynever 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 { |
There was a problem hiding this comment.
🎯 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( |
There was a problem hiding this comment.
♻️ 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, whoseonResumerunsResumeResyncagainst a container that is never mounted;- the
_consumeBondSlashed/_consumeBondClaimsloops; _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.
| // whole report over in a form that can be pasted into a message. | ||
| TextButton.icon( | ||
| onPressed: () { | ||
| Clipboard.setData(ClipboardData(text: _report())); |
There was a problem hiding this comment.
🎯 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.
| ), | ||
| const SizedBox(height: 12), | ||
| Text( | ||
| 'It failed while $step.', |
There was a problem hiding this comment.
🎨 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) { |
There was a problem hiding this comment.
🎯 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'; |
There was a problem hiding this comment.
♻️ 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); }).
| final text = error.toString(); | ||
| return text.length <= _maxErrorChars | ||
| ? text | ||
| : '${text.substring(0, _maxErrorChars)}…'; |
There was a problem hiding this comment.
🎯 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. Usetext.characters.take(_maxErrorChars).toString()instead;package:charactersalready comes with Flutter.- The cause is 12 px
#7A8296on#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.#8C94A8or lighter clears 4.5:1 without a visible change in tone.
…rd tested by running it, failure screen next steps
|
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
|
Closes #389. Carries the unticked half of #227.
The problem
main.dartisFuture<void> main() => bootstrapAndRun();with no guard, and the stretch ofbootstrapAndRunbeforerunAppruns a dozen steps. Anything that throws there meansrunAppnever runs: Flutter paints nothing, and the page is not broken — it is absent, with no message anywhere. Everything afterrunAppalready 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, beforemain()runs, which is why #370 fixed it inweb/index.htmland stated plainly that no app-leveltry/catchcould reach it.The fix
lib/core/startup_sequence.dartholds 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 paintsStartupFailureAppnaming 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:
registering font licencessetting up notificationsloading the enginereading your settingsapplying your log settingsopening the local databaseselecting the Mostro nodemirroring trade key indicesloading your identitysubscribing to bond noticessubscribing to bond claim updatessubscribing to trade updatessubscribing to chat messagesconnecting to the networkreading relay statusbuilding the interfaceThe 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
mainwhile this PR was open). They feed onlyEventCards;trade_state_provider.dartandchat_providers.dartsubscribe on their own, so the app works without them.The web smoke test now sees late failures.
mostroBridgeReadyused to be set at the first Rust call (selecting the Mostro node), andsmoke.mjsstopped watching there, so anything failing afterwards passed CI. That gap predates this PR: measured on86b74a7with athrowbefore 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 adebugPrint.markBridgeReady()now runs at the very end of startup, afterrunApp; any failure before it leaves onlymostroBridgeError, which the smoke test already waits for.smoke.mjsreportsstartup finished/startup failed: ….reading your settingsis a deliberaterequired. 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 databaseruns 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.dartfails if it ends up back inside a!kIsWebblock.connecting to the networkstays 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: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), andCLAUDE.mdrecords 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/detailwould otherwise stack four identical copies, with back popping to a clone.No retry button:
RustLib.initthrows 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
kIsWebin 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— cleanflutter test— 1344 passed after mergingmain(fd94e2b), including 27 from this PR: 8 overStartupSequence, 14 over the failure surface, 3 over the guard and 2 overopenDatabasecargo test— 661 passed (pre-commit hook)SMOKE_BOND_STORE=1 SMOKE_PUSH_WORKER=1; with athrowinjected before the interface is built it fails withstartup failed: Bad state: …What they pin, each mutation-checked (the fix reverted, the test seen red):
runGuardedwith fakes forrunAppand 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;Errorin an optional step is reported throughFlutterErrorin debug while startup continues; an environment failure is not;openDatabaseopensmostroon the web without asking for a directory;One static grep remains, deliberately: that
_startupcallsopenDatabasewithout a!kIsWebaround 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:
fd94e2b(merge withmain)mostro-clisells, the app buys — local regtest on Linux913c44f3throw: text, layout, both copy buttons, footer86b74a7and this branchthrow: before the fix it passes, after it fails with the causeAgent-run: the smoke test repeated on
913c44f3and on the merge; an isolated Linux debug run, before the merge, to check that no optional step raised anError(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:
mostro-cli, both completed[startup]line eachRustLib.initRustLib.init, from a deep URLFrom the earlier round, before the merge:
[startup]line, persistence gone until restoredSharedPreferencesReloading the page 30 times on a release build gave no blank page. In a long-lived
flutter runsession one reload came up blank with a flutter_rust_bridge panic inTradeKeyIndexStream; a fresh dev server, onmainand 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):
Known, not addressed here
[startup]degradations reachdebugPrintonly. 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.