ObserverRegistrations stores observer identity as a webview label
(src/subscriptions.rs:290), so the only way to join the count is to call the observe
command from a webview. A Rust caller that enables observation on a DatabaseWrapper
directly registers nothing, and since #53 made observation a property of the database
rather than of a handle, it shares the broker the count governs. It is invisible to the
count while being fully subject to its teardown.
Destroying a window needs no cooperation from anyone. When the one window that had called
observe() closes, the Destroyed handler releases its registrations, finds the database
at zero observers, and calls disable_observation() — ending a Rust subscription that
window never created and never asked to end. The Rust task's while let Some(event) = stream.next().await loop falls through and the task exits. Nothing logs it and nothing
re-enables observation, so every later write takes the unobserved path for the rest of the
process's life. This does not need the last window, which would end the process anyway: it
needs any window to close while another stays open, with the closing one being the only
webview that had registered that database.
Repro
Minimal, runnable repro (cargo run, prints an explicit verdict). It is a real Tauri app
because tauri::test::MockRuntime never emits RuntimeWindowEvent::Destroyed, so the
handler is unreachable from a mock app — worth knowing for a regression test. Two controls
are built in: the subscriber receives a committed change moments before the close, and
destroying a different window that never called observe() leaves it working.
https://github.com/yokuze/mcve/tree/9e77d09620300bad7dd0fc545beb3f3ba748dc4b/cases/005-tauri-plugin-sqlite-window-close-kills-rust-observation
Root cause (rev b31142e)
The behavior is documented — disable_observation's doc names the window case
(wrapper.rs#L706-L718),
as do unobserve's doc and the README caveats — but the documented workarounds do not
cover this trigger. "Coordinate above this crate" assumes someone performs an action; a
window closing is not one, and the release runs inside the plugin's own event handler.
"Use your own database file" gives a Rust consumer a file the webviews do not write to,
which is the reason it was observing. "Re-enable after a teardown" needs a teardown signal,
and there is none.
Expected
Widen who is allowed to hold a registration. ObserverRegistrations already keeps a set
per database and already tears down when that set empties; the only thing that has to
change is the identity stored in it — a webview label, or an opaque token handed to a
direct Rust caller. Hand that token back as a guard released by Drop, so a registration
cannot leak the way the phantom registrations that motivated the Destroyed handler did.
This is the same shape as the per-caller registration token proposed in #57, which reports
the other failure of label-keyed identity: there, two callers share one label; here, one
caller has no label at all.
ObserverRegistrationsstores observer identity as a webview label(
src/subscriptions.rs:290), so the only way to join the count is to call theobservecommand from a webview. A Rust caller that enables observation on a
DatabaseWrapperdirectly registers nothing, and since #53 made observation a property of the database
rather than of a handle, it shares the broker the count governs. It is invisible to the
count while being fully subject to its teardown.
Destroying a window needs no cooperation from anyone. When the one window that had called
observe()closes, theDestroyedhandler releases its registrations, finds the databaseat zero observers, and calls
disable_observation()— ending a Rust subscription thatwindow never created and never asked to end. The Rust task's
while let Some(event) = stream.next().awaitloop falls through and the task exits. Nothing logs it and nothingre-enables observation, so every later write takes the unobserved path for the rest of the
process's life. This does not need the last window, which would end the process anyway: it
needs any window to close while another stays open, with the closing one being the only
webview that had registered that database.
Repro
Minimal, runnable repro (
cargo run, prints an explicit verdict). It is a real Tauri appbecause
tauri::test::MockRuntimenever emitsRuntimeWindowEvent::Destroyed, so thehandler is unreachable from a mock app — worth knowing for a regression test. Two controls
are built in: the subscriber receives a committed change moments before the close, and
destroying a different window that never called
observe()leaves it working.https://github.com/yokuze/mcve/tree/9e77d09620300bad7dd0fc545beb3f3ba748dc4b/cases/005-tauri-plugin-sqlite-window-close-kills-rust-observation
Root cause (rev
b31142e)Destroyedhandler releases the label's registrations, then disables observation on every database that reached zero:src/lib.rs#L708-L748release_all_for_labelreports a database as unobserved purely on its label set emptying:src/subscriptions.rs#L401-L423observeis the only writer of registrations, and it registerswebview.label():src/commands.rs#L862-L873enable_observationcreates or reuses the database's broker, registering nothing anywhere:crates/sqlx-sqlite-toolkit/src/wrapper.rs#L636-L692The behavior is documented —
disable_observation's doc names the window case(
wrapper.rs#L706-L718),as do
unobserve's doc and the README caveats — but the documented workarounds do notcover this trigger. "Coordinate above this crate" assumes someone performs an action; a
window closing is not one, and the release runs inside the plugin's own event handler.
"Use your own database file" gives a Rust consumer a file the webviews do not write to,
which is the reason it was observing. "Re-enable after a teardown" needs a teardown signal,
and there is none.
Expected
Widen who is allowed to hold a registration.
ObserverRegistrationsalready keeps a setper database and already tears down when that set empties; the only thing that has to
change is the identity stored in it — a webview label, or an opaque token handed to a
direct Rust caller. Hand that token back as a guard released by
Drop, so a registrationcannot leak the way the phantom registrations that motivated the
Destroyedhandler did.This is the same shape as the per-caller registration token proposed in #57, which reports
the other failure of label-keyed identity: there, two callers share one label; here, one
caller has no label at all.