Skip to content

Queued control ops sent after the sync iteration ends throw "powersync_control: invalid state: No iteration is active" #81

Description

@MikeWhalenIII

Summary

We're hitting SqliteException: SQLite Error 21: 'powersync_control: invalid state: No iteration is active' from inside StreamingSyncImplementation.RustStreamingSyncIteration in a MAUI iOS app. In our case iOS suspended the app for about 15 minutes (which kills the socket), and when the app resumed and reconnected, control invocations that were still queued for the old iteration got forwarded to the Rust core after that iteration had already ended. The core rejects them with the error above.

This looks like the .NET version of powersync-js #943 ("Queued control commands sent after CloseSyncStream"). That one was fixed in the JS core in #929 and released in the Node (v0.19.2), React Native (v1.35.7) and Web (v1.39.0) SDKs, but I don't see an equivalent guard in the dotnet SDK.

Not the same bug as #75 (the updateBuffer thread safety issue), though it surfaces the same way: an unobserved task exception escaping the sync iteration task.

Versions

PowerSync.Common / PowerSync.Maui 0.1.3 (latest on NuGet as of 2026-07-16). .NET MAUI, iOS, self-hosted PowerSync service.

Stack trace

From our crash reporting. We got two identical exceptions about 15ms apart, both as TaskExceptionHolder_UnhandledException:

SqliteException: SQLite Error 21: 'powersync_control: invalid state: No iteration is active'.
  at Microsoft.Data.Sqlite.SqliteException.ThrowExceptionForRC(Int32, sqlite3)
  at Microsoft.Data.Sqlite.SqliteDataReader.NextResult()
  at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReader(CommandBehavior)
  at Microsoft.Data.Sqlite.SqliteCommand.ExecuteReaderAsync(CommandBehavior, CancellationToken)
  at Microsoft.Data.Sqlite.SqliteCommand.ExecuteDbDataReaderAsync(CommandBehavior, CancellationToken)
  at Dapper.SqlMapper.QueryRowAsync<SqliteBucketStorage.ControlResult>(...)
  at PowerSync.Common.Client.Sync.Bucket.SqliteBucketStorage.<>c__DisplayClass28_0.<<Control>b__0>d.MoveNext()
  at PowerSync.Common.MDSQLite.MDSQLiteAdapter.<>c__DisplayClass36_0`1.<<InternalTransaction>b__0>d[String].MoveNext()
  at PowerSync.Common.MDSQLite.MDSQLiteAdapter.RunTransaction(MDSQLiteTransaction ctx, Func`1 action)
  at PowerSync.Common.MDSQLite.MDSQLiteAdapter.InternalTransaction<String>(...)
  at PowerSync.Common.MDSQLite.MDSQLiteAdapter.WriteLock<String>(...)
  at PowerSync.Common.MDSQLite.MDSQLiteAdapter.WriteTransaction<String>(...)
  at PowerSync.Common.Client.Sync.Bucket.SqliteBucketStorage.Control(String op, Object payload)
  at PowerSync.Common.Client.Sync.Stream.StreamingSyncImplementation.<>c__DisplayClass41_0.<<RustStreamingSyncIteration>g__Control|3>d.MoveNext()
  at PowerSync.Common.Client.Sync.Stream.StreamingSyncImplementation.<>c__DisplayClass41_0.<<RustStreamingSyncIteration>b__6>d.MoveNext()

Root cause

Once the core iteration has been stopped, control invocations that were queued while it was live still get drained and sent through powersync_control. There is no active iteration at that point, so the core returns the invalid state error, and since this happens inside the iteration task it comes through as an unobserved exception. Nothing the app can catch.

Reproducing

It's timing dependent, so it doesn't happen on every reconnect. What triggered it for us:

  1. Streaming sync connected and healthy on iOS.
  2. App gets backgrounded and iOS suspends it for a few minutes, killing the socket.
  3. App comes back to the foreground and our resume handler calls PowerSyncDatabase.Connect(...) again.
  4. The old iteration tears down while control ops are still queued, and two SqliteExceptions escape as unobserved task exceptions.

Frequent reconnects make it more likely. We were running short-lived sync JWTs at the time, so the client was re-establishing the stream on every token expiry, and every one of those teardowns is a chance to hit this.

Impact

Nothing crashes and no data is lost; sync recovers on the next iteration. But the app has no way to catch or suppress these, so they show up as unhandled-exception noise in crash telemetry for every session that hits the race.

Suggested fix

Port the fix from powersync-js #929: stop draining queued control invocations once the iteration is closed. Alternatively (or additionally), treat this error as an expected outcome of an aborted iteration instead of letting it escape the task.

Files

  • PowerSync/PowerSync.Common/Client/Sync/Stream/StreamingSyncImplementation.cs: RustStreamingSyncIteration and its local Control function
  • PowerSync/PowerSync.Common/Client/Sync/Bucket/SqliteBucketStorage.cs: Control

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions