Skip to content

fix(sync): recover desktop syncing after a session goes stale - #517

Open
plusmobileapps wants to merge 4 commits into
mainfrom
claude/desktop-sync-supabase-issue-21bd61
Open

fix(sync): recover desktop syncing after a session goes stale#517
plusmobileapps wants to merge 4 commits into
mainfrom
claude/desktop-sync-supabase-issue-21bd61

Conversation

@plusmobileapps

Copy link
Copy Markdown
Collaborator

The problem

On desktop (reproduced on macOS, but the mechanism isn't Mac-specific), leaving the app open for a while ends with it silently syncing nothing to Supabase until it's force-quit and reopened.

Four things combine:

  1. Token refresh has no lifecycle backstop outside Android. supabase-kt refreshes the access token with a single in-process delay() job. The "stop auto-refresh on background, restart on foreground" safety net is Android-only (AuthConfig.enableLifecycleCallbacks"Currently only supported on Android"; androidMain/setupPlatform.kt installs the ProcessLifecycleOwner observers, and there is no desktop equivalent). After a sleep, an App Nap, or a Wi-Fi flap, nothing re-arms that timer from outside.
  2. A refresh failure left the app pretending to be signed in, so every repository kept firing PostgREST calls with an expired JWT.
  3. Every one of those failures was swallowed (catch { Logger.e }, catch (_: Exception) {}, .catch {}), so the failure mode was silence.
  4. Even after the SDK recovered on its own, sync never restarted. Sync only ran when AuthState changed to Authenticated. A refresh produces an equal AuthState.Authenticated, and StateFlow drops equal values — so the collector never re-fired. A process restart was the only thing that re-ran the initial sync, which is exactly the workaround that was being used.

The fix

Four commits, one concern each:

  • feat(auth)authenticatedSessions emits on every usable session (initial sign-in and every silent refresh), and refreshSessionIfNeeded() renews an expired/expiring token inline, which also re-arms the SDK's refresh job. RefreshFailure now logs instead of passing silently, and the session collector runs under a SupervisorJob.
  • feat(sync) — the four syncing repositories hang off authenticatedSessions instead of state, so a recovered token retries whatever was stranded; each syncWithRemote revives the session first.
  • feat(desktop)SyncCoordinator reconciles every repository in dependency order, throttled to one automatic run a minute. Desktop drives it from window focus (the strongest hint the machine just woke) plus a 15-minute heartbeat.
  • feat(sync) — a blocked sync now surfaces as a snackbar instead of looking like the app doing nothing.

Testing

  • New: SyncCoordinatorTest (7 cases — ordering, sign-out, session revival, throttle, force).
  • New: recipe repo pushes a recipe stranded by a dead token once a session refresh arrives; grocery repo re-syncs on a refresh with no AuthState change. Both fail without the repository change.
  • ./gradlew jvmTest green across the project.

Not covered by tests: the desktop focus/heartbeat wiring itself and the snackbar — those need the running app. Worth confirming by leaving a desktop build open overnight and checking that syncing resumes on focus without a restart.

🤖 Generated with Claude Code

plusmobileapps and others added 4 commits August 2, 2026 13:30
The desktop app can stay open for days, and outside Android the SDK's
token refresh is a single in-process timer with no lifecycle backstop —
after the machine sleeps it can miss its window, leaving every request
failing on an expired JWT until the app is restarted.

Two additions make that recoverable:

- `authenticatedSessions` emits on every usable session, not just the
  first sign-in. `state` can't carry this: a refresh produces an equal
  `AuthState.Authenticated`, which StateFlow drops, so nothing
  downstream ever learns the token came back.
- `refreshSessionIfNeeded()` refreshes an expired (or nearly expired)
  token inline, which also re-arms the SDK's auto-refresh job.

`RefreshFailure` now logs instead of passing silently, and the session
collector runs under a SupervisorJob so a throw can't freeze the auth
state for the rest of the process' life.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sync only ever ran when `AuthState` *changed* to Authenticated, on a
realtime Postgres event, or on a manual sync tap. A recovered token
produces no state change, so anything stranded while it was dead stayed
unsynced until the process restarted — which is what made the desktop
app look permanently stuck.

The four syncing repositories now hang off `authenticatedSessions`
instead, so the initial sign-in and every later refresh both trigger a
reconcile, and each `syncWithRemote` first calls
`refreshSessionIfNeeded()` so a sync started with a dead token repairs
it rather than failing every call silently.

Grocery keeps a separate `state` collector, since sign-out still has to
tear the realtime subscription down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mobile gets a free recovery path: the OS kills the process and the next
launch reconciles. A desktop window left open for days never gets one,
so a sync that stopped stays stopped.

`SyncCoordinator` reconciles every repository in dependency order,
reviving the session first and throttling to one automatic run a minute
so a burst of focus changes costs nothing. Desktop drives it from window
focus — the strongest hint the machine just woke, which is when the
refresh timer has usually slipped — plus a 15-minute heartbeat for a
window left focused and untouched. The per-screen sync buttons still
bypass the throttle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A sync that can't authenticate looked exactly like a sync that had
nothing to do: repositories log the failure and move on, so the app just
appeared to stop working. Desktop now surfaces `SessionExpired` from an
automatic sync as a snackbar.

Only the outcome the app is sure about is surfaced. Individual push
failures stay as they were — those already show up per item as
NOT_SYNCED, and promoting each one to a snackbar would be noise.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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.

1 participant