diff --git a/lib/src/native/resqlite_bindings.dart b/lib/src/native/resqlite_bindings.dart index 09c1119b..5aee18fb 100644 --- a/lib/src/native/resqlite_bindings.dart +++ b/lib/src/native/resqlite_bindings.dart @@ -418,6 +418,19 @@ external int resqliteDbStatusTotal( ffi.Pointer outHighwater, ); +/// Brackets a read worker's request so `resqlite_db_status_total` never +/// reads this NOMUTEX connection from the main isolate while the worker +/// is using it. See `resqlite_reader_set_busy` in `native/resqlite.h`. +@ffi.Native, ffi.Int, ffi.Int)>( + symbol: 'resqlite_reader_set_busy', + isLeaf: true, +) +external void resqliteReaderSetBusy( + ffi.Pointer db, + int readerId, + int busy, +); + /// Per-worker persistent buffer for read-table pointer marshalling. /// Allocated once; reused across calls. Eliminates a ~512-byte /// calloc/free pair per stream subscription diff --git a/lib/src/reader/read_worker.dart b/lib/src/reader/read_worker.dart index fd963661..e7f03535 100644 --- a/lib/src/reader/read_worker.dart +++ b/lib/src/reader/read_worker.dart @@ -104,6 +104,18 @@ void readerEntrypoint(List args) { final request = message as ReadRequest; + // Mark this worker's dedicated reader connection busy so + // Database.diagnostics() (sqlite3_db_status from the main isolate) + // skips it while we're stepping over it — the connections are + // NOMUTEX, so a concurrent status read is a data race. Cleared in + // `finally` below, and explicitly before Isolate.exit on the + // sacrifice path (exit skips finally). + resqliteReaderSetBusy( + ffi.Pointer.fromAddress(dbHandleAddr), + readerId, + 1, + ); + // Timeline marker scopes the reader-isolate's per-message work so // external profilers can see the cross-isolate breakdown. Gated // behind `kProfileMode` (compile-time const) so release builds pay @@ -180,7 +192,14 @@ void readerEntrypoint(List args) { if (sacrifice) { receivePort.close(); - // Isolate.exit skips `finally`; close the timeline span manually. + // Isolate.exit skips `finally`; release the busy bracket and + // close the timeline span manually. The result is fully + // materialized at this point — no further native reads occur. + resqliteReaderSetBusy( + ffi.Pointer.fromAddress(dbHandleAddr), + readerId, + 0, + ); if (kProfileMode) { Timeline.finishSync(); TraceliteProfile.end( @@ -205,6 +224,11 @@ void readerEntrypoint(List args) { ); eventPort.send((null, false, error)); } finally { + resqliteReaderSetBusy( + ffi.Pointer.fromAddress(dbHandleAddr), + readerId, + 0, + ); if (kProfileMode) { Timeline.finishSync(); TraceliteProfile.end( diff --git a/native/resqlite.c b/native/resqlite.c index 9298f28d..403eb052 100644 --- a/native/resqlite.c +++ b/native/resqlite.c @@ -314,6 +314,11 @@ typedef struct { resqlite_authz_ctx authz_ctx; resqlite_buf json_buf; // persistent buffer for resqlite_query_bytes int in_use; + // Set by the reader's worker isolate around each request + // (resqlite_reader_set_busy) so resqlite_db_status_total never reads + // this NOMUTEX connection while the worker is using it. The legacy + // acquire-path `in_use` is dead under dedicated assignment (exp 030). + atomic_int worker_busy; } resqlite_reader; // --------------------------------------------------------------------------- @@ -677,6 +682,7 @@ static resqlite_db* resqlite_open_impl(const char* path, int max_readers, continue; } db->readers[idx].in_use = 0; + atomic_init(&db->readers[idx].worker_busy, 0); // Install authorizer to capture read dependencies (table + column). // The context lives inline on the reader so its address is stable @@ -1239,6 +1245,12 @@ int resqlite_get_dirty_tables( return count; } +void resqlite_reader_set_busy(resqlite_db* db, int reader_id, int busy) { + if (!db || reader_id < 0 || reader_id >= db->reader_count) return; + atomic_store_explicit(&db->readers[reader_id].worker_busy, busy, + memory_order_release); +} + // Polish (post-2026-04): returns RESQLITE_DEPENDENCY_COUNT_UNKNOWN when // the cached entry's read-table dependencies are unreliable (overflow / OOM // during prepare). Zero would mean "stream has no table deps" → silent stuck @@ -1367,7 +1379,9 @@ int resqlite_db_status_total( sqlite3_mutex_enter(db->pool_mutex); for (int i = 0; i < db->reader_count; i++) { - if (db->readers[i].in_use) { + if (db->readers[i].in_use || + atomic_load_explicit(&db->readers[i].worker_busy, + memory_order_acquire)) { if (rc == SQLITE_OK) rc = SQLITE_BUSY; continue; } diff --git a/native/resqlite.h b/native/resqlite.h index 045c01f5..8560f70f 100644 --- a/native/resqlite.h +++ b/native/resqlite.h @@ -264,6 +264,18 @@ int resqlite_db_status_total( int* out_highwater ); +// Mark a dedicated reader connection busy/idle from its worker isolate. +// +// Connections are opened NOMUTEX, so `resqlite_db_status_total` must never +// call sqlite3_db_status() on a connection another thread is actively +// using (SQLITE_DBSTATUS_SCHEMA_USED measures via the connection's +// pnBytesFreed dry-run mechanism — toggling it under a live reader +// corrupts that reader's allocation accounting). The legacy acquire-path +// `in_use` flag is dead under dedicated reader assignment +// ([EXP-030](../experiments/030-dedicated-reader-assignment.md)), so read +// workers bracket each request with this call to make the busy guard real. +void resqlite_reader_set_busy(resqlite_db* db, int reader_id, int busy); + // --------------------------------------------------------------------------- // Read operations (use reader pool) // ---------------------------------------------------------------------------