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.
Summary
Section titled “Summary”| 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 |
Network loss
Section titled “Network loss”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).
App killed mid-race
Section titled “App killed mid-race”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
countdownorracing: return the id, and the listener doescontext.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.
onStartreopens 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.
startBackgroundTrackingreturns 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, andForegroundRaceTracker.startre-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.
Permissions denied
Section titled “Permissions denied”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.
Phone locked
Section titled “Phone locked”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 islocation(app/android/app/src/main/AndroidManifest.xml:37-40). iOS declares thelocationbackground 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.
Abort mid-race
Section titled “Abort mid-race”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).
Session expiry
Section titled “Session expiry”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.
Backend rejects an upload
Section titled “Backend rejects an upload”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.