diff --git a/packages/sqflite_watchos/CHANGELOG.md b/packages/sqflite_watchos/CHANGELOG.md index d4f6237..78b0219 100644 --- a/packages/sqflite_watchos/CHANGELOG.md +++ b/packages/sqflite_watchos/CHANGELOG.md @@ -10,13 +10,37 @@ 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. 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. + 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. + `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..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 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 (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. @@ -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,20 @@ 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`. 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 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..a6282d8 100644 --- a/packages/sqflite_watchos/README.md +++ b/packages/sqflite_watchos/README.md @@ -49,10 +49,23 @@ 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. 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 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. @@ -72,6 +85,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..e0ef030 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,37 @@ 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. Each isolate that uses the + /// factory has its own, which ends with it. 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 +61,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..4a952f3 --- /dev/null +++ b/packages/sqflite_watchos/lib/src/worker.dart @@ -0,0 +1,402 @@ +// 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. 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 +// tests. + +import 'dart:async'; +import 'dart:ffi'; +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; + +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; + + RawReceivePort? _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(); + + /// 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]) { + 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)); + _keepAliveWhileWaiting(); + 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()); + _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) { + _unsent.add(message); + _start(); + } else { + _send(calls, message); + } + } + + void _start() { + if (_answers != null) { + return; + } + final RawReceivePort answers = + _answers = RawReceivePort(_receive, 'sqflite_watchos answers'); + 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(); + }); + } + + /// 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. 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) + ?.completeError(_unsendable(error, message.arguments), stack); + } + } + } + + void _receive(Object? message) { + 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); + } + _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(); + 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 + ? '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. 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: 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) + '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(); +} + +/// 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) + : 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 _Crash) { + throw StateError('crashForTesting'); + } + if (message is! _Call) { + // 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; + } + // 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..d5f49bb --- /dev/null +++ b/packages/sqflite_watchos/test/worker_test.dart @@ -0,0 +1,580 @@ +// 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: 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'; + +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]); + +/// 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; + 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'); + } + + /// 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. + 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('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 { + 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); + // 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, + 'sql': 'SELECT ?', + 'arguments': [port], + }), + throwsA(isA().having( + (SqfliteDatabaseException e) => e.result, + 'details', + allOf( + containsPair('sql', 'SELECT ?'), isNot(contains('database'))))), + ); + // The isolate answers the next call. + expect( + await worker.invoke( + 'query', {'id': id, 'sql': 'SELECT 1 AS one'}), + { + 'columns': ['one'], + 'rows': >[ + [1], + ], + }); + }); + + 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'), + 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 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(); + // 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( + 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'), 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 { + 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'); + // 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.query('t'), hasLength(1)); + await next.close(); + // The old factory's database answers as closed. + await expectLater(db.query('t'), closed); + 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(); + }); + + 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'); + }); + }); +}