From 35c3d97ac2f20c4d76d3ab5fa186db61c80340c7 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 12 Jun 2026 10:29:13 +0200 Subject: [PATCH 1/5] Replace Valgrind check with sanitizers --- .github/workflows/tests.yml | 35 +++++---- .gitignore | 1 + dart/test/utils/native_test_utils.dart | 16 +++++ dart/tool/all_tests.dart | 28 ++++++++ dart/tool/run_tests.dart | 99 ++++++++++++++++++++++++++ tool/build_linux_sanitized.sh | 37 ++++++++++ 6 files changed, 197 insertions(+), 19 deletions(-) create mode 100644 dart/tool/all_tests.dart create mode 100644 dart/tool/run_tests.dart create mode 100755 tool/build_linux_sanitized.sh diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0aef8ac2..abed7df8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -222,30 +222,27 @@ jobs: working-directory: dart run: dart test - valgrind: - name: Testing with Valgrind on ${{ matrix.os }} - runs-on: ${{ matrix.os }} - if: github.event_name == 'push' || (github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name != github.repository) - strategy: - matrix: - include: - - os: ubuntu-latest + test_with_sanitizers: + runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 with: persist-credentials: false - - name: Install Rust + - uses: dart-lang/setup-dart@v1 + - name: Install Rust Nightly run: rustup install + - name: Install LLVM toolchain + run: | + sudo apt update + sudo apt install -y llvm clang lld - - name: Install valgrind - run: sudo apt update && sudo apt install -y valgrind + - name: Build with sanitizers + run: tool/build_linux_sanitized.sh - - name: Install Cargo Valgrind - # TODO: Use released version. Currently we rely on the git version while we wait for this - # to be released: https://github.com/jfrimmel/cargo-valgrind/commit/408c0b4fb56e84eddc2bb09c88a11ba3adc0c188 - run: cargo install --git https://github.com/jfrimmel/cargo-valgrind cargo-valgrind - #run: cargo install cargo-valgrind + - name: Test with MemorySanitizer + working-directory: dart + run: dart tool/run_tests.dart msan - - name: Test Core - run: | - cargo valgrind test -p powersync_core + - name: Test with AddressSanitizer + working-directory: dart + run: dart tool/run_tests.dart asan diff --git a/.gitignore b/.gitignore index f7f55d48..0fcf6f29 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ target/ *.tar.xz *.zip .build +/sanitized/ diff --git a/dart/test/utils/native_test_utils.dart b/dart/test/utils/native_test_utils.dart index d1852395..e1f332cd 100644 --- a/dart/test/utils/native_test_utils.dart +++ b/dart/test/utils/native_test_utils.dart @@ -1,5 +1,6 @@ import 'dart:ffi'; import 'dart:io'; +import 'dart:math'; import 'package:clock/clock.dart'; import 'package:fake_async/fake_async.dart'; @@ -14,12 +15,23 @@ const defaultSqlitePath = 'libsqlite3.so.0'; const cargoDebugPath = '../target/debug'; var didLoadExtension = false; +String? testingWithSanitizers = null; + CommonDatabase openTestDatabase( {VirtualFileSystem? vfs, String fileName = ':memory:'}) { if (!didLoadExtension) { loadExtension(); } + if (testingWithSanitizers != null && vfs == null) { + final inMemory = + InMemoryFileSystem(name: 'in-memory-${Random().nextInt(1 << 32)}'); + sqlite3.registerVirtualFileSystem(inMemory); + addTearDown(() => sqlite3.unregisterVirtualFileSystem(inMemory)); + + vfs = inMemory; + } + final db = sqlite3.open(fileName, vfs: vfs?.name); addTearDown(db.close); return db; @@ -64,6 +76,10 @@ String resolvePowerSyncLibrary() { } String _getLibraryForPlatform({String? path = cargoDebugPath}) { + if (testingWithSanitizers case final sanitizer?) { + return '../sanitized/core_extension/libpowersync_$sanitizer.linux.so'; + } + return switch (Abi.current()) { Abi.androidArm || Abi.androidArm64 || diff --git a/dart/tool/all_tests.dart b/dart/tool/all_tests.dart new file mode 100644 index 00000000..4a057254 --- /dev/null +++ b/dart/tool/all_tests.dart @@ -0,0 +1,28 @@ +import 'package:test/test.dart'; + +import '../test/crud_test.dart' as crud_test; +import '../test/error_test.dart' as error_test; +import '../test/js_key_encoding_test.dart' as json_key_encoding_test; +import '../test/migration_test.dart' as migration_test; +import '../test/schema_test.dart' as schema_test; +// Skipping sync_local_performance_test because it's slow. It's functionality is +// covered by other tests. +import '../test/sync_stream_test.dart' as sync_stream_test; +import '../test/update_hooks_test.dart' as update_hooks_test; + +import '../test/utils/native_test_utils.dart'; + +/// Runs all native tests. +/// +/// We aot-compile this file to run tests with different sanitizers. +void main(List args) { + testingWithSanitizers = args.single; + + group('crud_test.dart', crud_test.main); + group('error_test.dart', error_test.main); + group('js_key_encoding_test.dart', json_key_encoding_test.main); + group('migration_test.dart', migration_test.main); + group('schema_test.dart', schema_test.main); + group('sync_stream_test.dart', sync_stream_test.main); + group('update_hooks_test.dart', update_hooks_test.main); +} diff --git a/dart/tool/run_tests.dart b/dart/tool/run_tests.dart new file mode 100644 index 00000000..1b33ca58 --- /dev/null +++ b/dart/tool/run_tests.dart @@ -0,0 +1,99 @@ +import 'dart:io'; + +import 'package:path/path.dart' as p; + +/// Runs `all_tests.dart` as a single AOT-compiled executable with sanitizers +/// enabled. +/// +/// To run tests with a sanitizer, use `dart tool/run_tests.dart +/// --sanitizer $sanitizer`, where `$sanitizer` is either `asan` or `msan`. +/// Note that sanitizers are only supported on X64 Linux hosts. +/// +/// Running tests with sanitizers also requires an instrumented build of SQLite +/// and the core extension, which can be built with +/// `tool/build_linux_sanitized.sh`. +void main(List args) async { + Never invalidArgs() { + print('Usage: dart tool/run_tests.dart asan|msan'); + exit(1); + } + + if (args.length != 1) { + invalidArgs(); + } + + final sanitizer = args.single; + if (sanitizer != 'asan' && sanitizer != 'msan') invalidArgs(); + final expandedName = switch (sanitizer) { + 'asan' => 'address', + 'msan' => 'memory', + _ => throw AssertionError(), + }; + + final dir = await Directory.systemTemp.createTemp('core-extension-tests'); + final aotPath = p.join(dir.path, 'test.aot'); + final assetsConfig = await _createNativeAssetsConfig(dir, expandedName); + + try { + print('AOT-compiling tests'); + final result = await Process.run(Platform.executable, [ + 'compile', + 'aot-snapshot', + 'tool/all_tests.dart', + '--output', + aotPath, + '--target-sanitizer=$sanitizer', + '--extra-gen-kernel-options=--native-assets=${assetsConfig.path}', + ]); + + if (result.exitCode != 0 || !await File(aotPath).exists()) { + throw ''' +could not compile test script + +exitCode: ${result.exitCode} +stdout: ${result.stdout} +stderr: ${result.stderr} +'''; + } + + var runtimeName = 'dartaotruntime_$sanitizer'; + + print('Running with $runtimeName'); + final runtime = p.join(p.dirname(Platform.resolvedExecutable), runtimeName); + final process = await Process.start( + runtime, + [ + aotPath, + expandedName, + ], + mode: ProcessStartMode.inheritStdio); + final exit = await process.exitCode; + if (exit != 0) { + throw 'Expected exit code 0, got $exit'; + } + } finally { + await dir.delete(recursive: true); + } +} + +Future _createNativeAssetsConfig( + Directory tmpForRun, + String? expandedName, +) async { + final sqliteFile = p.normalize( + p.absolute('../sanitized/sqlite', 'libsqlite3_$expandedName.so'), + ); + + final yaml = ''' +format-version: [1, 0, 0] +native-assets: + linux_x64: + "package:sqlite3/src/ffi/libsqlite3.g.dart": + - absolute + - "$sqliteFile" +'''; + + final file = File(p.join(tmpForRun.path, 'assets.yaml')); + await file.writeAsString(yaml); + return file; +} diff --git a/tool/build_linux_sanitized.sh b/tool/build_linux_sanitized.sh new file mode 100755 index 00000000..7bab1e7d --- /dev/null +++ b/tool/build_linux_sanitized.sh @@ -0,0 +1,37 @@ +#!/bin/bash +set -e + +function compile_core() { + local sanitizer=$1 + + RUSTDOCFLAGS="-Zsanitizer=$sanitizer" RUSTFLAGS="-Zsanitizer=$sanitizer" cargo build \ + -p powersync_loadable \ + -Z build-std=panic_abort,core,alloc \ + --features nightly \ + --release \ + --target x86_64-unknown-linux-gnu + + mv "target/x86_64-unknown-linux-gnu/release/libpowersync.so" "sanitized/core_extension/libpowersync_$sanitizer.linux.so" +} + +function compile_sqlite() { + local sanitizer=$1 + + clang -O3 -fsanitize=$sanitizer -fno-omit-frame-pointer -fuse-ld=lld -fPIC \ + -DSQLITE_ENABLE_API_ARMOR=1 \ + -DSQLITE_OMIT_DEPRECATED \ + -DSQLITE_DQS=0 \ + -DSQLISQLITE_ENABLE_DBSTAT_VTABTE_ENABLE_FTS5 \ + -DSQLITE_ENABLE_STMTVTAB \ + -shared \ + crates/sqlite/sqlite/sqlite3.c \ + -o sanitized/sqlite/libsqlite3_$sanitizer.so +} + +mkdir -p sanitized/core_extension +compile_core address +compile_core memory + +mkdir -p sanitized/sqlite +compile_sqlite address +compile_sqlite memory From e568c2ddf62295f3a48455acbb26613e8d8e2739 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 12 Jun 2026 10:36:10 +0200 Subject: [PATCH 2/5] Run pub get --- .github/workflows/tests.yml | 3 +++ dart/test/utils/native_test_utils.dart | 3 +++ 2 files changed, 6 insertions(+) diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index abed7df8..5d62db1d 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -239,6 +239,9 @@ jobs: - name: Build with sanitizers run: tool/build_linux_sanitized.sh + - run: dart pub get + working-directory: dart + - name: Test with MemorySanitizer working-directory: dart run: dart tool/run_tests.dart msan diff --git a/dart/test/utils/native_test_utils.dart b/dart/test/utils/native_test_utils.dart index e1f332cd..3eeca1e3 100644 --- a/dart/test/utils/native_test_utils.dart +++ b/dart/test/utils/native_test_utils.dart @@ -24,6 +24,9 @@ CommonDatabase openTestDatabase( } if (testingWithSanitizers != null && vfs == null) { + // When testing with sanitizers, always use a Dart VFS since we use an + // uninstrumented libc that wouldn't report buffers for read files as + // initialized. final inMemory = InMemoryFileSystem(name: 'in-memory-${Random().nextInt(1 << 32)}'); sqlite3.registerVirtualFileSystem(inMemory); From 6d089e80b4e83dc5bc2699f34718a6ff5c0a611e Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 12 Jun 2026 11:45:29 +0200 Subject: [PATCH 3/5] Fix memory leak around update hooks --- crates/core/src/pre_close_vtab.rs | 4 ++++ crates/core/src/state.rs | 2 ++ crates/core/src/update_hooks.rs | 37 +++++++++---------------------- dart/pubspec.lock | 17 +++++++------- dart/pubspec.yaml | 9 ++++++++ 5 files changed, 35 insertions(+), 34 deletions(-) diff --git a/crates/core/src/pre_close_vtab.rs b/crates/core/src/pre_close_vtab.rs index 75def692..7f116712 100644 --- a/crates/core/src/pre_close_vtab.rs +++ b/crates/core/src/pre_close_vtab.rs @@ -8,6 +8,7 @@ use powersync_sqlite_nostd as sqlite; use sqlite::{Connection, ResultCode}; use crate::state::DatabaseState; +use crate::update_hooks::uninstall_update_hooks; use crate::vtab_util::*; /// A virtual table hack to implement a "pre-close hook" for databases. @@ -20,6 +21,7 @@ use crate::vtab_util::*; struct VirtualTable { base: sqlite::vtab, state: Rc, + db: *mut sqlite::sqlite3, } extern "C" fn connect( @@ -42,6 +44,7 @@ extern "C" fn connect( zErrMsg: core::ptr::null_mut(), }, state: DatabaseState::clone_from(aux), + db, })); *vtab = tab.cast::(); let _ = sqlite::vtab_config(db, 0); @@ -57,6 +60,7 @@ extern "C" fn disconnect(vtab: *mut sqlite::vtab) -> c_int { // So we can use this as a "pre-close" hook and ensure we clear prepared statements the core // extension might hold. vtab.state.release_resources(); + uninstall_update_hooks(vtab.db, &vtab.state); ResultCode::OK as c_int } diff --git a/crates/core/src/state.rs b/crates/core/src/state.rs index 4a1d292e..f9c2fa00 100644 --- a/crates/core/src/state.rs +++ b/crates/core/src/state.rs @@ -25,6 +25,8 @@ use crate::{ #[derive(Default)] pub struct DatabaseState { pub is_in_sync_local: Cell, + /// Whether the core extension has installed update, commit and rollback hooks. + pub core_extension_has_update_hooks: Cell, schema: RefCell>, pending_updates: RefCell>, commited_updates: RefCell>, diff --git a/crates/core/src/update_hooks.rs b/crates/core/src/update_hooks.rs index 9fb90bd3..4c7e46a7 100644 --- a/crates/core/src/update_hooks.rs +++ b/crates/core/src/update_hooks.rs @@ -1,5 +1,4 @@ use core::{ - cell::Cell, ffi::{CStr, c_char, c_int, c_void}, ptr::null_mut, }; @@ -21,11 +20,7 @@ use crate::{constants::SUBTYPE_JSON, error::PowerSyncError, state::DatabaseState /// The update hooks don't have to be uninstalled manually, that happens when the connection is /// closed and the function is unregistered. pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<(), ResultCode> { - let state = Box::new(HookState { - has_registered_hooks: Cell::new(false), - db, - state, - }); + let state = Box::new(HookState { db, state }); db.create_function_v2( "powersync_update_hooks", @@ -41,31 +36,13 @@ pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<() } struct HookState { - has_registered_hooks: Cell, db: *mut sqlite::sqlite3, state: Rc, } extern "C" fn destroy_function(ctx: *mut c_void) { let state = unsafe { Box::from_raw(ctx as *mut HookState) }; - - if state.has_registered_hooks.get() { - check_previous( - "update", - &state.state, - state.db.update_hook(None, null_mut()), - ); - check_previous( - "commit", - &state.state, - state.db.commit_hook(None, null_mut()), - ); - check_previous( - "rollback", - &state.state, - state.db.rollback_hook(None, null_mut()), - ); - } + uninstall_update_hooks(state.db, &state.state); } extern "C" fn powersync_update_hooks( @@ -107,7 +84,7 @@ extern "C" fn powersync_update_hooks( Rc::into_raw(db_state.clone()) as *mut c_void, ), ); - state.has_registered_hooks.set(true); + db_state.core_extension_has_update_hooks.set(true); } "get" => { let state = unsafe { user_data.as_ref().unwrap_unchecked() }; @@ -155,6 +132,14 @@ unsafe extern "C" fn rollback_hook_impl(ctx: *mut c_void) { state.track_rollback(); } +pub fn uninstall_update_hooks(db: *mut sqlite::sqlite3, state: &Rc) { + if state.core_extension_has_update_hooks.take() { + check_previous("update", state, db.update_hook(None, null_mut())); + check_previous("commit", state, db.commit_hook(None, null_mut())); + check_previous("rollback", state, db.rollback_hook(None, null_mut())); + } +} + fn check_previous(desc: &'static str, expected: &Rc, previous: *const c_void) { let expected = Rc::as_ptr(expected); diff --git a/dart/pubspec.lock b/dart/pubspec.lock index 858f1601..aa438004 100644 --- a/dart/pubspec.lock +++ b/dart/pubspec.lock @@ -237,10 +237,10 @@ packages: dependency: transitive description: name: native_toolchain_c - sha256: "89e83885ba09da5fdf2cdacc8002a712ca238c28b7f717910b34bcd27b0d03ac" + sha256: "8aaead321425bd3f03bd5894aa27c8ea6993eab95531da7e59f5d39c6e5708ec" url: "https://pub.dev" source: hosted - version: "0.17.4" + version: "0.18.0" node_preamble: dependency: transitive description: @@ -364,11 +364,12 @@ packages: sqlite3: dependency: "direct main" description: - name: sqlite3 - sha256: c6cfe9b1cc159c9eb8ba174b533a60b5126f9db8c6e34efb127d2bc04bc45034 - url: "https://pub.dev" - source: hosted - version: "3.1.4" + path: sqlite3 + ref: main + resolved-ref: "97268a5b32cf6313dd670e6fa57ae88d38a6564b" + url: "https://github.com/simolus3/sqlite3.dart.git" + source: git + version: "3.3.2" sqlite3_test: dependency: "direct dev" description: @@ -514,4 +515,4 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.999 <4.0.0" + dart: ">=3.10.0 <4.0.0" diff --git a/dart/pubspec.yaml b/dart/pubspec.yaml index ca5db351..e8a18b28 100644 --- a/dart/pubspec.yaml +++ b/dart/pubspec.yaml @@ -19,6 +19,15 @@ dev_dependencies: meta: ^1.16.0 path: ^1.9.1 +dependency_overrides: + # TODO: Remove after https://github.com/simolus3/sqlite3.dart/commit/97268a5b32cf6313dd670e6fa57ae88d38a6564b + # gets released. + sqlite3: + git: + url: https://github.com/simolus3/sqlite3.dart.git + path: sqlite3 + ref: main + # See https://pub.dev/documentation/sqlite3/latest/topics/hook-topic.html hooks: user_defines: From 551d2b63b716ba925de84d3b2a605421acb76731 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 12 Jun 2026 12:30:55 +0200 Subject: [PATCH 4/5] Include missing test --- crates/core/src/update_hooks.rs | 2 +- dart/tool/all_tests.dart | 2 ++ dart/tool/run_tests.dart | 6 +++--- tool/build_linux_sanitized.sh | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/core/src/update_hooks.rs b/crates/core/src/update_hooks.rs index 4c7e46a7..1501aa0f 100644 --- a/crates/core/src/update_hooks.rs +++ b/crates/core/src/update_hooks.rs @@ -18,7 +18,7 @@ use crate::{constants::SUBTYPE_JSON, error::PowerSyncError, state::DatabaseState /// and comitted since the last `powersync_update_hooks` call. /// /// The update hooks don't have to be uninstalled manually, that happens when the connection is -/// closed and the function is unregistered. +/// closed (`powersync_init()` installs a vtab calling `uninstall_update_hooks()`). pub fn register(db: *mut sqlite::sqlite3, state: Rc) -> Result<(), ResultCode> { let state = Box::new(HookState { db, state }); diff --git a/dart/tool/all_tests.dart b/dart/tool/all_tests.dart index 4a057254..545c8448 100644 --- a/dart/tool/all_tests.dart +++ b/dart/tool/all_tests.dart @@ -8,6 +8,7 @@ import '../test/schema_test.dart' as schema_test; // Skipping sync_local_performance_test because it's slow. It's functionality is // covered by other tests. import '../test/sync_stream_test.dart' as sync_stream_test; +import '../test/sync_test.dart' as sync_test; import '../test/update_hooks_test.dart' as update_hooks_test; import '../test/utils/native_test_utils.dart'; @@ -24,5 +25,6 @@ void main(List args) { group('migration_test.dart', migration_test.main); group('schema_test.dart', schema_test.main); group('sync_stream_test.dart', sync_stream_test.main); + group('sync_test.dart', sync_test.main); group('update_hooks_test.dart', update_hooks_test.main); } diff --git a/dart/tool/run_tests.dart b/dart/tool/run_tests.dart index 1b33ca58..3bf2bc8a 100644 --- a/dart/tool/run_tests.dart +++ b/dart/tool/run_tests.dart @@ -5,8 +5,8 @@ import 'package:path/path.dart' as p; /// Runs `all_tests.dart` as a single AOT-compiled executable with sanitizers /// enabled. /// -/// To run tests with a sanitizer, use `dart tool/run_tests.dart -/// --sanitizer $sanitizer`, where `$sanitizer` is either `asan` or `msan`. +/// To run tests with a sanitizer, use `dart tool/run_tests.dart $sanitizer`, +/// where `$sanitizer` is either `asan` or `msan`. /// Note that sanitizers are only supported on X64 Linux hosts. /// /// Running tests with sanitizers also requires an instrumented build of SQLite @@ -78,7 +78,7 @@ stderr: ${result.stderr} Future _createNativeAssetsConfig( Directory tmpForRun, - String? expandedName, + String expandedName, ) async { final sqliteFile = p.normalize( p.absolute('../sanitized/sqlite', 'libsqlite3_$expandedName.so'), diff --git a/tool/build_linux_sanitized.sh b/tool/build_linux_sanitized.sh index 7bab1e7d..369b0e74 100755 --- a/tool/build_linux_sanitized.sh +++ b/tool/build_linux_sanitized.sh @@ -21,7 +21,8 @@ function compile_sqlite() { -DSQLITE_ENABLE_API_ARMOR=1 \ -DSQLITE_OMIT_DEPRECATED \ -DSQLITE_DQS=0 \ - -DSQLISQLITE_ENABLE_DBSTAT_VTABTE_ENABLE_FTS5 \ + -DSQLITE_ENABLE_DBSTAT_VTAB \ + -DSQLITE_ENABLE_FTS5 \ -DSQLITE_ENABLE_STMTVTAB \ -shared \ crates/sqlite/sqlite/sqlite3.c \ From c040340e667ae999353d77e2b72eba67d6c3ec05 Mon Sep 17 00:00:00 2001 From: Simon Binder Date: Fri, 12 Jun 2026 12:36:34 +0200 Subject: [PATCH 5/5] Skip database locked tests --- dart/test/sync_test.dart | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/dart/test/sync_test.dart b/dart/test/sync_test.dart index d9478d86..ab92e249 100644 --- a/dart/test/sync_test.dart +++ b/dart/test/sync_test.dart @@ -978,7 +978,10 @@ void _syncTests({ expect(db.select('SELECT * FROM ps_buckets'), isEmpty); }); - group('recoverable', () { + group('recoverable', + skip: testingWithSanitizers != null + ? 'Unsupported in memory VFS' + : null, () { late CommonDatabase secondary; final checkpoint = { 'checkpoint': {