Summary
On native platforms, if PowerSyncBackendConnector.uploadData throws an error
object that cannot be sent across isolates (for example dio's DioException,
which references the request's CancelToken and through it an
_AsyncCompleter), the sync client is permanently and silently wedged:
- the error reply never reaches the sync isolate, so the upload stays "in
flight" forever while holding the crud mutex — uploadData is never called
again for the rest of the process lifetime;
- there is no logging on the sync side (the stalled-queue warning only fires
when uploadData returns without complete()), and SyncStatus.uploadError
is never set;
disconnect() and close() hang forever, because abort() awaits the
pending upload;
- only killing the process recovers.
The docs say "If this call throws an error, it is retried periodically", with
no restriction on the error type, so this is easy to hit: any connector that
uses dio and attaches a CancelToken to its upload request (a natural thing
to do) wedges on the first failed upload while offline — which is exactly
when uploads fail. Repro is 100% for us on powersync 2.3.3 / dio 5.10.0 /
iOS device; the relevant code is unchanged on current main.
Reproduction
import 'dart:async';
import 'package:powersync/powersync.dart';
/// Stand-in for any error holding isolate-unsendable state. Real-world
/// instance: dio's DioException — RequestOptions.cancelToken holds a
/// Completer, and Completer instances cannot cross an isolate boundary.
class UnsendableException implements Exception {
final Completer<void> completer = Completer();
@override
String toString() => 'upload failed';
}
class WedgingConnector extends PowerSyncBackendConnector {
@override
Future<PowerSyncCredentials?> fetchCredentials() async =>
PowerSyncCredentials(endpoint: '<instance url>', token: '<token>');
@override
Future<void> uploadData(PowerSyncDatabase database) async {
final tx = await database.getNextCrudTransaction();
if (tx == null) return;
throw UnsendableException();
}
}
- Open a database on iOS/Android/macOS (the sync-isolate path),
connect(),
and insert a row into any synced table so uploadData runs and throws.
- Observe: no retry after
retryDelay; further local writes never trigger
uploadData; SyncStatus.uploadError stays null; the main isolate gets an
unhandled ArgumentError: Illegal argument in isolate message: object is unsendable … that nothing attributes to PowerSync.
- Call
disconnect() (or close()): it never completes. The log shows
Ending Rust sync iteration but never Sync Isolate exit.
- Kill and restart the app:
ps_crud is intact and uploads normally.
With dio the trigger is simply: go offline, write a row, let the upload POST
fail once, rethrow the DioException as the docs' examples do.
Analysis (powersync 2.3.3 sources; same on main)
lib/src/database/native/native_powersync_database.dart — uploadCrud
messages are handled on the main isolate with
await (payload as PortCompleter).handle(() => connector.uploadData(this)).
lib/src/isolate_completer.dart — PortCompleter.handle catches the
error and calls completeError(error, stacktrace), which does
sendPort.send(PortResult.error(error, stackTrace)) — the raw error
object. For an unsendable error, send throws ArgumentError
synchronously; the throw happens inside the catch block, so it escapes
handle() as an unhandled async error on the main isolate, and no reply
is ever sent to the sync isolate.
lib/src/sync/streaming_sync.dart — _uploadAllCrud awaits
connector.uploadCrud() inside crudMutex.lock(...). With no reply, that
future pends forever, _activeCrudUpload never completes, and the crud
loop never processes another trigger.
streaming_sync.dart — abort() does
await Future.wait([future, if (_activeCrudUpload case final u?) u.future]),
so teardown blocks on the same pending future: disconnect()/close()
hang.
native_powersync_database.dart, _syncIsolate.shutdown() — the one
thing that would release the pending future, results.close() (it
completes pending IsolateResults with a StateError), runs after
await openedStreamingSync?.abort() — i.e. after the await that is hung
on it, so the escape hatch is unreachable.
- Aggravating:
_RemoteMutex.lock in
lib/src/database/native/sync_isolate_protocol.dart accepts an
abortTrigger parameter and ignores it, so the crud mutex acquisition
path has no timeout safety on native either.
Verified experimentally with dio 5.10.0 (Dart 3.x): a DioException whose
RequestOptions carries a CancelToken fails SendPort.send/Isolate.spawn
with Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _AsyncCompleter (path:
_completer in CancelToken <- cancelToken in RequestOptions <- requestOptions in DioException). Without a CancelToken the same
DioException happens to be sendable — which makes the failure look
nondeterministic across apps while being fully deterministic within one.
Suggested fixes
Any one of the first two prevents the permanent wedge; all three seem worth
doing:
- Sanitize at the port boundary. In
PortCompleter.completeError, try
the send and fall back to a stringified error (e.g. a RemoteError with
error.toString() and the stack trace string) when send throws. The
sync isolate then sees a normal upload failure and the documented
retry-periodically behavior is preserved for every error type.
- Make teardown robust. In
_syncIsolate.shutdown(), complete pending
IsolateResults (results.close()) before awaiting
openedStreamingSync.abort() — or bound the _activeCrudUpload wait —
so disconnect()/close() cannot hang on a lost reply.
- Honor
abortTrigger in _RemoteMutex.lock (or drop the parameter on
that path so the API doesn't suggest a safety that isn't there).
Verification of the diagnosis
Mapping every uploadData error to a strings-only exception type on our side
(no other change) fully resolves the issue in the same on-device scenario:
failures now surface as Data upload error warnings with
SyncStatus.uploadError populated, the SDK retries as documented while
offline, and the queue drains by itself within ~0.4 s of the stream
reconnecting. disconnect() completes normally again ("Sync Isolate exit"
returns to the log).
Environment
- powersync: 2.3.3 (also inspected current
main — same code paths)
- dio: 5.10.0 (real-world unsendable error; any unsendable error reproduces)
- Flutter: stable, iOS 18 physical device (native sync isolate path)
- Backend: custom (PowerSync service + REST upload endpoint)
Device log excerpt
[ps-chat] ↑ POST /api/v1/powersync/upload — 1 op(s) ← uploadData runs (offline)
[ps-chat] ↑ upload FAILED (connectionError status=null) ← DioException rethrown
[PowerSync] INFO: Starting Rust sync iteration ← stream retries fine…
… network returns, stream reconnects …
[PowerSync] INFO: Could not apply checkpoint due to local data. Will retry at completed upload or next checkpoint.
… and nothing else, ever: no further uploadData calls, no warnings …
… later, disconnect() requested:
[PowerSync] INFO: Ending Rust sync iteration. Immediate restart: false
(no "Sync Isolate exit"; disconnect() never returns)
Summary
On native platforms, if
PowerSyncBackendConnector.uploadDatathrows an errorobject that cannot be sent across isolates (for example dio's
DioException,which references the request's
CancelTokenand through it an_AsyncCompleter), the sync client is permanently and silently wedged:flight" forever while holding the crud mutex —
uploadDatais never calledagain for the rest of the process lifetime;
when
uploadDatareturns withoutcomplete()), andSyncStatus.uploadErroris never set;
disconnect()andclose()hang forever, becauseabort()awaits thepending upload;
The docs say "If this call throws an error, it is retried periodically", with
no restriction on the error type, so this is easy to hit: any connector that
uses dio and attaches a
CancelTokento its upload request (a natural thingto do) wedges on the first failed upload while offline — which is exactly
when uploads fail. Repro is 100% for us on
powersync 2.3.3/ dio 5.10.0 /iOS device; the relevant code is unchanged on current
main.Reproduction
connect(),and insert a row into any synced table so
uploadDataruns and throws.retryDelay; further local writes never triggeruploadData;SyncStatus.uploadErrorstays null; the main isolate gets anunhandled
ArgumentError: Illegal argument in isolate message: object is unsendable …that nothing attributes to PowerSync.disconnect()(orclose()): it never completes. The log showsEnding Rust sync iterationbut neverSync Isolate exit.ps_crudis intact and uploads normally.With dio the trigger is simply: go offline, write a row, let the upload POST
fail once, rethrow the
DioExceptionas the docs' examples do.Analysis (powersync 2.3.3 sources; same on main)
lib/src/database/native/native_powersync_database.dart—uploadCrudmessages are handled on the main isolate with
await (payload as PortCompleter).handle(() => connector.uploadData(this)).lib/src/isolate_completer.dart—PortCompleter.handlecatches theerror and calls
completeError(error, stacktrace), which doessendPort.send(PortResult.error(error, stackTrace))— the raw errorobject. For an unsendable error,
sendthrowsArgumentErrorsynchronously; the throw happens inside the
catchblock, so it escapeshandle()as an unhandled async error on the main isolate, and no replyis ever sent to the sync isolate.
lib/src/sync/streaming_sync.dart—_uploadAllCrudawaitsconnector.uploadCrud()insidecrudMutex.lock(...). With no reply, thatfuture pends forever,
_activeCrudUploadnever completes, and the crudloop never processes another trigger.
streaming_sync.dart—abort()doesawait Future.wait([future, if (_activeCrudUpload case final u?) u.future]),so teardown blocks on the same pending future:
disconnect()/close()hang.
native_powersync_database.dart,_syncIsolate.shutdown()— the onething that would release the pending future,
results.close()(itcompletes pending
IsolateResults with aStateError), runs afterawait openedStreamingSync?.abort()— i.e. after the await that is hungon it, so the escape hatch is unreachable.
_RemoteMutex.lockinlib/src/database/native/sync_isolate_protocol.dartaccepts anabortTriggerparameter and ignores it, so the crud mutex acquisitionpath has no timeout safety on native either.
Verified experimentally with dio 5.10.0 (Dart 3.x): a
DioExceptionwhoseRequestOptionscarries aCancelTokenfailsSendPort.send/Isolate.spawnwith
Illegal argument in isolate message: object is unsendable - Library:'dart:async' Class: _AsyncCompleter(path:_completer in CancelToken <- cancelToken in RequestOptions <- requestOptions in DioException). Without aCancelTokenthe sameDioExceptionhappens to be sendable — which makes the failure looknondeterministic across apps while being fully deterministic within one.
Suggested fixes
Any one of the first two prevents the permanent wedge; all three seem worth
doing:
PortCompleter.completeError, trythe send and fall back to a stringified error (e.g. a
RemoteErrorwitherror.toString()and the stack trace string) whensendthrows. Thesync isolate then sees a normal upload failure and the documented
retry-periodically behavior is preserved for every error type.
_syncIsolate.shutdown(), complete pendingIsolateResults (results.close()) before awaitingopenedStreamingSync.abort()— or bound the_activeCrudUploadwait —so
disconnect()/close()cannot hang on a lost reply.abortTriggerin_RemoteMutex.lock(or drop the parameter onthat path so the API doesn't suggest a safety that isn't there).
Verification of the diagnosis
Mapping every
uploadDataerror to a strings-only exception type on our side(no other change) fully resolves the issue in the same on-device scenario:
failures now surface as
Data upload errorwarnings withSyncStatus.uploadErrorpopulated, the SDK retries as documented whileoffline, and the queue drains by itself within ~0.4 s of the stream
reconnecting.
disconnect()completes normally again ("Sync Isolate exit"returns to the log).
Environment
main— same code paths)Device log excerpt