models: parse both shapes of user_failures - #8929
Conversation
lantern-box v0.0.104 changed each `user_failures` entry from a bare
RFC3339 timestamp to a `{"at": ..., "kind": ...}` object. Go got
back-compat for its own reads (`UserFailure.UnmarshalJSON`); Dart still
cast every entry to String, so the first entry radiance recorded threw
out of `SelectionHistory.fromJson`. `AvailableServers.fromJson` maps
over the whole list with no per-entry isolation, so one poisoned server
cost the client every server it had — Freshdesk #180591 shows a China
Android user on 9.1.17 hitting this 339 times over five hours, 92
seconds after upgrading.
Accept both shapes and drop entries we can't read instead of throwing:
`user_failures` has no readers on this side, so an unparseable timestamp
should never be able to take down the server list.
Also adds the first `fromJson` coverage this model has had. Five of the
seven new tests fail without the fix, one of them reproducing the
verbatim runtime error from the ticket.
Co-Authored-By: Claude <noreply@anthropic.com>
📝 WalkthroughWalkthroughChangesUser failure deserialization
Estimated code review effort: 2 (Simple) | ~10 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
There was a problem hiding this comment.
Pull request overview
Updates the Flutter/Dart AvailableServers model layer to be backward-compatible with a lantern-box JSON schema change where selection_history.user_failures entries changed from timestamp strings to {at, kind} objects, preventing a single malformed/unknown entry from breaking parsing of the entire server list.
Changes:
- Make
SelectionHistory.fromJsonaccept both legacy string entries and the new object form foruser_failures, dropping unreadable entries instead of throwing. - Keep
toJson()emitting the legacy “bare timestamp” shape foruser_failures. - Add focused unit tests covering both shapes, mixed windows, invalid entries, and a multi-server payload where only one server has
user_failures.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| lib/core/models/available_servers.dart | Adds tolerant parsing for both user_failures shapes and filters invalid entries to avoid poisoning full server-list parsing. |
| test/core/models/available_servers_test.dart | Adds regression/unit tests for user_failures parsing across legacy/new/mixed/invalid cases and multi-server payload behavior. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
) The comments introduced in #8929 cite v0.0.104 as the release that changed each `user_failures` entry from a bare RFC3339 string to a {"at", "kind"} object. That was the edge of the module cache I sampled, not the actual boundary. Verified against the tags: v0.0.99, v0.0.100 UserFailures []time.Time v0.0.101 .. v0.0.106 UserFailures []UserFailure The change landed in lantern-box #286 (b2c28bb), first tagged v0.0.101. These comments exist to point a future reader at the version where the shape changed, so the wrong number defeats their only purpose. Comments only — no behavior change. Co-authored-by: Adam Fisk <a@lantern.io> Co-authored-by: Claude <noreply@anthropic.com>
Nothing ran `flutter test test/` on a PR. The only two references to `flutter test` live in build-linux.yml and build-windows.yml, both workflow_call-only and both scoped to integration_test/ — so the unit suite never executed in CI at all. That is part of why the user_failures deserialization break (#8929) reached three releases: a unit test would have caught it, and a unit test now pins it, but on `pull_request` nothing would have run it. flutter test needs no Go toolchain, Android SDK, or Java, so this gate is just checkout + flutter + pubget/gen + test. Deletes test/widget_test.dart, which had to go first: it is the stock `flutter create` counter template with `pumpWidget` commented out, so it asserts against an empty widget tree and fails unconditionally. It was never adapted to this app and tests nothing. Co-authored-by: Adam Fisk <a@lantern.io> Co-authored-by: Claude <noreply@anthropic.com>
What's wrong
lantern-box #286 (
b2c28bb, 2026-07-09, first tagged v0.0.101) changedTagHistory.UserFailuresfrom[]time.Timeto[]UserFailure:So
user_failureswent from["2026-07-06T12:00:00Z"]to[{"at":"2026-07-06T12:00:00Z","kind":"stall"}]. Go got back-compat for its own reads (UserFailure.UnmarshalJSONaccepts both forms) — the Dart consumer didn't, and nothing tests the cross-language contract.available_servers.dart:261still didDateTime.parse(e as String).AvailableServers.fromJsonmaps over the whole list with no per-entry isolation, so one server carrying a recorded user failure throws out of the entire parse and the client is left with no server list at all.How it reached users
The Dart cast came first and was correct when written —
fc84cbe45(#8833, 2026-06-18) addeduser_failuresparsing three weeks before the Go side changed shape.What broke it was the dep bump:
6d2c05c8b(#8912, 2026-07-17) moved lantern-box v0.0.100 → v0.0.103, crossing the v0.0.101 boundary. It touchedgo.modandgo.sumand nothing else — zero Dart files — which is exactly why nobody noticed: the breaking change is invisible in the diff, and no test covers the Go↔Dart contract.So 9.1.16 is affected too, not just 9.1.17. The ticket only shows 9.1.17 because that user's upgrade history goes 9.1.15 → 9.1.17 — they skipped 9.1.16 entirely.
sequenceDiagram autonumber participant UI as VPN screen<br/>available_servers_notifier.dart participant Svc as platform service<br/>lantern_platform_service.dart participant LB as lantern-box v0.0.104<br/>adapter/autoselect_history.go participant Model as model<br/>available_servers.dart Note over LB: autoselect_history.go:81 ⚠️<br/>UserFailures is []UserFailure<br/>(was []time.Time in v0.0.100) UI->>Svc: available_servers_notifier.dart:37<br/>getLanternAvailableServers() Svc->>LB: lantern_platform_service.dart:1436<br/>MethodChannel getLanternAvailableServers LB-->>Svc: JSON string — user_failures entries<br/>are now objects (at + kind) Svc->>Model: lantern_platform_service.dart:1439<br/>AvailableServers.fromJson(jsonDecode(result)) Model->>Model: available_servers.dart:6<br/>map over all 55 servers, no per-entry isolation Model->>Model: available_servers.dart:138<br/>SelectionHistory.fromJson(selection_history) rect rgba(255, 200, 200, 0.3) Note over Model: available_servers.dart:261 🐛<br/>DateTime.parse(e as String)<br/>the entry is a Map, not a String end Model-->>Svc: throws — _Map is not a subtype of String in type cast Svc-->>UI: lantern_platform_service.dart:1446<br/>Left(Failure) — all 55 servers lost Note over UI: available_servers_notifier.dart:46<br/>logs and returns, so state stays stale<br/>(build() at :25 throws outright)Evidence
Freshdesk #180591 — China, Android, 9.1.17. From that user's
flutter.log:11:28:11.225; the first parse failure is at11:29:43.937— 92 seconds later, as soon as radiance recorded its first user-traffic failure.type '_Map<String, dynamic>' is not a subtype of type 'String' in type cast, from11:29:43through16:35:40, with the stack trace namingavailable_servers.dart:261.Servers JSON: [lines, all well-formed — the payload was fine, only the parse wasn't.Downstream: 130
updateServerLocation: type=auto, 58 "falling back to auto", ~23 churned tags. The UI can't learn which servers exist, so it keeps re-resolving against a stale list.Why this escaped testing:
user_failuresisomitempty, so a fresh upgrade parses fine; anddefaultUserFailureWindowis 5 minutes, so the window empties again after five failure-free minutes. On a clean network the bug is invisible. In China it never clears.Reproduction
The model has no imports beyond
dart:core, so it runs standalone against the payload the ticket's log captured verbatim (55 servers, 2 carryinguser_failures):Mutating only
user_failuresflips the outcome, which is what makes the dependency bump causal rather than correlated. One poisoned server out of 55 is enough.The fix
Accept both shapes, and drop entries we can't read instead of throwing:
entry is Maprather thanMap<String, dynamic>on purpose — this has to hold if the payload ever arrives viaStandardMessageCodecrather thanjsonDecode, which yieldsMap<Object?, Object?>.Dropping rather than throwing is the load-bearing half.
userFailureshas no readers anywhere inlib/— it is parsed, defaulted, and re-serialized, nothing else. A field nobody consumes should not be able to cost the client its server list, whatever shape a future lantern-box gives it.Tests
This model had zero
fromJsoncoverage before now. Seven new tests; five fail without the fix:omitemptypath)The list-level test round-trips its fixture through
jsonEncode/jsonDecodeso element types match the platform channel's, and pre-fix it reproduces the ticket's error string exactly:type '_Map<String, dynamic>' is not a subtype of type 'String' in type cast.Deliberately out of scope
AvailableServers.fromJson:6. One bad server still takes down the list for any other parse failure. Worth fixing, but it's a behavior change (partial lists) that deserves its own review.last_outcome_at/updated_atare never null. Go'somitemptydoesn't apply to structs, so a zerotime.Timemarshals as0001-01-01T00:00:00Z. Thejson[...] == null ? null : ...guards are dead code andtoJson'sif (lastOutcomeAt != null)never trips.Note
test/widget_test.dart("Counter increments smoke test") fails onorigin/mainas well — verified by stashing these two files and re-running. Pre-existing, untouched here.🤖 Generated with Claude Code