Skip to content

Failure modes

The happy paths are on Auth, Matchmaking and Race. This page takes each thing that goes wrong in the field and traces what the code actually does, with references. Where the code leaves a case unhandled, or does something different from the matching use case in footrace-flutter/docs/use-cases/, the section says so in a “Gap” note. The gaps come from reading the code, not from running it on a phone; the use cases are the way to confirm them.

A reminder of the two isolates, because most failures land on one side of that line. The main isolate holds every widget, every provider, the socket and Dio. The foreground-service isolate holds GPS, the durable fix queue and the uploader. They share nothing and talk only through FlutterForegroundTask.sendDataToMain and saveData.

Failure Distance keeps counting? Server catches up? User told? Gaps
Network loss yes, locally yes, after backoff “Reconnecting…” up to 30 s delay after reconnect
App killed if the service survives queued fixes, yes reopens the race settled race opens on Race tab; orphaned service
Permission refused before a race n/a n/a sheet per step stale gate within a session
Permission revoked mid-race no no banner, only if the gate re-checks nothing re-checks during a race
Phone locked yes, in the service yes notification none found in code; needs a device
Abort stops abort sent once result screen channel failure has no HTTP retry
Session expiry service keeps going until upload 401 sent to sign-in service orphaned
Upload 409/403/422/401 yes see table below 403/401 only 409 not turned into a finish; 422 drops transient failures

Use case: footrace-flutter/docs/use-cases/lose-network-mid-race.md.

sequenceDiagram
    autonumber
    participant SVC as service isolate
    participant U as PositionUploader
    participant RN as RaceNotifier
    participant PT as PhoenixSocketTransport
    participant B as backend

    Note over PT,B: airplane mode on
    PT-->>RN: statusStream: connecting
    RN->>RN: socketDown = true ("Reconnecting…")
    loop every 2 s
        SVC-->>RN: batch (local distance grows)
        SVC->>U: enqueue, drain()
        U--xB: POST fails
        U->>U: backoff 1, 2, 4 ... 30 s, fixes stay pending
    end
    Note over PT,B: airplane mode off
    PT-->>RN: statusStream: connected, socketDown = false
    U->>B: after current backoff, batches of 50 until empty
    B-->>U: 200 with latest state
    SVC-->>RN: upload reply, snap distance, opponent bar jumps

Main isolate, socket. When the WebSocket closes, PhoenixSocketTransport moves its status to connecting and leaves the phoenix_socket package to reconnect on its schedule of 0, 1, 2, 4, 8, 16 and 30 seconds (app/lib/data/socket/phoenix_transport.dart:58-67, 73-76). RaceNotifier listens to that stream and sets socketDown for anything but connected (app/lib/features/race/race_notifier.dart:141-143). The race screen shows a “Reconnecting…” banner while it is set (app/lib/features/race/race_screen.dart:309-310). The channel layer’s comment says the transport rejoins its channels after a reconnect (app/lib/data/socket/channel_transport.dart:46-47); that is the package’s behaviour and I did not verify it in the package source. How fast the close is detected after airplane mode goes on depends on the package’s heartbeat, also not verified.

Service isolate, uploads. A POST that throws (no response at all) leaves the batch pending and sets the next backoff: 1 s, then doubling, capped at 30 s (app/lib/tracking/upload/position_uploader.dart:157-168, 216-220). The backoff is slept at the start of the next drain (line 150). While a drain sleeps, _uploadInFlight stays set, so later flushes go into the sembast queue without starting another drain (app/lib/tracking/location_service.dart:194-201). Fixes are on disk, not in memory, so a long outage costs nothing but time. app/test/tracking/upload/position_uploader_test.dart:66-96 covers the backoff and the cap.

What the user sees. Local distance keeps climbing, because batches from the service to the UI need no network. The opponent’s bar stops, because it only moves on raceUpdate pushes and upload replies. When the network returns, the drain that was waiting runs, pushes batches of 50 until the queue is empty, and reports once with the last 200 reply (app/lib/tracking/location_service.dart:178-183). _applySnapshot folds it in: the opponent jumps to where they are, and your local count snaps up if the server is ahead (app/lib/features/race/race_notifier.dart:243-247). A stale server value never pulls your distance down, because the snap is one-directional and the display takes the larger of the two (lines 69-72). That covers the use case’s “distance resets on reconnect” failure mode.

Gap. After a long outage the backoff can be at 30 s, and it is slept before the next attempt, not cut short when the network returns. So the catch-up in step 4 of the use case (“within a few seconds”) can take up to half a minute. Nothing in the app watches connectivity to reset the backoff.

Before a race. Without a socket, the queue falls back to polling GET /api/competitors/:id/poll every 2 s (app/lib/features/queue/queue_notifier.dart:143-152), and the race screen falls back to GET /api/races/:id for its first state (app/lib/features/race/race_notifier.dart:144-157). Without HTTP at all, the queue shows “Couldn’t join the queue” with a retry (app/lib/features/queue/queue_screen.dart:136-143), and a cold start signs in from the cached user (Auth).

Use case: footrace-flutter/docs/use-cases/kill-app-mid-race.md.

What is remembered. When tracking starts, _ensureTracking writes the race id to ActiveRaceStore, a single SharedPreferences key (app/lib/features/race/race_notifier.dart:306, app/lib/data/storage/active_race_store.dart:12-26). It is cleared when the race ends or is left (lines 367-379). Unuploaded fixes are in the sembast file, which survives the process.

Whether the service survives. Swiping the app away kills the main isolate. The foreground service is declared with foregroundServiceType="location" (app/android/app/src/main/AndroidManifest.xml:37-40), and the manifest sets no stopWithTask. Whether flutter_foreground_task keeps the service alive after the task is removed I did not verify; the use case asks the tester to record which happens.

On relaunch. Auth restores the session and the router lands on /home. RaceTab.build listens to pendingRaceProvider (app/lib/features/race_tab/race_tab.dart:32-35). That FutureProvider (app/lib/features/race/race_notifier.dart:403-421) reads the stored id and asks GET /api/races/:id:

  • status countdown or racing: return the id, and the listener does context.go(AppRoutes.race(id));
  • network error: return null but keep the id, so nothing happens this launch;
  • any other error, or the race is finished or aborted: clear the id and return null.

Back on the race screen, a fresh RaceNotifier joins the channel, sees status racing, and because build passes schedule: true, calls _ensureTracking() at once (app/lib/features/race/race_notifier.dart:253-261). Then:

  • Service died with the app. A new service starts. onStart reopens the same sembast file, and the first drain uploads whatever was pending from before the kill, oldest first. The time between kill and relaunch is not recorded. The server’s distance rule is a haversine from the last stored position (backend/lib/racing/race_process.ex:402-413), so the first new fix is joined to the last old one by a straight line.
  • Service survived. startBackgroundTracking returns early when a service is already running (app/lib/tracking/location_service.dart:327). The old service carries on with its saved target, which is the same race, and ForegroundRaceTracker.start re-registers the message callback (app/lib/tracking/race_tracker.dart:47-50), so batches flow to the new notifier.

Local distance in the new notifier starts at zero and snaps to the server’s distance on the first snapshot, so “distance from before the kill is not lost” holds for whatever had been uploaded or queued.

Gap: a settled race opens on the Race tab. If the race finished while the app was dead, pendingRaceProvider clears the id and returns null. The app stays on the Race tab. Step 4 of the use case expects the race’s result screen instead, and its failure mode “Opens on the Race tab” names exactly this. Nothing navigates to /race/:id/result here.

Gap: an orphaned service. Only RaceNotifier._finish and abort stop the tracker (a search for raceTrackerProvider and stopBackgroundTracking in lib/ finds no other caller outside lib/dev/). If the service survived the kill and the race is over by the time the app comes back, no RaceNotifier is created, so nothing stops the service. It keeps sampling GPS; its uploader gets 409 and stops (app/lib/tracking/upload/position_uploader.dart:177-178); the notification stays. The next race then hits the early return on line 327 of location_service.dart, so its upload target is never written and its fixes are not uploaded. This is the outcome the use case calls the worst one. It depends on the service surviving the swipe, which is the unverified part.

Backend. I found no inactivity timeout in Racing.RaceProcess; the only scheduled message is the start (backend/lib/racing/race_process.ex:325-327). A runner whose phone stops sending stays racing until they abort or the opponent’s actions settle the race.

Use case: footrace-flutter/docs/use-cases/permissions-denied.md.

Before a race. The Matchmaking page covers the gate. Each refusal maps to a PermissionStep with its own copy and its own action (app/lib/tracking/permission_gate.dart:7-28, app/lib/features/permissions/permissions_screen.dart:123-172):

Situation Step Action button
Location off device-wide locationServicesOff “Open location settings”
Denied once, or never asked needsLocation “Allow location”
“While using the app” only needsAlwaysLocation “Allow all the time”
Denied for good deniedForever “Open app settings”
Notifications refused (Android 13+) needsNotification “Allow notifications”
Battery optimisation on suggestBatteryExemption “Exempt Footrace”, or “Skip”

“Not now” pops the sheet with false and _confirm does not push the queue (app/lib/features/race_tab/race_tab.dart:143-146), so nobody is queued for a race that cannot be tracked. The lobby uses the same gate (app/lib/features/lobby/lobby_screen.dart:64-65).

At race start. ForegroundRaceTracker.start asks again on its own: LocationService.requestLocationPermissions throws if location is off or denied (app/lib/tracking/location_service.dart:232-251). _ensureTracking catches that and sets trackingError: 'GPS not available: ...' (app/lib/features/race/race_notifier.dart:313-316), which the countdown shows in red (app/lib/features/race/race_screen.dart:235-241) and the racing view as a banner (lines 312-313). The race carries on without your distance.

Gap: the gate does not re-check by itself. permissionGateProvider is a kept-alive AsyncNotifier (app/lib/tracking/permission_gate.dart:196-197). It is re-evaluated only when the sheet asks for a step, when the sheet is open and the app resumes (app/lib/features/permissions/permissions_screen.dart:51-56), or on skipBatteryExemption. ensureGranted reads the cached value (line 25). So a permission revoked in system settings after an earlier grant in the same app session is not noticed by the gate.

Gap: revoking mid-race. PermissionBanner shows “Location access stopped. Your distance is not being counted.” when the gate says tracking is impossible (app/lib/features/permissions/permissions_screen.dart:191-224), and the racing view includes it (app/lib/features/race/race_screen.dart:311). But nothing re-evaluates the gate during a race, so the banner reflects the state from before the race. In the service isolate, a GPS stream error is only printed (app/lib/tracking/location_service.dart:68-71) and never sent to the UI. Step 9 of the use case, and its failure mode “Silent freeze on revoke”, describe exactly this risk. On Android, revoking a runtime permission in Settings normally kills the app’s process, service included, which turns this into the “app killed” case above; after the restart the gate is evaluated fresh and the banner would show. I have not confirmed either platform on a device.

Use case: footrace-flutter/docs/use-cases/locked-phone-race.md, plus the battery and notification checks in footrace-flutter/docs/qa/device-checklist.md.

This is the case the foreground service exists for, and the design handles it by keeping the whole recording path out of the UI:

  • GPS sampling, the durable queue and the uploads all run in the service isolate (app/lib/tracking/location_service.dart:37-221), which does not depend on the UI being drawn.
  • The notification is rewritten every two seconds with pace, fix count and max gap (lines 152-160), which is how you can see on the lock screen that it is still alive.
  • The plugin is initialised with allowWakeLock: true (line 304), and the Android service type is location (app/android/app/src/main/AndroidManifest.xml:37-40). iOS declares the location background mode (app/ios/Runner/Info.plist:73-77).
  • The permission gate pushes for “Allow all the time” and suggests the battery exemption, both of which Android needs for a long race in a pocket.

While the race screen is showing, WakelockPlus.enable() keeps the screen from turning off on its own (app/lib/features/race/race_screen.dart:47), so a locked phone is a deliberate press of the power button.

When the screen comes back, batches that arrived while locked have already been applied to LocalDistance, and the next upload reply snaps it to the server’s figure. Whether the main isolate keeps receiving batches while the screen is off, or gets them in a burst on unlock (the use case’s “jump on unlock” failure mode), depends on the OS and the plugin; I could not verify it from the code. Either way the uploads, and so the server’s distance, do not wait for the UI.

I found no code path specific to locking that could go wrong. This one needs the device run.

Use case: footrace-flutter/docs/use-cases/abort-mid-race.md.

RaceNotifier.abort() (app/lib/features/race/race_notifier.dart:347-365) cancels the start timers, then tries one of two paths: push abort on the race channel if a channel object exists, else POST /api/races/:id/abort. Whatever happens it then sets phase: aborted, outcome: aborted, and stops tracking, which stops the service and clears ActiveRaceStore. The screen moves to the result as soon as isOver is true. On the server both paths reach Racing.abort_race/1, which marks you aborted and gives the opponent a win by forfeit if they are the last one running (backend/lib/racing/race_process.ex:380-396, 470-490). The opponent’s app gets competitorAborted and shows “Your opponent left the race.” (app/lib/features/race/race_screen.dart:314-318).

Differs from the use case. The on-screen control is “Slide to leave race”, wired straight to abort() with no dialog (app/lib/features/race/race_screen.dart:385-389). The “Leave the race?” dialog with “Keep running” and “Leave” appears only for the system back gesture (lines 70-89, 98-102). The use case’s steps 3 and 4 (“Tap Leave race, then Keep running”) describe the dialog as if it followed a tap; the integration test drags the slider (app/integration_test/full_race_test.dart:119-131).

Gap: no HTTP retry when the channel push fails. The comment says “channel first, HTTP as fallback”, but the fallback is chosen by whether _channel is null, not by whether the push succeeded. If the socket is down and the push times out or errors, the exception is logged (line 360) and the HTTP call is never made. The app shows you as having left; the server still has you racing. The result screen reads server statuses, so it would show “Race in progress” and keep re-fetching every three seconds (app/lib/features/race/result_screen.dart:21-29, app/lib/data/models/race_result.dart:127-140).

Covered from the auth side on Auth. Mid-race, the two isolates see it differently.

Main isolate. Any Dio call that gets a 401 calls sessionExpired(), which clears the token and user and sets signedOut (app/lib/data/api/api_client.dart:45-58, app/lib/features/auth/auth_notifier.dart:74-78). The router sends the race screen to /login, and socketLifecycleProvider disconnects the socket. During a race the main isolate makes few HTTP calls (the channel carries most traffic), so this mostly happens on the next screen.

Service isolate. The uploader holds the token that was copied into saveData when the race started. A 401 on an upload stops it for good (UploadStop.unauthorized, app/lib/tracking/upload/position_uploader.dart:181-182), and RaceNotifier shows “Uploads stopped: unauthorized” (app/lib/features/race/race_notifier.dart:229-232). The uploader’s doc comment says a 401 “stops until the service is restarted with a fresh token” (line 109), but nothing restarts it, and the early return in startBackgroundTracking (app/lib/tracking/location_service.dart:327) would skip a new token anyway unless the service was stopped first.

Gap: signing out does not stop the race. raceProvider is not auto-disposed (app/lib/features/race/race_notifier.dart:397-399), and neither signOut() nor sessionExpired() touches the tracker or ActiveRaceStore. After a mid-race sign-out the notifier lives on without a screen, the service keeps running with its notification, and the stored race id is left for the next sign-in, possibly as a different user.

Tokens last 30 days and the app never refreshes them (POST /api/token/refresh exists on the backend but is unused, see Auth), so a mid-race expiry is rare. A refused socket reconnect is not surfaced at all; see Auth.

PositionUploader.drain() (app/lib/tracking/upload/position_uploader.dart:140-214) handles every non-200 from POST /api/races/:id/positions. The backend’s reasons are in RaceController.put_position/2 (backend/lib/footrace_web/controllers/api/race_controller.ex:50-89).

Status Backend reason Uploader Queue RaceNotifier
network failure none backoff, return fixes stay pending nothing
409 race finished or aborted (backend/lib/footrace_web/controllers/api/race_controller.ex:70-73) stop raceOver (app/lib/tracking/upload/position_uploader.dart:177-178) race’s fixes deleted (app/lib/tracking/location_service.dart:184-186) nothing
403 not a participant (backend/lib/footrace_web/controllers/api/race_controller.ex:65-68) stop forbidden (line 179-180) kept “Uploads stopped: forbidden”
401 Guardian: expired or invalid token stop unauthorized (line 181-182) kept “Uploads stopped: unauthorized”
422 malformed position, or any other {:error, _} (backend/lib/footrace_web/controllers/api/race_controller.ex:75-87) mark the batch uploaded, return (lines 183-191) batch dropped nothing
other (5xx) crash, proxy backoff, return (lines 192-200) kept nothing

All of these are covered by app/test/tracking/upload/position_uploader_test.dart:66-143. RaceNotifier reads only reply and stop from the report (app/lib/features/race/race_notifier.dart:214-232); the error string is never shown.

Fixes sent during the countdown get a 200 and are marked uploaded, but the backend drops them (backend/lib/racing/positions/ingest.ex:143-145). With the 3-second GPS lead that is a handful of fixes before the gun, and dropping them is intended.

Gap: a 409 is not turned into a finish. The race ending normally reaches the app over the channel. If the socket is down when the race ends, the 409 is the only signal the app gets, and _onTrackerMessage ignores a stop of raceOver. The report may carry no reply (it is the last 200 body from the same drain, if any), so the race screen can stay in racing until the socket comes back. Whether a rejoin then delivers the end is up to the package rejoin and RaceChannel.join/3, which refuses a finished race with race_over (backend/lib/footrace_web/channels/race_channel.ex:169-171); that refusal on a rejoin is not surfaced to the notifier.

Gap: 422 is also the backend’s generic error. put_position/2 returns 422 for a malformed batch and also for any unexpected {:error, reason}, logged as “Failed to record positions” (backend/lib/footrace_web/controllers/api/race_controller.ex:80-87). The uploader treats every 422 as permanent and drops the batch, so a transient database error on the server loses those fixes. A 5xx would have been retried.

Gap: the opponent’s win ends your race early. Not a rejection, but it arrives on the same upload reply: a reply whose race status is won makes RaceNotifier finish your race as lost and stop the tracker before you cross the line. See the caution on Race and footrace-flutter/docs/use-cases/bot-race-lose.md.