@powersync/common: queued control commands can be sent after CloseSyncStream
Package
@powersync/common@1.52.0 (latest on npm as of 2026-05-05)
Disclosure
This report is distilled from failures reproduced in a staged production environment. Product-specific data, names, and identifiers were redacted and the public repro/draft was edited with AI assistance. The repro and issue text were reviewed and validated by a human before submission.
Summary
AbstractStreamingSyncImplementation.rustSyncIteration() can keep draining already-queued JavaScript-side control commands after the core has emitted CloseSyncStream for the current iteration.
In that state the queued JavaScript event is real, but the core iteration is already closed. Forwarding another command to the same iteration can surface as:
powersync_control: invalid state: No iteration is active
Minimal reproduction
git clone https://github.com/whygee-dev/powersync-common-close-sync-stream-queue-drain-repro.git
cd powersync-common-close-sync-stream-queue-drain-repro
npm install
npm test
Observed output:
control calls: [ 'start', 'line_text', 'update_subscriptions', 'stop' ]
error: powersync_control: invalid state: No iteration is active; command=update_subscriptions
reproduced: queued subscription update processed after CloseSyncStream
The repro uses AbstractStreamingSyncImplementation directly with a minimal adapter and remote:
START asks JS to establish an HTTP sync stream.
- The first stream line is sent to the core as
line_text.
- While handling that line, the app calls
updateSubscriptions(), which injects update_subscriptions into the current control queue.
- The mocked core response to the line is
CloseSyncStream.
- The current queue is still drained, so
update_subscriptions is sent after close and the mocked core throws the same invalid-state error we saw.
Expected behavior
Once CloseSyncStream is received for an iteration, JS should stop forwarding queued commands into that closed iteration.
Queued app-side events can be ignored, deferred to the next iteration, or handled some other way, but they should not be sent to a core iteration that has already been closed.
Actual behavior
CloseSyncStream aborts the stream controller, but the queue-drain loop does not stop immediately after the await control(...) call that handled the close instruction. If entries are already queued on the current controlInvocations iterator, commands such as update_subscriptions, completed_upload, or binary/text line events can still be forwarded to powersync_control(...).
Relevant code shape in AbstractStreamingSyncImplementation.ts:
await control(line.command, line.payload);
and later:
} else if ('CloseSyncStream' in instruction) {
controller.abort();
hideDisconnectOnRestart = instruction.CloseSyncStream.hide_disconnect;
}
Suggested fix direction
Treat CloseSyncStream as terminal for the current queue drain. For example, set an iteration-close flag when handling CloseSyncStream, clear or stop the current controlInvocations, and break the queue loop after any control(...) call that closed or aborted the iteration.
Impact
Applications can legitimately enqueue subscription updates or upload notifications near the end of a sync iteration. Today, those queued events can be forwarded to a closed core iteration and turn a normal stream close/restart into an invalid-state error.
@powersync/common: queued control commands can be sent afterCloseSyncStreamPackage
@powersync/common@1.52.0(lateston npm as of 2026-05-05)Disclosure
This report is distilled from failures reproduced in a staged production environment. Product-specific data, names, and identifiers were redacted and the public repro/draft was edited with AI assistance. The repro and issue text were reviewed and validated by a human before submission.
Summary
AbstractStreamingSyncImplementation.rustSyncIteration()can keep draining already-queued JavaScript-side control commands after the core has emittedCloseSyncStreamfor the current iteration.In that state the queued JavaScript event is real, but the core iteration is already closed. Forwarding another command to the same iteration can surface as:
Minimal reproduction
Observed output:
The repro uses
AbstractStreamingSyncImplementationdirectly with a minimal adapter and remote:STARTasks JS to establish an HTTP sync stream.line_text.updateSubscriptions(), which injectsupdate_subscriptionsinto the current control queue.CloseSyncStream.update_subscriptionsis sent after close and the mocked core throws the same invalid-state error we saw.Expected behavior
Once
CloseSyncStreamis received for an iteration, JS should stop forwarding queued commands into that closed iteration.Queued app-side events can be ignored, deferred to the next iteration, or handled some other way, but they should not be sent to a core iteration that has already been closed.
Actual behavior
CloseSyncStreamaborts the stream controller, but the queue-drain loop does not stop immediately after theawait control(...)call that handled the close instruction. If entries are already queued on the currentcontrolInvocationsiterator, commands such asupdate_subscriptions,completed_upload, or binary/text line events can still be forwarded topowersync_control(...).Relevant code shape in
AbstractStreamingSyncImplementation.ts:and later:
Suggested fix direction
Treat
CloseSyncStreamas terminal for the current queue drain. For example, set an iteration-close flag when handlingCloseSyncStream, clear or stop the currentcontrolInvocations, and break the queue loop after anycontrol(...)call that closed or aborted the iteration.Impact
Applications can legitimately enqueue subscription updates or upload notifications near the end of a sync iteration. Today, those queued events can be forwarded to a closed core iteration and turn a normal stream close/restart into an invalid-state error.