From fa1799abc3f428a47631f5d40ce1c30eac55c9fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Fri, 30 Oct 2026 21:20:53 +0100 Subject: [PATCH 1/2] sqflite_watchos: SQL runs on a background isolate Issue #20, item 1, spec 0024. Every call ran on the calling isolate, the UI isolate in an app, so a long query held up a frame and the 2-second lock wait froze the UI. * lib/src/worker.dart runs the handler on one long-lived isolate, as sqflite_common_ffi's databaseFactoryFfi does: the first call starts it, every database shares it, and the databases, transactions and cursors live there. Each call crosses as a message with an id, and every answer comes back on one port. Calls made before the isolate is up keep their order, and a call queued behind a transaction does not hold up the call that ends it. It is adapted from sqflite_common_ffi's isolate_io.dart and method_call.dart and carries their BSD-2-Clause notice. * A SqfliteDatabaseException crosses as data (message, details, result code, transactionClosed) and is built again on the caller's side, so sqflite_common sees the same error. A file error or an ArgumentError is copied as it is, as Isolate.run copies one. An argument that cannot be sent fails its call as a SqfliteDatabaseException. * SqfliteWatchos.libraryPathOverride, a path the isolate opens, replaces libraryOverride, since a DynamicLibrary cannot cross isolates. resetForTesting returns a Future: the isolate closes its databases and ends. sqliteVersion stays synchronous, and registerWith binds nothing until the first call. * The busy test's comment names the likelier cause of the 39 ms wait on Ubuntu: flutter test runs the Dart profiler, which samples threads with SIGPROF on Linux and so ends SQLite's busy-handler sleeps early. On macOS the test also checks that the test isolate ticks while the worker waits. * README, CHANGELOG (0.0.1, unpublished, edited in place) and PORTING_REPORT describe the new model and what the lock wait blocks. Checked: 52 host tests on macOS (39 as before, 13 new). The two tick checks fail on the old code (0 ticks). Mutations that drop transactionClosed, send other errors as text, skip closing the databases, or await each call in turn each fail tests. flutter analyze, dart format, check_words, check_versions, check_test_names, symcheck and check_readme_snippets pass. Not run: CI's Ubuntu job, the Simulator and a physical watch. --- packages/sqflite_watchos/CHANGELOG.md | 23 +- packages/sqflite_watchos/PORTING_REPORT.md | 20 +- packages/sqflite_watchos/README.md | 25 +- .../sqflite_watchos/lib/sqflite_watchos.dart | 71 ++-- packages/sqflite_watchos/lib/src/handler.dart | 13 +- packages/sqflite_watchos/lib/src/sqlite3.dart | 4 +- packages/sqflite_watchos/lib/src/worker.dart | 345 +++++++++++++++++ .../sqflite_watchos/test/host_sqlite.dart | 18 + .../test/sqflite_watchos_test.dart | 115 +++++- .../sqflite_watchos/test/worker_test.dart | 346 ++++++++++++++++++ 10 files changed, 914 insertions(+), 66 deletions(-) create mode 100644 packages/sqflite_watchos/lib/src/worker.dart create mode 100644 packages/sqflite_watchos/test/host_sqlite.dart create mode 100644 packages/sqflite_watchos/test/worker_test.dart diff --git a/packages/sqflite_watchos/CHANGELOG.md b/packages/sqflite_watchos/CHANGELOG.md index d4f6237..b4fdc5e 100644 --- a/packages/sqflite_watchos/CHANGELOG.md +++ b/packages/sqflite_watchos/CHANGELOG.md @@ -1,7 +1,8 @@ ## 0.0.1 -First version, staged: checked on the watchOS 27.0 Simulator, not yet on a -physical watch. +First version, staged: not yet checked on a physical watch. Upstream's +integration test passed on the watchOS 27.0 Simulator before SQL moved to a +background isolate, and has not run since. * sqflite on the watch, on the system SQLite: `watchos/Package.swift` links `libsqlite3`, and Dart calls its C API over dart:ffi. @@ -10,13 +11,27 @@ physical watch. `continueOnError`, transactions with transaction ids, database files and bytes. `lib/src/handler.dart` is adapted from sqflite_common_ffi's `sqflite_ffi_impl.dart` and carries its BSD-2-Clause notice. +* SQL runs on a background isolate, as sqflite_common_ffi's + `databaseFactoryFfi` runs it, so a long query does not hold up a frame + (#20). One isolate, started by the first call and shared by every + database, holds the open databases, their transactions and cursors. A + `SqfliteDatabaseException` crosses back as its message, details, result + code and `transactionClosed`, so sqflite_common sees the same error; a + file error or an `ArgumentError` keeps its type. `lib/src/worker.dart` is + adapted from sqflite_common_ffi's `isolate_io.dart` and `method_call.dart` + and carries its BSD-2-Clause notice. * Arguments bind as on iOS: a `bool` as 1 or 0, a `Uint8List` or plain `List` as a blob. * A connection waits up to 2 seconds for another connection's lock, FMDB's - default on iOS. + default on iOS. The wait blocks the background isolate, not the UI, so two + connections to one file in one app cannot wait each other out. * An in-memory database is never a single instance. * `SqfliteWatchos` extends `SqflitePlatform`; `registerWith` makes its - `databaseFactoryWatchos` sqflite's default on watchOS. + `databaseFactoryWatchos` sqflite's default on watchOS, and binds nothing + until the first call. +* Test hooks: `SqfliteWatchos.libraryPathOverride`, the path of a SQLite + library for the background isolate to open, and `databasesPathOverride`. + `resetForTesting` closes the isolate's databases and stops it. * `sqflite_common: ">=2.5.14 <2.6.0"`: 2.5.14 fixes a hang when a cursor is closed inside a transaction, and the `src/` imports stop below the next minor. diff --git a/packages/sqflite_watchos/PORTING_REPORT.md b/packages/sqflite_watchos/PORTING_REPORT.md index 6052d4a..ff9146f 100644 --- a/packages/sqflite_watchos/PORTING_REPORT.md +++ b/packages/sqflite_watchos/PORTING_REPORT.md @@ -6,8 +6,8 @@ |---|---| | Implementation | ✅ Working (system SQLite over FFI) | | watchOS capability | Full — every method of sqflite's protocol. The deprecated `Sqflite.*` debug helpers call the method channel directly and throw (see the README). Not yet checked on a physical watch | -| Host unit tests (`flutter test`) | ✅ pass on macOS — real SQL against the host's system SQLite (`/usr/lib/libsqlite3.dylib`; `libsqlite3.so.0` on CI's Linux runner), through sqflite_common's `Database` API and the protocol below it | -| Upstream integration test | ✅ passes verbatim on the watchOS 27.0 Simulator (14 tests), against the watch's own `libsqlite3`. Run it with `flutter-watchos test`: it uses `package:test`'s `test`, which `flutter-watchos drive` does not collect, so `drive` reports nothing and waits | +| Host unit tests (`flutter test`) | ✅ pass on macOS — real SQL against the host's system SQLite (`/usr/lib/libsqlite3.dylib`; `libsqlite3.so.0` on CI's Linux runner), through sqflite_common's `Database` API on the background isolate, the protocol below it, and the isolate itself (order, errors, stopping) | +| Upstream integration test | ✅ passed verbatim on the watchOS 27.0 Simulator (14 tests), against the watch's own `libsqlite3`, before SQL moved to a background isolate; not run since. Run it with `flutter-watchos test`: it uses `package:test`'s `test`, which `flutter-watchos drive` does not collect, so `drive` reports nothing and waits | | Internal unified demo | not in the internal test app yet | Marking: ✅ full / passes · ◐ partial — reason given · ○ not applicable (no upstream test) · ✗ unsupported on watchOS. @@ -19,6 +19,7 @@ Generated by `flutter-watchos plugin port` on 2026-10-19. Source: `sqflite_darwin` 2.4.4+1 Protocol handler adapted from: `sqflite_common_ffi` 2.4.4, `lib/src/sqflite_ffi_impl.dart` (BSD-2-Clause, Alexandre Roux Tekartik) +Background isolate adapted from: `sqflite_common_ffi` 2.4.5, `lib/src/isolate_io.dart` and `lib/src/method_call.dart` (BSD-2-Clause, Alexandre Roux Tekartik) Base platform: ios (Objective-C) Output: `./sqflite_watchos` @@ -36,8 +37,8 @@ package keeps none of that native code: Each connection waits up to 2 seconds for another connection's lock, FMDB's default on iOS. On the watch the functions resolve from the process, because `watchos/Package.swift` links the system `libsqlite3`; - host tests open the host's system library instead, so the same code runs - real SQL there. + host tests have the background isolate open the host's system library by + path instead, so the same code runs real SQL there. - `lib/src/handler.dart` answers sqflite's method protocol: the same methods, arguments and results the method channel carries on iOS. It is adapted from sqflite_common_ffi's `lib/src/sqflite_ffi_impl.dart`, the @@ -46,6 +47,17 @@ package keeps none of that native code: the open transaction waits for it), `transactionClosed` when SQLite rolls a transaction back, paged cursors, batches with `continueOnError`, and single-instance databases, never for `:memory:`, as in sqflite_darwin. +- `lib/src/worker.dart` runs the handler on a background isolate, as + sqflite_common_ffi's `databaseFactoryFfi` does, where sqflite_darwin gives + each database a dispatch queue of its own. The first call starts the + isolate, and every database shares it. The handler and what it holds + (databases, transactions, cursors) live there; each call crosses as a + message, and a `SqfliteDatabaseException` crosses back as its message, + details, result code and `transactionClosed`. So the 2-second lock wait + blocks that isolate, not the UI, and two connections to one file cannot + wait each other out (see the README). It is adapted from + sqflite_common_ffi's `lib/src/isolate_io.dart` and `lib/src/method_call.dart`, + and carries its copyright notice. - `watchos/Classes/sqflite_watchos_ffi.m` has two helpers: the Documents directory, and `sqlite3_libversion()`, which also keeps `libsqlite3` linked. - sqflite federates through sqflite_common's default factory. `SqfliteWatchos` diff --git a/packages/sqflite_watchos/README.md b/packages/sqflite_watchos/README.md index bde9f0e..4026504 100644 --- a/packages/sqflite_watchos/README.md +++ b/packages/sqflite_watchos/README.md @@ -49,10 +49,21 @@ await db.insert('lap', {'at': DateTime.now().millisecondsSinceE | `readDatabaseBytes` / `writeDatabaseBytes` | supported | | `DatabaseException` helpers | `isUniqueConstraintError`, `isNoSuchTableError`, `isSyntaxError`, `isOpenFailedError`, `isReadOnlyError`, `isDatabaseClosedError`; `getResultCode()` is SQLite's extended result code | -SQL runs on the isolate that calls it, synchronously inside each `Future`. -On iOS sqflite runs it on a native background thread; on the watch a long -query holds up the frame it runs in, so keep heavy work small or move it to -an isolate of its own. +SQL runs on a background isolate, as `sqflite_common_ffi` runs it on +desktop, so a long query does not hold up a frame. The first call starts +the isolate, and every database shares it: the open databases, their +transactions and their cursors live there, and each call reaches it as a +message, with its arguments and results copied. Errors come back intact, as +a `DatabaseException` with SQLite's message and result code. + +The 2-second wait for another connection's lock blocks that isolate, not +the UI, and calls to every other database wait behind it. Two connections +to one file in the same app cannot wait each other out: the isolate that +would commit the first is the one waiting, so the second waits the full 2 +seconds and fails with `SQLITE_BUSY` (result code 5). Keep one connection +per file, as sqflite's default `singleInstance: true` does. On iOS each +database has a queue of its own, so there the second connection gets the +lock once the first commits. `SqfliteWatchos.sqliteVersion` reports the system SQLite's version. @@ -72,6 +83,6 @@ are accepted and ignored. ## License -BSD-2-Clause, as upstream sqflite's. `lib/src/handler.dart` is adapted from -sqflite_common_ffi and carries its copyright notice. See `LICENSE` for the -full text. +BSD-2-Clause, as upstream sqflite's. `lib/src/handler.dart` and +`lib/src/worker.dart` are adapted from sqflite_common_ffi and carry its +copyright notice. See `LICENSE` for the full text. diff --git a/packages/sqflite_watchos/lib/sqflite_watchos.dart b/packages/sqflite_watchos/lib/sqflite_watchos.dart index f70922e..d5a9913 100644 --- a/packages/sqflite_watchos/lib/sqflite_watchos.dart +++ b/packages/sqflite_watchos/lib/sqflite_watchos.dart @@ -6,12 +6,12 @@ // // Method-channel plugins are not supported on watchOS, so sqflite's method // protocol is answered in Dart (`src/handler.dart`) over the system SQLite's -// C API (`src/sqlite3.dart`). sqflite federates through sqflite_common's -// default database factory: `registerWith` sets it, as sqflite_darwin, which -// also extends SqflitePlatform, does with its method-channel factory. +// C API (`src/sqlite3.dart`), on a background isolate (`src/worker.dart`). +// sqflite federates through sqflite_common's default database factory: +// `registerWith` sets it, as sqflite_darwin, which also extends +// SqflitePlatform, does with its method-channel factory. import 'dart:ffi'; -import 'dart:io'; import 'package:ffi/ffi.dart'; // ignore: implementation_imports @@ -20,34 +20,36 @@ import 'package:sqflite_common/src/mixin/factory.dart' import 'package:sqflite_common/sqflite.dart'; import 'package:sqflite_platform_interface/sqflite_platform_interface.dart'; -import 'src/handler.dart'; -import 'src/sqlite3.dart'; +import 'src/worker.dart'; /// watchOS implementation of `sqflite`. class SqfliteWatchos extends SqflitePlatform { - /// Test hook: the library SQLite's C API resolves from, instead of the - /// process. Host tests pass the host's system SQLite. - static DynamicLibrary? libraryOverride; + /// Test hook: the path of the SQLite library the background isolate + /// opens, instead of resolving SQLite's C API from the process. Host tests + /// pass the host's system SQLite. It is a path because a [DynamicLibrary] + /// cannot be sent to another isolate. + static String? libraryPathOverride; /// Test hook: where relative database paths live, instead of the app's /// Documents directory. static String? databasesPathOverride; + static SqfliteWatchosWorker? _worker; static DatabaseFactory? _factory; /// The database factory that answers sqflite's calls on the watch, as /// [SqflitePlatform.databaseFactoryMethodChannel] answers them on iOS. + /// + /// Its calls run on a background isolate, which the first call starts and + /// every database the factory opens shares. static DatabaseFactory get databaseFactoryWatchos => _factory ??= () { - final DynamicLibrary library = - libraryOverride ?? DynamicLibrary.process(); - final SqfliteWatchosHandler handler = SqfliteWatchosHandler( - Sqlite3Library(library), - databasesPath: databasesPathOverride ?? _databasesPath(library), - platformVersion: 'watchOS ${Platform.operatingSystemVersion}', + final SqfliteWatchosWorker worker = _worker = SqfliteWatchosWorker( + libraryPath: libraryPathOverride, + databasesPath: databasesPathOverride, ); return buildDatabaseFactory( tag: 'watchos', - invokeMethod: handler.handle, + invokeMethod: worker.invoke, ); }(); @@ -58,20 +60,27 @@ class SqfliteWatchos extends SqflitePlatform { } /// Forgets the factory so the next use builds a new one, for tests that - /// change [libraryOverride] or [databasesPathOverride]. - static void resetForTesting() => _factory = null; - - static String _databasesPath(DynamicLibrary library) => library - .lookupFunction Function(), Pointer Function()>( - 'sqflite_watchos_databases_path')() - .toDartString(); + /// change [libraryPathOverride] or [databasesPathOverride]. + /// + /// The old factory's isolate answers the calls already made, closes every + /// database it has open and ends. The future completes once it has ended. + /// Calls on the old factory then fail. + static Future resetForTesting() { + final SqfliteWatchosWorker? worker = _worker; + _worker = null; + _factory = null; + return worker?.stop() ?? Future.value(); + } - /// The version of the SQLite the watch runs, for example `3.46.1`. - static String get sqliteVersion => - (libraryOverride ?? DynamicLibrary.process()) - .lookupFunction Function(), Pointer Function()>( - libraryOverride == null - ? 'sqflite_watchos_sqlite_version' - : 'sqlite3_libversion')() - .toDartString(); + /// The version of the SQLite the watch runs, for example `3.46.1`. It is + /// read on the calling isolate. + static String get sqliteVersion { + final String? path = libraryPathOverride; + return (path == null ? DynamicLibrary.process() : DynamicLibrary.open(path)) + .lookupFunction Function(), Pointer Function()>( + path == null + ? 'sqflite_watchos_sqlite_version' + : 'sqlite3_libversion')() + .toDartString(); + } } diff --git a/packages/sqflite_watchos/lib/src/handler.dart b/packages/sqflite_watchos/lib/src/handler.dart index 46cc4e8..b3007fe 100644 --- a/packages/sqflite_watchos/lib/src/handler.dart +++ b/packages/sqflite_watchos/lib/src/handler.dart @@ -14,8 +14,9 @@ // // sqflite_common drives every platform through one call, // `invokeMethod(method, arguments)`: on iOS and Android it crosses a method -// channel, here it lands in [SqfliteWatchosHandler.handle]. The methods and -// the shape of their arguments and results are sqflite's own. +// channel, here it crosses to a background isolate (worker.dart) and lands +// in [SqfliteWatchosHandler.handle]. The methods and the shape of their +// arguments and results are sqflite's own. import 'dart:async'; import 'dart:io'; @@ -273,6 +274,14 @@ class SqfliteWatchosHandler { db.close(); } + /// Closes every open database, as `closeDatabase` closes one: what is + /// queued on each fails. The background isolate calls it before it ends. + void closeAll() { + for (final _Database db in _databases.values.toList()) { + _forget(db); + } + } + void _delete(String path) { final _Database? open = _singleInstances[path]; if (open != null) { diff --git a/packages/sqflite_watchos/lib/src/sqlite3.dart b/packages/sqflite_watchos/lib/src/sqlite3.dart index beea6ff..fa753d4 100644 --- a/packages/sqflite_watchos/lib/src/sqlite3.dart +++ b/packages/sqflite_watchos/lib/src/sqlite3.dart @@ -5,8 +5,8 @@ // The part of SQLite's C API this package uses, bound over dart:ffi. // // On the watch these resolve from the process: watchOS ships libsqlite3 and -// the CLI links it. Host tests pass the macOS system library instead, so the -// same code runs real SQL there. +// the CLI links it. Host tests open the host's system library instead, so +// the same code runs real SQL there. import 'dart:convert'; import 'dart:ffi'; diff --git a/packages/sqflite_watchos/lib/src/worker.dart b/packages/sqflite_watchos/lib/src/worker.dart new file mode 100644 index 0000000..a5dc7d8 --- /dev/null +++ b/packages/sqflite_watchos/lib/src/worker.dart @@ -0,0 +1,345 @@ +// Copyright (c) 2019, Alexandre Roux Tekartik. All rights reserved. +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by the BSD 2-Clause license that can +// be found in the LICENSE file. +// +// The background isolate that runs sqflite's method protocol, and the +// calling isolate's side of it. +// +// Adapted from sqflite_common_ffi's lib/src/isolate_io.dart and +// lib/src/method_call.dart, the model its databaseFactoryFfi runs on: one +// long-lived isolate, started by the first call and shared by every +// database, whose first message is the port it listens on; each call +// answered without waiting for the one before it, so that a call queued +// behind a transaction does not hold up the call that ends it; and errors +// that cross as data and are built again on the caller's side. Here every +// answer comes back on one port, matched by an id, an error that is not +// sqflite's is copied as it is, as Isolate.run copies one, and a stop +// closes every database before the isolate ends. +// +// A DynamicLibrary cannot be sent to another isolate, so the isolate binds +// SQLite itself: from the process on the watch, or from a path in host +// tests. + +import 'dart:async'; +import 'dart:ffi'; +import 'dart:io'; +import 'dart:isolate'; + +import 'package:ffi/ffi.dart'; +// ignore: implementation_imports +import 'package:sqflite_common/src/exception.dart' + show SqfliteDatabaseException; + +import 'handler.dart'; +import 'sqlite3.dart'; + +/// Runs sqflite's method calls on a background isolate of its own. +class SqfliteWatchosWorker { + /// A worker whose isolate binds SQLite from the library at [libraryPath], + /// or from the process when it is null, and keeps relative paths under + /// [databasesPath], or under the app's Documents directory when it is + /// null. The isolate starts on the first call. + SqfliteWatchosWorker({this.libraryPath, this.databasesPath}); + + /// The SQLite library the isolate opens, or null for the process's. + final String? libraryPath; + + /// Where relative database paths live, or null for the app's Documents + /// directory. + final String? databasesPath; + + ReceivePort? _answers; + SendPort? _calls; + + /// What was posted before the isolate sent its port, in order. + final List _unsent = []; + + final Map> _pending = >{}; + int _lastId = 0; + + /// Why every call fails from now on: set once the isolate is stopping or + /// has ended. + String? _closed; + + /// An error the isolate did not catch, which it reports before it ends. + String? _uncaught; + + final Completer _ended = Completer(); + + /// Runs [method] with [arguments] on the isolate, starting it on the first + /// call, and completes with what the handler answered. + Future invoke(String method, [Object? arguments]) { + final String? closed = _closed; + if (closed != null) { + return Future.error(_closedError(closed)); + } + final int id = ++_lastId; + final Completer completer = Completer(); + _pending[id] = completer; + _post(_Call(id, method, arguments)); + return completer.future; + } + + /// Stops the isolate once it has answered the calls already made. It + /// closes every database it has open first, and what is queued behind a + /// transaction fails. The future completes when the isolate has ended. + /// Calls made after this one fail. + Future stop() { + if (_closed == null) { + _closed = 'the sqflite_watchos isolate was stopped'; + if (_answers == null) { + // It never started. + _ended.complete(); + } else { + _post(const _Stop()); + } + } + return _ended.future; + } + + void _post(Object message) { + final SendPort? calls = _calls; + if (calls == null) { + _unsent.add(message); + _start(); + } else { + _send(calls, message); + } + } + + void _start() { + if (_answers != null) { + return; + } + final ReceivePort answers = + _answers = ReceivePort('sqflite_watchos answers'); + answers.listen(_receive); + Isolate.spawn<_Start>( + _main, + _Start(answers.sendPort, libraryPath, databasesPath), + onExit: answers.sendPort, + onError: answers.sendPort, + debugName: 'sqflite_watchos', + ).then((Isolate _) {}, onError: (Object error) { + _closed ??= 'the sqflite_watchos isolate did not start: $error'; + _end(); + }); + } + + void _send(SendPort calls, Object message) { + try { + calls.send(message); + } catch (error, stack) { + // An argument the VM cannot copy to another isolate. Its call fails as + // a call with an argument the handler cannot bind does. + if (message is _Call) { + _pending + .remove(message.id) + ?.completeError(_unsendable(error, message.arguments), stack); + } + } + } + + void _receive(Object? message) { + switch (message) { + case final SendPort calls: + _calls = calls; + for (final Object unsent in _unsent) { + _send(calls, unsent); + } + _unsent.clear(); + case final _Answer answer: + final Completer? completer = _pending.remove(answer.id); + final Object? error = answer.error; + if (completer == null) { + return; + } + if (error == null) { + completer.complete(answer.result); + } else { + completer.completeError( + error is _SqfliteError ? error.exception() : error, + answer.stack, + ); + } + case [final Object? error, _]: + // What Isolate.spawn's onError sends: the error's text and its + // stack's. + _uncaught ??= '$error'; + case null: + // What Isolate.spawn's onExit sends, after every answer. + _end(); + } + } + + /// Fails every call the isolate did not answer, once it has ended. + void _end() { + _answers?.close(); + _calls = null; + _unsent.clear(); + final String closed = _closed ??= _uncaught == null + ? 'the sqflite_watchos isolate ended' + : 'the sqflite_watchos isolate ended: $_uncaught'; + final List> waiting = _pending.values.toList(); + _pending.clear(); + for (final Completer completer in waiting) { + completer.completeError(_closedError(closed)); + } + if (!_ended.isCompleted) { + _ended.complete(); + } + } + + /// The error of a call the isolate will not answer. Every database it had + /// open is closed, and isDatabaseClosedError() looks for database_closed. + static SqfliteDatabaseException _closedError(String reason) => + SqfliteDatabaseException('database_closed: $reason', null); + + /// The error of a call whose arguments cannot be sent, with the details + /// the handler gives a call it cannot bind. + static SqfliteDatabaseException _unsendable(Object error, Object? arguments) { + final Map details = { + if (arguments is Map && arguments['sql'] != null) + 'sql': arguments['sql'], + if (arguments is Map && arguments['arguments'] != null) + 'arguments': arguments['arguments'], + }; + return SqfliteDatabaseException('$error', details.isEmpty ? null : details); + } +} + +/// What the isolate starts from. +final class _Start { + const _Start(this.answers, this.libraryPath, this.databasesPath); + + final SendPort answers; + final String? libraryPath; + final String? databasesPath; +} + +/// One method call, as the isolate receives it. +final class _Call { + const _Call(this.id, this.method, this.arguments); + + final int id; + final String method; + final Object? arguments; +} + +/// Asks the isolate to close its databases and end. +final class _Stop { + const _Stop(); +} + +/// What a call answered: its result, or the error it failed with. +final class _Answer { + _Answer.result(this.id, this.result) + : error = null, + stack = null; + + /// A SqfliteDatabaseException crosses as data. Any other error crosses as + /// it is: isolates of one group copy it with its type. + _Answer.error(this.id, Object thrown, StackTrace this.stack) + : result = null, + error = + thrown is SqfliteDatabaseException ? _SqfliteError(thrown) : thrown; + + final int id; + final Object? result; + final Object? error; + final StackTrace? stack; +} + +/// A SqfliteDatabaseException as data: the fields sqflite_common reads. +final class _SqfliteError { + _SqfliteError(SqfliteDatabaseException e) + : message = e.message, + details = e.result, + resultCode = e.getResultCode(), + transactionClosed = e.transactionClosed; + + final String? message; + final Object? details; + final int? resultCode; + final bool transactionClosed; + + /// The exception again, on the caller's side. + SqfliteDatabaseException exception() => SqfliteDatabaseException( + message, + details, + resultCode: resultCode, + transactionClosed: transactionClosed, + ); +} + +/// The handler over the SQLite that [start] names, or what kept SQLite from +/// being bound. +({SqfliteWatchosHandler? handler, Object? error, StackTrace? stack}) _bind( + _Start start) { + try { + final String? path = start.libraryPath; + final DynamicLibrary library = + path == null ? DynamicLibrary.process() : DynamicLibrary.open(path); + final SqfliteWatchosHandler handler = SqfliteWatchosHandler( + Sqlite3Library(library), + databasesPath: start.databasesPath ?? _documentsPath(library), + platformVersion: 'watchOS ${Platform.operatingSystemVersion}', + ); + return (handler: handler, error: null, stack: null); + } catch (error, stack) { + return (handler: null, error: error, stack: stack); + } +} + +String _documentsPath(DynamicLibrary library) => library + .lookupFunction Function(), Pointer Function()>( + 'sqflite_watchos_databases_path')() + .toDartString(); + +/// The isolate: binds SQLite, sends the port it listens on, then answers +/// calls until it is stopped. +void _main(_Start start) { + final ( + :SqfliteWatchosHandler? handler, + :Object? error, + :StackTrace? stack, + ) = _bind(start); + final ReceivePort calls = ReceivePort('sqflite_watchos calls'); + calls.listen((Object? message) { + if (message is! _Call) { + // The stop, which comes after every call made before it. + handler?.closeAll(); + calls.close(); + return; + } + // A SQLite that could not be bound fails every call, as it once failed + // the factory. + final Future result = handler == null + ? Future.error(error!, stack) + : handler.handle(message.method, message.arguments); + // Not awaited: a call queued behind a transaction waits for later calls. + unawaited(result.then( + (Object? value) => + _answer(start.answers, _Answer.result(message.id, value)), + onError: (Object thrown, StackTrace trace) => + _answer(start.answers, _Answer.error(message.id, thrown, trace)), + )); + }); + start.answers.send(calls.sendPort); +} + +/// Sends [answer]. An error the VM cannot copy goes as a RemoteError with its +/// text, as Isolate.run sends one. +void _answer(SendPort answers, _Answer answer) { + try { + answers.send(answer); + } catch (sendError) { + final Object? error = answer.error; + final RemoteError remote = RemoteError( + error is _SqfliteError ? '${error.exception()}' : '${error ?? sendError}', + '${answer.stack ?? ''}', + ); + answers.send(_Answer.error(answer.id, remote, remote.stackTrace)); + } +} diff --git a/packages/sqflite_watchos/test/host_sqlite.dart b/packages/sqflite_watchos/test/host_sqlite.dart new file mode 100644 index 0000000..5117492 --- /dev/null +++ b/packages/sqflite_watchos/test/host_sqlite.dart @@ -0,0 +1,18 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// The host's system SQLite, which the host tests run real SQL on. The +// background isolate opens it by path, since a DynamicLibrary cannot be sent +// to another isolate; tests of the handler itself open it here. + +import 'dart:ffi'; +import 'dart:io'; + +/// The host's system SQLite: `/usr/lib` on macOS, the shared library's +/// soname on Linux. +String get hostSqlitePath => + Platform.isMacOS ? '/usr/lib/libsqlite3.dylib' : 'libsqlite3.so.0'; + +/// The host's system SQLite, opened on the calling isolate. +DynamicLibrary hostSqlite() => DynamicLibrary.open(hostSqlitePath); diff --git a/packages/sqflite_watchos/test/sqflite_watchos_test.dart b/packages/sqflite_watchos/test/sqflite_watchos_test.dart index a3b200e..27c0b83 100644 --- a/packages/sqflite_watchos/test/sqflite_watchos_test.dart +++ b/packages/sqflite_watchos/test/sqflite_watchos_test.dart @@ -5,11 +5,10 @@ // Host-side tests that run real SQL. The handler binds SQLite's C API, which // macOS and CI's Linux runner ship too, so these point it at the host's // system libsqlite3 and drive it through sqflite_common's own Database API, -// the way an app does. The watch resolves the same functions from its -// process instead. +// the way an app does, on the background isolate an app's calls run on. The +// watch resolves the same functions from its process instead. import 'dart:async'; -import 'dart:ffi'; import 'dart:io'; import 'dart:typed_data'; @@ -27,25 +26,26 @@ import 'package:sqflite_watchos/sqflite_watchos.dart'; import 'package:sqflite_watchos/src/handler.dart'; import 'package:sqflite_watchos/src/sqlite3.dart'; -/// The host's system SQLite: `/usr/lib` on macOS, the shared library's -/// soname on Linux. -DynamicLibrary hostSqlite() => Platform.isMacOS - ? DynamicLibrary.open('/usr/lib/libsqlite3.dylib') - : DynamicLibrary.open('libsqlite3.so.0'); +import 'host_sqlite.dart'; void main() { late Directory dir; late DatabaseFactory factory; - setUpAll(() { + setUpAll(() async { dir = Directory.systemTemp.createTempSync('sqflite_watchos_test'); - SqfliteWatchos.libraryOverride = hostSqlite(); + SqfliteWatchos.libraryPathOverride = hostSqlitePath; SqfliteWatchos.databasesPathOverride = dir.path; - SqfliteWatchos.resetForTesting(); + await SqfliteWatchos.resetForTesting(); factory = SqfliteWatchos.databaseFactoryWatchos; }); - tearDownAll(() => dir.deleteSync(recursive: true)); + tearDownAll(() async { + // Closes what is still open before the files go. A stop that never + // ends fails here instead of hanging the run. + await SqfliteWatchos.resetForTesting().timeout(const Duration(seconds: 10)); + dir.deleteSync(recursive: true); + }); Future openFresh(String name, {OpenDatabaseOptions? options}) async { @@ -157,18 +157,30 @@ void main() { {'timeout': 2000}, ]); await first.execute('BEGIN IMMEDIATE'); + int ticks = 0; + final Timer ticking = + Timer.periodic(const Duration(milliseconds: 10), (_) => ticks++); final Stopwatch waited = Stopwatch()..start(); await expectLater( second.insert('t', {'x': 1}), throwsA(isA().having( (DatabaseException e) => e.getResultCode(), 'SQLITE_BUSY', 5)), ); - // Apple's SQLite, which the watch runs, waits out the timeout first. The - // libsqlite3 of CI's Ubuntu runner failed the same insert after 39 ms, - // without waiting, so the wait is only checked on macOS; the timeout - // itself is checked above on both. + ticking.cancel(); + // The wait blocks the background isolate, so this one keeps ticking. + // Apple's SQLite, which the watch runs, waits out the timeout first. On + // CI's Ubuntu runner the same insert failed after 39 ms, most likely + // because of `flutter test` rather than Ubuntu's SQLite: its Dart + // profiler samples each thread about once a millisecond, on Linux by + // sending it SIGPROF, and each signal ends one of SQLite's busy-handler + // sleeps early, while SQLite counts the time it asked to sleep. macOS + // samples threads without a signal, and the watch sends none either. So + // the wait is only checked on macOS; the timeout itself is checked above + // on both. if (Platform.isMacOS) { expect(waited.elapsedMilliseconds, greaterThanOrEqualTo(1500)); + expect(ticks, greaterThanOrEqualTo(50), + reason: 'this isolate ran while the background one waited'); } await first.execute('ROLLBACK'); await second.close(); @@ -371,6 +383,28 @@ void main() { await db.close(); }); + test('a transaction SQLite rolled back fails with its own error', () async { + final Database db = await openFresh('rolled_back.db'); + await db.execute('CREATE TABLE t (x UNIQUE ON CONFLICT ROLLBACK)'); + // sqflite_common sends no ROLLBACK of its own once the error says the + // transaction is closed. Sent anyway, it would fail with "no + // transaction is active" and replace the unique-constraint error. + await expectLater( + db.transaction((Transaction txn) async { + await txn.insert('t', {'x': 1}); + await txn.insert('t', {'x': 1}); + }), + throwsA(isA().having( + (DatabaseException e) => e.isUniqueConstraintError(), + 'unique', + true)), + ); + expect(await db.query('t'), isEmpty); + await db.insert('t', {'x': 2}); + expect(await db.query('t'), hasLength(1)); + await db.close(); + }); + test('a call outside a running transaction waits for it', () async { final Database db = await openFresh('queue.db'); await db.execute('CREATE TABLE t (x)'); @@ -542,6 +576,55 @@ void main() { }); }); + group('the background isolate', () { + // A recursive count that keeps SQLite busy for a few hundred + // milliseconds: 0.4 s on an M-series Mac. + const String longCount = 'WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL ' + 'SELECT i + 1 FROM n WHERE i < 3000000) SELECT count(*) AS c FROM n'; + + test('a long statement leaves timers on the calling isolate running', + () async { + final Database db = await factory.openDatabase(inMemoryDatabasePath); + bool done = false; + int ticks = 0; + final Timer ticking = + Timer.periodic(const Duration(milliseconds: 5), (_) { + if (!done) { + ticks++; + } + }); + final List> rows = + await db.rawQuery(longCount).whenComplete(() { + done = true; + }); + ticking.cancel(); + expect(rows, >[ + {'c': 3000000}, + ]); + // Run on this isolate, the count would let no timer fire until it + // ended. + expect(ticks, greaterThanOrEqualTo(3)); + await db.close(); + }); + + test('a long statement on one database holds up a call on another', + () async { + // Every database shares the one isolate, which runs a call at a time. + final Database busy = await factory.openDatabase(inMemoryDatabasePath); + final Database other = await factory.openDatabase(inMemoryDatabasePath); + final List order = []; + final Future long = + busy.rawQuery(longCount).then((_) => order.add('long')); + // Posted once the count is running. + await Future.delayed(const Duration(milliseconds: 20)); + await other.rawQuery('SELECT 1').then((_) => order.add('short')); + await long; + expect(order, ['long', 'short']); + await busy.close(); + await other.close(); + }); + }); + test('sqliteVersion reads the library version', () { expect(SqfliteWatchos.sqliteVersion, matches(RegExp(r'^3\.\d+\.\d+'))); }); diff --git a/packages/sqflite_watchos/test/worker_test.dart b/packages/sqflite_watchos/test/worker_test.dart new file mode 100644 index 0000000..c198152 --- /dev/null +++ b/packages/sqflite_watchos/test/worker_test.dart @@ -0,0 +1,346 @@ +// Copyright 2026 The FlutterWatch Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. +// +// Host-side tests of the background isolate that runs the handler: calls +// keep their order, errors arrive as the handler threw them, and a stop +// closes the isolate's databases. Where it matters, the same calls also run +// through a handler on the test isolate, to compare. + +import 'dart:io'; +import 'dart:isolate'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:path/path.dart' as p; +import 'package:sqflite_common/sqflite.dart'; +// ignore: implementation_imports +import 'package:sqflite_common/src/exception.dart' + show SqfliteDatabaseException; +import 'package:sqflite_watchos/sqflite_watchos.dart'; +import 'package:sqflite_watchos/src/handler.dart'; +import 'package:sqflite_watchos/src/sqlite3.dart'; +import 'package:sqflite_watchos/src/worker.dart'; + +import 'host_sqlite.dart'; + +/// One way to make a method call: a worker's, or a handler's. +typedef Invoke = Future Function(String method, [Object? arguments]); + +void main() { + late Directory dir; + late SqfliteWatchosWorker worker; + late SqfliteWatchosHandler local; + + setUp(() { + dir = Directory.systemTemp.createTempSync('sqflite_watchos_worker_test'); + worker = SqfliteWatchosWorker( + libraryPath: hostSqlitePath, + databasesPath: dir.path, + ); + local = SqfliteWatchosHandler( + Sqlite3Library(hostSqlite()), + databasesPath: dir.path, + platformVersion: 'watchOS test', + ); + }); + + tearDown(() async { + // A stop that never ends fails here instead of hanging the run. + await worker.stop().timeout(const Duration(seconds: 10)); + local.closeAll(); + dir.deleteSync(recursive: true); + }); + + /// Opens [path] through [invoke] and returns the database's id. + Future open(Invoke invoke, [String path = inMemoryDatabasePath]) async => + ((await invoke('openDatabase', {'path': path}))! + as Map)['id']! as int; + + /// What [call] failed with. + Future failure(Future Function() call) async { + try { + await call(); + } catch (e) { + return e; + } + throw StateError('the call did not fail'); + } + + test('calls made before the isolate starts run in the order made', () async { + // The first call starts the isolate, and its first database is 1. None + // of these waits for the one before it. + final List> calls = >[ + worker.invoke( + 'openDatabase', {'path': inMemoryDatabasePath}), + worker.invoke( + 'execute', {'id': 1, 'sql': 'CREATE TABLE t (x)'}), + for (int i = 0; i < 20; i++) + worker.invoke('insert', { + 'id': 1, + 'sql': 'INSERT INTO t VALUES (?)', + 'arguments': [i], + }), + worker.invoke( + 'query', {'id': 1, 'sql': 'SELECT x FROM t'}), + ]; + final List results = await Future.wait(calls); + expect(results.first, {'id': 1}); + expect(results.sublist(2, 22), [for (int i = 1; i <= 20; i++) i]); + expect((results.last! as Map)['rows'], >[ + for (int i = 0; i < 20; i++) [i], + ]); + }); + + test('a call queued behind a transaction lets the calls after it run', + () async { + final int id = await open(worker.invoke); + await worker.invoke( + 'execute', {'id': id, 'sql': 'CREATE TABLE t (x)'}); + final Object? begun = await worker.invoke('execute', { + 'id': id, + 'sql': 'BEGIN IMMEDIATE', + 'inTransaction': true, + 'transactionId': null, + }); + final int txn = (begun! as Map)['transactionId']! as int; + // Queued in the isolate until the transaction ends. Were the isolate to + // wait for it, the calls that end the transaction would never run. + final Future outside = worker.invoke('insert', { + 'id': id, + 'sql': 'INSERT INTO t VALUES (?)', + 'arguments': ['outside'], + }); + expect( + await worker.invoke('insert', { + 'id': id, + 'sql': 'INSERT INTO t VALUES (?)', + 'arguments': ['inside'], + 'transactionId': txn, + }), + 1); + await worker.invoke('execute', { + 'id': id, + 'sql': 'COMMIT', + 'inTransaction': false, + 'transactionId': txn, + }); + expect(await outside, 2); + expect( + ((await worker.invoke('query', { + 'id': id, + 'sql': 'SELECT x FROM t' + }))! as Map)['rows'], + >[ + ['inside'], + ['outside'], + ]); + }); + + group('errors', () { + test('a database error arrives with the fields the handler gave it', + () async { + Future uniqueFailure(Invoke invoke) async { + final int id = await open(invoke); + await invoke('execute', { + 'id': id, + 'sql': 'CREATE TABLE t (name TEXT UNIQUE)', + }); + final Map insert = { + 'id': id, + 'sql': 'INSERT INTO t VALUES (?)', + 'arguments': ['a'], + }; + await invoke('insert', insert); + return (await failure(() => invoke('insert', insert))) + as SqfliteDatabaseException; + } + + final SqfliteDatabaseException here = await uniqueFailure(local.handle); + final SqfliteDatabaseException there = await uniqueFailure(worker.invoke); + expect(there.message, here.message); + expect(there.result, here.result); + expect(there.getResultCode(), here.getResultCode()); + expect(there.getResultCode(), 2067); // SQLITE_CONSTRAINT_UNIQUE + expect(there.transactionClosed, isFalse); + expect(there.isUniqueConstraintError('t.name'), isTrue); + expect('$there', '$here'); + }); + + test('transactionClosed arrives true when SQLite rolled back', () async { + Future rolledBack(Invoke invoke) async { + final int id = await open(invoke); + await invoke('execute', { + 'id': id, + 'sql': 'CREATE TABLE t (x UNIQUE ON CONFLICT ROLLBACK)', + }); + final Object? begun = await invoke('execute', { + 'id': id, + 'sql': 'BEGIN', + 'inTransaction': true, + 'transactionId': null, + }); + final Map insert = { + 'id': id, + 'sql': 'INSERT INTO t VALUES (1)', + 'transactionId': (begun! as Map)['transactionId'], + }; + await invoke('insert', insert); + return (await failure(() => invoke('insert', insert))) + as SqfliteDatabaseException; + } + + final SqfliteDatabaseException here = await rolledBack(local.handle); + final SqfliteDatabaseException there = await rolledBack(worker.invoke); + expect(there.transactionClosed, isTrue); + expect(there.transactionClosed, here.transactionClosed); + expect(there.message, here.message); + expect(there.result, here.result); + expect(there.getResultCode(), here.getResultCode()); + }); + + test('a file error and an unknown method keep their types', () async { + final Map missing = { + 'path': 'missing.db', + }; + final Object here = + await failure(() => local.handle('readDatabaseBytes', missing)); + final Object there = + await failure(() => worker.invoke('readDatabaseBytes', missing)); + expect( + there, + isA().having( + (PathNotFoundException e) => e.path, + 'path', + p.join(dir.path, 'missing.db'))); + expect(there.runtimeType, here.runtimeType); + expect('$there', '$here'); + + final Object unknownHere = await failure(() => local.handle('nope')); + final Object unknownThere = await failure(() => worker.invoke('nope')); + expect(unknownThere, isA()); + expect(unknownThere.runtimeType, unknownHere.runtimeType); + expect('$unknownThere', '$unknownHere'); + }); + + test('an argument that cannot be sent fails its call as a database error', + () async { + final ReceivePort port = ReceivePort(); + addTearDown(port.close); + final int id = await open(worker.invoke); + await expectLater( + worker.invoke('query', { + 'id': id, + 'sql': 'SELECT ?', + 'arguments': [port], + }), + throwsA(isA().having( + (SqfliteDatabaseException e) => e.result, + 'details', + containsPair('sql', 'SELECT ?'))), + ); + // The call never left this isolate, and the next one is answered. + expect( + await worker.invoke( + 'query', {'id': id, 'sql': 'SELECT 1 AS one'}), + { + 'columns': ['one'], + 'rows': >[ + [1], + ], + }); + }); + + test('a library that cannot be opened fails every call', () async { + final SqfliteWatchosWorker unbound = SqfliteWatchosWorker( + libraryPath: p.join(dir.path, 'no_sqlite_here'), + databasesPath: dir.path, + ); + addTearDown(unbound.stop); + await expectLater( + unbound.invoke('getDatabasesPath'), throwsA(isA())); + await expectLater( + unbound.invoke( + 'openDatabase', {'path': inMemoryDatabasePath}), + throwsA(isA())); + }); + }); + + group('stopping', () { + test('stop closes every database, then the isolate ends', () async { + final int id = await open(worker.invoke, 'locked.db'); + await worker.invoke( + 'execute', {'id': id, 'sql': 'CREATE TABLE t (x)'}); + await worker.invoke('execute', { + 'id': id, + 'sql': 'BEGIN IMMEDIATE', + 'inTransaction': true, + 'transactionId': null, + }); + // Queued behind the open transaction, so the stop fails it. + final Future queued = expectLater( + worker.invoke( + 'query', {'id': id, 'sql': 'SELECT x FROM t'}), + throwsA(isA())); + // stop() completes on the isolate's exit message. + await worker.stop(); + await queued; + // The write lock went with the connection: one opened here writes at + // once, where it would otherwise fail with SQLITE_BUSY. + final int other = await open(local.handle, 'locked.db'); + expect( + await local.handle('insert', { + 'id': other, + 'sql': 'INSERT INTO t VALUES (1)', + }), + 1); + // Later calls fail as closed, and none of them hangs. + await expectLater( + worker.invoke('getDatabasesPath'), + throwsA(isA().having( + (DatabaseException e) => e.isDatabaseClosedError(), + 'closed', + true)), + ); + }); + + test('resetForTesting stops the isolate of the factory', () async { + SqfliteWatchos.libraryPathOverride = hostSqlitePath; + SqfliteWatchos.databasesPathOverride = dir.path; + await SqfliteWatchos.resetForTesting(); + final DatabaseFactory first = SqfliteWatchos.databaseFactoryWatchos; + final Database db = await first.openDatabase('reset.db'); + await db.execute('CREATE TABLE t (x)'); + await db.execute('BEGIN IMMEDIATE'); + await SqfliteWatchos.resetForTesting(); + + final DatabaseFactory second = SqfliteWatchos.databaseFactoryWatchos; + expect(identical(second, first), isFalse); + // The old isolate closed its connection, and the write lock with it. + final Database next = await second.openDatabase('reset.db'); + expect(await next.insert('t', {'x': 1}), 1); + await next.close(); + // The old factory's database answers as closed. + await expectLater( + db.query('t'), + throwsA(isA().having( + (DatabaseException e) => e.isDatabaseClosedError(), + 'closed', + true)), + ); + await SqfliteWatchos.resetForTesting(); + }); + + test( + 'a library that cannot be opened fails the first call, not the ' + 'factory', () async { + SqfliteWatchos.libraryPathOverride = p.join(dir.path, 'no_sqlite_here'); + SqfliteWatchos.databasesPathOverride = dir.path; + await SqfliteWatchos.resetForTesting(); + // Building the factory binds nothing. + final DatabaseFactory factory = SqfliteWatchos.databaseFactoryWatchos; + await expectLater( + factory.getDatabasesPath(), throwsA(isA())); + await SqfliteWatchos.resetForTesting(); + }); + }); +} From 5498f86968472d931db98c3095ec9c4ef76352a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mehmet=20Ali=20Ustao=C4=9Flu?= Date: Fri, 30 Oct 2026 23:20:36 +0100 Subject: [PATCH 2/2] sqflite_watchos: the review's fixes From the review of the background isolate, spec 0024. All eight findings were low after verification. * The worker ends with the isolate that started it. When the worker's port arrives, that isolate adds it as an exit listener of its own, and the null exit message stops the worker as a stop does: it closes every database and ends. The answer port is a RawReceivePort that keeps the isolate alive only while an answer is due, a call is unsent or a stop has not finished, so an isolate that used sqflite can end on its own. Before, every isolate that used the factory left a worker behind, and an Isolate.spawn helper never ended. * SqfliteWatchosWorker gets two @visibleForTesting members: isStarted, and crashForTesting, which ends the isolate with an error it does not catch. * New worker tests: - an isolate ended by that error fails a call queued behind an open transaction with isDatabaseClosedError() and the error's text; - stop() completes only on the isolate's exit, after every answer; - a stop made with calls in flight, or before the isolate's port arrived, answers those calls first and still closes their databases; - the isolate starts on the first call, and building the factory starts none; - an argument that cannot be sent fails a whole batch; - an Isolate.run session leaves no worker once it ends, and an Isolate.spawn helper that used the factory ends on its own. * The stop test's matcher is the handler's own cancel error, and the lock checks write through a connection with busy_timeout 0, so a lock still held fails at once. * An argument that cannot be sent keeps failing the whole call, as a method channel's codec fails it on iOS. The CHANGELOG and worker.dart now say so, and that its details hold the call's SQL and arguments but no database, and a batch's none. * Upstream's integration test passed on the watchOS 27.0 Simulator with SQL on the background isolate (the owner's run of 2026-10-30), so PORTING_REPORT and the CHANGELOG record it. Checked: 60 host tests on macOS (42 and 18; 8 new). Each of these changes to the worker fails tests: leaking the calls an ended isolate never answered, ignoring its error report, closing without cancelling, a stop() that resolves early, a stop that fails the calls already made, a stop before the port that is dropped, an eager start, no exit listener, and an answer port that always keeps its isolate alive. flutter analyze, dart format, check_words, check_versions, check_test_names, symcheck and check_readme_snippets pass. --- packages/sqflite_watchos/CHANGELOG.md | 21 +- packages/sqflite_watchos/PORTING_REPORT.md | 13 +- packages/sqflite_watchos/README.md | 18 +- .../sqflite_watchos/lib/sqflite_watchos.dart | 3 +- packages/sqflite_watchos/lib/src/worker.dart | 81 ++++- .../sqflite_watchos/test/worker_test.dart | 308 +++++++++++++++--- 6 files changed, 375 insertions(+), 69 deletions(-) diff --git a/packages/sqflite_watchos/CHANGELOG.md b/packages/sqflite_watchos/CHANGELOG.md index b4fdc5e..78b0219 100644 --- a/packages/sqflite_watchos/CHANGELOG.md +++ b/packages/sqflite_watchos/CHANGELOG.md @@ -1,8 +1,7 @@ ## 0.0.1 -First version, staged: not yet checked on a physical watch. Upstream's -integration test passed on the watchOS 27.0 Simulator before SQL moved to a -background isolate, and has not run since. +First version, staged: checked on the watchOS 27.0 Simulator, not yet on a +physical watch. * sqflite on the watch, on the system SQLite: `watchos/Package.swift` links `libsqlite3`, and Dart calls its C API over dart:ffi. @@ -14,17 +13,27 @@ background isolate, and has not run since. * SQL runs on a background isolate, as sqflite_common_ffi's `databaseFactoryFfi` runs it, so a long query does not hold up a frame (#20). One isolate, started by the first call and shared by every - database, holds the open databases, their transactions and cursors. A + database, holds the open databases, their transactions and cursors. Each + isolate that uses sqflite gets its own, which closes its databases and + ends when that isolate ends, and keeps that isolate alive only while an + answer is due, so the isolate can still end on its own. A `SqfliteDatabaseException` crosses back as its message, details, result code and `transactionClosed`, so sqflite_common sees the same error; a file error or an `ArgumentError` keeps its type. `lib/src/worker.dart` is adapted from sqflite_common_ffi's `isolate_io.dart` and `method_call.dart` and carries its BSD-2-Clause notice. +* A value that cannot be sent to the background isolate, such as a port or + the `Future` of a forgotten `await`, fails the whole call before it is + sent, as a method channel's codec fails it on iOS: a batch runs none of + its operations, and `continueOnError` cannot apply. The error's details + hold the call's SQL and arguments but not its database; a batch's error + has no details. * Arguments bind as on iOS: a `bool` as 1 or 0, a `Uint8List` or plain `List` as a blob. * A connection waits up to 2 seconds for another connection's lock, FMDB's - default on iOS. The wait blocks the background isolate, not the UI, so two - connections to one file in one app cannot wait each other out. + default on iOS. The wait blocks the background isolate, not the UI. Two + connections to one file opened from the same isolate share its background + isolate, so they cannot wait each other out. * An in-memory database is never a single instance. * `SqfliteWatchos` extends `SqflitePlatform`; `registerWith` makes its `databaseFactoryWatchos` sqflite's default on watchOS, and binds nothing diff --git a/packages/sqflite_watchos/PORTING_REPORT.md b/packages/sqflite_watchos/PORTING_REPORT.md index ff9146f..d0b7e98 100644 --- a/packages/sqflite_watchos/PORTING_REPORT.md +++ b/packages/sqflite_watchos/PORTING_REPORT.md @@ -6,8 +6,8 @@ |---|---| | Implementation | ✅ Working (system SQLite over FFI) | | watchOS capability | Full — every method of sqflite's protocol. The deprecated `Sqflite.*` debug helpers call the method channel directly and throw (see the README). Not yet checked on a physical watch | -| Host unit tests (`flutter test`) | ✅ pass on macOS — real SQL against the host's system SQLite (`/usr/lib/libsqlite3.dylib`; `libsqlite3.so.0` on CI's Linux runner), through sqflite_common's `Database` API on the background isolate, the protocol below it, and the isolate itself (order, errors, stopping) | -| Upstream integration test | ✅ passed verbatim on the watchOS 27.0 Simulator (14 tests), against the watch's own `libsqlite3`, before SQL moved to a background isolate; not run since. Run it with `flutter-watchos test`: it uses `package:test`'s `test`, which `flutter-watchos drive` does not collect, so `drive` reports nothing and waits | +| Host unit tests (`flutter test`) | ✅ pass on macOS — real SQL against the host's system SQLite (`/usr/lib/libsqlite3.dylib`; `libsqlite3.so.0` on CI's Linux runner), through sqflite_common's `Database` API on the background isolate, the protocol below it, and the isolate itself (start, order, errors, stopping, and its end with the isolate that started it) | +| Upstream integration test | ✅ passes verbatim on the watchOS 27.0 Simulator (Series 12, 46 mm; 14 tests), against the watch's own `libsqlite3`, with SQL on the background isolate (2026-10-30). Upstream's example (PR #37) also ran its "Raw tests" page there with no failure in the console. Run it with `flutter-watchos test`: it uses `package:test`'s `test`, which `flutter-watchos drive` does not collect, so `drive` reports nothing and waits | | Internal unified demo | not in the internal test app yet | Marking: ✅ full / passes · ◐ partial — reason given · ○ not applicable (no upstream test) · ✗ unsupported on watchOS. @@ -53,9 +53,12 @@ package keeps none of that native code: isolate, and every database shares it. The handler and what it holds (databases, transactions, cursors) live there; each call crosses as a message, and a `SqfliteDatabaseException` crosses back as its message, - details, result code and `transactionClosed`. So the 2-second lock wait - blocks that isolate, not the UI, and two connections to one file cannot - wait each other out (see the README). It is adapted from + details, result code and `transactionClosed`. The isolate closes its + databases and ends when the isolate that started it ends, and keeps that + isolate alive only while an answer is due. The 2-second lock wait blocks + the background isolate, not the UI; two connections to one file opened + from the same isolate share it, so they cannot wait each other out (see + the README). It is adapted from sqflite_common_ffi's `lib/src/isolate_io.dart` and `lib/src/method_call.dart`, and carries its copyright notice. - `watchos/Classes/sqflite_watchos_ffi.m` has two helpers: the Documents diff --git a/packages/sqflite_watchos/README.md b/packages/sqflite_watchos/README.md index 4026504..a6282d8 100644 --- a/packages/sqflite_watchos/README.md +++ b/packages/sqflite_watchos/README.md @@ -53,17 +53,19 @@ SQL runs on a background isolate, as `sqflite_common_ffi` runs it on desktop, so a long query does not hold up a frame. The first call starts the isolate, and every database shares it: the open databases, their transactions and their cursors live there, and each call reaches it as a -message, with its arguments and results copied. Errors come back intact, as -a `DatabaseException` with SQLite's message and result code. +message, with its arguments and results copied. Each isolate that uses +sqflite gets a background isolate of its own, which closes its databases +and ends when that isolate ends. Errors come back intact, as a +`DatabaseException` with SQLite's message and result code. The 2-second wait for another connection's lock blocks that isolate, not the UI, and calls to every other database wait behind it. Two connections -to one file in the same app cannot wait each other out: the isolate that -would commit the first is the one waiting, so the second waits the full 2 -seconds and fails with `SQLITE_BUSY` (result code 5). Keep one connection -per file, as sqflite's default `singleInstance: true` does. On iOS each -database has a queue of its own, so there the second connection gets the -lock once the first commits. +to one file opened from the same isolate cannot wait each other out: the +background isolate that would commit the first is the one waiting, so the +second waits the full 2 seconds and fails with `SQLITE_BUSY` (result code +5). Keep one connection per file, as sqflite's default +`singleInstance: true` does. On iOS each database has a queue of its own, +so there the second connection gets the lock once the first commits. `SqfliteWatchos.sqliteVersion` reports the system SQLite's version. diff --git a/packages/sqflite_watchos/lib/sqflite_watchos.dart b/packages/sqflite_watchos/lib/sqflite_watchos.dart index d5a9913..e0ef030 100644 --- a/packages/sqflite_watchos/lib/sqflite_watchos.dart +++ b/packages/sqflite_watchos/lib/sqflite_watchos.dart @@ -41,7 +41,8 @@ class SqfliteWatchos extends SqflitePlatform { /// [SqflitePlatform.databaseFactoryMethodChannel] answers them on iOS. /// /// Its calls run on a background isolate, which the first call starts and - /// every database the factory opens shares. + /// every database the factory opens shares. Each isolate that uses the + /// factory has its own, which ends with it. static DatabaseFactory get databaseFactoryWatchos => _factory ??= () { final SqfliteWatchosWorker worker = _worker = SqfliteWatchosWorker( libraryPath: libraryPathOverride, diff --git a/packages/sqflite_watchos/lib/src/worker.dart b/packages/sqflite_watchos/lib/src/worker.dart index a5dc7d8..4a952f3 100644 --- a/packages/sqflite_watchos/lib/src/worker.dart +++ b/packages/sqflite_watchos/lib/src/worker.dart @@ -15,7 +15,10 @@ // that cross as data and are built again on the caller's side. Here every // answer comes back on one port, matched by an id, an error that is not // sqflite's is copied as it is, as Isolate.run copies one, and a stop -// closes every database before the isolate ends. +// closes every database before the isolate ends. The isolate also stops +// when the isolate that started it ends, and that one is kept alive only +// while it waits for an answer, so an isolate done with its databases can +// end and leaves no worker behind. // // A DynamicLibrary cannot be sent to another isolate, so the isolate binds // SQLite itself: from the process on the watch, or from a path in host @@ -27,6 +30,7 @@ import 'dart:io'; import 'dart:isolate'; import 'package:ffi/ffi.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; // ignore: implementation_imports import 'package:sqflite_common/src/exception.dart' show SqfliteDatabaseException; @@ -49,7 +53,7 @@ class SqfliteWatchosWorker { /// directory. final String? databasesPath; - ReceivePort? _answers; + RawReceivePort? _answers; SendPort? _calls; /// What was posted before the isolate sent its port, in order. @@ -67,6 +71,11 @@ class SqfliteWatchosWorker { final Completer _ended = Completer(); + /// Whether the isolate has started. The first call starts it, never the + /// constructor. + @visibleForTesting + bool get isStarted => _answers != null; + /// Runs [method] with [arguments] on the isolate, starting it on the first /// call, and completes with what the handler answered. Future invoke(String method, [Object? arguments]) { @@ -78,6 +87,7 @@ class SqfliteWatchosWorker { final Completer completer = Completer(); _pending[id] = completer; _post(_Call(id, method, arguments)); + _keepAliveWhileWaiting(); return completer.future; } @@ -93,11 +103,21 @@ class SqfliteWatchosWorker { _ended.complete(); } else { _post(const _Stop()); + _keepAliveWhileWaiting(); } } return _ended.future; } + /// Ends the isolate with an error it does not catch, as a bug in it would: + /// it closes nothing, and the calls it has not answered fail as closed. + @visibleForTesting + void crashForTesting() { + if (_closed == null) { + _post(const _Crash()); + } + } + void _post(Object message) { final SendPort? calls = _calls; if (calls == null) { @@ -112,9 +132,8 @@ class SqfliteWatchosWorker { if (_answers != null) { return; } - final ReceivePort answers = - _answers = ReceivePort('sqflite_watchos answers'); - answers.listen(_receive); + final RawReceivePort answers = + _answers = RawReceivePort(_receive, 'sqflite_watchos answers'); Isolate.spawn<_Start>( _main, _Start(answers.sendPort, libraryPath, databasesPath), @@ -127,12 +146,26 @@ class SqfliteWatchosWorker { }); } + /// Keeps this isolate alive while the worker owes it something: an answer, + /// a call not yet sent, or its end after a stop. Otherwise this isolate + /// may end once it has nothing else to do, and its end stops the worker. + void _keepAliveWhileWaiting() { + final RawReceivePort? answers = _answers; + if (answers != null && !_ended.isCompleted) { + answers.keepIsolateAlive = _pending.isNotEmpty || + _unsent.isNotEmpty || + (_closed != null && _calls != null); + } + } + void _send(SendPort calls, Object message) { try { calls.send(message); } catch (error, stack) { - // An argument the VM cannot copy to another isolate. Its call fails as - // a call with an argument the handler cannot bind does. + // An argument the VM cannot copy to another isolate. The whole call + // fails before it reaches the isolate, as a method channel's codec + // fails it on iOS: a batch runs none of its operations, and + // continueOnError cannot apply. if (message is _Call) { _pending .remove(message.id) @@ -145,6 +178,9 @@ class SqfliteWatchosWorker { switch (message) { case final SendPort calls: _calls = calls; + // When this isolate ends, by returning, Isolate.exit or a kill, the + // isolate gets a null exit message, which it takes as a stop. + Isolate.current.addOnExitListener(calls); for (final Object unsent in _unsent) { _send(calls, unsent); } @@ -170,12 +206,18 @@ class SqfliteWatchosWorker { case null: // What Isolate.spawn's onExit sends, after every answer. _end(); + return; } + _keepAliveWhileWaiting(); } /// Fails every call the isolate did not answer, once it has ended. void _end() { _answers?.close(); + final SendPort? calls = _calls; + if (calls != null) { + Isolate.current.removeOnExitListener(calls); + } _calls = null; _unsent.clear(); final String closed = _closed ??= _uncaught == null @@ -191,13 +233,17 @@ class SqfliteWatchosWorker { } } - /// The error of a call the isolate will not answer. Every database it had - /// open is closed, and isDatabaseClosedError() looks for database_closed. + /// The error of a call the isolate will not answer. None of the databases + /// it had open can be reached any more, and isDatabaseClosedError() looks + /// for database_closed. An isolate that ended by a stop closed them; one + /// that ended any other way left their connections open. static SqfliteDatabaseException _closedError(String reason) => SqfliteDatabaseException('database_closed: $reason', null); - /// The error of a call whose arguments cannot be sent, with the details - /// the handler gives a call it cannot bind. + /// The error of a call whose arguments cannot be sent: the VM's message, + /// with the call's SQL and arguments as details when it has them. Unlike + /// the handler's error for an argument it cannot bind, it names no + /// database, which only the isolate knows, and a batch's has no details. static SqfliteDatabaseException _unsendable(Object error, Object? arguments) { final Map details = { if (arguments is Map && arguments['sql'] != null) @@ -232,6 +278,12 @@ final class _Stop { const _Stop(); } +/// Makes the isolate throw an error it does not catch, for +/// [SqfliteWatchosWorker.crashForTesting]. +final class _Crash { + const _Crash(); +} + /// What a call answered: its result, or the error it failed with. final class _Answer { _Answer.result(this.id, this.result) @@ -307,8 +359,13 @@ void _main(_Start start) { ) = _bind(start); final ReceivePort calls = ReceivePort('sqflite_watchos calls'); calls.listen((Object? message) { + if (message is _Crash) { + throw StateError('crashForTesting'); + } if (message is! _Call) { - // The stop, which comes after every call made before it. + // A stop, which comes after every call made before it, or the exit + // message of the isolate that started this one, null: close every + // database and let this isolate end. handler?.closeAll(); calls.close(); return; diff --git a/packages/sqflite_watchos/test/worker_test.dart b/packages/sqflite_watchos/test/worker_test.dart index c198152..d5f49bb 100644 --- a/packages/sqflite_watchos/test/worker_test.dart +++ b/packages/sqflite_watchos/test/worker_test.dart @@ -2,11 +2,13 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. // -// Host-side tests of the background isolate that runs the handler: calls -// keep their order, errors arrive as the handler threw them, and a stop -// closes the isolate's databases. Where it matters, the same calls also run -// through a handler on the test isolate, to compare. +// Host-side tests of the background isolate that runs the handler: it starts +// on the first call, calls keep their order, errors arrive as the handler +// threw them, a stop closes its databases, and it ends with the isolate that +// started it. Where it matters, the same calls also run through a handler on +// the test isolate, to compare. +import 'dart:async'; import 'dart:io'; import 'dart:isolate'; @@ -26,6 +28,43 @@ import 'host_sqlite.dart'; /// One way to make a method call: a worker's, or a handler's. typedef Invoke = Future Function(String method, [Object? arguments]); +/// A recursive count that keeps SQLite busy for a few hundred milliseconds: +/// 0.4 s on an M-series Mac. +const String longCount = 'WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL ' + 'SELECT i + 1 FROM n WHERE i < 3000000) SELECT count(*) AS c FROM n'; + +/// Work on the factory in an isolate of its own, which ends without closing +/// anything: it opens `session.db`, takes its write lock and returns. +final class LockAndEnd { + /// A session on the SQLite at [libraryPath], with databases under + /// [databasesPath], that sends `locked` to [report] once it holds the lock. + const LockAndEnd(this.libraryPath, this.databasesPath, [this.report]); + + /// The SQLite library the factory's isolate opens. + final String libraryPath; + + /// Where `session.db` lives. + final String databasesPath; + + /// Told once the lock is held. + final SendPort? report; + + /// Runs the session. + Future run() async { + SqfliteWatchos.libraryPathOverride = libraryPath; + SqfliteWatchos.databasesPathOverride = databasesPath; + final Database db = + await SqfliteWatchos.databaseFactoryWatchos.openDatabase('session.db'); + await db.execute('CREATE TABLE IF NOT EXISTS t (x)'); + await db.execute('BEGIN IMMEDIATE'); + report?.send('locked'); + } +} + +/// The entry point of a helper isolate that runs [session] and then has +/// nothing left to do. +void runAndEnd(LockAndEnd session) => unawaited(session.run()); + void main() { late Directory dir; late SqfliteWatchosWorker worker; @@ -66,6 +105,41 @@ void main() { throw StateError('the call did not fail'); } + /// Inserts into `t` of [path] from this isolate, through a connection that + /// waits for no lock: it fails at once with SQLITE_BUSY while another + /// connection holds the write lock. + Future writeAtOnce(String path) async { + final int id = await open(local.handle, path); + try { + await local.handle('execute', + {'id': id, 'sql': 'PRAGMA busy_timeout = 0'}); + return await local.handle('insert', + {'id': id, 'sql': 'INSERT INTO t VALUES (1)'}); + } finally { + await local.handle('closeDatabase', {'id': id}); + } + } + + /// Waits, for 10 s at most, until [writeAtOnce] to [path] succeeds: until + /// the connection of another isolate that holds its lock has closed. + Future expectWritableSoon(String path) async { + final Stopwatch waited = Stopwatch()..start(); + while (true) { + try { + await writeAtOnce(path); + return; + } on DatabaseException catch (e) { + if (e.getResultCode() != 5 || waited.elapsed.inSeconds >= 10) { + rethrow; + } + } + await Future.delayed(const Duration(milliseconds: 10)); + } + } + + final Matcher closed = throwsA(isA().having( + (DatabaseException e) => e.isDatabaseClosedError(), 'closed', true)); + test('calls made before the isolate starts run in the order made', () async { // The first call starts the isolate, and its first database is 1. None // of these waits for the one before it. @@ -136,6 +210,30 @@ void main() { ]); }); + group('starting', () { + test('the isolate starts on the first call, not before', () async { + expect(worker.isStarted, isFalse); + await worker.invoke('getDatabasesPath'); + expect(worker.isStarted, isTrue); + }); + + test('building the factory starts no isolate', () async { + SqfliteWatchos.libraryPathOverride = hostSqlitePath; + SqfliteWatchos.databasesPathOverride = dir.path; + await SqfliteWatchos.resetForTesting(); + expect(SqfliteWatchos.databaseFactoryWatchos, isNotNull); + // A worker that never started stops within the microtask queue. A + // started one stops on its isolate's exit message, which is an event, + // so it could not have stopped yet. + bool stopped = false; + unawaited(SqfliteWatchos.resetForTesting().then((_) { + stopped = true; + })); + await Future.value(); + expect(stopped, isTrue); + }); + }); + group('errors', () { test('a database error arrives with the fields the handler gave it', () async { @@ -227,6 +325,8 @@ void main() { final ReceivePort port = ReceivePort(); addTearDown(port.close); final int id = await open(worker.invoke); + // The details hold the SQL and its arguments, but no database: only + // the isolate knows it, and the call never reached the isolate. await expectLater( worker.invoke('query', { 'id': id, @@ -236,9 +336,10 @@ void main() { throwsA(isA().having( (SqfliteDatabaseException e) => e.result, 'details', - containsPair('sql', 'SELECT ?'))), + allOf( + containsPair('sql', 'SELECT ?'), isNot(contains('database'))))), ); - // The call never left this isolate, and the next one is answered. + // The isolate answers the next call. expect( await worker.invoke( 'query', {'id': id, 'sql': 'SELECT 1 AS one'}), @@ -250,6 +351,37 @@ void main() { }); }); + test('an argument that cannot be sent fails a whole batch', () async { + final ReceivePort port = ReceivePort(); + addTearDown(port.close); + final int id = await open(worker.invoke); + await worker.invoke( + 'execute', {'id': id, 'sql': 'CREATE TABLE t (x)'}); + Map insert(Object value) => { + 'method': 'insert', + 'sql': 'INSERT INTO t VALUES (?)', + 'arguments': [value], + }; + // As a method channel's codec fails such a call on iOS: none of its + // operations runs, continueOnError cannot apply, and the error has no + // details. + await expectLater( + worker.invoke('batch', { + 'id': id, + 'continueOnError': true, + 'operations': [insert(1), insert(port), insert(2)], + }), + throwsA(isA().having( + (SqfliteDatabaseException e) => e.result, 'details', isNull)), + ); + expect( + ((await worker.invoke('query', { + 'id': id, + 'sql': 'SELECT x FROM t' + }))! as Map)['rows'], + isEmpty); + }); + test('a library that cannot be opened fails every call', () async { final SqfliteWatchosWorker unbound = SqfliteWatchosWorker( libraryPath: p.join(dir.path, 'no_sqlite_here'), @@ -276,31 +408,76 @@ void main() { 'inTransaction': true, 'transactionId': null, }); - // Queued behind the open transaction, so the stop fails it. - final Future queued = expectLater( - worker.invoke( - 'query', {'id': id, 'sql': 'SELECT x FROM t'}), - throwsA(isA())); - // stop() completes on the isolate's exit message. + // Queued behind the open transaction, so closing the database fails it. + Object? queuedError; + final Future queued = worker.invoke('query', { + 'id': id, + 'sql': 'SELECT x FROM t' + }).then((_) {}, onError: (Object e) { + queuedError = e; + }); await worker.stop(); - await queued; - // The write lock went with the connection: one opened here writes at - // once, where it would otherwise fail with SQLITE_BUSY. - final int other = await open(local.handle, 'locked.db'); + // stop() completes on the isolate's exit message, which comes after + // every answer: the handler's own error for the queued call is in. expect( - await local.handle('insert', { - 'id': other, - 'sql': 'INSERT INTO t VALUES (1)', - }), - 1); + queuedError, + isA() + .having((SqfliteDatabaseException e) => e.message, 'message', + 'Bad state: Database has been closed') + .having((SqfliteDatabaseException e) => e.result, 'details', + containsPair('sql', 'SELECT x FROM t'))); + await queued; + // The write lock went with the connection. + expect(await writeAtOnce('locked.db'), 1); // Later calls fail as closed, and none of them hangs. - await expectLater( - worker.invoke('getDatabasesPath'), - throwsA(isA().having( - (DatabaseException e) => e.isDatabaseClosedError(), - 'closed', - true)), - ); + await expectLater(worker.invoke('getDatabasesPath'), closed); + }); + + test('stop lets the calls already made finish first', () async { + final int id = await open(worker.invoke); + await worker.invoke( + 'execute', {'id': id, 'sql': 'CREATE TABLE t (x)'}); + final List order = []; + // Both still running or waiting in the isolate when the stop is posted. + final Future count = worker.invoke('query', { + 'id': id, + 'sql': longCount + }).whenComplete(() => order.add('count')); + final Future insert = worker.invoke('insert', { + 'id': id, + 'sql': 'INSERT INTO t VALUES (1)', + }).whenComplete(() => order.add('insert')); + await worker.stop().whenComplete(() => order.add('stop')); + expect(order, ['count', 'insert', 'stop']); + expect( + (await count)! as Map, + containsPair('rows', >[ + [3000000], + ])); + expect(await insert, 1); + }); + + test('a stop made before the isolate has sent its port follows the calls', + () async { + // The first call starts the isolate; the stop is posted before its + // port arrives, behind these three. + final List> calls = >[ + worker.invoke('openDatabase', {'path': 'early.db'}), + worker.invoke( + 'execute', {'id': 1, 'sql': 'CREATE TABLE t (x)'}), + worker.invoke( + 'execute', {'id': 1, 'sql': 'BEGIN IMMEDIATE'}), + ]; + final Future stopped = worker.stop(); + expect(await Future.wait(calls), [ + {'id': 1}, + null, + null, + ]); + await stopped; + // The stop reached the isolate, which closed the database and so its + // write lock. + expect(await writeAtOnce('early.db'), 1); }); test('resetForTesting stops the isolate of the factory', () async { @@ -311,22 +488,25 @@ void main() { final Database db = await first.openDatabase('reset.db'); await db.execute('CREATE TABLE t (x)'); await db.execute('BEGIN IMMEDIATE'); + // Posted to the old isolate, and still running there at the reset. + bool counted = false; + final Future count = db.rawQuery(longCount).then((_) { + counted = true; + }); + await Future.delayed(Duration.zero); await SqfliteWatchos.resetForTesting(); - + // The reset completed once the old isolate had answered it and ended. + expect(counted, isTrue); + await count; final DatabaseFactory second = SqfliteWatchos.databaseFactoryWatchos; expect(identical(second, first), isFalse); // The old isolate closed its connection, and the write lock with it. + expect(await writeAtOnce('reset.db'), 1); final Database next = await second.openDatabase('reset.db'); - expect(await next.insert('t', {'x': 1}), 1); + expect(await next.query('t'), hasLength(1)); await next.close(); // The old factory's database answers as closed. - await expectLater( - db.query('t'), - throwsA(isA().having( - (DatabaseException e) => e.isDatabaseClosedError(), - 'closed', - true)), - ); + await expectLater(db.query('t'), closed); await SqfliteWatchos.resetForTesting(); }); @@ -342,5 +522,59 @@ void main() { factory.getDatabasesPath(), throwsA(isA())); await SqfliteWatchos.resetForTesting(); }); + + test('an isolate that ends abnormally fails the calls it never answered', + () async { + final int id = await open(worker.invoke, 'ended.db'); + await worker.invoke( + 'execute', {'id': id, 'sql': 'CREATE TABLE t (x)'}); + await worker.invoke('execute', { + 'id': id, + 'sql': 'BEGIN IMMEDIATE', + 'inTransaction': true, + 'transactionId': null, + }); + // Queued behind the open transaction: only the isolate's end fails it, + // with the error the isolate reported before it ended. + final Future queued = expectLater( + worker.invoke( + 'query', {'id': id, 'sql': 'SELECT x FROM t'}), + throwsA(isA() + .having((DatabaseException e) => e.isDatabaseClosedError(), + 'closed', true) + .having((DatabaseException e) => '$e', 'text', + contains('crashForTesting'))), + ); + worker.crashForTesting(); + await queued; + await expectLater(worker.invoke('getDatabasesPath'), closed); + }); + }); + + group('lifetime', () { + test('an Isolate.run session leaves no worker once it ends', () async { + await Isolate.run(LockAndEnd(hostSqlitePath, dir.path).run); + // The session ended with its database open and its write lock held. + // Its end stopped its worker, which closed the database, and only a + // stop does that, before it ended too. + await expectWritableSoon('session.db'); + }); + + test('a helper isolate that used the factory ends on its own', () async { + final ReceivePort events = ReceivePort(); + addTearDown(events.close); + await Isolate.spawn( + runAndEnd, + LockAndEnd(hostSqlitePath, dir.path, events.sendPort), + onExit: events.sendPort, + onError: events.sendPort, + ); + // Its session, then its exit (null): nothing keeps it alive once its + // last call is answered. + expect(await events.take(2).toList().timeout(const Duration(seconds: 10)), + ['locked', null]); + // Its end stopped its worker too. + await expectWritableSoon('session.db'); + }); }); }