diff --git a/lib/data/db/database.dart b/lib/data/db/database.dart index 103df36..93d3939 100644 --- a/lib/data/db/database.dart +++ b/lib/data/db/database.dart @@ -7,6 +7,7 @@ import 'package:flutter/services.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:sharedinbox/core/db_schema_version.dart'; +import 'package:sqlite3/sqlite3.dart' show Database; part 'database.g.dart'; @@ -793,18 +794,34 @@ Future resolveDatabasePathForTesting() => _resolveDatabasePath(); void resetDatabasePathForTesting() => _dbPath = null; Future androidFallbackPathForTesting() => _androidFallbackPath(); +/// Configures PRAGMAs on a newly opened SQLite connection. +/// +/// busy_timeout must come first so subsequent statements retry on SQLITE_BUSY +/// instead of immediately failing. +/// +/// journal_mode = WAL is wrapped in a try/catch because a concurrent +/// WorkManager background task may already have the DB open when the app +/// starts. SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) is +/// returned in that situation; it only occurs when the DB is already in WAL +/// mode, so the pragma would be a no-op anyway and it is safe to continue. +void _setupPragmas(Database db) { + db.execute('PRAGMA busy_timeout = 5000;'); + try { + db.execute('PRAGMA journal_mode = WAL;'); + } on SqliteException catch (e) { + // resultCode strips the extended bits: both SQLITE_BUSY (5) and + // SQLITE_BUSY_SNAPSHOT (261) reduce to 5. Re-throw anything else. + if (e.resultCode != 5) rethrow; + } +} + LazyDatabase _openConnection() { return LazyDatabase(() async { final file = File(await _resolveDatabasePath()); - return NativeDatabase.createInBackground( - file, - setup: (db) { - // WAL lets readers and writers proceed concurrently (different account - // sync loops share the same DB). busy_timeout makes SQLite retry for - // up to 5 s instead of immediately returning SQLITE_BUSY. - db.execute('PRAGMA journal_mode = WAL;'); - db.execute('PRAGMA busy_timeout = 5000;'); - }, - ); + return NativeDatabase.createInBackground(file, setup: _setupPragmas); }); } + +// Exposed so tests can run the exact production setup logic on a raw +// sqlite3 connection (same pattern as resolveDatabasePathForTesting). +void setupPragmasForTesting(Database db) => _setupPragmas(db); diff --git a/pubspec.yaml b/pubspec.yaml index a4b5218..99c9055 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -19,6 +19,7 @@ dependencies: # Local persistence (offline-first) drift: ^2.20.3 + sqlite3: ^3.1.5 # used directly in lib/data/db/database.dart (_setupPragmas) sqlite3_flutter_libs: ^0.6.0+eol path_provider: ^2.1.5 path: ^1.9.1 @@ -78,7 +79,6 @@ dev_dependencies: mockito: ^5.4.4 fake_async: ^1.3.1 path_provider_platform_interface: ^2.1.2 - sqlite3: ^3.1.5 # used directly in test/unit/db_test_helper.dart; 3.x required for Database.close() url_launcher_platform_interface: ^2.3.2 plugin_platform_interface: ^2.1.8 flutter_launcher_icons: ^0.14.0 diff --git a/scripts/setup_dagger_remote.sh b/scripts/setup_dagger_remote.sh index 0f01768..974e93a 100755 --- a/scripts/setup_dagger_remote.sh +++ b/scripts/setup_dagger_remote.sh @@ -76,11 +76,12 @@ if [ "$_elapsed" -gt 10 ]; then echo "::warning::ssh-keyscan took ${_elapsed}s — Dagger engine host may be slow to respond" fi -# Create a background SSH tunnel to the Dagger engine. -# We map local port 8080 to remote port 1774 (where our socat bridge is listening). +# Create a background SSH tunnel to the Dagger engine Unix socket. +# Forwards local TCP port 8080 directly to /run/dagger/engine.sock on the remote host, +# eliminating the need for a socat bridge on the server side. echo "Establishing SSH tunnel to $DAGGER_ENGINE_HOST..." _t0=$SECONDS -timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:localhost:1774 "dagger@$DAGGER_ENGINE_HOST" +timeout 30 ssh -i ~/.ssh/dagger_key -o StrictHostKeyChecking=no -f -N -L 8080:/run/dagger/engine.sock "dagger@$DAGGER_ENGINE_HOST" _elapsed=$(( SECONDS - _t0 )) if [ "$_elapsed" -gt 10 ]; then echo "::warning::SSH tunnel setup took ${_elapsed}s" diff --git a/test/unit/migration_test.dart b/test/unit/migration_test.dart index e6e375f..c52cfd6 100644 --- a/test/unit/migration_test.dart +++ b/test/unit/migration_test.dart @@ -510,4 +510,40 @@ void main() { await db.close(); }); }); + + // Regression test for https://codeberg.org/guettli/sharedinbox/issues/508: + // _openConnection's setup callback must not crash when PRAGMA journal_mode = + // WAL fails with SQLITE_BUSY_SNAPSHOT (extended code 261, primary code 5) + // because a WorkManager background task already has the DB open in WAL mode. + group('WAL setup (#508)', () { + test( + 'setupPragmasForTesting does not throw when WAL is already active and ' + 'another connection holds an open read transaction', + () { + final dbFile = File('test_wal_busy_508.db'); + if (dbFile.existsSync()) dbFile.deleteSync(); + addTearDown(() { + if (dbFile.existsSync()) dbFile.deleteSync(); + }); + + // conn1: enable WAL and keep a read transaction open — simulates a + // WorkManager background task that opened the DB before the foreground + // app starts. + final conn1 = sqlite.sqlite3.open(dbFile.path); + conn1.execute('PRAGMA journal_mode = WAL;'); + conn1.execute('BEGIN;'); + conn1.select('SELECT 1;'); + + // conn2: run the exact production setup through setupPragmasForTesting. + // This must not throw even though conn1 holds an open transaction and + // the DB is already in WAL mode. + final conn2 = sqlite.sqlite3.open(dbFile.path); + expect(() => setupPragmasForTesting(conn2), returnsNormally); + + conn1.execute('ROLLBACK;'); + conn1.close(); + conn2.close(); + }, + ); + }); }