Skip to content

fix(stop-detail): single-flight departures observe on resume - #166

Closed
ai-tiro wants to merge 1 commit into
fix/161-stopdetail-rotationfrom
fix/162-stopdetail-foreground-dup
Closed

fix(stop-detail): single-flight departures observe on resume#166
ai-tiro wants to merge 1 commit into
fix/161-stopdetail-rotationfrom
fix/162-stopdetail-foreground-dup

Conversation

@ai-tiro

@ai-tiro ai-tiro commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator

Closes #162.

Stacked PR. Base is fix/161-stopdetail-rotation (#165), which stacks on fix/160-nearby-permission (#164). Review/merge after #165. The diff against this base is just the startObserving() change + its test.

What I did

  • StopDetailViewModel.startObserving() now cancelAndJoin()s the prior observe job before the new coroutine subscribes to observeDepartures, instead of firing a cooperative cancel() and immediately launching the new collection.
  • The previous Job is captured into a local before observeJob is reassigned, so the new coroutine joins the old job and never self-joins (no deadlock).
  • Added a unit test: a pause→resume cycle leaves exactly one live collector (observedKeys size grows by one per resume, not two) and a single tick is applied once.

Why / what I discovered

The bug (issue #162): foregrounding on a stop-detail screen intermittently fired two departures requests ~66 ms apart. repeatOnLifecycle(RESUMED) calls startObserving() on every Pause→Resume. The old code did observeJob?.cancel() then launched the new collection synchronously. Job.cancel() only requests cooperative cancellation — the previously-paused-but-not-yet-stopped observe coroutine could still be parked inside the suspending fetch and emit/fetch at the same instant the fresh collection subscribed. Small window, hence intermittent (the issue saw it once at 09:06:11.558/.624, and a later cycle fired only once). cancelAndJoin() closes the window deterministically: the old subscription is fully torn down before the new one starts, so exactly one fetch begins per resume.

I confirmed the race is real (not already neutralised by the #161 Loading-retention change): that change only stops the duplicate from flickering the skeleton — it does not stop the duplicate network fetch. So a genuine production fix was warranted, not just hardening.

What I considered / didn't do

  • Guard "don't start if a job is already active for the same key": rejected — refresh() and the legitimate resume-after-real-pause both want a fresh collection, so an "already active → no-op" guard would suppress wanted fetches. cancelAndJoin keeps one-fetch-per-resume while still honouring every deliberate restart.
  • No change to stopObserving() (plain cancel() is correct there — nothing races a teardown) or to the 30s cadence / background-pause / pagination paths.

Testing

  • ./gradlew :feature:stop-detail:compileDebugKotlin :feature:stop-detail:testDebugUnitTest — green, 39 tests, 0 failures. The previous fix's issue 161 tests stay green.
  • New test resume single-flights the observe … issue 162 asserts single-flight at the ViewModel seam.
  • Test gap: a fully faithful repeatOnLifecycle lifecycle-driven instrumentation test (real LifecycleOwner Pause→Resume timing) is heavier than the seam test and not added here; the ViewModel-level single-flight assertion covers the production contract. No one-way-door changes.

🤖 Generated with Claude Code

Bringing the app to the foreground on a stop-detail screen intermittently
fired TWO departures requests ~66 ms apart instead of one (issue #162).

Root cause: startObserving() used a plain observeJob?.cancel() and then
immediately launched the new collection. cancel() only *requests* cooperative
cancellation, so the previous observe coroutine could still be parked inside
the suspending fetch and fire a departures request at the same moment the
fresh collection subscribed — an intermittent race with a small window.

Fix: the new job now cancelAndJoin()s the prior job before subscribing to
observeDepartures, so the old subscription is fully torn down first and exactly
one fetch starts per resume. The prior Job is captured before reassigning
observeJob, so the join never targets the coroutine it runs in (no self-join
deadlock). The genuine first start has no prior job and subscribes immediately;
the 30s polling cadence, background-pause behaviour, and the previous stacked
fix's Loading-retention / idempotent-header contracts are all preserved.

Adds a single-flight unit test asserting a pause->resume cycle leaves exactly
one live collector and applies a single tick once.

Co-Authored-By: ai-tiro <ai-tiro@jfx.ac>
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown

Debug APK: app-debug-6d270f330b44b03f2478c1ede6cf6dd64a1d5993.apk (built from 6d270f3 at 2026-06-18 01:31 UTC)

Requires GitHub login. Artifact expires after 3 days.

itsjfx added a commit that referenced this pull request Jun 21, 2026
chore(mobile): combine stop-detail + nearby fixes for QA (#165 #166 #174 #176)
@ai-tiro

ai-tiro commented Jun 21, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #179, which combined these commits and has merged to master. Closing as superseded.

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.

2 participants