Skip to content

Commit 769cc14

Browse files
committed
Handle unexpected close on drift remote
1 parent 9676564 commit 769cc14

7 files changed

Lines changed: 356 additions & 39 deletions

File tree

‎drift/CHANGELOG.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
## 2.35.1-dev
22

3+
- Web: Properly release transactions held by closing tabs.
34
- Web: Fix writes made in transactions or through `RETURNING` statements not being persisted to IndexedDB (#3864).
45

56
## 2.35.0

‎drift/lib/src/remote/server_impl.dart‎

Lines changed: 128 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,8 @@ class ServerImplementation implements DriftServer {
2222
/// Whether this server should close the executor after shutting down.
2323
final bool closeExecutorWhenShutdown;
2424

25-
final Map<int, QueryExecutor> _managedExecutors = {};
25+
final Map<int, _ManagedExecutor> _managedExecutors = {};
26+
2627
int _currentExecutorId = 0;
2728
int _knownSchemaVersion = 0;
2829

@@ -78,7 +79,10 @@ class ServerImplementation implements DriftServer {
7879
comm.notify(ServerInfo(connection.dialect));
7980

8081
_activeChannels.add(comm);
81-
return comm.closed.then((_) => _activeChannels.remove(comm));
82+
return comm.closed.whenComplete(() {
83+
_activeChannels.remove(comm);
84+
return _abandonExecutorsOf(comm);
85+
});
8286
}
8387

8488
@override
@@ -119,11 +123,10 @@ class ServerImplementation implements DriftServer {
119123
return _handleEnsureOpen(comms, payload);
120124
} else if (payload is ExecuteQuery) {
121125
final token = runCancellable(
122-
() => _runQuery(
123-
payload.method,
124-
payload.sql,
125-
payload.args,
126+
() => _useExecutor(
126127
payload.executorId,
128+
(executor) =>
129+
_runQuery(payload.method, payload.sql, payload.args, executor),
127130
),
128131
);
129132
_cancellableOperations[request.id] = token;
@@ -136,7 +139,11 @@ class ServerImplementation implements DriftServer {
136139
_tableUpdateNotifications.add(payload);
137140
dispatchTableUpdateNotification(payload, comms);
138141
} else if (payload is RunNestedExecutorControl) {
139-
return _transactionControl(comms, payload.control, payload.executorId);
142+
return _useExecutor(
143+
payload.executorId,
144+
(ex) =>
145+
_transactionControl(comms, payload.control, payload.executorId, ex),
146+
);
140147
} else if (payload is RequestCancellation) {
141148
_cancellableOperations[payload.originalRequestId]?.cancel();
142149
return null;
@@ -148,23 +155,24 @@ class ServerImplementation implements DriftServer {
148155
Future<ResponsePayload> _handleEnsureOpen(
149156
DriftCommunication comms,
150157
EnsureOpen open,
151-
) async {
152-
final executor = await _loadExecutor(open.executorId);
153-
_knownSchemaVersion = open.schemaVersion;
158+
) {
159+
return _useExecutor(open.executorId, (executor) async {
160+
_knownSchemaVersion = open.schemaVersion;
154161

155-
return PrimitiveResponsePayload.bool(
156-
await executor.ensureOpen(_ServerDbUser(this, comms, open.schemaVersion)),
157-
);
162+
return PrimitiveResponsePayload.bool(
163+
await executor.ensureOpen(
164+
_ServerDbUser(this, comms, open.schemaVersion),
165+
),
166+
);
167+
});
158168
}
159169

160170
Future<ResponsePayload?> _runQuery(
161171
StatementMethod method,
162172
String sql,
163173
List<Object?> args,
164-
int? transactionId,
174+
QueryExecutor executor,
165175
) async {
166-
final executor = await _loadExecutor(transactionId);
167-
168176
// Give cancellations more time to come in
169177
await Future<void>.delayed(Duration.zero);
170178
checkIfCancelled();
@@ -190,35 +198,58 @@ class ServerImplementation implements DriftServer {
190198
BatchedStatements stmts,
191199
int? transactionId,
192200
) async {
193-
final executor = await _loadExecutor(transactionId);
194-
await executor.runBatched(stmts);
201+
await _useExecutor(transactionId, (tx) => tx.runBatched(stmts));
195202
return null;
196203
}
197204

198-
Future<QueryExecutor> _loadExecutor(int? transactionId) async {
199-
await _waitForTurn(transactionId);
200-
return transactionId != null
201-
? _managedExecutors[transactionId]!
202-
: connection;
205+
Future<T> _useExecutor<T>(
206+
int? executorId,
207+
Future<T> Function(QueryExecutor executor) block,
208+
) {
209+
if (executorId != null) {
210+
final managed = _managedExecutors[executorId]!;
211+
if (managed.owner.isClosed) {
212+
throw StateError('Owner closed');
213+
}
214+
215+
final closeGuard = Completer<void>();
216+
managed.closeGuards.add(closeGuard.future);
217+
218+
return _waitForTurn(
219+
executorId,
220+
).then((_) => block(managed.executor)).whenComplete(() {
221+
managed.closeGuards.remove(closeGuard.future);
222+
closeGuard.complete();
223+
});
224+
} else {
225+
return _waitForTurn(null).then((_) => block(connection));
226+
}
203227
}
204228

205-
Future<int> _spawnTransaction(DriftCommunication comm, int? executor) async {
206-
final transaction = (await _loadExecutor(executor)).beginTransaction();
207-
await transaction.ensureOpen(
208-
_ServerDbUser(this, comm, _knownSchemaVersion),
209-
);
210-
return _putExecutor(transaction, beforeCurrent: true);
229+
Future<int> _spawnTransaction(
230+
DriftCommunication comm,
231+
QueryExecutor ex,
232+
) async {
233+
final tx = ex.beginTransaction();
234+
await tx.ensureOpen(_ServerDbUser(this, comm, _knownSchemaVersion));
235+
236+
return _putOwnedExecutor(tx, comm);
211237
}
212238

213-
Future<int> _spawnExclusive(DriftCommunication comm, int? executor) async {
214-
final exclusive = (await _loadExecutor(executor)).beginExclusive();
239+
Future<int> _spawnExclusive(DriftCommunication comm, QueryExecutor ex) async {
240+
final exclusive = ex.beginExclusive();
215241
await exclusive.ensureOpen(_ServerDbUser(this, comm, _knownSchemaVersion));
216-
return _putExecutor(exclusive, beforeCurrent: true);
242+
243+
return _putOwnedExecutor(exclusive, comm);
217244
}
218245

219-
int _putExecutor(QueryExecutor executor, {bool beforeCurrent = false}) {
246+
int _putExecutor(
247+
QueryExecutor executor,
248+
DriftCommunication owner, {
249+
bool beforeCurrent = false,
250+
}) {
220251
final id = _currentExecutorId++;
221-
_managedExecutors[id] = executor;
252+
_managedExecutors[id] = _ManagedExecutor(executor, owner);
222253

223254
if (beforeCurrent && _executorBacklog.isNotEmpty) {
224255
_executorBacklog.insert(0, id);
@@ -229,22 +260,32 @@ class ServerImplementation implements DriftServer {
229260
return id;
230261
}
231262

263+
int _putOwnedExecutor(QueryExecutor executor, DriftCommunication owner) {
264+
final id = _putExecutor(executor, owner, beforeCurrent: true);
265+
if (owner.isClosed) {
266+
// The client went away while this executor was waiting for its turn,
267+
// so nothing will ever commit it or roll it back.
268+
unawaited(_abandonExecutor(id));
269+
}
270+
return id;
271+
}
272+
232273
Future<ResponsePayload?> _transactionControl(
233274
DriftCommunication comm,
234275
NestedExecutorControl action,
235276
int? executorId,
277+
QueryExecutor executor,
236278
) async {
237279
if (action == NestedExecutorControl.beginTransaction) {
238280
return PrimitiveResponsePayload.int(
239-
await _spawnTransaction(comm, executorId),
281+
await _spawnTransaction(comm, executor),
240282
);
241283
} else if (action == NestedExecutorControl.startExclusive) {
242284
return PrimitiveResponsePayload.int(
243-
await _spawnExclusive(comm, executorId),
285+
await _spawnExclusive(comm, executor),
244286
);
245287
}
246288

247-
final executor = await _loadExecutor(executorId);
248289
if (action == NestedExecutorControl.endExclusive) {
249290
await executor.close();
250291
_releaseExecutor(executorId!);
@@ -284,6 +325,39 @@ class ServerImplementation implements DriftServer {
284325
return null;
285326
}
286327

328+
/// Rolls back the transactions and closes the exclusive executors [comm]
329+
/// opened and can no longer finish because it closed.
330+
///
331+
/// A client that disappears mid-transaction, like a browser tab that is
332+
/// closed, otherwise leaves its executor at the head of the backlog.
333+
Future<void> _abandonExecutorsOf(DriftCommunication comm) async {
334+
await Future.wait([
335+
for (final MapEntry(:key, :value) in _managedExecutors.entries)
336+
if (value.owner == comm) _abandonExecutor(key),
337+
]);
338+
}
339+
340+
Future<void> _abandonExecutor(int id) async {
341+
final executor = _managedExecutors[id];
342+
if (executor == null) return;
343+
344+
await _waitForTurn(id);
345+
// Statements that already hold this executor finish first: rolling back
346+
// underneath one would let it run outside its transaction.
347+
while (executor.closeGuards.isNotEmpty) {
348+
await executor.closeGuards.first;
349+
}
350+
351+
try {
352+
await switch (executor.executor) {
353+
final TransactionExecutor tx => tx.rollback(),
354+
final other => other.close(),
355+
};
356+
} finally {
357+
_releaseExecutor(id);
358+
}
359+
}
360+
287361
void _releaseExecutor(int id) {
288362
_managedExecutors.remove(id);
289363
_executorBacklog.remove(id);
@@ -325,6 +399,22 @@ class ServerImplementation implements DriftServer {
325399
}
326400
}
327401

402+
/// A managed executor and the channel that opened it.
403+
///
404+
/// A client can disappear without closing its transactions - a closed browser
405+
/// tab sends no rollback and no close message - so the server has to know who
406+
/// opened an executor to end it when that client is gone. [owner] is null for
407+
/// the executors the server itself puts up, such as the one a `beforeOpen`
408+
/// callback runs on.
409+
final class _ManagedExecutor {
410+
final QueryExecutor executor;
411+
final DriftCommunication owner;
412+
413+
final Set<Future<void>> closeGuards = {};
414+
415+
_ManagedExecutor(this.executor, this.owner);
416+
}
417+
328418
class _ServerDbUser implements QueryExecutorUser {
329419
final ServerImplementation _server;
330420
final DriftCommunication connection;
@@ -338,7 +428,7 @@ class _ServerDbUser implements QueryExecutorUser {
338428
QueryExecutor executor,
339429
OpeningDetails details,
340430
) async {
341-
final id = _server._putExecutor(executor, beforeCurrent: true);
431+
final id = _server._putExecutor(executor, connection, beforeCurrent: true);
342432
try {
343433
await connection.request<void>(RunBeforeOpen(details, id));
344434
} finally {

‎drift/lib/src/web/channel_new.dart‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ extension WebPortToChannel on web.MessagePort {
2828
/// Both "ends" of a JS channel calling [channel] on their part must use the
2929
/// value for [explicitClose].
3030
///
31+
/// When [closeSignal] is set, the returned channel is automatically closed
32+
/// when the future completes.
33+
///
3134
/// When [webNativeSerialization] is enabled, the [StreamChannel] can only be
3235
/// used for drift databases using the `package:drift/remote.dart` protocol
3336
/// and is not suitable for any other message.
@@ -38,6 +41,7 @@ extension WebPortToChannel on web.MessagePort {
3841
bool explicitClose = false,
3942
bool webNativeSerialization = false,
4043
int nativeSerializionVersion = 0,
44+
Future<void>? closeSignal,
4145
}) {
4246
final controller = StreamChannelController<Object?>();
4347
final protocol = WebProtocol(
@@ -56,6 +60,7 @@ extension WebPortToChannel on web.MessagePort {
5660
controller.local.sink.add(message.dartify());
5761
}
5862
}.toJS;
63+
closeSignal?.whenComplete(controller.local.sink.close);
5964

6065
controller.local.stream.listen(
6166
(e) {

‎drift/lib/src/web/wasm_setup.dart‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -259,6 +259,7 @@ final class _ProbeResult implements WasmProbeResult {
259259
enableMigrations: enableMigrations,
260260
protocolVersion: sharedWorker!.version,
261261
newSerialization: sharedWorker.version >= ProtocolVersion.v3,
262+
clientLock: await holdClientLock(),
262263
);
263264

264265
message.sendTo(sharedWorker.send);
@@ -274,6 +275,7 @@ final class _ProbeResult implements WasmProbeResult {
274275
enableMigrations: enableMigrations,
275276
protocolVersion: dedicatedWorker.version,
276277
newSerialization: dedicatedWorker.version >= ProtocolVersion.v3,
278+
clientLock: await holdClientLock(),
277279
);
278280

279281
message.sendTo(dedicatedWorker.send);

‎drift/lib/src/web/wasm_setup/protocol.dart‎

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,13 @@ final class ServeDriftDatabase extends WasmInitializationMessage {
270270
final bool enableMigrations;
271271
final bool newSerialization;
272272

273+
/// The name of a Web Lock held by the client for as long as it lives.
274+
///
275+
/// Workers request this lock, which is granted to them once the client is
276+
/// gone. Sent unconditionally: the field is nullable, so a worker that
277+
/// predates it ignores it and a newer worker simply finds it absent.
278+
final String? clientLock;
279+
273280
ServeDriftDatabase({
274281
required this.sqlite3WasmUri,
275282
required this.port,
@@ -279,6 +286,7 @@ final class ServeDriftDatabase extends WasmInitializationMessage {
279286
required this.protocolVersion,
280287
required this.enableMigrations,
281288
required this.newSerialization,
289+
this.clientLock,
282290
});
283291

284292
factory ServeDriftDatabase.fromJsPayload(JSObject payload) {
@@ -299,6 +307,7 @@ final class ServeDriftDatabase extends WasmInitializationMessage {
299307
? (payload['new_serialization'] as JSBoolean).toDart
300308
: true,
301309
protocolVersion: version,
310+
clientLock: (payload['client_lock'] as JSString?)?.toDart,
302311
);
303312
}
304313

@@ -311,7 +320,8 @@ final class ServeDriftDatabase extends WasmInitializationMessage {
311320
..['database'] = databaseName.toJS
312321
..['initPort'] = initializationPort
313322
..['migrations'] = enableMigrations.toJS
314-
..['new_serialization'] = newSerialization.toJS;
323+
..['new_serialization'] = newSerialization.toJS
324+
..['client_lock'] = clientLock?.toJS;
315325

316326
protocolVersion.writeToJs(object);
317327

0 commit comments

Comments
 (0)