Release transactions held by web clients that are gone - #3866
mattcosta5651 wants to merge 2 commits into
Conversation
On the web, every tab talks to one drift server in a worker, and a client transaction is a series of round trips: begin, statements, commit. The server keeps the open transaction at the head of its backlog, so requests from other clients wait for their turn. A tab that is closed mid-transaction never sends the commit, and the server had no way to notice. `MessagePort` has no close event for a context going away, as `WebPortToChannel.channel` documents, and `serve` only removed a closed channel from `_activeChannels` without releasing the executors that channel had opened. The abandoned transaction stayed at the head of the backlog, and every later transaction in every surviving tab waited behind it until all clients were closed. Two halves: - Liveness: clients take a uniquely named Web Lock held for their whole lifetime and send the name in `ServeDriftDatabase.clientLock` (new protocol version v5). The worker requests the same lock, which the browser grants once the client's context is gone, and then closes that client's channel. This reuses the `locks` getter and `AcquireLock` extension already in `wasm_setup/shared.dart`. - Release: the server tracks which channel opened each transaction or exclusive executor. When a channel closes it rolls back and releases what that channel still held, newest first so nested executors end before their parents. An executor granted to a channel that closed while it was waiting for its turn is abandoned the same way, and a request naming a released executor now fails instead of waiting forever. Both new tests in remote_test.dart time out against the current code and pass with this change.
simolus3
left a comment
There was a problem hiding this comment.
I agree with the fix of using navigator locks to detect this. I'm happy to run tests with a real worker (we also do that in CI), but I have a few minor comments first.
- The lock name comes from crypto.randomUUID() rather than the clock plus a random int. - No new protocol version: clientLock is nullable, so it is sent unconditionally and a worker that predates it ignores it. - _managedExecutors holds the executor and the channel that opened it in one _ManagedExecutor, replacing the side map. - _loadExecutor keeps the plain `!`: a closed client cannot send statements, so a released executor is unreachable there. - A closed channel's executors are abandoned concurrently; each still waits for its own turn, which a parent only gets once its children have ended. Also drains statements already in flight against an executor before rolling it back. A handler resolves its executor and then yields, so a rollback that started in between could let that statement run outside the transaction it was issued in -- the assertion that catches a closed executor is stripped in release builds. Covered by a new test.
|
All five points are addressed in the latest push, thanks — One thing I'd like your call on, from the You're right that a closed client can't send statements — but an unawaited statement from a live client can, which is the case the I've left the simple form here since you asked for it. If you'd like the fail-fast guard as well, it's two lines and I'm happy to push it: return !_managedExecutors.containsKey(transactionId) ||
(_executorBacklog.isNotEmpty && _executorBacklog.first == transactionId);Also worth flagging for your worker-based run: the branch now drains statements already in flight against an executor before rolling it back. A handler resolves its executor and then yields, so a rollback starting in between could let that statement run outside its transaction — the assertion that would catch it is stripped in release builds. There's a test for it in 🤖 Generated with Claude Code |
|
Thanks! I have merged this in 9d2330b, also added an integration test. |
The problem
On the web all tabs share one drift server in a worker, and a client transaction is a series of round trips. The server keeps the open transaction at the head of
_executorBacklog, so every other client waits for its turn.A tab closed mid-transaction never sends its commit, and drift cannot currently notice.
MessagePorthas no close event for a context going away —WebPortToChannel.channelsays as much in its doc comment — andServerImplementation.serveonly removes a closed channel from_activeChannels; the executors that channel opened are never released. The abandoned transaction stays at the head of the backlog and every later transaction in every surviving tab waits behind it until all clients are gone.I found this in a Flutter web app whose end-to-end test opens a second tab and closes it. That tab was closed 17ms into a cache transaction, and the first tab then sat in a
wanderagent_cachetransaction for 422 seconds that had not run a single statement, with nothing else open in its own connection. A two-tab reproduction in Chromium hangs on 2.34.2/2.35.0 and completes with this change.The change
Liveness. Clients take a uniquely named Web Lock and hold it for their whole lifetime, sending the name in
ServeDriftDatabase.clientLockbehind a new protocol versionv5. The worker requests the same lock; the browser grants it only once the client's context is destroyed, which is the signal that the client is gone, and the worker then ends that client's channel. This builds on thelocksgetter and theAcquireLockextension already inwasm_setup/shared.dart. Where Web Locks are unavailable, or either side predates v5, behaviour is unchanged.Release.
ServerImplementationrecords which channel opened each transaction or exclusive executor. When a channel closes, it rolls back and releases what that channel still held — newest first, so a nested executor ends before its parent. An executor granted to a channel that closed while waiting for its turn is abandoned the same way, and_loadExecutornow throws for a released executor rather than waiting for a turn that can never come.Tests
Two tests in
remote_test.dart, in the style of the existingnested transactionscase, using a client whose channel stops delivering messages without a rollback or close — what a closed tab does. Both time out against currentdevelopand pass with this change:dart analyzeis clean and the rest ofremote_test.dartstill passes. I have not been able to run the browser suites on this machine, so the worker side has been exercised through an app build rather than drift's own web tests — worth a look from someone who can run those.Happy to adjust anything here, including whether this deserves its own protocol version or should ride along without one.
🤖 Generated with Claude Code