fix: swallow SQLITE_BUSY when setting WAL mode to prevent startup crash (#508) #510
+27
-10
@@ -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<String> resolveDatabasePathForTesting() => _resolveDatabasePath();
|
||||
void resetDatabasePathForTesting() => _dbPath = null;
|
||||
Future<String?> 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);
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user