From 2f870d0a71c3874b697085e9f8a1112cdc8a108a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 17:25:36 -0300 Subject: [PATCH 01/14] Fix local account selection in CLI and global tray consumers --- crates/cli/src/args.rs | 4 + crates/cli/src/main.rs | 34 +++- crates/cli/tests/cli_surface.rs | 56 ++++++- crates/daemon/src/account/mod.rs | 83 +++++++++- crates/daemon/src/macos_main.rs | 4 +- crates/daemon/src/main.rs | 54 +----- crates/daemon/src/server/handshake.rs | 32 +++- crates/daemon/src/server/mod.rs | 26 ++- crates/daemon/src/server/tests.rs | 83 ++++++++++ crates/daemon/src/tray/accounts.rs | 228 ++++++++++++++++++++++++++ crates/daemon/src/tray/linux.rs | 15 +- crates/daemon/src/tray/macos.rs | 13 +- crates/daemon/src/tray/mod.rs | 14 +- crates/daemon/src/tray/windows.rs | 15 +- crates/daemon/src/window/mod.rs | 19 +++ crates/session/src/store/identity.rs | 75 +++++++++ crates/session/src/store/mod.rs | 2 + crates/wire/src/request.rs | 6 + crates/wire/src/response.rs | 4 + docs/cli.md | 16 +- docs/relink-consolidation.md | 46 ++++++ 21 files changed, 730 insertions(+), 99 deletions(-) create mode 100644 crates/daemon/src/tray/accounts.rs create mode 100644 crates/session/src/store/identity.rs create mode 100644 docs/relink-consolidation.md diff --git a/crates/cli/src/args.rs b/crates/cli/src/args.rs index 8b7f167f..d6b4b073 100644 --- a/crates/cli/src/args.rs +++ b/crates/cli/src/args.rs @@ -22,6 +22,10 @@ pub struct OxidezapCli { #[usage(long, global, env = "OXIDEZAP_ACCOUNT")] pub account: Option, + /// Select a local account inside the daemon, independently of --account + #[usage(long, global, env = "OXIDEZAP_LOCAL_ACCOUNT")] + pub local_account: Option, + /// Override daemon socket path #[usage(long, global, env = "OXIDEZAP_SOCKET")] pub socket: Option, diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index cdb62337..80567c77 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -191,6 +191,19 @@ fn main() -> ExitCode { } } + let local_account = match cli.local_account.as_deref().map(str::parse::) { + Some(Ok(id)) if id > 0 => Some(id), + None => None, + _ => { + print_error( + output_mode, + "invalid_local_account", + "--local-account must be a positive SQLite account id", + ); + return ExitCode::from(2); + } + }; + // Connect, starting the daemon beside this binary when nothing is // listening. An explicit socket never spawns: the caller named a daemon it // expects to exist. @@ -232,13 +245,28 @@ fn main() -> ExitCode { let handshake_req = ClientRequest::Hello { protocol: CURRENT_PROTOCOL_VERSION, client_name: "oxidezap-cli".into(), + local_account, + account_selection: true, read_only: cli.read_only, session_events, }; - if let Err(err) = client.request(handshake_req) { - print_error(output_mode, &err.code, &err.message); - return ExitCode::from(3); + match client.request(handshake_req) { + Ok(DaemonResponse::AccountSelected { + local_account: selected, + }) if local_account.is_none_or(|requested| requested == selected) => {} + Ok(_) => { + print_error( + output_mode, + "account_selection_unsupported", + "daemon did not confirm local account selection; update the daemon before retrying", + ); + return ExitCode::from(3); + } + Err(err) => { + print_error(output_mode, &err.code, &err.message); + return ExitCode::from(3); + } } // Execute command diff --git a/crates/cli/tests/cli_surface.rs b/crates/cli/tests/cli_surface.rs index ef897f7a..0677a616 100644 --- a/crates/cli/tests/cli_surface.rs +++ b/crates/cli/tests/cli_surface.rs @@ -9,7 +9,8 @@ fn cli() -> Command { command .env_remove("OXIDEZAP_ACCOUNT") .env_remove("OXIDEZAP_SOCKET") - .env_remove("OXIDEZAP_READONLY"); + .env_remove("OXIDEZAP_READONLY") + .env_remove("OXIDEZAP_LOCAL_ACCOUNT"); command } @@ -370,10 +371,10 @@ fn export_continues_from_the_daemon_cursor() { let mut writer = stream; let mut line = String::new(); - // Handshake: any request is answered with an Ack. + // The daemon confirms the account before receiving business requests. reader.read_line(&mut line).expect("hello"); writer - .write_all(b"{\"id\":1,\"status\":\"ok\",\"type\":\"ack\"}\n") + .write_all(b"{\"id\":1,\"status\":\"ok\",\"type\":\"account_selected\",\"data\":{\"local_account\":2}}\n") .expect("answer hello"); let mut page = 0; @@ -452,3 +453,52 @@ fn export_continues_from_the_daemon_cursor() { "the second page must continue from the daemon cursor, not a message id" ); } +/// A daemon predating account selection ignores unknown hello fields. Its Ack +/// must never authorize a send against the historical account by accident. +#[cfg(unix)] +#[test] +fn an_old_daemon_cannot_silently_ignore_local_account_selection() { + use std::io::{BufRead as _, Write as _}; + let dir = std::env::temp_dir().join(format!("oxi-old-hello-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let socket = dir.join("daemon.sock"); + let listener = std::os::unix::net::UnixListener::bind(&socket).unwrap(); + let server = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut reader = std::io::BufReader::new(stream); + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let hello: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(hello["params"]["local_account"], 2); + assert_eq!(hello["params"]["account_selection"], true); + reader + .get_mut() + .write_all(b"{\"id\":1,\"status\":\"ok\",\"type\":\"ack\"}\n") + .unwrap(); + line.clear(); + assert_eq!( + reader.read_line(&mut line).unwrap(), + 0, + "no business request may follow an unconfirmed selection" + ); + }); + let output = cli() + .args([ + "--socket", + socket.to_str().unwrap(), + "--local-account", + "2", + "--json", + "status", + ]) + .output() + .unwrap(); + assert_eq!(output.status.code(), Some(3)); + let error: serde_json::Value = serde_json::from_slice(&output.stderr).unwrap(); + assert_eq!(error["error"]["code"], "account_selection_unsupported"); + server.join().unwrap(); + std::fs::remove_dir_all(dir).unwrap(); +} diff --git a/crates/daemon/src/account/mod.rs b/crates/daemon/src/account/mod.rs index 18e55c71..125877ab 100644 --- a/crates/daemon/src/account/mod.rs +++ b/crates/daemon/src/account/mod.rs @@ -437,6 +437,34 @@ impl AccountRegistry { runtimes } + /// Resolve once, never switch an established wire connection to another identity. + pub fn resolve_wire_account(&self, explicit: Option) -> Result { + let runtimes: Vec<_> = self + .runtimes() + .into_iter() + .filter(|runtime| !runtime.is_stopping()) + .collect(); + if let Some(raw) = explicit { + let id = AccountId::new(raw).map_err(|error| error.to_string())?; + return runtimes + .iter() + .any(|runtime| runtime.id() == id) + .then_some(id) + .ok_or_else(|| format!("local account {id} is unavailable")); + } + let candidates: Vec<_> = runtimes + .iter() + .map(|runtime| { + ( + runtime.id(), + runtime.hub().windows_attached(), + runtime.hub().connection().is_connected(), + ) + }) + .collect(); + select_wire_account(&candidates) + } + /// Drive one registered runtime while publishing lifecycle transitions. pub async fn run( &self, @@ -485,6 +513,53 @@ impl AccountRegistry { } } +fn select_wire_account(candidates: &[(AccountId, bool, bool)]) -> Result { + for eligible in [ + candidates + .iter() + .filter(|(_, window, _)| *window) + .collect::>(), + candidates + .iter() + .filter(|(_, _, connected)| *connected) + .collect(), + candidates.iter().collect(), + ] { + match eligible.as_slice() { + [only] => return Ok(only.0), + [] => {} + _ => { + return Err( + "multiple local accounts are available; pass --local-account ".into(), + ); + } + } + } + Err("no local account is available".into()) +} + +#[cfg(test)] +mod wire_selection_tests { + use super::*; + #[test] + fn resolves_successor_without_legacy_and_refuses_ambiguous_identity() { + let a = AccountId::new(2).unwrap(); + let b = AccountId::new(3).unwrap(); + assert_eq!(select_wire_account(&[(a, false, true)]), Ok(a)); + assert_eq!( + select_wire_account(&[(AccountId::LEGACY, false, false), (a, false, true)]), + Ok(a) + ); + assert_eq!( + select_wire_account(&[(a, true, false), (b, false, true)]), + Ok(a) + ); + assert!(select_wire_account(&[(a, false, true), (b, false, true)]).is_err()); + assert!(select_wire_account(&[(a, true, true), (b, true, true)]).is_err()); + assert!(select_wire_account(&[]).is_err()); + } +} + /// Builds, registers and drives account runtimes for as long as the daemon /// runs — not just the ones alive at startup. /// @@ -835,12 +910,8 @@ impl AccountSupervisor { /// The same, using an already-built hub instead of building one /// internally. /// - /// The one caller this exists for is the daemon's own bootstrap: on - /// macOS the tray is built on the main thread, from a `StateHub` that has - /// to exist before the async runtime starts spawning anything at all - /// (see `macos_main`), so `main` builds it first and hands it in here - /// rather than [`Self::spawn`] building a second, disconnected one that - /// nobody would ever publish to. + /// Useful when an embedded caller already owns an account hub. The tray + /// uses the registry and no longer requires a pre-built legacy hub. /// /// # Panics /// diff --git a/crates/daemon/src/macos_main.rs b/crates/daemon/src/macos_main.rs index c2062c2a..b0575922 100644 --- a/crates/daemon/src/macos_main.rs +++ b/crates/daemon/src/macos_main.rs @@ -14,7 +14,7 @@ use core_foundation::runloop::{CFRunLoop, kCFRunLoopDefaultMode}; use objc2::MainThreadMarker; use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy}; -use crate::state::StateHub; +use crate::tray::TrayHub; use crate::tray::macos::MacTray; /// Run the daemon with the tray on this thread. @@ -24,7 +24,7 @@ use crate::tray::macos::MacTray; /// is a warning rather than a failure — the daemon is useful headless, the /// way it is on a bare window manager — and the runloop is pumped either /// way, so the two paths cannot drift. -pub fn run(runtime: tokio::runtime::Runtime, hub: Arc) -> Result<()> { +pub fn run(runtime: tokio::runtime::Runtime, hub: Arc) -> Result<()> { // The daemon owns an AppKit status item, not a user-facing application. // `LSUIElement` is the launch-time declaration; setting the same policy // explicitly also covers the unbundled executable the GUI starts and diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index 7734f1cf..bd9789a4 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -13,15 +13,13 @@ use oxidezap_daemon::{ account::{AccountRegistry, AccountSupervisor}, - listener, media, server, shutdown, state, tray, + listener, media, server, shutdown, tray, }; use std::sync::Arc; use anyhow::{Context, Result}; -use crate::state::StateHub; -use oxidezap_core::AccountId; use oxidezap_session::StoreRegistry; #[cfg(target_os = "macos")] @@ -97,7 +95,7 @@ fn main() -> Result<()> { // The hub is cheap and needs no runtime, so it is made here: on macOS // the tray is built from it on this thread before anything blocks. - let hub = StateHub::for_account(AccountId::LEGACY); + let hub = tray::TrayHub::new(AccountRegistry::new()); // AppKit pins the menu-bar icon to the main thread (see `macos_main`): // there the daemon runs one thread over and this thread pumps the @@ -108,7 +106,7 @@ fn main() -> Result<()> { return runtime.block_on(run(hub)); } -async fn run(hub: Arc) -> Result<()> { +async fn run(hub: Arc) -> Result<()> { // Registered before anything can ask us to stop. Until these handlers // exist SIGTERM still has its default disposition: a service manager // stopping the daemon during startup would kill it on the spot, without @@ -166,7 +164,8 @@ async fn run(hub: Arc) -> Result<()> { // multi-account daemon has one running task per account, all of which // have to stop on the same ask, and `shutdown::requested()` already // broadcasts to as many waiters as are watching it. - let registry = AccountRegistry::new(); + let registry = hub.registry(); + let tray_updates = tokio::spawn(Arc::clone(&hub).follow()); let stores = Arc::new(StoreRegistry::new(oxidezap_session::resolve_database_path())); let supervisor = AccountSupervisor::new( Arc::clone(®istry), @@ -174,55 +173,17 @@ async fn run(hub: Arc) -> Result<()> { server::MAX_CLIENTS, ); - // The hub built on this thread (see `main`) is always for - // `AccountId::LEGACY`: on macOS the tray already attached to it before - // this function ever ran, so that account's runtime is spawned through - // it rather than through a second, disconnected hub `spawn` would build. - // It is used only when a spawned account really is that id. - // - // Which ids get spawned is exactly what the shared database lists. An - // empty listing is *not* treated as "the legacy slot": `RemoveAccount` can - // retire the legacy id like any other, and a startup that recreated it - // would (a) silently reopen a removed account's slot and (b) resurrect the - // id, which upstream's `AUTOINCREMENT` allocation exists to prevent — its - // `PersistenceManager::new` recreates the bound `device` row whenever it - // is missing. So an empty database allocates its next account the same way - // `CreateAccount` does, which yields 1 on a genuinely fresh install and the - // next free id after every account has been removed. match stores.accounts().await { Ok(existing) if existing.is_empty() => match stores.create_account().await { Ok(id) => { - if id == AccountId::LEGACY { - supervisor - .spawn_with_hub(AccountId::LEGACY, Arc::clone(&hub)) - .await; - } else { - supervisor.spawn(id).await; - } + supervisor.spawn(id).await; } Err(e) => log::error!("could not allocate the first account: {e:#}"), }, Ok(existing) => { for account in existing { - if account.id == AccountId::LEGACY { - // Still here: spawn it through the pre-built hub rather - // than a second, disconnected one `spawn` would build. - supervisor - .spawn_with_hub(AccountId::LEGACY, Arc::clone(&hub)) - .await; - } else { - // Every other account this database already knows - // about, started the same way `CreateAccount` will - // start a brand new one — its own hub, plugin host and - // command channel, none of it shared with the legacy - // slot. - supervisor.spawn(account.id).await; - } + supervisor.spawn(account.id).await; } - // If `AccountId::LEGACY` was removed, it is simply absent from - // `existing` and nothing above spawns it — which is the whole - // fix: a removed id stays removed across a restart instead of - // this loop quietly recreating its slot. } Err(e) => { log::error!("could not list existing accounts at startup: {e:#}; starting with none"); @@ -298,6 +259,7 @@ async fn run(hub: Arc) -> Result<()> { // down with it), so unlike before this request is unconditional rather // than gated on which branch above returned. shutdown::request("daemon exiting"); + tray_updates.abort(); // While that drains, a repeated signal escalates rather than queues // behind it: the teardown has joins without a deadline, and a second // signal is somebody saying the first one is taking too long. diff --git a/crates/daemon/src/server/handshake.rs b/crates/daemon/src/server/handshake.rs index f296aa72..dfc10a78 100644 --- a/crates/daemon/src/server/handshake.rs +++ b/crates/daemon/src/server/handshake.rs @@ -143,17 +143,19 @@ pub(super) struct Attached { pub(super) is_wire: bool, /// Wire hello request ID. pub(super) wire_hello_id: Option, + pub(super) wire_account: Option, + pub(super) account_selection: bool, } impl Attached { - /// A wire-protocol client, bound to the daemon's default account. - /// - /// The wire hello predates account scoping and carries no account: the CLI - /// reaches whichever daemon owns the socket it connected to. On a - /// multi-account daemon that is the legacy slot, which every install has - /// and which a fresh one allocates first; a wire client that wants another - /// local account is a follow-up, not a wire field yet. - pub(super) fn wire(session_events: bool, read_only: bool, wire_hello_id: Option) -> Self { + /// Wire account selection is resolved once against the registry before attachment. + pub(super) fn wire( + session_events: bool, + read_only: bool, + wire_hello_id: Option, + wire_account: Option, + account_selection: bool, + ) -> Self { Self { session_events, scope: ClientScope::Account { @@ -164,6 +166,8 @@ impl Attached { read_only, is_wire: true, wire_hello_id, + wire_account, + account_selection, } } } @@ -179,11 +183,19 @@ pub(super) fn check_hello(line: &str) -> Result> { oxidezap_wire::request::ClientRequest::Hello { protocol, client_name: _, + local_account, + account_selection, read_only, session_events, } => { if protocol == oxidezap_wire::envelope::CURRENT_PROTOCOL_VERSION { - Ok(Attached::wire(session_events, read_only, Some(env.id))) + Ok(Attached::wire( + session_events, + read_only, + Some(env.id), + local_account, + account_selection, + )) } else { let err = oxidezap_wire::envelope::ResponseEnvelope { id: Some(env.id), @@ -232,6 +244,8 @@ pub(super) fn check_hello(line: &str) -> Result> { read_only: false, is_wire: false, wire_hello_id: None, + wire_account: None, + account_selection: false, }), ClientRequest::Hello { protocol, .. } => Err(always( id, diff --git a/crates/daemon/src/server/mod.rs b/crates/daemon/src/server/mod.rs index 1d1b4576..8981d855 100644 --- a/crates/daemon/src/server/mod.rs +++ b/crates/daemon/src/server/mod.rs @@ -177,7 +177,7 @@ pub(crate) async fn serve_client_with_registry( // a task and a descriptor and given nothing back. A peer that connects // and says nothing would otherwise sit here for as long as it liked, and // a reconnect loop doing it would take the listener down with it. - let attached = match oxidezap_session::with_timeout( + let mut attached = match oxidezap_session::with_timeout( handshake(&mut reader, &mut writer, &mut buf), HANDSHAKE_TIMEOUT, ) @@ -196,6 +196,22 @@ pub(crate) async fn serve_client_with_registry( } }; + if attached.is_wire && (attached.account_selection || attached.wire_account.is_some()) { + match registry.resolve_wire_account(attached.wire_account) { + Ok(account) => attached.scope = oxidezap_ipc::ClientScope::Account { account }, + Err(detail) => { + let response = oxidezap_wire::envelope::ResponseEnvelope { + id: attached.wire_hello_id, + result: oxidezap_wire::envelope::ResponseResult::Error { + error: oxidezap_wire::error::ApiError::invalid_request(detail), + }, + }; + write_line(&mut writer, &serde_json::to_string(&response)?).await?; + return Ok(()); + } + } + } + let (hub, plugins, commands) = match attached.scope { oxidezap_ipc::ClientScope::Control => { return serve_control_client(reader, writer, registry).await; @@ -288,7 +304,13 @@ pub(crate) async fn serve_client_with_registry( let resp = oxidezap_wire::envelope::ResponseEnvelope { id: attached.wire_hello_id, result: oxidezap_wire::envelope::ResponseResult::Ok { - payload: Box::new(oxidezap_wire::response::DaemonResponse::Ack), + payload: Box::new(if attached.account_selection { + oxidezap_wire::response::DaemonResponse::AccountSelected { + local_account: hub.account_id().get(), + } + } else { + oxidezap_wire::response::DaemonResponse::Ack + }), }, }; write_line(&mut writer, &serde_json::to_string(&resp)?).await?; diff --git a/crates/daemon/src/server/tests.rs b/crates/daemon/src/server/tests.rs index aef35538..0c1fe0d2 100644 --- a/crates/daemon/src/server/tests.rs +++ b/crates/daemon/src/server/tests.rs @@ -55,6 +55,8 @@ fn attached(session_events: bool, owns_window: bool, call_video: bool) -> Attach read_only: false, is_wire: false, wire_hello_id: None, + wire_account: None, + account_selection: false, } } @@ -1332,6 +1334,8 @@ async fn a_wire_client_that_lags_is_disconnected_rather_than_stalled() { let wire_hello = serde_json::to_string(&RequestEnvelope { id: 1, request: WireRequest::Hello { + local_account: None, + account_selection: false, protocol: oxidezap_wire::envelope::CURRENT_PROTOCOL_VERSION, client_name: "test".into(), read_only: false, @@ -1899,6 +1903,8 @@ async fn wire_hello_and_diagnostics_flow() { let wire_hello = serde_json::to_string(&RequestEnvelope { id: 1, request: WireRequest::Hello { + local_account: None, + account_selection: false, protocol: oxidezap_wire::envelope::CURRENT_PROTOCOL_VERSION, client_name: "test-client".into(), read_only: true, @@ -2466,3 +2472,80 @@ async fn replacing_an_account_runtime_closes_old_frontend_attachments() { line.clear(); assert_eq!(reader.read_line(&mut line).await.unwrap(), 0); } +#[tokio::test] +async fn scoped_wire_hello_confirms_successor_and_never_rebinds_after_selection_changes() { + use oxidezap_wire::{ + envelope::{RequestEnvelope, ResponseEnvelope, ResponseResult}, + request::ClientRequest as WireRequest, + response::DaemonResponse as WireResponse, + }; + let registry = AccountRegistry::new(); + let successor = oxidezap_core::AccountId::new(2).unwrap(); + let hub = StateHub::for_account(successor); + hub.apply(crate::state::Change::live( + oxidezap_ipc::DaemonEvent::ConnectionChanged(oxidezap_ipc::ConnectionState::Connected), + )); + let (commands, _receiver) = tokio::sync::mpsc::channel(1); + registry.insert(Arc::new(AccountRuntime::new( + successor, + Arc::clone(&hub), + no_plugins(), + commands, + ))); + let (mut client, server) = tokio::io::duplex(64 * 1024); + let served = tokio::spawn(serve_client_with_registry( + Box::new(server), + Arc::clone(®istry), + )); + let hello = RequestEnvelope { + id: 1, + request: WireRequest::Hello { + protocol: oxidezap_wire::envelope::CURRENT_PROTOCOL_VERSION, + client_name: "synthetic".into(), + local_account: None, + account_selection: true, + read_only: true, + session_events: false, + }, + }; + client + .write_all(format!("{}\n", serde_json::to_string(&hello).unwrap()).as_bytes()) + .await + .unwrap(); + let mut reader = BufReader::new(client); + let mut line = String::new(); + reader.read_line(&mut line).await.unwrap(); + assert!( + matches!(serde_json::from_str::(&line).unwrap().result, + ResponseResult::Ok { payload } if *payload == WireResponse::AccountSelected { local_account: 2 }) + ); + + // A different account now owns a window. The established connection still + // reads the selected successor, rather than following a new default. + let other_id = oxidezap_core::AccountId::new(3).unwrap(); + let other = StateHub::for_account(other_id); + let _window = other.attach_window(); + let (commands, _receiver) = tokio::sync::mpsc::channel(1); + registry.insert(Arc::new(AccountRuntime::new( + other_id, + other, + no_plugins(), + commands, + ))); + let request = RequestEnvelope { + id: 2, + request: WireRequest::GetStatus, + }; + reader + .get_mut() + .write_all(format!("{}\n", serde_json::to_string(&request).unwrap()).as_bytes()) + .await + .unwrap(); + line.clear(); + reader.read_line(&mut line).await.unwrap(); + assert!( + matches!(serde_json::from_str::(&line).unwrap().result, + ResponseResult::Ok { payload } if matches!(*payload, WireResponse::Status(ref status) if status.state == "connected")) + ); + served.abort(); +} diff --git a/crates/daemon/src/tray/accounts.rs b/crates/daemon/src/tray/accounts.rs new file mode 100644 index 00000000..dcb23adb --- /dev/null +++ b/crates/daemon/src/tray/accounts.rs @@ -0,0 +1,228 @@ +//! A daemon-wide tray follows current runtimes, including replacements and removals. +use std::sync::Arc; + +use tokio::sync::{mpsc, watch}; + +use crate::account::AccountRegistry; +use crate::state::{StateHub, TrayState}; + +pub struct TrayHub { + registry: Arc, + state: watch::Sender, + empty: Arc, +} + +impl TrayHub { + pub fn new(registry: Arc) -> Arc { + let (state, _) = watch::channel(TrayState { + connected: false, + unread: 0, + }); + Arc::new(Self { + registry, + state, + empty: StateHub::new(), + }) + } + + pub fn registry(&self) -> Arc { + Arc::clone(&self.registry) + } + pub fn watch_tray(&self) -> watch::Receiver { + self.state.subscribe() + } + + pub fn hubs(&self) -> Vec> { + let hubs: Vec<_> = self + .registry + .runtimes() + .into_iter() + .filter(|runtime| !runtime.is_stopping()) + .map(|runtime| runtime.hub()) + .collect(); + if hubs.is_empty() { + vec![Arc::clone(&self.empty)] + } else { + hubs + } + } + + pub fn windows_attached(&self) -> bool { + self.hubs().iter().any(|hub| hub.windows_attached()) + } + pub fn show(&self) { + crate::window::show_every(self.hubs()); + } + pub fn hide(&self) { + for hub in self.hubs() { + crate::window::hide(&hub); + } + } + + fn publish(&self) { + let values: Vec<_> = self + .hubs() + .iter() + .map(|hub| hub.watch_tray().borrow().clone()) + .collect(); + let state = aggregate(&values); + self.state.send_if_modified(|previous| { + if *previous == state { + false + } else { + *previous = state; + true + } + }); + } + + pub async fn follow(self: Arc) { + let mut accounts = self.registry.subscribe(); + let (changed, mut changes) = mpsc::channel(1); + let mut watchers = tokio::task::JoinSet::new(); + loop { + watchers.abort_all(); + while watchers.join_next().await.is_some() {} + accounts.borrow_and_update(); + for hub in self.hubs() { + let mut state = hub.watch_tray(); + let changed = changed.clone(); + watchers.spawn(async move { + while state.changed().await.is_ok() { + state.borrow_and_update(); + let _ = changed.try_send(()); + } + }); + } + self.publish(); + loop { + tokio::select! { + result = accounts.changed() => { + if result.is_err() { return; } + break; + } + Some(()) = changes.recv() => self.publish(), + } + } + } + } +} + +fn aggregate(states: &[TrayState]) -> TrayState { + TrayState { + connected: states.iter().any(|state| state.connected), + unread: states.iter().fold(0u32, |total, state| { + total.saturating_add(state.shown_unread()) + }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::account::AccountRuntime; + use oxidezap_core::AccountId; + use oxidezap_ipc::{ConnectionState, DaemonEvent}; + + fn runtime(id: i32) -> Arc { + let id = AccountId::new(id).unwrap(); + let (commands, _receiver) = mpsc::channel(1); + Arc::new(AccountRuntime::new( + id, + StateHub::for_account(id), + Arc::new(oxidezap_plugin_host::Plugins::nothing_loaded(Arc::new( + |_| {}, + ))), + commands, + )) + } + + #[tokio::test] + async fn removed_runtime_cannot_publish_stale_tray_state_or_own_window_actions() { + let registry = AccountRegistry::new(); + let old = runtime(2); + old.hub() + .apply(crate::state::Change::live(DaemonEvent::ConnectionChanged( + ConnectionState::Connected, + ))); + registry.insert(Arc::clone(&old)); + let tray = TrayHub::new(Arc::clone(®istry)); + let mut state = tray.watch_tray(); + let following = tokio::spawn(Arc::clone(&tray).follow()); + tokio::time::timeout(std::time::Duration::from_secs(5), state.changed()) + .await + .unwrap() + .unwrap(); + assert!(state.borrow_and_update().connected); + registry.remove(old.id()); + tokio::time::timeout(std::time::Duration::from_secs(5), state.changed()) + .await + .unwrap() + .unwrap(); + assert!(!state.borrow_and_update().connected); + + let successor = runtime(3); + registry.insert(Arc::clone(&successor)); + let mut signals = successor.hub().subscribe_signals(); + let _window = successor.hub().attach_window(); + let _stale_window = old.hub().attach_window(); + let mut stale_signals = old.hub().subscribe_signals(); + assert!(tray.windows_attached()); + crate::window::Toggle::default().click_every(tray.hubs()); + assert!(matches!( + serde_json::from_str::(&signals.try_recv().unwrap()) + .unwrap(), + oxidezap_ipc::DaemonMessage::HideWindow + )); + assert!(stale_signals.try_recv().is_err()); + tray.show(); + assert!(matches!( + serde_json::from_str::(&signals.try_recv().unwrap()) + .unwrap(), + oxidezap_ipc::DaemonMessage::ShowWindow + )); + assert!(stale_signals.try_recv().is_err()); + following.abort(); + } + + #[test] + fn disconnected_retained_history_does_not_hide_connected_successor() { + assert_eq!( + aggregate(&[ + TrayState { + connected: false, + unread: 100 + }, + TrayState { + connected: true, + unread: 7 + }, + ]), + TrayState { + connected: true, + unread: 7 + } + ); + assert_eq!( + aggregate(&[]), + TrayState { + connected: false, + unread: 0 + } + ); + assert_eq!( + aggregate(&[ + TrayState { + connected: true, + unread: u32::MAX + }, + TrayState { + connected: true, + unread: 1 + }, + ]) + .unread, + u32::MAX + ); + } +} diff --git a/crates/daemon/src/tray/linux.rs b/crates/daemon/src/tray/linux.rs index 84cd5310..9744deb0 100644 --- a/crates/daemon/src/tray/linux.rs +++ b/crates/daemon/src/tray/linux.rs @@ -8,7 +8,8 @@ use ksni::{ menu::StandardItem, }; -use crate::state::{StateHub, TrayState}; +use super::TrayHub; +use crate::state::TrayState; /// The themed name the icon takes while something is waiting to be read. /// @@ -24,7 +25,7 @@ struct Item { /// pure observer; its menu items and a click on it are the one place it /// speaks back — and the one thing it asks, whether a window is /// attached, is what the first item is named from. - hub: Arc, + hub: Arc, /// What a click on the icon does, and when the last one was: a double /// click arrives as two, and the second must not undo the first. click: crate::window::Toggle, @@ -97,7 +98,7 @@ impl KsniTray for Item { /// `crate::window::Toggle`, which is also what keeps a double click, /// which arrives as two of these, from hiding and then reopening. fn activate(&mut self, _x: i32, _y: i32) { - self.click.click(&self.hub); + self.click.click_every(self.hub.hubs()); } /// The host is about to open the menu. @@ -137,7 +138,7 @@ impl KsniTray for Item { // front end decides what going away means for it. See // `crate::window::hide`. activate: Box::new(|item: &mut Self| { - crate::window::hide(&item.hub); + item.hub.hide(); }), ..Default::default() } @@ -149,7 +150,7 @@ impl KsniTray for Item { // when nobody is attached, which is the state the tray is // most often clicked in. See `crate::window::show`. activate: Box::new(|item: &mut Self| { - crate::window::show(&item.hub); + item.hub.show(); }), ..Default::default() } @@ -192,7 +193,7 @@ impl super::Tray for LinuxTray { } } -pub async fn start(hub: Arc) -> Result> { +pub async fn start(hub: Arc) -> Result> { let item = Item { state: TrayState { connected: false, @@ -217,7 +218,7 @@ mod tests { fn item(connected: bool, unread: u32) -> Item { Item { state: TrayState { connected, unread }, - hub: StateHub::new(), + hub: TrayHub::new(crate::account::AccountRegistry::new()), click: crate::window::Toggle::default(), } } diff --git a/crates/daemon/src/tray/macos.rs b/crates/daemon/src/tray/macos.rs index 961f314a..bcaa2d37 100644 --- a/crates/daemon/src/tray/macos.rs +++ b/crates/daemon/src/tray/macos.rs @@ -15,7 +15,8 @@ use tokio::sync::watch; use tray_icon::menu::{Menu, MenuItem, PredefinedMenuItem}; use tray_icon::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; -use crate::state::{StateHub, TrayState}; +use super::TrayHub; +use crate::state::TrayState; const OPEN_ID: &str = "oxidezap-open"; const HIDE_ID: &str = "oxidezap-hide"; @@ -35,7 +36,7 @@ pub struct MacTray { impl MacTray { /// Build the icon and paint the current state. Call on the main thread: /// anywhere else AppKit answers `NotMainThread`. - pub fn start(hub: &StateHub) -> Result { + pub fn start(hub: &TrayHub) -> Result { // Read before building, like every other tray: the icon must be // right the moment it appears, not only after the next change. let mut watch = hub.watch_tray(); @@ -53,7 +54,7 @@ impl MacTray { /// picks arrived since the last one. The caller runs the runloop around /// this — without it there is nothing to drain, since AppKit dispatches /// to this thread alone. - pub fn pump(&mut self, hub: &StateHub) { + pub fn pump(&mut self, hub: &TrayHub) { // Latest only: several updates between pumps collapse into one // repaint, which is what keeps a burst of receipts from repainting // the icon once per message. A dead hub means the daemon is going @@ -75,7 +76,7 @@ impl MacTray { .. } ) { - self.click.click(hub); + self.click.click_every(hub.hubs()); } } while let Ok(event) = tray_icon::menu::MenuEvent::receiver().try_recv() { @@ -85,8 +86,8 @@ impl MacTray { // Either published to nobody is harmless, which is what // makes fixed items safe where Linux renames one item for // what it would do. - OPEN_ID => crate::window::show(hub), - HIDE_ID => crate::window::hide(hub), + OPEN_ID => hub.show(), + HIDE_ID => hub.hide(), QUIT_ID => crate::shutdown::request("tray menu"), _ => {} } diff --git a/crates/daemon/src/tray/mod.rs b/crates/daemon/src/tray/mod.rs index a9702cbf..de6f809b 100644 --- a/crates/daemon/src/tray/mod.rs +++ b/crates/daemon/src/tray/mod.rs @@ -15,7 +15,9 @@ use std::sync::Arc; use anyhow::Result; -use crate::state::{StateHub, TrayState}; +use crate::state::TrayState; +mod accounts; +pub use accounts::TrayHub; /// The dot the icon-less platforms paint. Shared rather than once per tray, /// so no two platforms can disagree about which state gets which colour. @@ -45,7 +47,7 @@ pub trait Tray: Send { } /// Start a tray and keep it following the hub until the returned handle drops. -pub async fn spawn(hub: Arc) -> Result { +pub async fn spawn(hub: Arc) -> Result { let tray = platform_tray(Arc::clone(&hub)).await?; let mut watch = hub.watch_tray(); @@ -83,12 +85,12 @@ impl Drop for TrayHandle { } #[cfg(target_os = "linux")] -async fn platform_tray(hub: Arc) -> Result> { +async fn platform_tray(hub: Arc) -> Result> { linux::start(hub).await } #[cfg(target_os = "windows")] -async fn platform_tray(hub: Arc) -> Result> { +async fn platform_tray(hub: Arc) -> Result> { windows::start(hub).await } @@ -97,11 +99,11 @@ async fn platform_tray(hub: Arc) -> Result> { /// itself and pumps it from its runloop. Bailing keeps [`spawn`] compiling /// on every platform while saying where the real one lives. #[cfg(target_os = "macos")] -async fn platform_tray(_hub: Arc) -> Result> { +async fn platform_tray(_hub: Arc) -> Result> { anyhow::bail!("the macOS tray lives on the main thread; see tray::macos") } #[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))] -async fn platform_tray(_hub: Arc) -> Result> { +async fn platform_tray(_hub: Arc) -> Result> { anyhow::bail!("no tray implementation for this platform yet") } diff --git a/crates/daemon/src/tray/windows.rs b/crates/daemon/src/tray/windows.rs index ca11ab9d..6761c8e1 100644 --- a/crates/daemon/src/tray/windows.rs +++ b/crates/daemon/src/tray/windows.rs @@ -24,7 +24,8 @@ use anyhow::{Context, Result}; use tray_icon::menu::{Menu, MenuItem, PredefinedMenuItem}; use tray_icon::{MouseButton, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent}; -use crate::state::{StateHub, TrayState}; +use super::TrayHub; +use crate::state::TrayState; /// How often the tray thread wakes to drain menu and click events while no /// state update is waiting. @@ -40,7 +41,7 @@ const QUIT_ID: &str = "oxidezap-quit"; /// once that thread has the icon up, or an error when the notification area /// would not take it — which the daemon logs and carries on without, like /// every other tray failure. -pub async fn start(hub: Arc) -> Result> { +pub async fn start(hub: Arc) -> Result> { // Rendered before the icon appears, like the Linux tray: the icon must // be right the moment it shows, not only after the next change. let initial = hub.watch_tray().borrow_and_update().clone(); @@ -78,7 +79,7 @@ impl super::Tray for WindowsTray { /// Build the icon and run it until the daemon goes away. fn run( - hub: Arc, + hub: Arc, initial: TrayState, states: Receiver, ready: tokio::sync::oneshot::Sender>, @@ -121,7 +122,7 @@ fn build(initial: &TrayState) -> Result { } /// Drain state updates and tray events until the daemon drops the handle. -fn pump(hub: Arc, tray: TrayIcon, states: Receiver) { +fn pump(hub: Arc, tray: TrayIcon, states: Receiver) { let mut click = crate::window::Toggle::default(); let tray_events = TrayIconEvent::receiver(); let menu_events = tray_icon::menu::MenuEvent::receiver(); @@ -149,7 +150,7 @@ fn pump(hub: Arc, tray: TrayIcon, states: Receiver) { .. } ) { - click.click(&hub); + click.click_every(hub.hubs()); } } while let Ok(event) = menu_events.try_recv() { @@ -160,8 +161,8 @@ fn pump(hub: Arc, tray: TrayIcon, states: Receiver) { // makes fixed items safe where Linux renames one item for // what it would do: a stale label there can at worst do // nothing, and here there is no stale label at all. - OPEN_ID => crate::window::show(&hub), - HIDE_ID => crate::window::hide(&hub), + OPEN_ID => hub.show(), + HIDE_ID => hub.hide(), QUIT_ID => crate::shutdown::request("tray menu"), _ => {} } diff --git a/crates/daemon/src/window/mod.rs b/crates/daemon/src/window/mod.rs index 5757a3cd..b973f245 100644 --- a/crates/daemon/src/window/mod.rs +++ b/crates/daemon/src/window/mod.rs @@ -101,6 +101,25 @@ impl Toggle { self.click_at(hub, Instant::now()); } + /// Toggle all current account windows as one daemon-wide gesture. + pub fn click_every(&mut self, hubs: Vec>) { + let now = Instant::now(); + if self + .last + .is_some_and(|last| now.saturating_duration_since(last) < SAME_GESTURE) + { + return; + } + self.last = Some(now); + if hubs.iter().any(|hub| hub.windows_attached()) { + for hub in hubs { + hide(&hub); + } + } else { + show_every(hubs); + } + } + /// [`Self::click`], at a moment the caller names. Split so a test can /// place two clicks a known distance apart without sleeping between /// them. diff --git a/crates/session/src/store/identity.rs b/crates/session/src/store/identity.rs new file mode 100644 index 00000000..5ce04e38 --- /dev/null +++ b/crates/session/src/store/identity.rs @@ -0,0 +1,75 @@ +//! Conservative evidence for a relink decision. This never removes an account. +use whatsapp_rust::wacore_binary::jid::Jid; + +use super::StoredAccount; + +/// Both persisted namespaces must agree. A shared LID alone is not proof of +/// stability across relinks, and a shared phone number alone is not enough +/// to retire retained history after number reassignment. +#[must_use] +pub fn same_account_identity(old: &StoredAccount, new: &StoredAccount) -> bool { + fn canonical(raw: &Option, phone: bool) -> Option { + let jid: Jid = raw.as_deref()?.parse().ok()?; + if jid.user.is_empty() + || jid.integrator != 0 + || (phone && !jid.is_pn()) + || (!phone && !jid.is_lid()) + { + return None; + } + // The parser deliberately accepts non-user and server-only JIDs too. + // Do not let those contracts broaden account identity matching. + if !jid.user.bytes().all(|byte| byte.is_ascii_digit()) { + return None; + } + Some(jid.to_non_ad()) + } + let (Some(old_pn), Some(new_pn), Some(old_lid), Some(new_lid)) = ( + canonical(&old.jid, true), + canonical(&new.jid, true), + canonical(&old.lid, false), + canonical(&new.lid, false), + ) else { + return false; + }; + old_pn == new_pn && old_lid == new_lid +} + +#[cfg(test)] +mod tests { + use super::*; + use oxidezap_core::AccountId; + + fn account(id: i32, pn: Option<&str>, lid: Option<&str>) -> StoredAccount { + StoredAccount { + id: AccountId::new(id).unwrap(), + jid: pn.map(str::to_owned), + lid: lid.map(str::to_owned), + push_name: "Synthetic fixture".into(), + linked: true, + } + } + + #[test] + fn device_suffixes_do_not_make_a_new_whatsapp_identity() { + let old = account(1, Some("100001:7@s.whatsapp.net"), Some("200001:7@lid")); + let new = account(2, Some("100001:12@s.whatsapp.net"), Some("200001:12@lid")); + assert!(same_account_identity(&old, &new)); + } + + #[test] + fn incomplete_conflicting_and_different_evidence_preserves_both_accounts() { + let old = account(1, Some("100001@s.whatsapp.net"), Some("200001@lid")); + for new in [ + account(2, None, Some("200001@lid")), + account(2, Some("100001@s.whatsapp.net"), None), + account(2, Some("100002@s.whatsapp.net"), Some("200001@lid")), + account(2, Some("100001@s.whatsapp.net"), Some("200002@lid")), + account(2, Some("100002@s.whatsapp.net"), Some("200002@lid")), + account(2, Some("100001@g.us"), Some("200001@lid")), + account(2, Some("100001@s.whatsapp.net"), Some("@lid")), + ] { + assert!(!same_account_identity(&old, &new)); + } + } +} diff --git a/crates/session/src/store/mod.rs b/crates/session/src/store/mod.rs index e835f357..fdade53b 100644 --- a/crates/session/src/store/mod.rs +++ b/crates/session/src/store/mod.rs @@ -17,10 +17,12 @@ //! opens a connection, because installing a VFS after the fact does not move //! a database that is already open. +mod identity; #[cfg_attr(target_family = "wasm", path = "web.rs")] #[cfg_attr(not(target_family = "wasm"), path = "native.rs")] mod platform; mod registry; +pub use identity::same_account_identity; pub use platform::{database_path, open_for_account, prepare, wipe}; pub use registry::{StoreRegistry, StoredAccount}; diff --git a/crates/wire/src/request.rs b/crates/wire/src/request.rs index c9b41eef..3e3f3cfc 100644 --- a/crates/wire/src/request.rs +++ b/crates/wire/src/request.rs @@ -27,6 +27,12 @@ pub enum ClientRequest { Hello { protocol: u32, client_name: String, + /// Local SQLite account within this daemon, independent of its profile/socket. + #[serde(default, skip_serializing_if = "Option::is_none")] + local_account: Option, + /// Require account-selection acknowledgement before any business request. + #[serde(default)] + account_selection: bool, read_only: bool, session_events: bool, }, diff --git a/crates/wire/src/response.rs b/crates/wire/src/response.rs index fb68215e..15527515 100644 --- a/crates/wire/src/response.rs +++ b/crates/wire/src/response.rs @@ -14,6 +14,10 @@ use crate::envelope::PageCursor; pub enum DaemonResponse { /// Generic confirmation of an acknowledged action. Ack, + /// The immutable local account selected for this connection. + AccountSelected { + local_account: i32, + }, /// Connection and account status. Status(ConnectionStatusDto), diff --git a/docs/cli.md b/docs/cli.md index 34784d55..39f5e2eb 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,11 +62,23 @@ command that deletes a profile's store. ## Accounts -One account is one daemon over one store: separate socket, lock, database -and media cache. The daemon takes `--account ` (or `OXIDEZAP_ACCOUNT`); +A named profile has a separate daemon socket, lock, database and media cache. +Each daemon can hold several local WhatsApp accounts in that shared database. The daemon takes `--account ` (or `OXIDEZAP_ACCOUNT`); the CLI selects with the same flag or variable, and `--socket` overrides both. `--account` wins over the environment. +`--local-account ` selects the local account inside that daemon. +It also accepts `OXIDEZAP_LOCAL_ACCOUNT`. Without it, the daemon chooses the +only account with a window, otherwise the only connected account, otherwise +the only available account. Multiple candidates at any step require an +explicit id. This choice is fixed for the connection, including event streams. + +The CLI requires an account-selection acknowledgement before sending a +command. An older daemon that ignores the new hello fields is refused with +`account_selection_unsupported`. Existing wire clients that omit the new +fields retain their legacy-account behavior. No protocol or release version +changes are needed for this capability negotiation. + An id is validated, never sanitized: `[A-Za-z0-9][A-Za-z0-9_-]*`, and anything else is refused with `invalid_account_id` (exit 2). Sanitizing would map `wo/rk` onto `work` — two profiles converging on one store — and diff --git a/docs/relink-consolidation.md b/docs/relink-consolidation.md new file mode 100644 index 00000000..1a0437e4 --- /dev/null +++ b/docs/relink-consolidation.md @@ -0,0 +1,46 @@ +# Relink consolidation + +CLI and tray account selection must work independently of consolidation. +A relink still allocates a separate account and retains the rejected account. +Automatic removal is not enabled by the consumer-selection change. + +The proposed identity gate requires both persisted PN and LID to be present, +valid in their own namespaces, and equal after `Jid::to_non_ad`. Names, +photos, partial matches, PN alone and LID alone provide no deletion authority. +Conflicting or incomplete evidence retains both accounts. The pure +`store::same_account_identity` predicate captures this conservative gate; +it is not a durability check and has no cleanup side effects. + +The pinned whatsapp-rust commit is +`633f01480fe3a95510172c6312d01484563dcf3c`. Its +`wacore/binary/src/jid.rs` separates `user`, `server`, `agent`, `device` +and `integrator`. `to_non_ad` clears device and agent while retaining the +namespace and integrator. `SqliteStore::list_devices_impl` reads persisted +PN/LID, but its `linked` flag means only that PN parsed successfully. +It is not proof that the complete new session is durable. No claim about +LID stability across relinks follows from these contracts. + +Before activating automatic retirement, the lifecycle needs these additions: + +1. A successful new-session durability barrier. Web's relaxed IndexedDB VFS + does not expose completion of ordinary writes. Its in-memory SQL state + must not authorize deletion of retained history. See `session/src/store/web.rs`. +2. A durable retirement journal outside rows deleted with the old account. + `session_recovery.successor` currently belongs to the old account and has + `ON DELETE SET NULL` on the target. It cannot retain redirects or resume + media/plugin cleanup after deletion. Freeze the successor under lifecycle + serialization before recording retirement. Reject cycles and changes of + identity, and process successor chains without crossing different accounts. +3. Use the existing lifecycle to stop and join old sessions, writers and + plugins before cleanup. Do not copy keys or reassign device ids. Persist + cleanup progress separately, since `finish_forget` currently logs media + and plugin-settings cleanup failures and can still return completed. +4. Publish an explicit retirement redirect and close old scoped connections. + Consumers must acknowledge a new attachment before switching identity. + GUI active and retained selections need reconciliation on snapshots and + restart, including windows still showing old offline history. + +Cleanup failure must preserve the valid new session and a retryable journal. +Restart and concurrent-window tests must cover every journal boundary, old +cleanup failure, chains, conflicts, different accounts and stale events before +activation. No real session database is needed to exercise these cases. From eac46ad1d3b59c28b5e2a4959d21e247d50df53e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 17:48:22 -0300 Subject: [PATCH 02/14] Consolidate confirmed same-account relinks with a resumable retirement journal --- .../down.sql | 1 + .../up.sql | 8 + crates/chat-store/src/lib.rs | 1 + crates/chat-store/src/store/mod.rs | 1 + crates/chat-store/src/store/retirement.rs | 84 +++ crates/daemon/src/account/mod.rs | 62 +- crates/daemon/src/account/retirement.rs | 677 ++++++++++++++++++ crates/daemon/src/embedded.rs | 29 +- crates/daemon/src/listener/tab.rs | 17 +- crates/daemon/src/listener/web/mod.rs | 24 + crates/daemon/src/main.rs | 7 +- crates/daemon/src/server/mod.rs | 50 ++ crates/daemon/src/session_bridge/mod.rs | 153 ++-- crates/daemon/src/session_bridge/tests.rs | 40 ++ crates/gui/src/app/mod.rs | 23 +- crates/gui/src/app/recovery.rs | 33 + crates/gui/src/platform/account.rs | 57 ++ crates/gui/src/session/frames.rs | 3 + crates/gui/src/session/mod.rs | 4 + crates/ipc/src/protocol.rs | 5 + crates/session/src/lib.rs | 4 +- crates/session/src/store/mod.rs | 2 +- crates/session/src/store/native.rs | 5 + crates/session/src/store/registry.rs | 85 +++ crates/session/src/store/web.rs | 2 +- docs/relink-consolidation.md | 60 +- 26 files changed, 1326 insertions(+), 111 deletions(-) create mode 100644 crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql create mode 100644 crates/chat-store/migrations/2026-10-26-000001_account_retirement/up.sql create mode 100644 crates/chat-store/src/store/retirement.rs create mode 100644 crates/daemon/src/account/retirement.rs diff --git a/crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql b/crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql new file mode 100644 index 00000000..ad1213ac --- /dev/null +++ b/crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql @@ -0,0 +1 @@ +DROP TABLE account_retirement; diff --git a/crates/chat-store/migrations/2026-10-26-000001_account_retirement/up.sql b/crates/chat-store/migrations/2026-10-26-000001_account_retirement/up.sql new file mode 100644 index 00000000..de0885e8 --- /dev/null +++ b/crates/chat-store/migrations/2026-10-26-000001_account_retirement/up.sql @@ -0,0 +1,8 @@ +-- Survives both device-row deletion and interrupted external cleanup. +CREATE TABLE account_retirement ( + old_id INTEGER PRIMARY KEY NOT NULL CHECK(old_id > 0), + successor INTEGER NOT NULL CHECK(successor > old_id), + pn TEXT NOT NULL, + lid TEXT NOT NULL, + complete INTEGER NOT NULL DEFAULT 0 CHECK(complete IN (0, 1)) +); diff --git a/crates/chat-store/src/lib.rs b/crates/chat-store/src/lib.rs index 0c20663b..98101101 100644 --- a/crates/chat-store/src/lib.rs +++ b/crates/chat-store/src/lib.rs @@ -53,6 +53,7 @@ pub use materialize::{ }; pub use queries::MessageReadState; pub use store::ChatStore; +pub use store::retirement::AccountRetirement; pub use store::shared_history::PendingHistoryBundle; pub use types::{ ArrivalCursor, AvatarDescriptor, ChatArchiveFilter, ChatCursor, ChatEntry, ChatNameExpected, diff --git a/crates/chat-store/src/store/mod.rs b/crates/chat-store/src/store/mod.rs index ae307ec2..2f9f0d19 100644 --- a/crates/chat-store/src/store/mod.rs +++ b/crates/chat-store/src/store/mod.rs @@ -23,6 +23,7 @@ mod reaction; pub(crate) mod read_state; mod receipt; mod recovery; +pub(crate) mod retirement; mod revoke; pub(crate) mod shared_history; mod writer; diff --git a/crates/chat-store/src/store/retirement.rs b/crates/chat-store/src/store/retirement.rs new file mode 100644 index 00000000..1a928d9e --- /dev/null +++ b/crates/chat-store/src/store/retirement.rs @@ -0,0 +1,84 @@ +use diesel::prelude::*; +use diesel::sql_types::{Bool, Integer, Text}; +use whatsapp_rust_sqlite_storage::SqliteStore; + +use super::ChatStore; +use crate::error::{Result, db_err}; + +/// A durable redirect and retry record, independent of the retired device row. +#[derive(Clone, Debug, PartialEq, Eq, QueryableByName)] +pub struct AccountRetirement { + #[diesel(sql_type = Integer)] + pub old_id: i32, + #[diesel(sql_type = Integer)] + pub successor: i32, + #[diesel(sql_type = Text)] + pub pn: String, + #[diesel(sql_type = Text)] + pub lid: String, + #[diesel(sql_type = Bool)] + pub complete: bool, +} + +impl ChatStore { + pub async fn account_retirements(store: &SqliteStore) -> Result> { + Ok(store.shared().run(|conn| { + diesel::sql_query("SELECT old_id, successor, pn, lid, complete FROM account_retirement ORDER BY old_id") + .load(conn).map_err(db_err) + }).await?) + } + + /// Freeze an existing recovery edge and its exact persisted identity evidence. + /// The caller must pin the successor lifecycle and verify full device loading. + pub async fn begin_account_retirement( + store: &SqliteStore, + old_id: i32, + successor: i32, + old_pn: String, + old_lid: String, + pn: String, + lid: String, + ) -> Result { + if old_id <= 0 || successor <= old_id { + return Ok(false); + } + Ok(store.shared().run(move |conn| { + conn.immediate_transaction::<_, diesel::result::Error, _>(|conn| { + let count = diesel::sql_query( + "INSERT INTO account_retirement (old_id, successor, pn, lid) \ + SELECT ?, ?, ?, ? WHERE \ + EXISTS (SELECT 1 FROM session_recovery WHERE device_id = ? AND successor = ?) AND \ + EXISTS (SELECT 1 FROM device WHERE id = ? AND pn = ? AND lid = ?) AND \ + EXISTS (SELECT 1 FROM device WHERE id = ? AND pn = ? AND lid = ?) \ + ON CONFLICT(old_id) DO NOTHING") + .bind::(old_id).bind::(successor) + .bind::(&pn).bind::(&lid) + .bind::(old_id).bind::(successor) + .bind::(old_id).bind::(&old_pn).bind::(&old_lid) + .bind::(successor).bind::(&pn).bind::(&lid) + .execute(conn)?; + if count == 0 { return Ok(false); } + // Do this before removing the intermediate row. Its FK otherwise + // sets predecessors to NULL and loses the rest of a relink chain. + diesel::sql_query("UPDATE session_recovery SET successor = ? WHERE successor = ? AND device_id < ?") + .bind::(successor).bind::(old_id).bind::(old_id) + .execute(conn)?; + Ok(true) + }).map_err(db_err) + }).await?) + } + + pub async fn finish_account_retirement(store: &SqliteStore, old_id: i32) -> Result<()> { + store + .shared() + .run(move |conn| { + diesel::sql_query("UPDATE account_retirement SET complete = 1 WHERE old_id = ?") + .bind::(old_id) + .execute(conn) + .map(|_| ()) + .map_err(db_err) + }) + .await?; + Ok(()) + } +} diff --git a/crates/daemon/src/account/mod.rs b/crates/daemon/src/account/mod.rs index 125877ab..eb512a0f 100644 --- a/crates/daemon/src/account/mod.rs +++ b/crates/daemon/src/account/mod.rs @@ -13,6 +13,8 @@ //! reimplementing the same construction `main.rs` used to do once, inline, //! for the one account a single-account daemon ever had. +mod retirement; + use std::collections::HashMap; #[cfg(not(target_family = "wasm"))] use std::future::Future; @@ -193,6 +195,10 @@ pub struct AccountRegistry { construction: tokio::sync::Mutex<()>, accounts: RwLock>>, overview: watch::Sender>, + retirements: watch::Sender>, + retirement_proofs: RwLock>, + retirement_pending: RwLock>, + retirement_ready: RwLock>, /// The supervisor spawning and respawning runtimes into this registry, /// if one exists. `Weak`, not `Arc`: `AccountSupervisor` already holds an /// `Arc`, so a strong pointer back would be a reference @@ -221,6 +227,10 @@ impl AccountRegistry { construction: tokio::sync::Mutex::new(()), accounts: RwLock::new(HashMap::new()), overview, + retirements: watch::channel(Vec::new()).0, + retirement_proofs: RwLock::new(HashMap::new()), + retirement_pending: RwLock::new(std::collections::HashSet::new()), + retirement_ready: RwLock::new(std::collections::HashSet::new()), #[cfg(not(target_family = "wasm"))] supervisor: std::sync::OnceLock::new(), }) @@ -246,6 +256,12 @@ impl AccountRegistry { use std::collections::hash_map::Entry; let id = runtime.id(); + runtime.lifecycle().reserve_retirement_successor( + self.retirement_pending + .read() + .unwrap_or_else(|error| error.into_inner()) + .contains(&id), + ); let inserted = match self .accounts .write() @@ -277,6 +293,9 @@ impl AccountRegistry { /// Retain the rejected account and reuse one separate pairing slot across retries. pub async fn relink(self: &Arc, id: AccountId) -> Result { let _guard = self.relink.lock().await; + if let Some(successor) = self.retirement_target(id) { + return Ok(successor); + } let old = self .get(id) .ok_or_else(|| anyhow::anyhow!("account not found"))?; @@ -357,6 +376,9 @@ impl AccountRegistry { stores: Arc, ) -> Option<(Arc, mpsc::Receiver)> { let _construction = self.construction.lock().await; + if self.has_retirement(id) { + return None; + } if self.get(id).is_some() { return None; } @@ -386,10 +408,13 @@ impl AccountRegistry { EmbeddedNext::Removed } AccountExit::SessionEnded => EmbeddedNext::Restart, - AccountExit::SessionLoggedOut - | AccountExit::Stopped - | AccountExit::ResetIncomplete - | AccountExit::RemoveIncomplete => EmbeddedNext::Finished, + AccountExit::SessionLoggedOut => { + self.set_status(id, AccountStatus::Error); + EmbeddedNext::Finished + } + AccountExit::Stopped | AccountExit::ResetIncomplete | AccountExit::RemoveIncomplete => { + EmbeddedNext::Finished + } } } @@ -442,7 +467,7 @@ impl AccountRegistry { let runtimes: Vec<_> = self .runtimes() .into_iter() - .filter(|runtime| !runtime.is_stopping()) + .filter(|runtime| !runtime.is_stopping() && !self.has_retirement(runtime.id())) .collect(); if let Some(raw) = explicit { let id = AccountId::new(raw).map_err(|error| error.to_string())?; @@ -1188,9 +1213,18 @@ impl AccountSupervisor { id: AccountId, disposition: AccountDisposition, ) -> LifecycleOutcome { + let _relink = self.registry.relink.lock().await; + if self.registry.retirement_target(id).is_some() { + return LifecycleOutcome::NoAccount; + } let Some(runtime) = self.registry.get(id) else { return LifecycleOutcome::NoAccount; }; + if runtime.lifecycle().set_disposition(disposition) + == session_bridge::DispositionOutcome::Conflict + { + return LifecycleOutcome::Incomplete; + } // Invalidate any restart timer before touching storage: it must not // resurrect an account this request is retiring or resetting. self.bump_generation(id); @@ -1363,9 +1397,21 @@ impl AccountSupervisor { reply: answered, }) .await; - if sent.is_ok() && matches!(accepted.await, Ok(CommandOutcome::Accepted)) { - let _ = reply.send(LifecycleOutcome::Accepted); - return; + if sent.is_ok() { + match accepted.await { + Ok(CommandOutcome::Accepted) => { + let _ = reply.send(LifecycleOutcome::Accepted); + return; + } + Ok(_) => { + // A live runtime refused the request, for example + // because consolidation pinned its valid session. + // Refusal is never permission for terminal cleanup. + let _ = reply.send(LifecycleOutcome::Incomplete); + return; + } + Err(_) => {} + } } // No live session to ask — a logout, an exhausted recovery, or // a pending restart whose runtime has no task yet. Finish the diff --git a/crates/daemon/src/account/retirement.rs b/crates/daemon/src/account/retirement.rs new file mode 100644 index 00000000..74b5a09d --- /dev/null +++ b/crates/daemon/src/account/retirement.rs @@ -0,0 +1,677 @@ +use super::*; +use oxidezap_session::{StoredAccount, same_account_identity}; + +impl AccountRegistry { + pub fn has_retirement(&self, id: AccountId) -> bool { + self.retirements.borrow().iter().any(|(old, _)| *old == id) + } + + pub fn retirement_target(&self, id: AccountId) -> Option { + let records = self.retirements.borrow().clone(); + let target = final_target(id, &records)?; + let runtime = self.get(target)?; + if runtime.is_stopping() { + return None; + } + let proof = self + .retirement_proofs + .read() + .unwrap_or_else(|error| error.into_inner()) + .get(&id)? + .clone(); + live_identity_matches(&runtime, &proof).then_some(target) + } + + pub(crate) fn watch_retirements(&self) -> watch::Receiver> { + self.retirements.subscribe() + } + + pub async fn restore_retirements(&self, stores: &StoreRegistry) -> Result<()> { + let records = stores.retirements().await?; + let all: Vec<_> = records + .iter() + .filter_map(|record| { + Some(( + AccountId::new(record.old_id).ok()?, + AccountId::new(record.successor).ok()?, + )) + }) + .collect(); + let mut redirects = Vec::new(); + let mut proofs = HashMap::new(); + let mut pending = std::collections::HashSet::new(); + for record in records { + let old = AccountId::new(record.old_id)?; + let Some(target) = final_target(old, &all) else { + continue; + }; + let Some(stored) = stores.durable_pairing(target).await? else { + continue; + }; + let proof = StoredAccount { + id: old, + jid: Some(record.pn), + lid: Some(record.lid), + push_name: String::new(), + linked: true, + }; + if !same_account_identity(&proof, &stored) { + continue; + } + redirects.push((old, target)); + proofs.insert(old, proof); + if !record.complete { + pending.insert(target); + } + } + *self + .retirement_proofs + .write() + .unwrap_or_else(|error| error.into_inner()) = proofs; + *self + .retirement_pending + .write() + .unwrap_or_else(|error| error.into_inner()) = pending.clone(); + for runtime in self.runtimes() { + runtime + .lifecycle() + .reserve_retirement_successor(pending.contains(&runtime.id())); + } + self.retirements.send_if_modified(|previous| { + if *previous == redirects { + false + } else { + *previous = redirects; + true + } + }); + let current = self.retirements.borrow().clone(); + let ready: std::collections::HashSet<_> = current + .iter() + .filter_map(|(old, _)| self.retirement_target(*old).map(|_| *old)) + .collect(); + let changed = { + let mut previous = self + .retirement_ready + .write() + .unwrap_or_else(|error| error.into_inner()); + if *previous == ready { + false + } else { + *previous = ready; + true + } + }; + // Account identity can become available after the registry was restored. + // Wake control consumers once that changes, without polling their UI. + if changed { + self.retirements.send_modify(|_| {}); + } + + Ok(()) + } + + pub async fn follow_relinks(self: Arc, stores: Arc) { + loop { + tokio::select! { + () = crate::shutdown::requested() => return, + () = oxidezap_session::sleep(std::time::Duration::from_secs(1)) => {}, + } + if let Err(error) = self.consolidate_relinks(&stores).await { + log::warn!("relink consolidation will retry: {error}"); + } + } + } + + /// No cleanup can run until an isolated successor has a complete device + /// load from durable storage and agrees in both identity namespaces. + pub(crate) async fn consolidate_relinks(&self, stores: &Arc) -> Result<()> { + self.consolidate_relinks_with(stores, |old, hub| { + let stores = Arc::clone(stores); + async move { + session_bridge::finish_forget(old, &stores, &hub, AccountDisposition::Remove).await + } + }) + .await + } + + async fn consolidate_relinks_with( + &self, + stores: &Arc, + cleanup: F, + ) -> Result<()> + where + F: Fn(AccountId, Arc) -> Fut, + Fut: std::future::Future, + { + if !oxidezap_session::store_is_durable() { + return Ok(()); + } + let _relink = self.relink.lock().await; + self.restore_retirements(stores).await?; + let stored = stores.accounts().await?; + let records = stores.retirements().await?; + for old in &stored { + if records.iter().any(|record| record.old_id == old.id.get()) { + continue; + } + let Some(runtime) = self.get(old.id) else { + continue; + }; + if runtime.status() != AccountStatus::Error + || !runtime.hub().is_logged_out() + || !runtime.lifecycle().is_quiesced() + { + continue; + } + let Some(next) = stores.existing_pairing_successor(old.id).await? else { + continue; + }; + let Some(successor) = self.get(next) else { + continue; + }; + if !successor.hub().connection().is_connected() { + continue; + } + let lifecycle = successor.lifecycle(); + let Some(_pin) = lifecycle.pin() else { + continue; + }; + if !successor.hub().connection().is_connected() { + continue; + } + let Some(new) = stores.durable_pairing(next).await? else { + continue; + }; + if !live_identity_matches(&successor, &new) || !same_account_identity(old, &new) { + continue; + } + if stores.begin_retirement(old, &new).await? { + self.restore_retirements(stores).await?; + let outcome = runtime + .lifecycle() + .set_disposition(AccountDisposition::Remove); + if outcome == session_bridge::DispositionOutcome::Conflict { + continue; + } + } + } + + // A crash after intent, row removal, or external cleanup always reaches + // this same idempotent path. Never construct a session for a retired id. + for record in stores + .retirements() + .await? + .into_iter() + .filter(|record| !record.complete) + { + let old = AccountId::new(record.old_id)?; + let recorded_next = AccountId::new(record.successor)?; + let next = self + .retirement_target(recorded_next) + .unwrap_or(recorded_next); + let Some(successor) = self.get(next) else { + continue; + }; + if !successor.hub().connection().is_connected() { + continue; + } + let lifecycle = successor.lifecycle(); + let Some(_pin) = lifecycle.pin() else { + continue; + }; + let Some(new) = stores.durable_pairing(next).await? else { + continue; + }; + let proof = StoredAccount { + id: old, + jid: Some(record.pn), + lid: Some(record.lid), + push_name: String::new(), + linked: true, + }; + if !live_identity_matches(&successor, &new) || !same_account_identity(&proof, &new) { + continue; + } + let old_lifecycle = self.get(old).map(|runtime| runtime.lifecycle()); + let hub = if let Some(runtime) = self.get(old) { + if !runtime.lifecycle().is_quiesced() || runtime.status() != AccountStatus::Error { + continue; + } + if runtime + .lifecycle() + .set_disposition(AccountDisposition::Remove) + == session_bridge::DispositionOutcome::Conflict + { + continue; + } + runtime.hub() + } else { + StateHub::for_account(old) + }; + let _media = if let Some(lifecycle) = old_lifecycle { + Some(lifecycle.drain_media().await) + } else { + None + }; + if cleanup(old, hub).await == session_bridge::AccountExit::RemoveCompleted { + self.remove(old); + stores.finish_retirement(old).await?; + self.restore_retirements(stores).await?; + } + } + Ok(()) + } +} + +fn final_target(id: AccountId, records: &[(AccountId, AccountId)]) -> Option { + let mut target = id; + for _ in 0..records.len() { + let Some((_, next)) = records.iter().find(|(old, _)| *old == target) else { + break; + }; + if next.get() <= target.get() { + return None; + } + target = *next; + } + (target != id).then_some(target) +} + +fn live_identity_matches(runtime: &AccountRuntime, stored: &StoredAccount) -> bool { + let Some(identity) = runtime.hub().snapshot().account else { + return false; + }; + let live = StoredAccount { + id: runtime.id(), + jid: identity.jid, + lid: identity.lid, + push_name: String::new(), + linked: true, + }; + same_account_identity(&live, stored) +} +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use oxidezap_ipc::{AccountIdentity, ConnectionState, DaemonEvent}; + use wacore::store::{Device, traits::DeviceStore as _}; + + struct Fixture { + stores: Arc, + registry: Arc, + old: AccountId, + new: AccountId, + } + + impl Fixture { + async fn new(tag: &str, new_pn: &str, new_lid: Option<&str>) -> Self { + let stores = Arc::new(StoreRegistry::new(format!( + "file:retirement-{tag}-{}?mode=memory&cache=shared", + std::process::id() + ))); + let old = stores.create_account().await.unwrap(); + let backend = stores.store(old).await.unwrap(); + stores.prepare_chat_schema(&backend).await.unwrap(); + let history = oxidezap_chat_store::ChatStore::new_prepared(&backend) + .await + .unwrap(); + history + .record_logout("Synthetic unlink".into()) + .await + .unwrap(); + let new = stores.pairing_successor(old).await.unwrap(); + let mut device = Device::new(); + device.pn = Some("100001:1@s.whatsapp.net".parse().unwrap()); + device.lid = Some("200001:1@lid".parse().unwrap()); + device.account = Some(Arc::new(Default::default())); + backend.save(&device).await.unwrap(); + let mut next_device = Device::new(); + next_device.pn = Some(new_pn.parse().unwrap()); + next_device.lid = new_lid.map(|value| value.parse().unwrap()); + next_device.account = Some(Arc::new(Default::default())); + stores + .store(new) + .await + .unwrap() + .save(&next_device) + .await + .unwrap(); + let registry = AccountRegistry::new(); + let old_runtime = Self::runtime(old, Arc::clone(&stores)); + old_runtime + .hub() + .apply(crate::state::Change::live(DaemonEvent::ConnectionChanged( + ConnectionState::LoggedOut { + message: "Synthetic unlink".into(), + }, + ))); + old_runtime.lifecycle().mark_quiesced(); + old_runtime.set_status(AccountStatus::Error); + registry.insert(old_runtime); + let new_runtime = Self::connected(new, Arc::clone(&stores), new_pn, new_lid); + registry.insert(new_runtime); + Self { + stores, + registry, + old, + new, + } + } + + fn runtime(id: AccountId, stores: Arc) -> Arc { + let (commands, _receiver) = mpsc::channel(1); + Arc::new(AccountRuntime::new_with_registry( + id, + StateHub::for_account(id), + Arc::new(oxidezap_plugin_host::Plugins::nothing_loaded(Arc::new( + |_| {}, + ))), + commands, + stores, + )) + } + + fn connected( + id: AccountId, + stores: Arc, + pn: &str, + lid: Option<&str>, + ) -> Arc { + let runtime = Self::runtime(id, stores); + runtime.hub().set_account(AccountIdentity { + name: None, + jid: Some(pn.into()), + lid: lid.map(str::to_owned), + }); + runtime + .hub() + .apply(crate::state::Change::live(DaemonEvent::ConnectionChanged( + ConnectionState::Connected, + ))); + runtime + } + } + + #[tokio::test] + async fn cleanup_failure_restarts_from_durable_intent_without_removing_the_new_session() { + let f = Fixture::new("resume", "100001:9@s.whatsapp.net", Some("200001:9@lid")).await; + f.registry + .consolidate_relinks_with(&f.stores, |_, _| async { + session_bridge::AccountExit::RemoveIncomplete + }) + .await + .unwrap(); + assert_eq!(f.registry.retirement_target(f.old), Some(f.new)); + let intent = f.stores.retirements().await.unwrap(); + assert_eq!(intent.len(), 1); + assert!(!intent[0].complete); + assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); + assert_eq!(f.stores.accounts().await.unwrap().len(), 2); + + // A fresh daemon restores redirects before constructing old runtimes. + let restarted = AccountRegistry::new(); + restarted.restore_retirements(&f.stores).await.unwrap(); + restarted.insert(Fixture::connected( + f.new, + Arc::clone(&f.stores), + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + )); + restarted + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }) + .await + .unwrap(); + assert_eq!( + f.stores + .accounts() + .await + .unwrap() + .iter() + .map(|account| account.id) + .collect::>(), + vec![f.new] + ); + assert!(f.stores.retirements().await.unwrap()[0].complete); + restarted + .consolidate_relinks_with(&f.stores, |_, _| async { + panic!("completed cleanup must not run twice") + }) + .await + .unwrap(); + } + + #[tokio::test] + async fn conflicting_or_incomplete_identity_never_commits_retirement() { + for (tag, pn, lid) in [ + ("different", "100002@s.whatsapp.net", Some("200002@lid")), + ("conflict", "100001@s.whatsapp.net", Some("200002@lid")), + ("incomplete", "100001@s.whatsapp.net", None), + ] { + let f = Fixture::new(tag, pn, lid).await; + f.registry + .consolidate_relinks_with(&f.stores, |_, _| async { + panic!("identity did not authorize cleanup") + }) + .await + .unwrap(); + assert!(f.stores.retirements().await.unwrap().is_empty()); + assert_eq!(f.stores.accounts().await.unwrap().len(), 2); + } + } + + #[tokio::test] + async fn missing_device_commit_and_live_old_writers_preserve_retained_history() { + let f = Fixture::new("unsigned", "100001:9@s.whatsapp.net", Some("200001:9@lid")).await; + let store = f.stores.store(f.new).await.unwrap(); + let mut device = store.load().await.unwrap().unwrap(); + device.account = None; + store.save(&device).await.unwrap(); + f.registry + .consolidate_relinks_with(&f.stores, |_, _| async { + panic!("device is not completely paired") + }) + .await + .unwrap(); + assert!(f.stores.retirements().await.unwrap().is_empty()); + + device.account = Some(Arc::new(Default::default())); + store.save(&device).await.unwrap(); + let not_joined = Fixture::runtime(f.old, Arc::clone(&f.stores)); + not_joined + .hub() + .apply(crate::state::Change::live(DaemonEvent::ConnectionChanged( + ConnectionState::LoggedOut { + message: "Synthetic unlink".into(), + }, + ))); + not_joined.set_status(AccountStatus::Error); + f.registry.remove(f.old); + f.registry.insert(not_joined); + f.registry + .consolidate_relinks_with(&f.stores, |_, _| async { + panic!("old tasks have not joined") + }) + .await + .unwrap(); + assert!(f.stores.retirements().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn changed_persisted_identity_cannot_commit_a_stale_decision() { + let f = Fixture::new( + "stale-proof", + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + ) + .await; + let snapshot = f.stores.accounts().await.unwrap(); + let old = snapshot.iter().find(|account| account.id == f.old).unwrap(); + let new = snapshot.iter().find(|account| account.id == f.new).unwrap(); + let store = f.stores.store(f.new).await.unwrap(); + let mut replacement = store.load().await.unwrap().unwrap(); + replacement.pn = Some("100002@s.whatsapp.net".parse().unwrap()); + replacement.lid = Some("200002@lid".parse().unwrap()); + store.save(&replacement).await.unwrap(); + assert!(!f.stores.begin_retirement(old, new).await.unwrap()); + assert!(f.stores.retirements().await.unwrap().is_empty()); + assert_eq!( + f.stores.existing_pairing_successor(f.old).await.unwrap(), + Some(f.new) + ); + assert_eq!(f.stores.accounts().await.unwrap().len(), 2); + } + + #[tokio::test] + async fn restart_after_row_purge_before_completion_is_idempotent() { + let f = Fixture::new("purged", "100001:9@s.whatsapp.net", Some("200001:9@lid")).await; + f.registry + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + // The process died before marking completion. + session_bridge::AccountExit::RemoveIncomplete + } + }) + .await + .unwrap(); + assert!(!f.stores.retirements().await.unwrap()[0].complete); + let restarted = AccountRegistry::new(); + restarted.restore_retirements(&f.stores).await.unwrap(); + restarted.insert(Fixture::connected( + f.new, + Arc::clone(&f.stores), + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + )); + restarted + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }) + .await + .unwrap(); + assert!(f.stores.retirements().await.unwrap()[0].complete); + assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); + assert_eq!(restarted.retirement_target(f.old), Some(f.new)); + } + + #[tokio::test] + async fn successor_chains_survive_removing_the_intermediate_account() { + let f = Fixture::new("chain", "100001:9@s.whatsapp.net", Some("200001:9@lid")).await; + let backend = f.stores.store(f.new).await.unwrap(); + let history = oxidezap_chat_store::ChatStore::new_prepared(&backend) + .await + .unwrap(); + history + .record_logout("Synthetic second unlink".into()) + .await + .unwrap(); + let final_id = f.stores.pairing_successor(f.new).await.unwrap(); + let mut device = Device::new(); + device.pn = Some("100001:13@s.whatsapp.net".parse().unwrap()); + device.lid = Some("200001:13@lid".parse().unwrap()); + device.account = Some(Arc::new(Default::default())); + f.stores + .store(final_id) + .await + .unwrap() + .save(&device) + .await + .unwrap(); + let intermediate = f.registry.get(f.new).unwrap(); + intermediate + .hub() + .apply(crate::state::Change::live(DaemonEvent::ConnectionChanged( + ConnectionState::LoggedOut { + message: "Synthetic second unlink".into(), + }, + ))); + intermediate.lifecycle().mark_quiesced(); + intermediate.set_status(AccountStatus::Error); + f.registry.insert(Fixture::connected( + final_id, + Arc::clone(&f.stores), + "100001:13@s.whatsapp.net", + Some("200001:13@lid"), + )); + for _ in 0..2 { + f.registry + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }) + .await + .unwrap(); + } + assert_eq!( + f.stores + .accounts() + .await + .unwrap() + .iter() + .map(|account| account.id) + .collect::>(), + vec![final_id] + ); + assert_eq!(f.registry.retirement_target(f.old), Some(final_id)); + assert_eq!(f.registry.retirement_target(f.new), Some(final_id)); + assert!( + f.stores + .retirements() + .await + .unwrap() + .iter() + .all(|record| record.complete) + ); + } + + #[tokio::test] + async fn concurrent_passes_cleanup_once_and_pin_successor_through_completion() { + let f = Fixture::new( + "concurrent", + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + ) + .await; + let calls = std::sync::atomic::AtomicUsize::new(0); + let cleanup = |old, _hub| { + let calls = &calls; + let stores = Arc::clone(&f.stores); + let successor = f.registry.get(f.new).unwrap(); + async move { + calls.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + assert_eq!( + successor + .lifecycle() + .set_disposition(AccountDisposition::Reset), + session_bridge::DispositionOutcome::Conflict + ); + assert!(!successor.is_stopping()); + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }; + let (first, second) = tokio::join!( + f.registry.consolidate_relinks_with(&f.stores, &cleanup), + f.registry.consolidate_relinks_with(&f.stores, &cleanup), + ); + first.unwrap(); + second.unwrap(); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); + } +} diff --git a/crates/daemon/src/embedded.rs b/crates/daemon/src/embedded.rs index 89360a3f..8f2c2a55 100644 --- a/crates/daemon/src/embedded.rs +++ b/crates/daemon/src/embedded.rs @@ -94,13 +94,30 @@ async fn service() -> Result, StartFailed> { let registry = AccountRegistry::new(); let stores = Arc::new(StoreRegistry::new(oxidezap_session::resolve_database_path())); + registry + .restore_retirements(&stores) + .await + .map_err(|error| StartFailed::Storage(error.to_string()))?; let stored = stores .accounts() .await .map_err(|error| StartFailed::Storage(error.to_string()))?; - for id in startup_accounts(stored.into_iter().map(|account| account.id)) { - registry.start_account(id, Arc::clone(&stores)).await; + let ids = if stored.is_empty() { + vec![ + stores + .create_account() + .await + .map_err(|error| StartFailed::Storage(error.to_string()))?, + ] + } else { + startup_accounts(stored.into_iter().map(|account| account.id)) + }; + for id in ids { + if !registry.has_retirement(id) { + registry.start_account(id, Arc::clone(&stores)).await; + } } + oxidezap_session::spawn(Arc::clone(®istry).follow_relinks(Arc::clone(&stores))); #[cfg(target_family = "wasm")] let tabs = match crate::listener::tab::serve(®istry) { @@ -122,11 +139,7 @@ async fn service() -> Result, StartFailed> { } fn startup_accounts(accounts: impl Iterator) -> Vec { - let mut ids: Vec<_> = accounts.collect(); - if ids.is_empty() { - ids.push(AccountId::LEGACY); - } - ids + accounts.collect() } fn running() -> Option> { @@ -184,7 +197,7 @@ mod tests { assert_eq!(startup_accounts([successor].into_iter()), vec![successor]); assert_eq!( startup_accounts(std::iter::empty()), - vec![AccountId::LEGACY] + Vec::::new() ); } } diff --git a/crates/daemon/src/listener/tab.rs b/crates/daemon/src/listener/tab.rs index 1200cd52..d4636e18 100644 --- a/crates/daemon/src/listener/tab.rs +++ b/crates/daemon/src/listener/tab.rs @@ -200,8 +200,10 @@ fn accept( let staged: Rc>> = Rc::new(RefCell::new(HashSet::new())); let (client, server) = tokio::io::duplex(PIPE); + let serving_registry = Arc::clone(®istry); oxidezap_session::spawn(async move { - if let Err(e) = crate::server::serve_client_with_registry(Box::new(server), registry).await + if let Err(e) = + crate::server::serve_client_with_registry(Box::new(server), serving_registry).await { log::debug!("a tab disconnected: {e}"); } @@ -234,7 +236,7 @@ fn accept( // refused the connection, which is the one symptom that reached a // console — from the *asking* tab, naming a frame it had sent perfectly // well. Nothing about it pointed here. - let handling = handler(&frames, &requests, &staged); + let handling = handler(&frames, &requests, &staged, Arc::clone(®istry)); frames.set_onmessage(Some(handling.as_ref().unchecked_ref())); let open = Rc::new(RefCell::new(Some(Connection { channel: frames.clone(), @@ -344,6 +346,7 @@ fn handler( frames: &BroadcastChannel, requests: &tokio::sync::mpsc::UnboundedSender, staged: &Rc>>, + registry: Arc, ) -> Closure { let frames = frames.clone(); let requests = requests.clone(); @@ -406,6 +409,16 @@ fn handler( ) else { return; }; + let account = + oxidezap_ipc::account_id_of(&key).unwrap_or(oxidezap_core::AccountId::LEGACY); + if registry + .get(account) + .is_none_or(|runtime| runtime.is_stopping()) + || registry.retirement_target(account).is_some() + { + let _ = post_failure(&frames, "staged", id, "account is unavailable"); + return; + } let _ = match crate::media::put_owned(&key, bytes) { Ok(_) => { staged.borrow_mut().insert(key); diff --git a/crates/daemon/src/listener/web/mod.rs b/crates/daemon/src/listener/web/mod.rs index 908d489b..b4f6dfe9 100644 --- a/crates/daemon/src/listener/web/mod.rs +++ b/crates/daemon/src/listener/web/mod.rs @@ -291,6 +291,30 @@ async fn serve( } if let Some(key) = request.path.strip_prefix(&format!("{WEB_MEDIA_PATH}/")) { + let decoded = http::percent_decode(key); + let account = + oxidezap_ipc::account_id_of(&decoded).unwrap_or(oxidezap_core::AccountId::LEGACY); + let Some(runtime) = registry.get(account) else { + return respond( + stream.get_mut(), + 404, + "text/plain", + origin.as_deref(), + b"account is unavailable", + ) + .await; + }; + let lifecycle = runtime.lifecycle(); + let Some(_media) = lifecycle.media_lease().await else { + return respond( + stream.get_mut(), + 404, + "text/plain", + origin.as_deref(), + b"account is stopping", + ) + .await; + }; // A staging upload is the one write this endpoint takes, and it reads // from the buffered reader rather than the socket: the head was read // a byte at a time but a client may send head and body in one diff --git a/crates/daemon/src/main.rs b/crates/daemon/src/main.rs index bd9789a4..f9b5b369 100644 --- a/crates/daemon/src/main.rs +++ b/crates/daemon/src/main.rs @@ -167,6 +167,8 @@ async fn run(hub: Arc) -> Result<()> { let registry = hub.registry(); let tray_updates = tokio::spawn(Arc::clone(&hub).follow()); let stores = Arc::new(StoreRegistry::new(oxidezap_session::resolve_database_path())); + registry.restore_retirements(&stores).await?; + let relink_updates = tokio::spawn(Arc::clone(®istry).follow_relinks(Arc::clone(&stores))); let supervisor = AccountSupervisor::new( Arc::clone(®istry), Arc::clone(&stores), @@ -182,7 +184,9 @@ async fn run(hub: Arc) -> Result<()> { }, Ok(existing) => { for account in existing { - supervisor.spawn(account.id).await; + if !registry.has_retirement(account.id) { + supervisor.spawn(account.id).await; + } } } Err(e) => { @@ -260,6 +264,7 @@ async fn run(hub: Arc) -> Result<()> { // than gated on which branch above returned. shutdown::request("daemon exiting"); tray_updates.abort(); + relink_updates.abort(); // While that drains, a repeated signal escalates rather than queues // behind it: the teardown has joins without a deadline, and a second // signal is somebody saying the first one is taking too long. diff --git a/crates/daemon/src/server/mod.rs b/crates/daemon/src/server/mod.rs index 8981d855..a27153c6 100644 --- a/crates/daemon/src/server/mod.rs +++ b/crates/daemon/src/server/mod.rs @@ -196,6 +196,7 @@ pub(crate) async fn serve_client_with_registry( } }; + let mut retirements = registry.watch_retirements(); if attached.is_wire && (attached.account_selection || attached.wire_account.is_some()) { match registry.resolve_wire_account(attached.wire_account) { Ok(account) => attached.scope = oxidezap_ipc::ClientScope::Account { account }, @@ -217,6 +218,19 @@ pub(crate) async fn serve_client_with_registry( return serve_control_client(reader, writer, registry).await; } oxidezap_ipc::ClientScope::Account { account } => { + if let Some(successor) = registry.retirement_target(account) { + if !attached.is_wire { + write_line( + &mut writer, + &serde_json::to_string(&DaemonMessage::AccountRetired { + account, + successor, + })?, + ) + .await?; + } + return Ok(()); + } let Some(runtime) = registry.get(account) else { let frame = error_frame( None, @@ -334,7 +348,23 @@ pub(crate) async fn serve_client_with_registry( let mut awaiting_resync = false; loop { + if let Some(successor) = registry.retirement_target(hub.account_id()) { + if !attached.is_wire { + write_line( + &mut writer, + &serde_json::to_string(&DaemonMessage::AccountRetired { + account: hub.account_id(), + successor, + })?, + ) + .await?; + } + return Ok(()); + } tokio::select! { + changed = retirements.changed() => { + if changed.is_err() { return Ok(()); } + } // Normally drain published state before reading more requests, so // a client that floods the socket cannot starve its own event // stream. After a lag that inverts, because recovery comes first. @@ -631,11 +661,22 @@ where S: AsyncRead + AsyncWrite + Unpin, { let mut accounts = registry.subscribe(); + let mut retirements = registry.watch_retirements(); let hello = serde_json::to_string(&DaemonMessage::ControlHello { protocol: PROTOCOL_VERSION, accounts: (*registry.snapshot()).clone(), })?; write_line(&mut writer, &hello).await?; + let redirects = retirements.borrow_and_update().clone(); + for (account, _) in redirects { + if let Some(successor) = registry.retirement_target(account) { + write_line( + &mut writer, + &serde_json::to_string(&DaemonMessage::AccountRetired { account, successor })?, + ) + .await?; + } + } let mut buf = Vec::with_capacity(1024); // The control plane's own out-of-band answers — a plugin install, listing // or removal is disk work, and a control connection still pushes @@ -648,6 +689,15 @@ where // Out-of-band answers first, so a plugin install/removal/listing // lands as soon as it is ready rather than behind a state push. Some(frame) = inbox.recv() => write_line(&mut writer, &frame).await?, + changed = retirements.changed() => { + if changed.is_err() { return Ok(()); } + let redirects = retirements.borrow_and_update().clone(); + for (account, _) in redirects { + if let Some(successor) = registry.retirement_target(account) { + write_line(&mut writer, &serde_json::to_string(&DaemonMessage::AccountRetired { account, successor })?).await?; + } + } + } changed = accounts.changed() => { if changed.is_err() { return Ok(()); diff --git a/crates/daemon/src/session_bridge/mod.rs b/crates/daemon/src/session_bridge/mod.rs index 571e3d0d..ef61d743 100644 --- a/crates/daemon/src/session_bridge/mod.rs +++ b/crates/daemon/src/session_bridge/mod.rs @@ -58,7 +58,27 @@ pub struct RuntimeLifecycle { /// decides from this alone whether to respawn the id (`Reset`), drop it /// for good (`Remove`), or do neither — a session that ended on its own, /// or a process-wide shutdown, leaves this `None`. - disposition: Arc>>, + disposition: Arc>, + quiesced: Arc, + media: Arc>, +} + +#[derive(Debug, Default)] +struct LifecycleState { + disposition: Option, + pins: usize, + retirement_reserved: bool, +} + +/// Keeps a confirmed successor from being reset while its predecessor is retired. +pub(crate) struct LifecyclePin { + state: Arc>, +} +impl Drop for LifecyclePin { + fn drop(&mut self) { + let mut state = self.state.lock().unwrap_or_else(|error| error.into_inner()); + state.pins -= 1; + } } impl RuntimeLifecycle { @@ -66,7 +86,9 @@ impl RuntimeLifecycle { pub fn new() -> Self { Self { stopping: Arc::new(AtomicBool::new(false)), - disposition: Arc::new(Mutex::new(None)), + disposition: Arc::new(Mutex::new(LifecycleState::default())), + quiesced: Arc::new(AtomicBool::new(false)), + media: Arc::new(tokio::sync::RwLock::new(())), } } @@ -97,16 +119,18 @@ impl RuntimeLifecycle { .disposition .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - match *slot { - None => { - *slot = Some(disposition); + match slot.disposition { + None if slot.pins == 0 && !slot.retirement_reserved => { + slot.disposition = Some(disposition); DispositionOutcome::Recorded } Some(held) if held == disposition => DispositionOutcome::AlreadySame, - Some(_) => DispositionOutcome::Conflict, + _ => DispositionOutcome::Conflict, } }; - self.mark_stopping(); + if outcome != DispositionOutcome::Conflict { + self.mark_stopping(); + } outcome } @@ -114,10 +138,52 @@ impl RuntimeLifecycle { /// asked before it stopped. #[must_use] pub fn disposition(&self) -> Option { - *self - .disposition + self.disposition .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) + .disposition + } + + pub(crate) fn pin(&self) -> Option { + let mut state = self + .disposition + .lock() + .unwrap_or_else(|error| error.into_inner()); + if state.disposition.is_some() || self.is_stopping() { + return None; + } + state.pins += 1; + Some(LifecyclePin { + state: Arc::clone(&self.disposition), + }) + } + + pub(crate) fn reserve_retirement_successor(&self, reserved: bool) { + self.disposition + .lock() + .unwrap_or_else(|error| error.into_inner()) + .retirement_reserved = reserved; + } + + #[cfg(not(target_family = "wasm"))] + pub(crate) async fn media_lease(&self) -> Option> { + if self.is_stopping() { + return None; + } + let lease = Arc::clone(&self.media).read_owned().await; + (!self.is_stopping()).then_some(lease) + } + + pub(crate) async fn drain_media(&self) -> tokio::sync::OwnedRwLockWriteGuard<()> { + Arc::clone(&self.media).write_owned().await + } + + pub(crate) fn mark_quiesced(&self) { + self.quiesced.store(true, Ordering::SeqCst); + } + + pub(crate) fn is_quiesced(&self) -> bool { + self.quiesced.load(Ordering::SeqCst) } } @@ -290,20 +356,16 @@ pub async fn run( // and refuses a write from an older one — see `plugin_host::Origin`. A // page can pair again without reloading, so what is refused has to be the // departed account's handles rather than every handle from here on. - { + let plugins_closed = { let plugins = Arc::clone(&bridge.plugins); - if oxidezap_session::unblock(move || plugins.shutdown()) - .await - .is_err() - { + let result = oxidezap_session::unblock(move || plugins.shutdown()).await; + if result.is_err() { log::error!("the plugin threads did not finish"); } - } + result.is_ok() + }; - // Before anything is deleted, and on a blocking thread because joining - // one is: the publisher writes this account's media, and a wipe that - // starts while it is still draining its queue deletes a directory that - // is about to be written into again. + // The publisher can still write media until its task has joined. if let Some(publisher) = bridge.stop_publishing() { publisher.join().await; } @@ -330,7 +392,7 @@ pub async fn run( // the account and take this with it. let logged_out = bridge.hub.is_logged_out(); let exit = if let Some(disposition) = disposition { - if !closed { + if !closed || !plugins_closed { log::error!( "local state was NOT reset: the session is still closing, and resetting the \ account from under it could let it repopulate what the reset just cleared. \ @@ -351,6 +413,9 @@ pub async fn run( AccountExit::SessionEnded }; crate::avatar::purge(&bridge.hub); + if closed && plugins_closed { + bridge.lifecycle.mark_quiesced(); + } Ok(exit) } @@ -390,6 +455,31 @@ pub async fn finish_forget( ); return disposition.incomplete(); } + // A plugin's own settings are this account's data too — an autoreply's + // "already answered these people" is a list of people — and they sit in + // their own directory beside the plugins rather than inside the store. + // Nothing is writing them any more: the caller stopped the session. + #[cfg(not(target_family = "wasm"))] + if let Some(dir) = crate::plugins::account_state_dir(account_id) + && let Err(e) = std::fs::remove_dir_all(&dir) + && e.kind() != std::io::ErrorKind::NotFound + { + // Only the settings are at stake here: the permissions were retired + // before the credentials went, so nothing that survives this can let a + // plugin act on whoever pairs next. + log::error!("could not clear the plugins' stored settings: {e}"); + return disposition.incomplete(); + } + // The store is one file; the media is a directory beside it, and it is + // just as much this account's data. Everything, staged uploads included: + // the account is going, and so is anything that was going to be sent under + // it. + if let Err(e) = + crate::media::AccountMedia::new(hub.account_id()).wipe(crate::media::Wipe::Everything) + { + log::error!("could not clear the media cache: {e}"); + return disposition.incomplete(); + } // Purges this account's rows — upstream's and, through the `ON DELETE // CASCADE` the chat-store migration adds, this crate's own. Never the // whole-file wipe a single-account daemon used: the shared database holds @@ -420,29 +510,6 @@ pub async fn finish_forget( if !completed { return disposition.incomplete(); } - // A plugin's own settings are this account's data too — an autoreply's - // "already answered these people" is a list of people — and they sit in - // their own directory beside the plugins rather than inside the store. - // Nothing is writing them any more: the caller stopped the session. - #[cfg(not(target_family = "wasm"))] - if let Some(dir) = crate::plugins::account_state_dir(account_id) - && let Err(e) = std::fs::remove_dir_all(&dir) - && e.kind() != std::io::ErrorKind::NotFound - { - // Only the settings are at stake here: the permissions were retired - // before the credentials went, so nothing that survives this can let a - // plugin act on whoever pairs next. - log::error!("could not clear the plugins' stored settings: {e}"); - } - // The store is one file; the media is a directory beside it, and it is - // just as much this account's data. Everything, staged uploads included: - // the account is going, and so is anything that was going to be sent under - // it. - if let Err(e) = - crate::media::AccountMedia::new(hub.account_id()).wipe(crate::media::Wipe::Everything) - { - log::error!("could not clear the media cache: {e}"); - } disposition.completed() } diff --git a/crates/daemon/src/session_bridge/tests.rs b/crates/daemon/src/session_bridge/tests.rs index eb8f5173..76440531 100644 --- a/crates/daemon/src/session_bridge/tests.rs +++ b/crates/daemon/src/session_bridge/tests.rs @@ -886,3 +886,43 @@ async fn work_in_flight_is_capped_rather_than_queued() { "permits come back when the work they paid for is over" ); } +#[test] +fn a_successor_pin_refuses_reset_without_stopping_the_valid_session() { + let lifecycle = RuntimeLifecycle::new(); + let pin = lifecycle.pin().unwrap(); + assert_eq!( + lifecycle.set_disposition(AccountDisposition::Reset), + DispositionOutcome::Conflict + ); + assert!(!lifecycle.is_stopping()); + assert_eq!(lifecycle.disposition(), None); + drop(pin); + assert_eq!( + lifecycle.set_disposition(AccountDisposition::Reset), + DispositionOutcome::Recorded + ); + assert!(lifecycle.is_stopping()); + assert!(lifecycle.pin().is_none()); +} +#[cfg(not(target_family = "wasm"))] +#[tokio::test(start_paused = true)] +async fn retirement_drains_existing_media_and_rejects_new_writers() { + let lifecycle = RuntimeLifecycle::new(); + let upload = lifecycle.media_lease().await.unwrap(); + assert_eq!( + lifecycle.set_disposition(AccountDisposition::Remove), + DispositionOutcome::Recorded + ); + assert!(lifecycle.media_lease().await.is_none()); + let drain = lifecycle.drain_media(); + tokio::pin!(drain); + assert!( + tokio::time::timeout(std::time::Duration::from_secs(1), &mut drain) + .await + .is_err() + ); + drop(upload); + let _exclusive = tokio::time::timeout(std::time::Duration::from_secs(1), drain) + .await + .unwrap(); +} diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index f84d236a..23739155 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -760,6 +760,7 @@ pub struct WhatsAppApp { /// a later archived page), so resolving only against `chats` at click time /// would silently lose the action. notification_window: Option, + account_window: Option, /// The window the send-confirmation modal builds its caption field in. /// An editor entity needs a live `Window` at construction, and the modal /// opens from event continuations that only hold the app — the same @@ -1206,11 +1207,18 @@ impl WhatsAppApp { /// by the reader as it runs, and the one-shot receivers the callers hold /// only fire while it does. The events themselves are dropped. fn spawn_control_reader(mut ui_rx: crate::session::Events, cx: &mut Context) -> Task<()> { - cx.spawn( - async move |_: WeakEntity, _cx| { - while ui_rx.recv().await.is_some() {} - }, - ) + cx.spawn(async move |entity: WeakEntity, cx| { + while let Some(event) = ui_rx.recv().await { + if let FromDaemon::AccountRetired { account, successor } = event { + if entity + .update(cx, |app, cx| app.account_retired(account, successor, cx)) + .is_err() + { + break; + } + } + } + }) } /// The control plane if this window has one. @@ -1227,6 +1235,9 @@ impl WhatsAppApp { cx.spawn(async move |entity: WeakEntity, cx| { while let Some(message) = ui_rx.recv().await { let result = match message { + FromDaemon::AccountRetired { account, successor } => { + entity.update(cx, |app, cx| app.account_retired(account, successor, cx)) + } FromDaemon::Session(event) => entity.update(cx, |app, cx| { // The two events that carry media, and so the only // two that can leave this window holding more of it @@ -1427,6 +1438,7 @@ impl WhatsAppApp { window_activation: None, notified_messages: IndexMap::new(), notification_window: None, + account_window: None, modal_window: None, pending_notification_tags: VecDeque::new(), latest_notification_tag: None, @@ -2173,6 +2185,7 @@ impl WhatsAppApp { } fn observe_window_activation(&mut self, window: &mut Window, cx: &mut Context) { + self.account_window = Some(window.window_handle()); if self.window_activation.is_some() { return; } diff --git a/crates/gui/src/app/recovery.rs b/crates/gui/src/app/recovery.rs index 758b1f60..febb062d 100644 --- a/crates/gui/src/app/recovery.rs +++ b/crates/gui/src/app/recovery.rs @@ -159,6 +159,39 @@ impl WhatsAppApp { self.account_selection.retained.is_some() } + pub(super) fn account_retired( + &mut self, + account: oxidezap_core::AccountId, + successor: oxidezap_core::AccountId, + cx: &mut Context, + ) { + let Some(active) = self.account_selection.retire(account, successor) else { + return; + }; + self.save_account_selection(cx); + if active { + self.event_task.take(); + self.client.take(); + self.app_state = AppState::Loading; + if let Some(window) = self.account_window { + cx.spawn(async move |app: WeakEntity, cx| { + let _ = window.update(cx, |_, window, cx| { + let _ = app.update(cx, |app, cx| { + // A newer retirement can arrive while this window update waits. + if app.account_selection.active != successor { + return; + } + app.clear_account_view(window, cx); + app.retry_connection(cx); + }); + }); + }) + .detach(); + } + } + cx.notify(); + } + pub fn open_retained_history(&mut self, window: &mut gpui::Window, cx: &mut Context) { let Some(account) = self.account_selection.retained.take() else { return; diff --git a/crates/gui/src/platform/account.rs b/crates/gui/src/platform/account.rs index 34c3250e..d3853523 100644 --- a/crates/gui/src/platform/account.rs +++ b/crates/gui/src/platform/account.rs @@ -19,6 +19,26 @@ impl Default for AccountSelection { } impl AccountSelection { + /// Apply only an explicit daemon retirement. Unrelated and stale events + /// cannot change this window's chosen identity. + pub fn retire(&mut self, account: AccountId, successor: AccountId) -> Option { + if successor.get() <= account.get() { + return None; + } + let active = self.active == account; + let retained = self.retained == Some(account); + if !active && !retained { + return None; + } + if active { + self.active = successor; + } + if retained || self.retained == Some(self.active) { + self.retained = None; + } + Some(active) + } + pub fn load() -> Self { if cfg!(test) { return Self::default(); @@ -292,6 +312,43 @@ mod imp { mod tests { use super::*; + #[test] + fn retirement_reconciles_each_windows_active_and_retained_references() { + let old = AccountId::LEGACY; + let next = AccountId::new(2).unwrap(); + let final_id = AccountId::new(3).unwrap(); + let mut offline = AccountSelection { + active: old, + retained: None, + }; + let mut paired = AccountSelection { + active: next, + retained: Some(old), + }; + assert_eq!(offline.retire(old, next), Some(true)); + assert_eq!(paired.retire(old, next), Some(false)); + assert_eq!(offline, paired); + assert_eq!(paired.retained, None); + assert_eq!(offline.retire(next, final_id), Some(true)); + assert_eq!( + offline.retire(old, next), + None, + "stale event must not move selection backwards" + ); + assert_eq!( + offline.retire(final_id, old), + None, + "invalid cycle must be ignored" + ); + let raw = serde_json::to_string(&offline).unwrap(); + assert_eq!( + serde_json::from_str::(&raw) + .unwrap() + .active, + final_id + ); + } + #[test] fn a_reloaded_selection_keeps_the_active_and_retained_accounts_distinct() { let selection = AccountSelection { diff --git a/crates/gui/src/session/frames.rs b/crates/gui/src/session/frames.rs index a5f84052..adad3b0f 100644 --- a/crates/gui/src/session/frames.rs +++ b/crates/gui/src/session/frames.rs @@ -309,6 +309,9 @@ impl<'a> Frames<'a> { // Accepted with no id: a request sent without one, which nobody // is waiting on an answer for. DaemonMessage::Accepted { id: None } => {} + DaemonMessage::AccountRetired { account, successor } => { + self.publish(FromDaemon::AccountRetired { account, successor })?; + } DaemonMessage::AccountCreated { id, account } => { if let Some(Awaiting::Account(tx)) = take_pending(self.pending, id) { let _ = tx.send(Ok(account)); diff --git a/crates/gui/src/session/mod.rs b/crates/gui/src/session/mod.rs index c3daa815..4a6f9cf7 100644 --- a/crates/gui/src/session/mod.rs +++ b/crates/gui/src/session/mod.rs @@ -132,6 +132,10 @@ pub struct Attachment { pub use oxidezap_core::Fault; pub enum FromDaemon { + AccountRetired { + account: AccountId, + successor: AccountId, + }, /// Something the session said. Session(Box), /// The calls in progress at the moment this client attached. diff --git a/crates/ipc/src/protocol.rs b/crates/ipc/src/protocol.rs index 1e5e9ad0..14dcb4da 100644 --- a/crates/ipc/src/protocol.rs +++ b/crates/ipc/src/protocol.rs @@ -363,6 +363,11 @@ pub enum ClientScope { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum DaemonMessage { + /// A durable relink redirect. Scoped consumers must explicitly reattach. + AccountRetired { + account: oxidezap_core::AccountId, + successor: oxidezap_core::AccountId, + }, /// The first frame on a control connection. ControlHello { protocol: u32, diff --git a/crates/session/src/lib.rs b/crates/session/src/lib.rs index 9c64a75a..ac7431bd 100644 --- a/crates/session/src/lib.rs +++ b/crates/session/src/lib.rs @@ -19,7 +19,9 @@ mod whatsapp; pub use whatsapp::{OutgoingAcceptCase, outgoing_accept_events}; pub use exec::{Cancelled, Task, sleep, spawn, unblock, with_timeout}; -pub use store::{StoreRegistry, StoredAccount}; +pub use store::{ + StoreRegistry, StoredAccount, is_durable as store_is_durable, same_account_identity, +}; pub use video::{CameraPreview, set_camera_preferences}; pub use whatsapp::{ AvatarRecorder, BackfillFile, BackfillReport, ChannelView, ContactCheckView, ContactView, diff --git a/crates/session/src/store/mod.rs b/crates/session/src/store/mod.rs index fdade53b..974037f7 100644 --- a/crates/session/src/store/mod.rs +++ b/crates/session/src/store/mod.rs @@ -24,7 +24,7 @@ mod platform; mod registry; pub use identity::same_account_identity; -pub use platform::{database_path, open_for_account, prepare, wipe}; +pub use platform::{database_path, is_durable, open_for_account, prepare, wipe}; pub use registry::{StoreRegistry, StoredAccount}; /// The database's name, wherever it is kept. diff --git a/crates/session/src/store/native.rs b/crates/session/src/store/native.rs index 612d2055..10233392 100644 --- a/crates/session/src/store/native.rs +++ b/crates/session/src/store/native.rs @@ -28,6 +28,11 @@ pub async fn prepare() -> Result<(), String> { Ok(()) } +/// Native SQLite commits complete through the platform filesystem VFS. +pub fn is_durable() -> bool { + true +} + /// Delete the local session: device identity, Signal state and chat history /// all live in the one SQLite file. /// diff --git a/crates/session/src/store/registry.rs b/crates/session/src/store/registry.rs index c8ddda47..5a2afa11 100644 --- a/crates/session/src/store/registry.rs +++ b/crates/session/src/store/registry.rs @@ -161,6 +161,20 @@ impl StoreRegistry { }) } + pub async fn existing_pairing_successor( + &self, + id: AccountId, + ) -> Result, oxidezap_chat_store::ChatStoreError> { + let store = self.store(id).await?; + oxidezap_chat_store::ChatStore::pairing_successor(&store) + .await? + .map(|raw| { + AccountId::new(raw) + .map_err(|error| StoreError::Validation(error.to_string()).into()) + }) + .transpose() + } + /// Allocate one separate pairing account. The daemon serializes this operation. pub async fn pairing_successor( &self, @@ -179,6 +193,70 @@ impl StoreRegistry { } } + /// Read and validate the complete persisted paired device, never the live cache. + /// Relaxed browser storage cannot prove ordinary-write durability. + pub async fn durable_pairing( + &self, + id: AccountId, + ) -> Result, StoreError> { + use wacore::store::traits::DeviceStore as _; + if !super::is_durable() { + return Ok(None); + } + let store = self.store(id).await?; + let Some(device) = store.load().await? else { + return Ok(None); + }; + if device.account.is_none() { + return Ok(None); + } + Ok(Some(StoredAccount { + id, + jid: device.pn.map(|jid| jid.to_string()), + lid: device.lid.map(|jid| jid.to_string()), + push_name: device.push_name, + linked: true, + })) + } + + pub async fn retirements( + &self, + ) -> Result, oxidezap_chat_store::ChatStoreError> + { + let store = self.store(AccountId::LEGACY).await?; + self.prepare_chat_schema(&store).await?; + oxidezap_chat_store::ChatStore::account_retirements(&store).await + } + + pub async fn begin_retirement( + &self, + old: &StoredAccount, + new: &StoredAccount, + ) -> Result { + if !super::is_durable() || !super::same_account_identity(old, new) { + return Ok(false); + } + let store = self.store(old.id).await?; + oxidezap_chat_store::ChatStore::begin_account_retirement( + &store, + old.id.get(), + new.id.get(), + old.jid.clone().unwrap_or_default(), + old.lid.clone().unwrap_or_default(), + new.jid.clone().unwrap_or_default(), + new.lid.clone().unwrap_or_default(), + ) + .await + } + + pub async fn finish_retirement( + &self, + old: AccountId, + ) -> Result<(), oxidezap_chat_store::ChatStoreError> { + let store = self.store(old).await?; + oxidezap_chat_store::ChatStore::finish_account_retirement(&store, old.get()).await + } + /// Wipe one account's WhatsApp state and start it over under the same id. /// /// # Caller obligation @@ -202,7 +280,14 @@ impl StoreRegistry { /// The caller must have already stopped every live session/writer bound to /// `id` before calling this. See the module-level note. pub async fn remove_account(&self, id: AccountId) -> Result<(), StoreError> { + use wacore::store::traits::DeviceStore as _; let base = self.base().await?; + // A retirement journal outlives this row. A restart after the delete + // but before its completion marker must not resurrect the device or + // fail cleanup merely because the atomic row purge already committed. + if !base.store(id.as_i32()).exists().await? { + return Ok(()); + } base.remove_device(id.as_i32()).await?; Ok(()) } diff --git a/crates/session/src/store/web.rs b/crates/session/src/store/web.rs index bb1d390e..89f688cc 100644 --- a/crates/session/src/store/web.rs +++ b/crates/session/src/store/web.rs @@ -145,7 +145,7 @@ pub async fn prepare() -> Result<(), String> { /// backends differ in exactly the place durability is decided: one accepts /// the pragma that describes when a write has landed, and the other has no /// disk at the moment of the write to describe. -fn is_durable() -> bool { +pub fn is_durable() -> bool { STORE.with(|cell| matches!(cell.get(), Some(Backend::Durable(_)))) } diff --git a/docs/relink-consolidation.md b/docs/relink-consolidation.md index 1a0437e4..7c3ac47e 100644 --- a/docs/relink-consolidation.md +++ b/docs/relink-consolidation.md @@ -1,46 +1,24 @@ # Relink consolidation -CLI and tray account selection must work independently of consolidation. -A relink still allocates a separate account and retains the rejected account. -Automatic removal is not enabled by the consumer-selection change. +Pairing uses an isolated local account. Retirement requires a connected +successor, a complete paired device loaded from durable storage, and both +PN and LID matching after the dependency's `Jid::to_non_ad` normalization. +Missing, malformed, different or conflicting identifiers preserve both +accounts. Names, photos and partial strings provide no identity evidence. -The proposed identity gate requires both persisted PN and LID to be present, -valid in their own namespaces, and equal after `Jid::to_non_ad`. Names, -photos, partial matches, PN alone and LID alone provide no deletion authority. -Conflicting or incomplete evidence retains both accounts. The pure -`store::same_account_identity` predicate captures this conservative gate; -it is not a durability check and has no cleanup side effects. +The retirement journal survives device-row deletion. It records identity +evidence and a strictly increasing successor before cleanup, preserves +successor chains and resumes incomplete cleanup after restart. A successor +cannot be reset while pending retirement depends on it. The lifecycle joins +old session, chat, plugin and publication writers, drains web media operations, +then reuses account-scoped cleanup. Failure preserves the successor and +leaves the journal retryable. Credentials and device ids are never moved. -The pinned whatsapp-rust commit is -`633f01480fe3a95510172c6312d01484563dcf3c`. Its -`wacore/binary/src/jid.rs` separates `user`, `server`, `agent`, `device` -and `integrator`. `to_non_ad` clears device and agent while retaining the -namespace and integrator. `SqliteStore::list_devices_impl` reads persisted -PN/LID, but its `linked` flag means only that PN parsed successfully. -It is not proof that the complete new session is durable. No claim about -LID stability across relinks follows from these contracts. +A retired connection closes. Native consumers receive an explicit redirect +and must attach again; the CLI never changes its bound identity. GUI active +and retained selections reconcile the redirect, including after restart. +Redirects are revalidated against the successor's identity before use. -Before activating automatic retirement, the lifecycle needs these additions: - -1. A successful new-session durability barrier. Web's relaxed IndexedDB VFS - does not expose completion of ordinary writes. Its in-memory SQL state - must not authorize deletion of retained history. See `session/src/store/web.rs`. -2. A durable retirement journal outside rows deleted with the old account. - `session_recovery.successor` currently belongs to the old account and has - `ON DELETE SET NULL` on the target. It cannot retain redirects or resume - media/plugin cleanup after deletion. Freeze the successor under lifecycle - serialization before recording retirement. Reject cycles and changes of - identity, and process successor chains without crossing different accounts. -3. Use the existing lifecycle to stop and join old sessions, writers and - plugins before cleanup. Do not copy keys or reassign device ids. Persist - cleanup progress separately, since `finish_forget` currently logs media - and plugin-settings cleanup failures and can still return completed. -4. Publish an explicit retirement redirect and close old scoped connections. - Consumers must acknowledge a new attachment before switching identity. - GUI active and retained selections need reconciliation on snapshots and - restart, including windows still showing old offline history. - -Cleanup failure must preserve the valid new session and a retryable journal. -Restart and concurrent-window tests must cover every journal boundary, old -cleanup failure, chains, conflicts, different accounts and stale events before -activation. No real session database is needed to exercise these cases. +Native SQLite and durable OPFS support consolidation. The relaxed IndexedDB +backend cannot confirm ordinary-write durability, so it preserves old +history until a durable backend is available. From fad79d98abf16a2cb7bf0fcbbcf735b892c95627 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 17:58:45 -0300 Subject: [PATCH 03/14] Keep failed cleanup retryable and tighten persisted-session evidence --- crates/daemon/src/account/mod.rs | 91 ++++++++++++++++++++- crates/daemon/src/account/retirement.rs | 101 +++++++++++++++++++++--- crates/daemon/src/session_bridge/mod.rs | 14 ++++ crates/daemon/src/tray/accounts.rs | 19 ++++- crates/gui/src/app/mod.rs | 15 ++-- crates/session/src/store/registry.rs | 29 ++++++- 6 files changed, 244 insertions(+), 25 deletions(-) diff --git a/crates/daemon/src/account/mod.rs b/crates/daemon/src/account/mod.rs index eb512a0f..11aaecfb 100644 --- a/crates/daemon/src/account/mod.rs +++ b/crates/daemon/src/account/mod.rs @@ -1102,6 +1102,12 @@ impl AccountSupervisor { self.registry.remove(id); } AccountExit::ResetIncomplete | AccountExit::RemoveIncomplete => { + if let Some(runtime) = self.registry.get(id) + && runtime.lifecycle().is_quiesced() + && let Some(disposition) = runtime.lifecycle().disposition() + { + runtime.lifecycle().retry_failed_disposition(disposition); + } // The teardown already logged exactly why the storage // mutation did not run. Left registered, untouched: the // user can still act on this account, and respawning or @@ -1213,6 +1219,23 @@ impl AccountSupervisor { id: AccountId, disposition: AccountDisposition, ) -> LifecycleOutcome { + self.finish_terminal_with(id, disposition, |hub| { + let stores = Arc::clone(&self.stores); + async move { session_bridge::finish_forget(id, &stores, &hub, disposition).await } + }) + .await + } + + async fn finish_terminal_with( + self: &Arc, + id: AccountId, + disposition: AccountDisposition, + cleanup: F, + ) -> LifecycleOutcome + where + F: FnOnce(Arc) -> Fut, + Fut: std::future::Future, + { let _relink = self.registry.relink.lock().await; if self.registry.retirement_target(id).is_some() { return LifecycleOutcome::NoAccount; @@ -1228,8 +1251,9 @@ impl AccountSupervisor { // Invalidate any restart timer before touching storage: it must not // resurrect an account this request is retiring or resetting. self.bump_generation(id); - let exit = - session_bridge::finish_forget(id, &self.stores, &runtime.hub(), disposition).await; + let lifecycle = runtime.lifecycle(); + let _media = lifecycle.drain_media().await; + let exit = cleanup(runtime.hub()).await; use session_bridge::AccountExit; match exit { AccountExit::ResetCompleted => { @@ -1247,7 +1271,10 @@ impl AccountSupervisor { } // The teardown's own logs say why; the row is untouched, which is // a state the user can act on again. - _ => LifecycleOutcome::Incomplete, + _ => { + runtime.lifecycle().retry_failed_disposition(disposition); + LifecycleOutcome::Incomplete + } } } @@ -2074,6 +2101,64 @@ mod tests { tokio::time::timeout(std::time::Duration::from_secs(5), supervisor.join_all()).await; } + #[cfg(not(target_family = "wasm"))] + #[tokio::test] + async fn a_temporary_terminal_cleanup_failure_does_not_report_a_permanent_in_flight_operation() + { + use super::{AccountSupervisor, LifecycleOutcome, RestartPolicy}; + use crate::session_bridge::{AccountDisposition, AccountExit}; + let registry = AccountRegistry::new(); + let stores = Arc::new(oxidezap_session::StoreRegistry::new(format!( + "file:terminal-cleanup-retry-{}?mode=memory&cache=shared", + std::process::id() + ))); + let old = stores.create_account().await.unwrap(); + let other = stores.create_account().await.unwrap(); + let runtime = runtime(old.get()); + registry.insert(Arc::clone(&runtime)); + let supervisor = AccountSupervisor::with_shutdown( + Arc::clone(®istry), + Arc::clone(&stores), + 4, + RestartPolicy::production(), + || Box::pin(std::future::ready(())), + ); + let failed = supervisor + .finish_terminal_with(old, AccountDisposition::Remove, |_| async { + AccountExit::RemoveIncomplete + }) + .await; + assert_eq!(failed, LifecycleOutcome::Incomplete); + assert!( + !runtime.is_stopping(), + "the reaper must not short-circuit the next request as already in flight" + ); + assert_eq!(runtime.lifecycle().disposition(), None); + assert_eq!(stores.accounts().await.unwrap().len(), 2); + let retried = supervisor + .finish_terminal_with(old, AccountDisposition::Remove, |_| { + let stores = Arc::clone(&stores); + async move { + stores.remove_account(old).await.unwrap(); + AccountExit::RemoveCompleted + } + }) + .await; + assert_eq!(retried, LifecycleOutcome::Accepted); + assert!(registry.get(old).is_none()); + assert_eq!( + stores + .accounts() + .await + .unwrap() + .iter() + .map(|account| account.id) + .collect::>(), + vec![other] + ); + supervisor.join_all().await; + } + /// A restart that fires after the account was removed is a no-op: the /// generation the timer carried no longer matches. #[cfg(not(target_family = "wasm"))] diff --git a/crates/daemon/src/account/retirement.rs b/crates/daemon/src/account/retirement.rs index 74b5a09d..d3f840a5 100644 --- a/crates/daemon/src/account/retirement.rs +++ b/crates/daemon/src/account/retirement.rs @@ -45,7 +45,7 @@ impl AccountRegistry { let Some(target) = final_target(old, &all) else { continue; }; - let Some(stored) = stores.durable_pairing(target).await? else { + let Ok(Some(stored)) = stores.durable_pairing(target).await else { continue; }; let proof = StoredAccount { @@ -180,7 +180,7 @@ impl AccountRegistry { if !successor.hub().connection().is_connected() { continue; } - let Some(new) = stores.durable_pairing(next).await? else { + let Ok(Some(new)) = stores.durable_pairing(next).await else { continue; }; if !live_identity_matches(&successor, &new) || !same_account_identity(old, &new) { @@ -220,7 +220,7 @@ impl AccountRegistry { let Some(_pin) = lifecycle.pin() else { continue; }; - let Some(new) = stores.durable_pairing(next).await? else { + let Ok(Some(new)) = stores.durable_pairing(next).await else { continue; }; let proof = StoredAccount { @@ -324,12 +324,12 @@ mod tests { let mut device = Device::new(); device.pn = Some("100001:1@s.whatsapp.net".parse().unwrap()); device.lid = Some("200001:1@lid".parse().unwrap()); - device.account = Some(Arc::new(Default::default())); + Fixture::pair(&mut device); backend.save(&device).await.unwrap(); let mut next_device = Device::new(); next_device.pn = Some(new_pn.parse().unwrap()); next_device.lid = new_lid.map(|value| value.parse().unwrap()); - next_device.account = Some(Arc::new(Default::default())); + Fixture::pair(&mut next_device); stores .store(new) .await @@ -359,6 +359,19 @@ mod tests { } } + fn pair(device: &mut Device) { + let account = device + .account + .get_or_insert_with(|| Arc::new(Default::default())); + let account = Arc::make_mut(account); + // Synthetic protobuf ADVDeviceIdentity { key_index: 1 } and + // structurally complete signatures. No real identity or keys. + account.details = Some(vec![0x18, 0x01]); + account.account_signature_key = Some(vec![1; 32]); + account.account_signature = Some(vec![2; 64]); + account.device_signature = Some(vec![3; 64]); + } + fn runtime(id: AccountId, stores: Arc) -> Arc { let (commands, _receiver) = mpsc::channel(1); Arc::new(AccountRuntime::new_with_registry( @@ -447,6 +460,36 @@ mod tests { .unwrap(); } + #[tokio::test] + async fn partial_retirement_retries_on_the_same_runtime_without_daemon_restart() { + let f = Fixture::new( + "same-runtime-retry", + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + ) + .await; + f.registry + .consolidate_relinks_with(&f.stores, |_, _| async { + session_bridge::AccountExit::RemoveIncomplete + }) + .await + .unwrap(); + assert!(f.registry.get(f.old).unwrap().is_stopping()); + f.registry + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }) + .await + .unwrap(); + assert!(f.registry.get(f.old).is_none()); + assert!(f.stores.retirements().await.unwrap()[0].complete); + assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); + } + #[tokio::test] async fn conflicting_or_incomplete_identity_never_commits_retirement() { for (tag, pn, lid) in [ @@ -481,7 +524,7 @@ mod tests { .unwrap(); assert!(f.stores.retirements().await.unwrap().is_empty()); - device.account = Some(Arc::new(Default::default())); + Fixture::pair(&mut device); store.save(&device).await.unwrap(); let not_joined = Fixture::runtime(f.old, Arc::clone(&f.stores)); not_joined @@ -581,7 +624,7 @@ mod tests { let mut device = Device::new(); device.pn = Some("100001:13@s.whatsapp.net".parse().unwrap()); device.lid = Some("200001:13@lid".parse().unwrap()); - device.account = Some(Arc::new(Default::default())); + Fixture::pair(&mut device); f.stores .store(final_id) .await @@ -666,12 +709,52 @@ mod tests { } }; let (first, second) = tokio::join!( - f.registry.consolidate_relinks_with(&f.stores, &cleanup), - f.registry.consolidate_relinks_with(&f.stores, &cleanup), + f.registry.consolidate_relinks_with(&f.stores, cleanup), + f.registry.consolidate_relinks_with(&f.stores, cleanup), ); first.unwrap(); second.unwrap(); assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); } + + #[tokio::test] + async fn a_completed_redirect_cannot_select_a_replaced_whatsapp_identity() { + let f = Fixture::new( + "replaced-target", + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + ) + .await; + f.registry + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }) + .await + .unwrap(); + assert_eq!(f.registry.retirement_target(f.old), Some(f.new)); + let store = f.stores.store(f.new).await.unwrap(); + let mut replacement = store.load().await.unwrap().unwrap(); + replacement.pn = Some("100002@s.whatsapp.net".parse().unwrap()); + replacement.lid = Some("200002@lid".parse().unwrap()); + store.save(&replacement).await.unwrap(); + f.registry + .get(f.new) + .unwrap() + .hub() + .set_account(AccountIdentity { + name: None, + jid: Some("100002@s.whatsapp.net".into()), + lid: Some("200002@lid".into()), + }); + assert_eq!(f.registry.retirement_target(f.old), None); + f.registry.restore_retirements(&f.stores).await.unwrap(); + assert!(!f.registry.has_retirement(f.old)); + assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); + assert_eq!(f.stores.accounts().await.unwrap().len(), 1); + } } diff --git a/crates/daemon/src/session_bridge/mod.rs b/crates/daemon/src/session_bridge/mod.rs index ef61d743..5bc13a7b 100644 --- a/crates/daemon/src/session_bridge/mod.rs +++ b/crates/daemon/src/session_bridge/mod.rs @@ -158,6 +158,19 @@ impl RuntimeLifecycle { }) } + /// Only after terminal cleanup returned: there is no operation left in + /// flight, so a temporary failure must permit an explicit retry. + pub(crate) fn retry_failed_disposition(&self, expected: AccountDisposition) { + let mut state = self + .disposition + .lock() + .unwrap_or_else(|error| error.into_inner()); + if state.disposition == Some(expected) { + state.disposition = None; + self.stopping.store(false, Ordering::SeqCst); + } + } + pub(crate) fn reserve_retirement_successor(&self, reserved: bool) { self.disposition .lock() @@ -400,6 +413,7 @@ pub async fn run( ); disposition.incomplete() } else { + let _media = bridge.lifecycle.drain_media().await; finish_forget(account_id, &stores_for_reset, &bridge.hub, disposition).await } } else if stopping || commands_closed { diff --git a/crates/daemon/src/tray/accounts.rs b/crates/daemon/src/tray/accounts.rs index dcb23adb..34d8d14f 100644 --- a/crates/daemon/src/tray/accounts.rs +++ b/crates/daemon/src/tray/accounts.rs @@ -33,11 +33,18 @@ impl TrayHub { } pub fn hubs(&self) -> Vec> { + self.current_hubs(true) + } + + fn current_hubs(&self, include_closing_windows: bool) -> Vec> { let hubs: Vec<_> = self .registry .runtimes() .into_iter() - .filter(|runtime| !runtime.is_stopping()) + .filter(|runtime| { + !runtime.is_stopping() + || (include_closing_windows && runtime.hub().windows_attached()) + }) .map(|runtime| runtime.hub()) .collect(); if hubs.is_empty() { @@ -61,7 +68,7 @@ impl TrayHub { fn publish(&self) { let values: Vec<_> = self - .hubs() + .current_hubs(false) .iter() .map(|hub| hub.watch_tray().borrow().clone()) .collect(); @@ -78,13 +85,15 @@ impl TrayHub { pub async fn follow(self: Arc) { let mut accounts = self.registry.subscribe(); + let mut retirements = self.registry.watch_retirements(); let (changed, mut changes) = mpsc::channel(1); let mut watchers = tokio::task::JoinSet::new(); loop { watchers.abort_all(); while watchers.join_next().await.is_some() {} accounts.borrow_and_update(); - for hub in self.hubs() { + retirements.borrow_and_update(); + for hub in self.current_hubs(false) { let mut state = hub.watch_tray(); let changed = changed.clone(); watchers.spawn(async move { @@ -97,6 +106,10 @@ impl TrayHub { self.publish(); loop { tokio::select! { + result = retirements.changed() => { + if result.is_err() { return; } + break; + } result = accounts.changed() => { if result.is_err() { return; } break; diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 23739155..9a74b3fa 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -1202,20 +1202,17 @@ impl WhatsAppApp { /// Drain the control connection's events. /// /// The control plane carries no account state, so there is nothing to - /// apply. What this task is for is the *request table*: the answers to - /// `SetLogLevel`, a plugin install, a removal and a listing are resolved - /// by the reader as it runs, and the one-shot receivers the callers hold - /// only fire while it does. The events themselves are dropped. + /// apply. The reader resolves global requests and forwards explicit account + /// retirements so active and retained selections survive cleanup and restart. fn spawn_control_reader(mut ui_rx: crate::session::Events, cx: &mut Context) -> Task<()> { cx.spawn(async move |entity: WeakEntity, cx| { while let Some(event) = ui_rx.recv().await { - if let FromDaemon::AccountRetired { account, successor } = event { - if entity + if let FromDaemon::AccountRetired { account, successor } = event + && entity .update(cx, |app, cx| app.account_retired(account, successor, cx)) .is_err() - { - break; - } + { + break; } } }) diff --git a/crates/session/src/store/registry.rs b/crates/session/src/store/registry.rs index 5a2afa11..4c16c8d2 100644 --- a/crates/session/src/store/registry.rs +++ b/crates/session/src/store/registry.rs @@ -207,7 +207,34 @@ impl StoreRegistry { let Some(device) = store.load().await? else { return Ok(None); }; - if device.account.is_none() { + let Some(account) = device.account.as_deref() else { + return Ok(None); + }; + // PairUtils::do_pair_crypto verifies the account signature and adds + // the companion signature before this container is persisted. A PN + // alone, or a partially saved container, is not a durability proof. + if account + .account_signature_key + .as_ref() + .is_none_or(|bytes| bytes.len() != 32) + || account + .account_signature + .as_ref() + .is_none_or(|bytes| bytes.len() != 64) + || account + .device_signature + .as_ref() + .is_none_or(|bytes| bytes.len() != 64) + { + return Ok(None); + } + let Some(details) = account.details.as_deref() else { + return Ok(None); + }; + let Ok(details) = whatsapp_rust::waproto::codec::adv_device_identity_decode(details) else { + return Ok(None); + }; + if details.key_index.is_none() { return Ok(None); } Ok(Some(StoredAccount { From 65fd578f4916607b56f0383f43ff68f5fed2e736 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:00:06 -0300 Subject: [PATCH 04/14] Keep select policy before retirement notification branches --- crates/daemon/src/server/mod.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/daemon/src/server/mod.rs b/crates/daemon/src/server/mod.rs index a27153c6..51d8caba 100644 --- a/crates/daemon/src/server/mod.rs +++ b/crates/daemon/src/server/mod.rs @@ -362,9 +362,6 @@ pub(crate) async fn serve_client_with_registry( return Ok(()); } tokio::select! { - changed = retirements.changed() => { - if changed.is_err() { return Ok(()); } - } // Normally drain published state before reading more requests, so // a client that floods the socket cannot starve its own event // stream. After a lag that inverts, because recovery comes first. @@ -380,6 +377,11 @@ pub(crate) async fn serve_client_with_registry( // less than the stanza it turns into. biased; + changed = retirements.changed() => { + if changed.is_err() { return Ok(()); } + } + + // A replacement has a new hub and sender. End this attachment so // every frontend reconnects to the registered runtime. _ = commands.closed() => return Ok(()), From 920227f2f3d18c88b70104dbf397150a6a181455 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:02:08 -0300 Subject: [PATCH 05/14] Cover retirement of already-open native and wire consumers --- crates/daemon/src/account/retirement.rs | 103 ++++++++++++++++++++++++ 1 file changed, 103 insertions(+) diff --git a/crates/daemon/src/account/retirement.rs b/crates/daemon/src/account/retirement.rs index d3f840a5..e0ae13c4 100644 --- a/crates/daemon/src/account/retirement.rs +++ b/crates/daemon/src/account/retirement.rs @@ -757,4 +757,107 @@ mod tests { assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); assert_eq!(f.stores.accounts().await.unwrap().len(), 1); } + + #[tokio::test] + async fn open_native_consumers_receive_a_redirect_and_wire_consumers_close_without_rebinding() { + use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}; + let f = Fixture::new( + "open-consumers", + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + ) + .await; + let old = f.registry.remove(f.old).unwrap(); + let (commands, _idle_commands) = mpsc::channel(1); + let offline = Arc::new(AccountRuntime::new_with_registry( + f.old, + old.hub(), + old.plugins(), + commands, + Arc::clone(&f.stores), + )); + offline.set_status(AccountStatus::Error); + offline.lifecycle().mark_quiesced(); + f.registry.insert(offline); + let (mut native, native_server) = tokio::io::duplex(64 * 1024); + let native_task = tokio::spawn(crate::server::serve_client_with_registry( + Box::new(native_server), + Arc::clone(&f.registry), + )); + let hello = oxidezap_ipc::Request::bare(oxidezap_ipc::ClientRequest::Hello { + protocol: oxidezap_ipc::PROTOCOL_VERSION, + scope: oxidezap_ipc::ClientScope::Account { account: f.old }, + session_events: false, + owns_window: false, + call_video: false, + }); + native + .write_all(format!("{}\n", serde_json::to_string(&hello).unwrap()).as_bytes()) + .await + .unwrap(); + let mut native = BufReader::new(native); + let mut line = String::new(); + native.read_line(&mut line).await.unwrap(); + assert!(matches!( + serde_json::from_str::(&line).unwrap(), + oxidezap_ipc::DaemonMessage::Hello { .. } + )); + + let (mut wire, wire_server) = tokio::io::duplex(64 * 1024); + let wire_task = tokio::spawn(crate::server::serve_client_with_registry( + Box::new(wire_server), + Arc::clone(&f.registry), + )); + let hello = oxidezap_wire::envelope::RequestEnvelope { + id: 1, + request: oxidezap_wire::request::ClientRequest::Hello { + protocol: oxidezap_wire::envelope::CURRENT_PROTOCOL_VERSION, + client_name: "synthetic".into(), + local_account: Some(f.old.get()), + account_selection: true, + read_only: true, + session_events: false, + }, + }; + wire.write_all(format!("{}\n", serde_json::to_string(&hello).unwrap()).as_bytes()) + .await + .unwrap(); + let mut wire = BufReader::new(wire); + line.clear(); + wire.read_line(&mut line).await.unwrap(); + let selected: serde_json::Value = serde_json::from_str(&line).unwrap(); + assert_eq!(selected["data"]["local_account"], f.old.get()); + + let snapshot = f.stores.accounts().await.unwrap(); + assert!( + f.stores + .begin_retirement(&snapshot[0], &snapshot[1]) + .await + .unwrap() + ); + f.registry.restore_retirements(&f.stores).await.unwrap(); + line.clear(); + tokio::time::timeout( + std::time::Duration::from_secs(5), + native.read_line(&mut line), + ) + .await + .unwrap() + .unwrap(); + assert!( + matches!(serde_json::from_str::(&line).unwrap(), + oxidezap_ipc::DaemonMessage::AccountRetired { account, successor } if account == f.old && successor == f.new) + ); + line.clear(); + assert_eq!( + tokio::time::timeout(std::time::Duration::from_secs(5), wire.read_line(&mut line)) + .await + .unwrap() + .unwrap(), + 0 + ); + assert!(f.registry.get(f.new).is_some()); + native_task.await.unwrap().unwrap(); + wire_task.await.unwrap().unwrap(); + } } From 7a552ea554e45f632d47e47f72638725364e96d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:11:04 -0300 Subject: [PATCH 06/14] Validate retirement recovery across reopened databases and migration downgrade --- .../down.sql | 4 + crates/chat-store/src/store/mod.rs | 40 ++++ crates/daemon/src/account/mod.rs | 11 +- crates/daemon/src/account/retirement.rs | 210 ++++++++++++++---- crates/session/src/store/identity.rs | 9 +- docs/relink-consolidation.md | 6 +- 6 files changed, 227 insertions(+), 53 deletions(-) diff --git a/crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql b/crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql index ad1213ac..bb28c143 100644 --- a/crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql +++ b/crates/chat-store/migrations/2026-10-26-000001_account_retirement/down.sql @@ -1 +1,5 @@ +-- Losing an intent or redirect could strand cleanup or select a removed id. +CREATE TABLE retirement_downgrade_guard (empty INTEGER NOT NULL CHECK(empty = 0)); +INSERT INTO retirement_downgrade_guard SELECT COUNT(*) FROM account_retirement; +DROP TABLE retirement_downgrade_guard; DROP TABLE account_retirement; diff --git a/crates/chat-store/src/store/mod.rs b/crates/chat-store/src/store/mod.rs index 2f9f0d19..9dbf4614 100644 --- a/crates/chat-store/src/store/mod.rs +++ b/crates/chat-store/src/store/mod.rs @@ -1209,6 +1209,13 @@ mod migration_tests { .expect("create store"); ChatStore::new(&store).await.expect("run migrations"); + assert!(has_table(&store, "account_retirement").await); + store + .shared() + .run(revert_last_chat_migration) + .await + .expect("an empty retirement journal downgrade is reversible"); + assert!(!has_table(&store, "account_retirement").await); assert!(has_table(&store, "session_recovery").await); store .shared() @@ -1342,6 +1349,39 @@ mod migration_tests { assert_eq!(table_count(&store).await, 1); } + #[tokio::test] + async fn retirement_downgrade_preserves_pending_cleanup_and_completed_redirects() { + let store = SqliteStore::open(&format!( + "file:retirement-downgrade-{}?mode=memory&cache=shared", + std::process::id() + )) + .await + .unwrap(); + ChatStore::prepare(&store).await.unwrap(); + store.shared().run(|conn| { + diesel::sql_query("INSERT INTO account_retirement (old_id, successor, pn, lid) VALUES (1, 2, '100001@s.whatsapp.net', '200001@lid')").execute(conn).map(|_| ()).map_err(crate::error::db_err) + }).await.unwrap(); + for complete in [false, true] { + if complete { + ChatStore::finish_account_retirement(&store, 1) + .await + .unwrap(); + } + store + .shared() + .run(revert_last_chat_migration) + .await + .expect_err("journal records cannot be silently discarded by downgrade"); + let records = ChatStore::account_retirements(&store).await.unwrap(); + assert_eq!(records.len(), 1); + assert_eq!(records[0].complete, complete); + assert!( + !has_table(&store, "retirement_downgrade_guard").await, + "failed migration must roll back its guard table" + ); + } + } + async fn table_count(store: &SqliteStore) -> i64 { #[derive(QueryableByName)] struct Count { diff --git a/crates/daemon/src/account/mod.rs b/crates/daemon/src/account/mod.rs index 11aaecfb..39c5075f 100644 --- a/crates/daemon/src/account/mod.rs +++ b/crates/daemon/src/account/mod.rs @@ -1407,7 +1407,16 @@ impl AccountSupervisor { // observed through the account set, exactly as the // command-channel path answers. if runtime.is_stopping() { - let _ = reply.send(LifecycleOutcome::Accepted); + let outcome = if runtime + .lifecycle() + .disposition() + .is_some_and(|held| held != disposition) + { + LifecycleOutcome::Incomplete + } else { + LifecycleOutcome::Accepted + }; + let _ = reply.send(outcome); return; } // A live session owns the command channel; ask it to stop and diff --git a/crates/daemon/src/account/retirement.rs b/crates/daemon/src/account/retirement.rs index e0ae13c4..3a815da0 100644 --- a/crates/daemon/src/account/retirement.rs +++ b/crates/daemon/src/account/retirement.rs @@ -302,25 +302,70 @@ mod tests { registry: Arc, old: AccountId, new: AccountId, + database: TempDb, + } + + struct TempDb(std::path::PathBuf); + impl TempDb { + fn new(tag: &str) -> Self { + static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + loop { + let sequence = SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let path = std::env::temp_dir().join(format!( + "oxidezap-relink-{tag}-{}-{sequence}.db", + std::process::id() + )); + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(_) => return Self(path), + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(error) => panic!("could not create synthetic database: {error}"), + } + } + } + fn url(&self) -> String { + self.0.to_string_lossy().into_owned() + } + } + impl Drop for TempDb { + fn drop(&mut self) { + for suffix in ["", "-wal", "-shm"] { + let mut path = self.0.clone().into_os_string(); + path.push(suffix); + let _ = std::fs::remove_file(path); + } + } } impl Fixture { async fn new(tag: &str, new_pn: &str, new_lid: Option<&str>) -> Self { - let stores = Arc::new(StoreRegistry::new(format!( - "file:retirement-{tag}-{}?mode=memory&cache=shared", - std::process::id() - ))); + let database = TempDb::new(tag); + let stores = Arc::new(StoreRegistry::new(database.url())); let old = stores.create_account().await.unwrap(); let backend = stores.store(old).await.unwrap(); stores.prepare_chat_schema(&backend).await.unwrap(); let history = oxidezap_chat_store::ChatStore::new_prepared(&backend) .await .unwrap(); + let peer: wacore_binary::Jid = "900001@s.whatsapp.net".parse().unwrap(); + history + .record_outgoing( + &peer, + "RETAINED", + &Default::default(), + wacore::time::now_utc(), + ) + .unwrap(); + history.flush().await.unwrap(); history .record_logout("Synthetic unlink".into()) .await .unwrap(); let new = stores.pairing_successor(old).await.unwrap(); + history.close().await.unwrap(); let mut device = Device::new(); device.pn = Some("100001:1@s.whatsapp.net".parse().unwrap()); device.lid = Some("200001:1@lid".parse().unwrap()); @@ -356,9 +401,62 @@ mod tests { registry, old, new, + database, } } + async fn retains_history(&self) -> bool { + let backend = self.stores.store(self.old).await.unwrap(); + let history = oxidezap_chat_store::ChatStore::new_prepared(&backend) + .await + .unwrap(); + let peer: wacore_binary::Jid = "900001@s.whatsapp.net".parse().unwrap(); + let retained = history.message(&peer, "RETAINED").await.unwrap().is_some(); + history.close().await.unwrap(); + retained + } + + async fn relink_new(&self, pn: &str, lid: &str) -> AccountId { + let backend = self.stores.store(self.new).await.unwrap(); + let history = oxidezap_chat_store::ChatStore::new_prepared(&backend) + .await + .unwrap(); + history + .record_logout("Synthetic second unlink".into()) + .await + .unwrap(); + let final_id = self.stores.pairing_successor(self.new).await.unwrap(); + history.close().await.unwrap(); + let mut device = Device::new(); + device.pn = Some(pn.parse().unwrap()); + device.lid = Some(lid.parse().unwrap()); + Self::pair(&mut device); + self.stores + .store(final_id) + .await + .unwrap() + .save(&device) + .await + .unwrap(); + let intermediate = self.registry.get(self.new).unwrap(); + intermediate + .hub() + .apply(crate::state::Change::live(DaemonEvent::ConnectionChanged( + ConnectionState::LoggedOut { + message: "Synthetic second unlink".into(), + }, + ))); + intermediate.lifecycle().mark_quiesced(); + intermediate.set_status(AccountStatus::Error); + self.registry.insert(Self::connected( + final_id, + Arc::clone(&self.stores), + pn, + Some(lid), + )); + final_id + } + fn pair(device: &mut Device) { let account = device .account @@ -423,17 +521,21 @@ mod tests { assert_eq!(f.stores.accounts().await.unwrap().len(), 2); // A fresh daemon restores redirects before constructing old runtimes. + let restarted_stores = Arc::new(StoreRegistry::new(f.database.url())); let restarted = AccountRegistry::new(); - restarted.restore_retirements(&f.stores).await.unwrap(); + restarted + .restore_retirements(&restarted_stores) + .await + .unwrap(); restarted.insert(Fixture::connected( f.new, - Arc::clone(&f.stores), + Arc::clone(&restarted_stores), "100001:9@s.whatsapp.net", Some("200001:9@lid"), )); restarted - .consolidate_relinks_with(&f.stores, |old, _| { - let stores = Arc::clone(&f.stores); + .consolidate_relinks_with(&restarted_stores, |old, _| { + let stores = Arc::clone(&restarted_stores); async move { stores.remove_account(old).await.unwrap(); session_bridge::AccountExit::RemoveCompleted @@ -453,7 +555,7 @@ mod tests { ); assert!(f.stores.retirements().await.unwrap()[0].complete); restarted - .consolidate_relinks_with(&f.stores, |_, _| async { + .consolidate_relinks_with(&restarted_stores, |_, _| async { panic!("completed cleanup must not run twice") }) .await @@ -506,6 +608,7 @@ mod tests { .unwrap(); assert!(f.stores.retirements().await.unwrap().is_empty()); assert_eq!(f.stores.accounts().await.unwrap().len(), 2); + assert!(f.retains_history().await); } } @@ -586,17 +689,21 @@ mod tests { .await .unwrap(); assert!(!f.stores.retirements().await.unwrap()[0].complete); + let restarted_stores = Arc::new(StoreRegistry::new(f.database.url())); let restarted = AccountRegistry::new(); - restarted.restore_retirements(&f.stores).await.unwrap(); + restarted + .restore_retirements(&restarted_stores) + .await + .unwrap(); restarted.insert(Fixture::connected( f.new, - Arc::clone(&f.stores), + Arc::clone(&restarted_stores), "100001:9@s.whatsapp.net", Some("200001:9@lid"), )); restarted - .consolidate_relinks_with(&f.stores, |old, _| { - let stores = Arc::clone(&f.stores); + .consolidate_relinks_with(&restarted_stores, |old, _| { + let stores = Arc::clone(&restarted_stores); async move { stores.remove_account(old).await.unwrap(); session_bridge::AccountExit::RemoveCompleted @@ -612,42 +719,9 @@ mod tests { #[tokio::test] async fn successor_chains_survive_removing_the_intermediate_account() { let f = Fixture::new("chain", "100001:9@s.whatsapp.net", Some("200001:9@lid")).await; - let backend = f.stores.store(f.new).await.unwrap(); - let history = oxidezap_chat_store::ChatStore::new_prepared(&backend) - .await - .unwrap(); - history - .record_logout("Synthetic second unlink".into()) - .await - .unwrap(); - let final_id = f.stores.pairing_successor(f.new).await.unwrap(); - let mut device = Device::new(); - device.pn = Some("100001:13@s.whatsapp.net".parse().unwrap()); - device.lid = Some("200001:13@lid".parse().unwrap()); - Fixture::pair(&mut device); - f.stores - .store(final_id) - .await - .unwrap() - .save(&device) - .await - .unwrap(); - let intermediate = f.registry.get(f.new).unwrap(); - intermediate - .hub() - .apply(crate::state::Change::live(DaemonEvent::ConnectionChanged( - ConnectionState::LoggedOut { - message: "Synthetic second unlink".into(), - }, - ))); - intermediate.lifecycle().mark_quiesced(); - intermediate.set_status(AccountStatus::Error); - f.registry.insert(Fixture::connected( - final_id, - Arc::clone(&f.stores), - "100001:13@s.whatsapp.net", - Some("200001:13@lid"), - )); + let final_id = f + .relink_new("100001:13@s.whatsapp.net", "200001:13@lid") + .await; for _ in 0..2 { f.registry .consolidate_relinks_with(&f.stores, |old, _| { @@ -718,6 +792,44 @@ mod tests { assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); } + #[tokio::test] + async fn collapsing_a_successor_chain_keeps_an_earlier_different_accounts_history() { + let f = Fixture::new( + "different-parent", + "100002:9@s.whatsapp.net", + Some("200002:9@lid"), + ) + .await; + let final_id = f + .relink_new("100002:13@s.whatsapp.net", "200002:13@lid") + .await; + for _ in 0..2 { + f.registry + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }) + .await + .unwrap(); + } + assert_eq!( + f.stores + .accounts() + .await + .unwrap() + .iter() + .map(|account| account.id) + .collect::>(), + vec![f.old, final_id] + ); + assert_eq!(f.registry.retirement_target(f.old), None); + assert_eq!(f.registry.retirement_target(f.new), Some(final_id)); + assert!(f.retains_history().await); + } + #[tokio::test] async fn a_completed_redirect_cannot_select_a_replaced_whatsapp_identity() { let f = Fixture::new( diff --git a/crates/session/src/store/identity.rs b/crates/session/src/store/identity.rs index 5ce04e38..7b8ec6a0 100644 --- a/crates/session/src/store/identity.rs +++ b/crates/session/src/store/identity.rs @@ -19,7 +19,7 @@ pub fn same_account_identity(old: &StoredAccount, new: &StoredAccount) -> bool { } // The parser deliberately accepts non-user and server-only JIDs too. // Do not let those contracts broaden account identity matching. - if !jid.user.bytes().all(|byte| byte.is_ascii_digit()) { + if jid.user.starts_with('0') || !jid.user.bytes().all(|byte| byte.is_ascii_digit()) { return None; } Some(jid.to_non_ad()) @@ -57,6 +57,13 @@ mod tests { assert!(same_account_identity(&old, &new)); } + #[test] + fn reserved_zero_identifiers_cannot_authorize_retirement() { + let old = account(1, Some("0@s.whatsapp.net"), Some("0@lid")); + let new = account(2, Some("0@s.whatsapp.net"), Some("0@lid")); + assert!(!same_account_identity(&old, &new)); + } + #[test] fn incomplete_conflicting_and_different_evidence_preserves_both_accounts() { let old = account(1, Some("100001@s.whatsapp.net"), Some("200001@lid")); diff --git a/docs/relink-consolidation.md b/docs/relink-consolidation.md index 7c3ac47e..5ca1065b 100644 --- a/docs/relink-consolidation.md +++ b/docs/relink-consolidation.md @@ -4,7 +4,8 @@ Pairing uses an isolated local account. Retirement requires a connected successor, a complete paired device loaded from durable storage, and both PN and LID matching after the dependency's `Jid::to_non_ad` normalization. Missing, malformed, different or conflicting identifiers preserve both -accounts. Names, photos and partial strings provide no identity evidence. +accounts. Hosted and unsupported namespaces also retain history. Names, +photos and partial strings provide no identity evidence. The retirement journal survives device-row deletion. It records identity evidence and a strictly increasing successor before cleanup, preserves @@ -12,7 +13,8 @@ successor chains and resumes incomplete cleanup after restart. A successor cannot be reset while pending retirement depends on it. The lifecycle joins old session, chat, plugin and publication writers, drains web media operations, then reuses account-scoped cleanup. Failure preserves the successor and -leaves the journal retryable. Credentials and device ids are never moved. +leaves the journal retryable. Credentials and device ids are never moved. A nonempty journal blocks +schema downgrade so cleanup intents and redirects cannot be discarded. A retired connection closes. Native consumers receive an explicit redirect and must attach again; the CLI never changes its bound identity. GUI active From c969ba291a226089385e790f94436f0869d7c346 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:21:13 -0300 Subject: [PATCH 07/14] Use portable atomic for relink fixture sequence --- crates/daemon/src/account/retirement.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/daemon/src/account/retirement.rs b/crates/daemon/src/account/retirement.rs index 3a815da0..d3d01241 100644 --- a/crates/daemon/src/account/retirement.rs +++ b/crates/daemon/src/account/retirement.rs @@ -308,7 +308,7 @@ mod tests { struct TempDb(std::path::PathBuf); impl TempDb { fn new(tag: &str) -> Self { - static SEQUENCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); + static SEQUENCE: portable_atomic::AtomicU64 = portable_atomic::AtomicU64::new(0); loop { let sequence = SEQUENCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed); let path = std::env::temp_dir().join(format!( From 570c0f391e43c8fa246aa5624d35c6ac4be559ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:24:01 -0300 Subject: [PATCH 08/14] Keep retirement protections on read failures and drain cleanup outside reaper --- crates/daemon/src/account/mod.rs | 140 ++++++++++++++++++++++-- crates/daemon/src/account/retirement.rs | 115 ++++++++++++++++--- crates/daemon/src/listener/web/mod.rs | 67 +++++++----- crates/daemon/src/session_bridge/mod.rs | 15 +++ 4 files changed, 286 insertions(+), 51 deletions(-) diff --git a/crates/daemon/src/account/mod.rs b/crates/daemon/src/account/mod.rs index 39c5075f..6fc179bb 100644 --- a/crates/daemon/src/account/mod.rs +++ b/crates/daemon/src/account/mod.rs @@ -197,6 +197,7 @@ pub struct AccountRegistry { overview: watch::Sender>, retirements: watch::Sender>, retirement_proofs: RwLock>, + retired_ids: RwLock>, retirement_pending: RwLock>, retirement_ready: RwLock>, /// The supervisor spawning and respawning runtimes into this registry, @@ -229,6 +230,7 @@ impl AccountRegistry { overview, retirements: watch::channel(Vec::new()).0, retirement_proofs: RwLock::new(HashMap::new()), + retired_ids: RwLock::new(std::collections::HashSet::new()), retirement_pending: RwLock::new(std::collections::HashSet::new()), retirement_ready: RwLock::new(std::collections::HashSet::new()), #[cfg(not(target_family = "wasm"))] @@ -635,6 +637,7 @@ pub struct AccountSupervisor { /// and cannot interleave with the reaper's own view of which accounts are /// live — see [`SupervisorCommand`]. commands: mpsc::UnboundedSender, + active_runs: watch::Sender>, /// Set once by [`Self::join_all`], read by [`Self::handle_exit`] (a /// completed reset must not respawn into a daemon that is leaving) and by /// [`Self::create_and_spawn`] (refuse a `CreateAccount` that arrives @@ -734,6 +737,7 @@ impl RestartPolicy { /// session that eventually dropped from a login that never got off the ground. #[cfg(not(target_family = "wasm"))] struct AccountRun { + terminal_cleanup: bool, id: AccountId, exit: session_bridge::AccountExit, ran_for: std::time::Duration, @@ -820,6 +824,7 @@ impl AccountSupervisor { restart, command_capacity, commands, + active_runs: watch::channel(std::collections::HashSet::new()).0, shutting_down: std::sync::atomic::AtomicBool::new(false), wake: tokio::sync::Notify::new(), failures: FailureCounts::default(), @@ -1070,7 +1075,18 @@ impl AccountSupervisor { /// fix for this type's old deadlock. async fn handle_exit(self: &Arc, run: AccountRun) { use session_bridge::AccountExit; - let AccountRun { id, exit, ran_for } = run; + let AccountRun { + id, + exit, + ran_for, + terminal_cleanup, + } = run; + if terminal_cleanup { + return; + } + self.active_runs.send_modify(|active| { + active.remove(&id); + }); // A session that ran long enough was healthy, whatever ended it: the // next failure starts the backoff over rather than continuing a run of // attempts that may be minutes old. @@ -1127,7 +1143,17 @@ impl AccountSupervisor { ); self.registry.set_status(id, AccountStatus::Error); } - AccountExit::SessionEnded => self.recover(id), + AccountExit::SessionEnded => { + if self + .registry + .get(id) + .is_some_and(|runtime| runtime.is_stopping()) + { + self.registry.set_status(id, AccountStatus::Error); + } else { + self.recover(id); + } + } AccountExit::Stopped => { // A process-wide shutdown, or a command channel nothing can // send on again. Left registered with whatever terminal status @@ -1197,7 +1223,11 @@ impl AccountSupervisor { if self.shutting_down.load(Ordering::SeqCst) { return; } - if self.registry.get(id).is_none() { + if self + .registry + .get(id) + .is_none_or(|runtime| runtime.is_stopping()) + { return; } // Bumped here too: this restart is the generation a later timer would @@ -1237,7 +1267,7 @@ impl AccountSupervisor { Fut: std::future::Future, { let _relink = self.registry.relink.lock().await; - if self.registry.retirement_target(id).is_some() { + if self.registry.has_retirement(id) { return LifecycleOutcome::NoAccount; } let Some(runtime) = self.registry.get(id) else { @@ -1386,6 +1416,9 @@ impl AccountSupervisor { runtime, command_rx, } => { + self.active_runs.send_modify(|active| { + active.insert(runtime.id()); + }); tasks.spawn(Self::run_task(Arc::clone(self), runtime, command_rx)); } SupervisorCommand::RestartDue { id, generation } => { @@ -1451,10 +1484,43 @@ impl AccountSupervisor { } // No live session to ask — a logout, an exhausted recovery, or // a pending restart whose runtime has no task yet. Finish the - // storage mutation here, where the supervisor is the authority - // on whether anything is still writing. - let outcome = self.finish_terminal(id, disposition).await; - let _ = reply.send(outcome); + // mutation in a tracked task, after any old session has been + // joined. The reaper must keep serving other accounts while + // media leases or external cleanup are still draining. + if runtime.lifecycle().set_disposition(disposition) + == session_bridge::DispositionOutcome::Conflict + { + let _ = reply.send(LifecycleOutcome::Incomplete); + return; + } + self.bump_generation(id); + let mut active = self.active_runs.subscribe(); + let was_running = active.borrow().contains(&id); + let supervisor = Arc::clone(self); + tasks.spawn(async move { + while active.borrow_and_update().contains(&id) { + if active.changed().await.is_err() { + break; + } + } + // Joining the old task precedes any terminal mutation. + // A failed close is not permission to remove its storage. + if !was_running || runtime.lifecycle().is_quiesced() { + supervisor.finish_terminal(id, disposition).await; + } else { + log::warn!( + "account {} did not quiesce; terminal cleanup deferred", + id.get() + ); + } + AccountRun { + terminal_cleanup: true, + id, + exit: session_bridge::AccountExit::Stopped, + ran_for: std::time::Duration::ZERO, + } + }); + let _ = reply.send(LifecycleOutcome::Accepted); } } } @@ -1486,6 +1552,7 @@ impl AccountSupervisor { } }; AccountRun { + terminal_cleanup: false, id, exit, ran_for: started.elapsed(), @@ -1688,6 +1755,7 @@ mod tests { assert_eq!(registry.runtimes().len(), 1); let (_, ensured) = tokio::join!( supervisor.handle_exit(super::AccountRun { + terminal_cleanup: false, id, exit: crate::session_bridge::AccountExit::ResetCompleted, ran_for: std::time::Duration::from_secs(1), @@ -1784,6 +1852,7 @@ mod tests { fn run(id: i32, exit: AccountExit) -> AccountRun { AccountRun { + terminal_cleanup: false, id: AccountId::new(id).unwrap(), exit, ran_for: std::time::Duration::from_secs(1), @@ -1934,6 +2003,7 @@ mod tests { async move { supervisor .handle_exit(AccountRun { + terminal_cleanup: false, id: AccountId::LEGACY, exit: crate::session_bridge::AccountExit::ResetCompleted, ran_for: std::time::Duration::from_secs(1), @@ -2008,6 +2078,7 @@ mod tests { assert!(registry.insert(runtime(1))); supervisor .handle_exit(AccountRun { + terminal_cleanup: false, id: AccountId::LEGACY, exit: crate::session_bridge::AccountExit::RemoveCompleted, ran_for: std::time::Duration::from_secs(1), @@ -2092,6 +2163,9 @@ mod tests { LifecycleOutcome::Accepted, "a terminal account's removal must run directly, not fail on a dead channel" ); + tokio::time::timeout(std::time::Duration::from_secs(5), supervisor.join_all()) + .await + .expect("terminal cleanup must finish"); assert!( registry.get(id).is_none(), "the completed removal drops the runtime" @@ -2105,9 +2179,55 @@ mod tests { .any(|account| account.id == id), "and the device row is really gone" ); + } - let _ = - tokio::time::timeout(std::time::Duration::from_secs(5), supervisor.join_all()).await; + #[cfg(not(target_family = "wasm"))] + #[tokio::test] + async fn pending_terminal_cleanup_does_not_block_the_supervisor() { + use super::{AccountSupervisor, LifecycleOutcome, RestartPolicy}; + let registry = AccountRegistry::new(); + let stores = Arc::new(oxidezap_session::StoreRegistry::new(format!( + "file:terminal-join-wait-{}?mode=memory&cache=shared", + std::process::id() + ))); + let old = AccountId::new(2000000001).unwrap(); + let other = AccountId::new(2000000002).unwrap(); + let old_runtime = runtime(old.get()); + let other_runtime = runtime(other.get()); + registry.insert(Arc::clone(&old_runtime)); + registry.insert(Arc::clone(&other_runtime)); + let supervisor = AccountSupervisor::with_shutdown( + registry, + stores, + 4, + RestartPolicy::production(), + || std::future::ready(()), + ); + supervisor.active_runs.send_modify(|active| { + active.insert(old); + }); + let pin = other_runtime.lifecycle().pin().unwrap(); + assert_eq!( + supervisor.remove_account(old).await, + LifecycleOutcome::Accepted + ); + let answer = tokio::time::timeout( + std::time::Duration::from_secs(1), + supervisor.remove_account(other), + ) + .await + .expect("joining an old task must not block another account's request"); + assert_eq!(answer, LifecycleOutcome::Incomplete); + drop(pin); + // Simulate an old task that joined without proving a successful close. + // No storage or external cleanup is permitted in this case. + supervisor.active_runs.send_modify(|active| { + active.remove(&old); + }); + tokio::time::timeout(std::time::Duration::from_secs(1), supervisor.join_all()) + .await + .expect("tracked terminal task must finish"); + assert!(supervisor.registry.get(old).is_some()); } #[cfg(not(target_family = "wasm"))] diff --git a/crates/daemon/src/account/retirement.rs b/crates/daemon/src/account/retirement.rs index d3d01241..12938155 100644 --- a/crates/daemon/src/account/retirement.rs +++ b/crates/daemon/src/account/retirement.rs @@ -3,7 +3,10 @@ use oxidezap_session::{StoredAccount, same_account_identity}; impl AccountRegistry { pub fn has_retirement(&self, id: AccountId) -> bool { - self.retirements.borrow().iter().any(|(old, _)| *old == id) + self.retired_ids + .read() + .unwrap_or_else(|error| error.into_inner()) + .contains(&id) } pub fn retirement_target(&self, id: AccountId) -> Option { @@ -27,6 +30,21 @@ impl AccountRegistry { } pub async fn restore_retirements(&self, stores: &StoreRegistry) -> Result<()> { + self.restore_retirements_with(stores, |target| async move { + Ok(stores.durable_pairing(target).await?) + }) + .await + } + + async fn restore_retirements_with( + &self, + stores: &StoreRegistry, + pairing: F, + ) -> Result<()> + where + F: Fn(AccountId) -> Fut, + Fut: std::future::Future>>, + { let records = stores.retirements().await?; let all: Vec<_> = records .iter() @@ -37,6 +55,7 @@ impl AccountRegistry { )) }) .collect(); + let retired_ids = all.iter().map(|(old, _)| *old).collect(); let mut redirects = Vec::new(); let mut proofs = HashMap::new(); let mut pending = std::collections::HashSet::new(); @@ -45,7 +64,11 @@ impl AccountRegistry { let Some(target) = final_target(old, &all) else { continue; }; - let Ok(Some(stored)) = stores.durable_pairing(target).await else { + if !record.complete { + pending.insert(target); + } + // A failed read must leave the previous protection snapshot intact. + let Some(stored) = pairing(target).await? else { continue; }; let proof = StoredAccount { @@ -60,10 +83,11 @@ impl AccountRegistry { } redirects.push((old, target)); proofs.insert(old, proof); - if !record.complete { - pending.insert(target); - } } + *self + .retired_ids + .write() + .unwrap_or_else(|error| error.into_inner()) = retired_ids; *self .retirement_proofs .write() @@ -186,13 +210,29 @@ impl AccountRegistry { if !live_identity_matches(&successor, &new) || !same_account_identity(old, &new) { continue; } - if stores.begin_retirement(old, &new).await? { - self.restore_retirements(stores).await?; - let outcome = runtime - .lifecycle() - .set_disposition(AccountDisposition::Remove); - if outcome == session_bridge::DispositionOutcome::Conflict { - continue; + let old_lifecycle = runtime.lifecycle(); + if !old_lifecycle.start_retirement() { + continue; + } + match stores.begin_retirement(old, &new).await { + Ok(true) => { + // Protect both sides immediately after commit, including + // when refreshing the full journal encounters a read error. + self.retired_ids + .write() + .unwrap_or_else(|error| error.into_inner()) + .insert(old.id); + self.retirement_pending + .write() + .unwrap_or_else(|error| error.into_inner()) + .insert(next); + lifecycle.reserve_retirement_successor(true); + self.restore_retirements(stores).await?; + } + Ok(false) => old_lifecycle.retry_failed_disposition(AccountDisposition::Remove), + Err(error) => { + old_lifecycle.retry_failed_disposition(AccountDisposition::Remove); + return Err(error.into()); } } } @@ -865,11 +905,60 @@ mod tests { }); assert_eq!(f.registry.retirement_target(f.old), None); f.registry.restore_retirements(&f.stores).await.unwrap(); - assert!(!f.registry.has_retirement(f.old)); + assert!(f.registry.has_retirement(f.old)); assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); assert_eq!(f.stores.accounts().await.unwrap().len(), 1); } + #[tokio::test] + async fn unreadable_or_incomplete_successors_keep_retirement_protections() { + let f = Fixture::new( + "protection-read-error", + "100001:9@s.whatsapp.net", + Some("200001:9@lid"), + ) + .await; + let accounts = f.stores.accounts().await.unwrap(); + assert!( + f.stores + .begin_retirement(&accounts[0], &accounts[1]) + .await + .unwrap() + ); + f.registry.restore_retirements(&f.stores).await.unwrap(); + let result = f + .registry + .restore_retirements_with(&f.stores, |_| async { + anyhow::bail!("synthetic storage read failure") + }) + .await; + assert!(result.is_err()); + assert!(f.registry.has_retirement(f.old)); + assert_eq!(f.registry.retirement_target(f.old), Some(f.new)); + assert!(f.registry.get(f.new).unwrap().lifecycle().pin().is_some()); + f.registry + .restore_retirements_with(&f.stores, |_| async { Ok(None) }) + .await + .unwrap(); + assert!(f.registry.has_retirement(f.old)); + assert_eq!(f.registry.retirement_target(f.old), None); + assert!( + f.registry + .retirement_pending + .read() + .unwrap() + .contains(&f.new) + ); + assert_eq!( + f.registry + .get(f.new) + .unwrap() + .lifecycle() + .set_disposition(AccountDisposition::Remove), + session_bridge::DispositionOutcome::Conflict + ); + } + #[tokio::test] async fn open_native_consumers_receive_a_redirect_and_wire_consumers_close_without_rebinding() { use tokio::io::{AsyncBufReadExt as _, AsyncWriteExt as _, BufReader}; diff --git a/crates/daemon/src/listener/web/mod.rs b/crates/daemon/src/listener/web/mod.rs index b4f6dfe9..3fe597a7 100644 --- a/crates/daemon/src/listener/web/mod.rs +++ b/crates/daemon/src/listener/web/mod.rs @@ -315,34 +315,45 @@ async fn serve( ) .await; }; - // A staging upload is the one write this endpoint takes, and it reads - // from the buffered reader rather than the socket: the head was read - // a byte at a time but a client may send head and body in one - // segment, so the first of the payload can already be sitting in the - // buffer. Reading the raw stream would drop exactly that much. - // - // `PUT` and nothing else, because the preflight in [`http`] - // advertises exactly this list: taking a method the browser is told it may not - // use is a route no page can reach, and one the next reader has to - // work out is dead. - if request.method == "PUT" { - let key = key.to_string(); - return crate::media::http::receive( - &mut stream, - &key, - origin.as_deref(), - request.content_length, - ) - .await; - } - // The other half of staging: a send abandoned before the request ran - // leaves a payload nothing will ever read, and staged uploads are - // deliberately spared by the cache sweep, so without this they stay - // until the account is wiped. Narrowed the same way the write is. - if request.method == "DELETE" { - return crate::media::http::discard(stream.get_mut(), key, origin.as_deref()).await; - } - return crate::media::http::serve(stream.get_mut(), key, origin.as_deref()).await; + // Bound the entire transfer, including socket writes, so a stalled + // peer cannot hold the account's cleanup lease indefinitely. + let transfer = async { + // A staging upload is the one write this endpoint takes, and it reads + // from the buffered reader rather than the socket: the head was read + // a byte at a time but a client may send head and body in one + // segment, so the first of the payload can already be sitting in the + // buffer. Reading the raw stream would drop exactly that much. + // + // `PUT` and nothing else, because the preflight in [`http`] + // advertises exactly this list: taking a method the browser is told it may not + // use is a route no page can reach, and one the next reader has to + // work out is dead. + if request.method == "PUT" { + let key = key.to_string(); + crate::media::http::receive( + &mut stream, + &key, + origin.as_deref(), + request.content_length, + ) + .await + } + // The other half of staging: a send abandoned before the request ran + // leaves a payload nothing will ever read, and staged uploads are + // deliberately spared by the cache sweep, so without this they stay + // until the account is wiped. Narrowed the same way the write is. + else if request.method == "DELETE" { + crate::media::http::discard(stream.get_mut(), key, origin.as_deref()).await + } else { + crate::media::http::serve(stream.get_mut(), key, origin.as_deref()).await + } + }; + return oxidezap_session::with_timeout(transfer, std::time::Duration::from_secs(30)) + .await + .unwrap_or_else(|| { + log::warn!("account media transfer timed out"); + Ok(()) + }); } respond( diff --git a/crates/daemon/src/session_bridge/mod.rs b/crates/daemon/src/session_bridge/mod.rs index 5bc13a7b..1932f411 100644 --- a/crates/daemon/src/session_bridge/mod.rs +++ b/crates/daemon/src/session_bridge/mod.rs @@ -144,6 +144,21 @@ impl RuntimeLifecycle { .disposition } + /// Reserve a quiesced old runtime before persisting a retirement intent. + /// An intermediate successor may itself have a pending predecessor. + pub(crate) fn start_retirement(&self) -> bool { + let mut state = self + .disposition + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !self.is_quiesced() || state.disposition.is_some() || state.pins != 0 { + return false; + } + state.disposition = Some(AccountDisposition::Remove); + self.mark_stopping(); + true + } + pub(crate) fn pin(&self) -> Option { let mut state = self .disposition From a1f8eb5f879f2028385b062a783b52faf2f0b506 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:26:34 -0300 Subject: [PATCH 09/14] Keep upload disk operations leased when bounding stalled HTTP peers --- crates/daemon/src/listener/web/http.rs | 10 +++- crates/daemon/src/listener/web/mod.rs | 78 +++++++++++++------------- 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/crates/daemon/src/listener/web/http.rs b/crates/daemon/src/listener/web/http.rs index 69a5fc14..00c32e32 100644 --- a/crates/daemon/src/listener/web/http.rs +++ b/crates/daemon/src/listener/web/http.rs @@ -373,9 +373,13 @@ pub(crate) async fn respond( )); } head.push_str("\r\n"); - stream.write_all(head.as_bytes()).await?; - stream.write_all(body).await?; - stream.flush().await?; + tokio::time::timeout(std::time::Duration::from_secs(30), async { + stream.write_all(head.as_bytes()).await?; + stream.write_all(body).await?; + stream.flush().await + }) + .await + .context("HTTP response write timed out")??; Ok(()) } diff --git a/crates/daemon/src/listener/web/mod.rs b/crates/daemon/src/listener/web/mod.rs index 3fe597a7..58a486c4 100644 --- a/crates/daemon/src/listener/web/mod.rs +++ b/crates/daemon/src/listener/web/mod.rs @@ -315,45 +315,45 @@ async fn serve( ) .await; }; - // Bound the entire transfer, including socket writes, so a stalled - // peer cannot hold the account's cleanup lease indefinitely. - let transfer = async { - // A staging upload is the one write this endpoint takes, and it reads - // from the buffered reader rather than the socket: the head was read - // a byte at a time but a client may send head and body in one - // segment, so the first of the payload can already be sitting in the - // buffer. Reading the raw stream would drop exactly that much. - // - // `PUT` and nothing else, because the preflight in [`http`] - // advertises exactly this list: taking a method the browser is told it may not - // use is a route no page can reach, and one the next reader has to - // work out is dead. - if request.method == "PUT" { - let key = key.to_string(); - crate::media::http::receive( - &mut stream, - &key, - origin.as_deref(), - request.content_length, - ) - .await - } - // The other half of staging: a send abandoned before the request ran - // leaves a payload nothing will ever read, and staged uploads are - // deliberately spared by the cache sweep, so without this they stay - // until the account is wiped. Narrowed the same way the write is. - else if request.method == "DELETE" { - crate::media::http::discard(stream.get_mut(), key, origin.as_deref()).await - } else { - crate::media::http::serve(stream.get_mut(), key, origin.as_deref()).await - } - }; - return oxidezap_session::with_timeout(transfer, std::time::Duration::from_secs(30)) - .await - .unwrap_or_else(|| { - log::warn!("account media transfer timed out"); - Ok(()) - }); + // A staging upload is the one write this endpoint takes, and it reads + // from the buffered reader rather than the socket: the head was read + // a byte at a time but a client may send head and body in one + // segment, so the first of the payload can already be sitting in the + // buffer. Reading the raw stream would drop exactly that much. + // + // `PUT` and nothing else, because the preflight in [`http`] + // advertises exactly this list: taking a method the browser is told it may not + // use is a route no page can reach, and one the next reader has to + // work out is dead. + if request.method == "PUT" { + let key = key.to_string(); + return crate::media::http::receive( + &mut stream, + &key, + origin.as_deref(), + request.content_length, + ) + .await; + } + // The other half of staging: a send abandoned before the request ran + // leaves a payload nothing will ever read, and staged uploads are + // deliberately spared by the cache sweep, so without this they stay + // until the account is wiped. Narrowed the same way the write is. + if request.method == "DELETE" { + return crate::media::http::discard(stream.get_mut(), key, origin.as_deref()).await; + } + // Only the read-only download may be cancelled as a whole. Upload + // and discard must finish their disk operations while holding the + // lease; their socket responses have their own bounded writes. + return oxidezap_session::with_timeout( + crate::media::http::serve(stream.get_mut(), key, origin.as_deref()), + std::time::Duration::from_secs(30), + ) + .await + .unwrap_or_else(|| { + log::warn!("account media download timed out"); + Ok(()) + }); } respond( From 641b8fcd140fc5db78afdc50ca1f5fdd91274f3e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:26:59 -0300 Subject: [PATCH 10/14] Cover stalled HTTP response cleanup with a synthetic media lease --- crates/daemon/src/listener/web/http.rs | 30 +++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/crates/daemon/src/listener/web/http.rs b/crates/daemon/src/listener/web/http.rs index 00c32e32..8d92d29f 100644 --- a/crates/daemon/src/listener/web/http.rs +++ b/crates/daemon/src/listener/web/http.rs @@ -373,8 +373,16 @@ pub(crate) async fn respond( )); } head.push_str("\r\n"); + write_response(stream, head.as_bytes(), body).await +} + +async fn write_response( + stream: &mut (impl tokio::io::AsyncWrite + Unpin), + head: &[u8], + body: &[u8], +) -> Result<()> { tokio::time::timeout(std::time::Duration::from_secs(30), async { - stream.write_all(head.as_bytes()).await?; + stream.write_all(head).await?; stream.write_all(body).await?; stream.flush().await }) @@ -387,6 +395,26 @@ pub(crate) async fn respond( pub(super) mod tests { use super::*; + #[tokio::test(start_paused = true)] + async fn stalled_response_releases_its_account_media_lease() { + let lifecycle = std::sync::Arc::new(crate::session_bridge::RuntimeLifecycle::new()); + let lease = lifecycle.media_lease().await.unwrap(); + let (mut writer, _unread_peer) = tokio::io::duplex(1); + let transfer = tokio::spawn(async move { + let _lease = lease; + write_response(&mut writer, b"synthetic header", b"synthetic body").await + }); + let cleanup_lifecycle = std::sync::Arc::clone(&lifecycle); + let cleanup = tokio::spawn(async move { cleanup_lifecycle.drain_media().await }); + tokio::task::yield_now().await; + assert!(!transfer.is_finished()); + assert!(!cleanup.is_finished()); + tokio::time::advance(std::time::Duration::from_secs(31)).await; + let error = transfer.await.unwrap().unwrap_err(); + assert!(error.to_string().contains("timed out")); + drop(cleanup.await.unwrap()); + } + /// A token the tests can also write into a request. pub(crate) const TEST_TOKEN: &str = "0123456789abcdef"; From a82edf85ba7f8c49220245e6e44e8d750e289628 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:36:37 -0300 Subject: [PATCH 11/14] Release terminal waiters after failed joins without allowing unsafe cleanup --- crates/daemon/src/account/mod.rs | 107 ++++++++++++++++++++++++++++++- 1 file changed, 104 insertions(+), 3 deletions(-) diff --git a/crates/daemon/src/account/mod.rs b/crates/daemon/src/account/mod.rs index 6fc179bb..647aac97 100644 --- a/crates/daemon/src/account/mod.rs +++ b/crates/daemon/src/account/mod.rs @@ -638,6 +638,8 @@ pub struct AccountSupervisor { /// live — see [`SupervisorCommand`]. commands: mpsc::UnboundedSender, active_runs: watch::Sender>, + run_tasks: RwLock>, + failed_runs: RwLock>, /// Set once by [`Self::join_all`], read by [`Self::handle_exit`] (a /// completed reset must not respawn into a daemon that is leaving) and by /// [`Self::create_and_spawn`] (refuse a `CreateAccount` that arrives @@ -825,6 +827,8 @@ impl AccountSupervisor { command_capacity, commands, active_runs: watch::channel(std::collections::HashSet::new()).0, + run_tasks: RwLock::new(HashMap::new()), + failed_runs: RwLock::new(std::collections::HashSet::new()), shutting_down: std::sync::atomic::AtomicBool::new(false), wake: tokio::sync::Notify::new(), failures: FailureCounts::default(), @@ -1084,6 +1088,10 @@ impl AccountSupervisor { if terminal_cleanup { return; } + self.run_tasks + .write() + .unwrap_or_else(|error| error.into_inner()) + .retain(|_, account| *account != id); self.active_runs.send_modify(|active| { active.remove(&id); }); @@ -1308,6 +1316,25 @@ impl AccountSupervisor { } } + fn handle_join_error(&self, error: tokio::task::JoinError) { + log::error!("an account task failed: {error}"); + let id = self + .run_tasks + .write() + .unwrap_or_else(|error| error.into_inner()) + .remove(&error.id()); + if let Some(id) = id { + self.failed_runs + .write() + .unwrap_or_else(|error| error.into_inner()) + .insert(id); + self.active_runs.send_modify(|active| { + active.remove(&id); + }); + self.registry.set_status(id, AccountStatus::Error); + } + } + /// Continuously reap finished account tasks and act on what they /// returned, for as long as the daemon runs — not just at final /// shutdown. Spawned once, by [`Self::with_shutdown`]; [`Self::join_all`] @@ -1355,7 +1382,7 @@ impl AccountSupervisor { reaped_any = true; } Some(Err(e)) => { - log::error!("an account task panicked: {e}"); + self.handle_join_error(e); reaped_any = true; } None => {} @@ -1393,7 +1420,7 @@ impl AccountSupervisor { } joined = tasks.join_next(), if !tasks.is_empty() => match joined { Some(Ok(run)) => self.handle_exit(run).await, - Some(Err(e)) => log::error!("an account task panicked: {e}"), + Some(Err(e)) => self.handle_join_error(e), None => {} }, () = self.wake.notified(), if !self.shutting_down.load(Ordering::SeqCst) => {} @@ -1419,7 +1446,16 @@ impl AccountSupervisor { self.active_runs.send_modify(|active| { active.insert(runtime.id()); }); - tasks.spawn(Self::run_task(Arc::clone(self), runtime, command_rx)); + let id = runtime.id(); + self.failed_runs + .write() + .unwrap_or_else(|error| error.into_inner()) + .remove(&id); + let task = tasks.spawn(Self::run_task(Arc::clone(self), runtime, command_rx)); + self.run_tasks + .write() + .unwrap_or_else(|error| error.into_inner()) + .insert(task.id(), id); } SupervisorCommand::RestartDue { id, generation } => { self.restart_due(id, generation).await; @@ -1433,6 +1469,18 @@ impl AccountSupervisor { let _ = reply.send(LifecycleOutcome::NoAccount); return; }; + // A joined panic proves termination, but not successful close. + // Keep storage intact until a fresh runtime can close safely. + if self + .failed_runs + .read() + .unwrap_or_else(|error| error.into_inner()) + .contains(&id) + && !runtime.lifecycle().is_quiesced() + { + let _ = reply.send(LifecycleOutcome::Incomplete); + return; + } // A runtime already stopping has a task that will finish the // teardown and be reaped, so the operation is in flight: do // not run a second storage mutation underneath it. `Accepted` @@ -2181,6 +2229,59 @@ mod tests { ); } + #[cfg(not(target_family = "wasm"))] + #[tokio::test] + async fn a_joined_panic_releases_cleanup_waiters_without_authorizing_storage_removal() { + use super::{AccountSupervisor, LifecycleOutcome, RestartPolicy}; + let registry = AccountRegistry::new(); + let stores = Arc::new(oxidezap_session::StoreRegistry::new(format!( + "file:terminal-panic-{}?mode=memory&cache=shared", + std::process::id() + ))); + let id = stores.create_account().await.unwrap(); + let account = runtime(id.get()); + registry.insert(Arc::clone(&account)); + let supervisor = AccountSupervisor::with_shutdown( + registry, + Arc::clone(&stores), + 4, + RestartPolicy::production(), + || std::future::ready(()), + ); + let failed = tokio::spawn(async { panic!("synthetic account task failure") }); + supervisor + .run_tasks + .write() + .unwrap() + .insert(failed.id(), id); + supervisor.active_runs.send_modify(|active| { + active.insert(id); + }); + assert_eq!( + supervisor.remove_account(id).await, + LifecycleOutcome::Accepted + ); + supervisor.handle_join_error(failed.await.unwrap_err()); + assert!(!supervisor.active_runs.borrow().contains(&id)); + assert_eq!( + supervisor.remove_account(id).await, + LifecycleOutcome::Incomplete + ); + tokio::time::timeout(std::time::Duration::from_secs(1), supervisor.join_all()) + .await + .expect("a joined panic cannot strand tracked cleanup or shutdown"); + assert!(supervisor.registry.get(id).is_some()); + assert!(!account.lifecycle().is_quiesced()); + assert!( + stores + .accounts() + .await + .unwrap() + .iter() + .any(|account| account.id == id) + ); + } + #[cfg(not(target_family = "wasm"))] #[tokio::test] async fn pending_terminal_cleanup_does_not_block_the_supervisor() { From 3a4e10044bd424090294968b3fd28b6236e43511 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:39:04 -0300 Subject: [PATCH 12/14] Locate retirement downgrade tests by migration identity --- crates/chat-store/src/store/mod.rs | 48 ++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) diff --git a/crates/chat-store/src/store/mod.rs b/crates/chat-store/src/store/mod.rs index 9dbf4614..7c608b14 100644 --- a/crates/chat-store/src/store/mod.rs +++ b/crates/chat-store/src/store/mod.rs @@ -1199,6 +1199,44 @@ mod migration_tests { result } + /// Isolate this migration's downgrade edge from later independent + /// migrations, rather than assuming a fixed latest version or count. + fn rewind_to_retirement_migration( + conn: &mut diesel::SqliteConnection, + ) -> std::result::Result<(), StoreError> { + use diesel::migration::{Migration, MigrationSource}; + use diesel::sqlite::Sqlite; + let migrations: Vec>> = + MIGRATIONS.migrations().map_err(StoreError::Migration)?; + let target = migrations + .iter() + .find(|migration| { + migration + .name() + .to_string() + .ends_with("_account_retirement") + }) + .expect("the account retirement migration is embedded") + .name() + .version() + .as_owned(); + loop { + let applied = conn.applied_migrations().map_err(StoreError::Migration)?; + assert!( + applied.contains(&target), + "retirement migration must remain applied" + ); + let newer = migrations.iter().any(|migration| { + let version = migration.name().version(); + version > target && applied.contains(&version) + }); + if !newer { + return Ok(()); + } + revert_last_chat_migration(conn)?; + } + } + #[tokio::test] async fn stable_id_downgrade_round_trips_then_sender_identity_refuses() { let store = SqliteStore::open(&format!( @@ -1208,6 +1246,11 @@ mod migration_tests { .await .expect("create store"); ChatStore::new(&store).await.expect("run migrations"); + store + .shared() + .run(rewind_to_retirement_migration) + .await + .expect("isolate the retirement downgrade edge"); assert!(has_table(&store, "account_retirement").await); store @@ -1358,6 +1401,11 @@ mod migration_tests { .await .unwrap(); ChatStore::prepare(&store).await.unwrap(); + store + .shared() + .run(rewind_to_retirement_migration) + .await + .expect("isolate the retirement downgrade edge"); store.shared().run(|conn| { diesel::sql_query("INSERT INTO account_retirement (old_id, successor, pn, lid) VALUES (1, 2, '100001@s.whatsapp.net', '200001@lid')").execute(conn).map(|_| ()).map_err(crate::error::db_err) }).await.unwrap(); From 70655cb56a95727cdbde6f2cd382c17c9627de90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:39:55 -0300 Subject: [PATCH 13/14] Match the retirement ledger version explicitly --- crates/chat-store/src/store/mod.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/chat-store/src/store/mod.rs b/crates/chat-store/src/store/mod.rs index 7c608b14..b4c8991d 100644 --- a/crates/chat-store/src/store/mod.rs +++ b/crates/chat-store/src/store/mod.rs @@ -1208,14 +1208,10 @@ mod migration_tests { use diesel::sqlite::Sqlite; let migrations: Vec>> = MIGRATIONS.migrations().map_err(StoreError::Migration)?; + // Diesel strips the directory date's hyphens in the shared ledger. let target = migrations .iter() - .find(|migration| { - migration - .name() - .to_string() - .ends_with("_account_retirement") - }) + .find(|migration| migration.name().version().to_string() == "20261026000001") .expect("the account retirement migration is embedded") .name() .version() From a64273ce55f8af102249730807fda0397d50aed1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:48:09 -0300 Subject: [PATCH 14/14] Quiesce retained logged-out runtimes after confirming a durable relink --- crates/daemon/src/account/mod.rs | 7 ++- crates/daemon/src/account/retirement.rs | 83 ++++++++++++++++++++----- crates/daemon/src/session_bridge/mod.rs | 55 ++++++++++++++-- 3 files changed, 126 insertions(+), 19 deletions(-) diff --git a/crates/daemon/src/account/mod.rs b/crates/daemon/src/account/mod.rs index 647aac97..58716784 100644 --- a/crates/daemon/src/account/mod.rs +++ b/crates/daemon/src/account/mod.rs @@ -169,7 +169,12 @@ impl AccountRuntime { Arc::clone(&self.plugins), command_rx, self.lifecycle.clone(), - shutdown, + async { + tokio::select! { + () = shutdown => {}, + () = self.lifecycle.retirement_stop_requested() => {}, + } + }, ) .await; self.set_status(if result.is_ok() { diff --git a/crates/daemon/src/account/retirement.rs b/crates/daemon/src/account/retirement.rs index 12938155..99e135a3 100644 --- a/crates/daemon/src/account/retirement.rs +++ b/crates/daemon/src/account/retirement.rs @@ -182,10 +182,7 @@ impl AccountRegistry { let Some(runtime) = self.get(old.id) else { continue; }; - if runtime.status() != AccountStatus::Error - || !runtime.hub().is_logged_out() - || !runtime.lifecycle().is_quiesced() - { + if !runtime.hub().is_logged_out() { continue; } let Some(next) = stores.existing_pairing_successor(old.id).await? else { @@ -227,11 +224,12 @@ impl AccountRegistry { .unwrap_or_else(|error| error.into_inner()) .insert(next); lifecycle.reserve_retirement_successor(true); + old_lifecycle.request_retirement_stop(); self.restore_retirements(stores).await?; } - Ok(false) => old_lifecycle.retry_failed_disposition(AccountDisposition::Remove), + Ok(false) => old_lifecycle.cancel_retirement_reservation(), Err(error) => { - old_lifecycle.retry_failed_disposition(AccountDisposition::Remove); + old_lifecycle.cancel_retirement_reservation(); return Err(error.into()); } } @@ -275,16 +273,25 @@ impl AccountRegistry { } let old_lifecycle = self.get(old).map(|runtime| runtime.lifecycle()); let hub = if let Some(runtime) = self.get(old) { - if !runtime.lifecycle().is_quiesced() || runtime.status() != AccountStatus::Error { + runtime.lifecycle().request_retirement_stop(); + if !runtime.lifecycle().is_quiesced() + || !matches!( + runtime.status(), + AccountStatus::Error | AccountStatus::Stopped + ) + { continue; } - if runtime - .lifecycle() - .set_disposition(AccountDisposition::Remove) - == session_bridge::DispositionOutcome::Conflict + #[cfg(not(target_family = "wasm"))] + if self + .supervisor() + .is_some_and(|supervisor| supervisor.active_runs.borrow().contains(&old)) { continue; } + if !runtime.lifecycle().claim_retirement_cleanup() { + continue; + } runtime.hub() } else { StateHub::for_account(old) @@ -649,6 +656,7 @@ mod tests { assert!(f.stores.retirements().await.unwrap().is_empty()); assert_eq!(f.stores.accounts().await.unwrap().len(), 2); assert!(f.retains_history().await); + assert!(!f.registry.get(f.old).unwrap().is_stopping()); } } @@ -677,16 +685,63 @@ mod tests { message: "Synthetic unlink".into(), }, ))); - not_joined.set_status(AccountStatus::Error); + not_joined.set_status(AccountStatus::Running); f.registry.remove(f.old); - f.registry.insert(not_joined); + f.registry.insert(Arc::clone(¬_joined)); f.registry .consolidate_relinks_with(&f.stores, |_, _| async { panic!("old tasks have not joined") }) .await .unwrap(); - assert!(f.stores.retirements().await.unwrap().is_empty()); + let records = f.stores.retirements().await.unwrap(); + assert_eq!(records.len(), 1); + assert!(!records[0].complete); + assert!(f.retains_history().await); + assert_eq!(not_joined.lifecycle().disposition(), None); + tokio::time::timeout( + std::time::Duration::from_secs(1), + not_joined.lifecycle().retirement_stop_requested(), + ) + .await + .expect("a verified relink must stop its retained predecessor"); + + let supervisor = AccountSupervisor::with_shutdown( + Arc::clone(&f.registry), + Arc::clone(&f.stores), + 4, + RestartPolicy::production(), + || std::future::ready(()), + ); + supervisor.active_runs.send_modify(|active| { + active.insert(f.old); + }); + not_joined.lifecycle().mark_quiesced(); + not_joined.set_status(AccountStatus::Stopped); + f.registry + .consolidate_relinks_with(&f.stores, |_, _| async { + panic!("quiescence alone does not authorize cleanup before the task is joined") + }) + .await + .unwrap(); + assert!(f.retains_history().await); + supervisor.active_runs.send_modify(|active| { + active.remove(&f.old); + }); + f.registry + .consolidate_relinks_with(&f.stores, |old, _| { + let stores = Arc::clone(&f.stores); + async move { + stores.remove_account(old).await.unwrap(); + session_bridge::AccountExit::RemoveCompleted + } + }) + .await + .unwrap(); + assert!(f.registry.get(f.old).is_none()); + assert!(f.stores.retirements().await.unwrap()[0].complete); + assert!(f.stores.durable_pairing(f.new).await.unwrap().is_some()); + supervisor.join_all().await; } #[tokio::test] diff --git a/crates/daemon/src/session_bridge/mod.rs b/crates/daemon/src/session_bridge/mod.rs index 1932f411..11282a45 100644 --- a/crates/daemon/src/session_bridge/mod.rs +++ b/crates/daemon/src/session_bridge/mod.rs @@ -60,6 +60,7 @@ pub struct RuntimeLifecycle { /// or a process-wide shutdown, leaves this `None`. disposition: Arc>, quiesced: Arc, + retirement_stop: tokio::sync::watch::Sender, media: Arc>, } @@ -68,6 +69,7 @@ struct LifecycleState { disposition: Option, pins: usize, retirement_reserved: bool, + retiring: bool, } /// Keeps a confirmed successor from being reset while its predecessor is retired. @@ -88,6 +90,7 @@ impl RuntimeLifecycle { stopping: Arc::new(AtomicBool::new(false)), disposition: Arc::new(Mutex::new(LifecycleState::default())), quiesced: Arc::new(AtomicBool::new(false)), + retirement_stop: tokio::sync::watch::channel(false).0, media: Arc::new(tokio::sync::RwLock::new(())), } } @@ -120,7 +123,7 @@ impl RuntimeLifecycle { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); match slot.disposition { - None if slot.pins == 0 && !slot.retirement_reserved => { + None if slot.pins == 0 && !slot.retirement_reserved && !slot.retiring => { slot.disposition = Some(disposition); DispositionOutcome::Recorded } @@ -144,16 +147,60 @@ impl RuntimeLifecycle { .disposition } - /// Reserve a quiesced old runtime before persisting a retirement intent. - /// An intermediate successor may itself have a pending predecessor. + /// Reserve the old lifecycle before committing an identity-confirmed intent. + /// This is not a removal disposition: teardown must retain storage. pub(crate) fn start_retirement(&self) -> bool { let mut state = self .disposition .lock() .unwrap_or_else(|error| error.into_inner()); - if !self.is_quiesced() || state.disposition.is_some() || state.pins != 0 { + if state.disposition.is_some() || state.pins != 0 || state.retiring { return false; } + state.retiring = true; + self.mark_stopping(); + true + } + + pub(crate) fn cancel_retirement_reservation(&self) { + let mut state = self + .disposition + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !*self.retirement_stop.borrow() { + state.retiring = false; + self.stopping.store(false, Ordering::SeqCst); + } + } + + pub(crate) fn request_retirement_stop(&self) { + self.mark_stopping(); + self.retirement_stop.send_replace(true); + } + + pub(crate) async fn retirement_stop_requested(&self) { + let mut stop = self.retirement_stop.subscribe(); + while !*stop.borrow_and_update() { + if stop.changed().await.is_err() { + return; + } + } + } + + pub(crate) fn claim_retirement_cleanup(&self) -> bool { + let mut state = self + .disposition + .lock() + .unwrap_or_else(|error| error.into_inner()); + if !self.is_quiesced() + || state.pins != 0 + || state + .disposition + .is_some_and(|held| held != AccountDisposition::Remove) + { + return false; + } + state.retiring = true; state.disposition = Some(AccountDisposition::Remove); self.mark_stopping(); true