Skip to content

connect() after disconnectAndClear() silently never starts the sync client (native/Android); all subsequent connect/disconnect calls hang too #445

Description

@daimos123

Summary

During an account-switch flow (sign out, then sign in with a different user), we call disconnectAndClear() followed by connect() with a fresh connector.
On one real-device occurrence, connect() never started the sync client: no Starting Rust sync iteration log, no error, no isolate exit — total silence. Every subsequent disconnect() / connect() call (triggered by further auth transitions) also produced no sync activity, consistent with them queueing forever on syncConnectMutex behind the stuck connect().
Only killing and restarting the app recovered. The same flow works fine most of the time — this looks like a race.

Environment

  • powersync 2.2.0 (Flutter, Rust sync client, Sync Streams)
  • Android, real device (Samsung, arm64), debug build
  • Supabase auth/backend, PowerSyncBackendConnector recreated on each connect()
  • Attachment queue (@experimental attachments API) active with 62 synced attachments, periodic sync every 30 s
  • We diffed connection_manager.dart / native_powersync_database.dart between 2.2.0 and 2.3.2: the connect/disconnect/mutex mechanics are
    identical, so we believe 2.3.2 is affected as well.

Timeline (from logcat, single app process)

Context: for ~20 minutes before the incident the device had no DNS (Failed host lookup), with the Rust client retrying every 5 s — the sync recovered right before the account switch (checkpoint applied).

21:32:03  Starting Rust sync iteration
21:32:08  Validated and applied checkpoint          (old account, recovery after DNS outage)
21:32:10  supabase signedOut -> our code calls db.disconnect()
21:32:10  Ending Rust sync iteration. Immediate restart: false
21:32:11  Sync Isolate exit
21:32:30  supabase signedIn (DIFFERENT user id) -> our code calls:
            await db.disconnectAndClear();          // completes: local db cleared
            await db.connect(connector: fresh);     // <- never starts the sync client
21:32:30  Found 0 active attachments               (attachment queue reacting to the cleared table)
—— from here on: NOTHING from the sync client, forever ——
21:45:16  signedOut -> db.disconnect()              // no "Ending ...", no "Isolate exit": hangs silently
21:45:38  signedIn (same user) -> disconnectAndClear() + connect()  // silent too

The attachment queue's periodic timer kept running normally the whole time (so the main isolate event loop was healthy). db.currentStatus stayed
frozen at the state set by disconnectAndClear() (connected: false, connecting: false, hasSynced: false) — that is how our UI-level watchdog
now detects the condition.

What we could rule out

  • disconnectAndClear() completed (the cleared attachments table triggered watch queries; our code after it ran — the new owner id was persisted).
  • The previous sync isolate exited cleanly 19 s before the failing connect() (Sync Isolate exit logged at 21:32:11).
  • No exception surfaced from connect() into our try/catch around the auth-transition handler (or it was swallowed — see next point): nothing
    was reported through our error pipeline either.
  • The app was NOT hot-restarted between those events (single process).

Candidate spots we noticed while reading 2.2.0 sources

We could not pin down the exact mechanism, but while reading connection_manager.dart / native_powersync_database.dart / sync_isolate_protocol.dart we noticed a few places that could produce a silent never-completing connect():

  1. ConnectionManager.connect() holds syncConnectMutex across connectInternal(), which awaits hasInitPort.future. If the freshly spawned isolate never sends init, connect() never returns and every later connect()/disconnect() queues on the mutex forever — matching the observed "everything silent from this point on".
  2. _abortCurrentSync()'s else branch (disconnector.aborted == true) awaits onCompletion but does not reset _abortActiveSync to null; the following assert(_abortActiveSync == null) in connect() would throw in debug builds (an AssertionError escaping through the caller rather than a clean state).
  3. _RemoteMutex.lock() ignores its abortTrigger parameter, so a sync isolate stuck acquiring the remote sync/crud mutex cannot be aborted: openedStreamingSync?.abort() would hang in shutdown(), the isolate never exits, and disconnect() then hangs in waitForShutdown() while holding syncConnectMutex.
  4. Mutex grant/release messages (receiveMessages) and the exit notification (receiveExit) arrive on different ports with no ordering guarantee; handleChildIsolateExit() completing held grants relies on those orderings being benign.

Given the DNS-outage recovery, an abort 2 s after a checkpoint, and an attachment queue writing concurrently, a race between those pieces seems
plausible.

Repro

Not deterministic — the same account-switch flow passed twice under instrumentation the previous day. Happy to run an instrumented build or
provide the full logcat if that helps.

Workaround we ship meanwhile

App-level watchdog: after asking for a reconnection, if the status stream stays frozen with no sign of life (connected/connecting/downloading/
errors all absent) for 20 s while the first sync is pending, we tell the user to restart the app.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions