Development Documentation (main branch) - For stable release docs, see docs.rs/eidetica
Skip to main content

eidetica/service/
client.rs

1//! Remote connection client for the Eidetica service.
2//!
3//! `RemoteConnection` connects to an Eidetica service server and forwards
4//! storage operations as RPC calls. It backs the `RemoteBackend` implementation
5//! of the `Backend` seam and is not itself a `BackendImpl`.
6//!
7//! Authentication uses the client-side-signing flow described in the Service
8//! Architecture doc § Security Model: `RemoteConnection::trusted_login` drives
9//! the daemon's `TrustedLoginUser` / `TrustedLoginProve` challenge-response,
10//! decrypts the user's root signing key in-process, and signs the challenge
11//! locally. The daemon never sees the password or the plaintext signing key.
12//! After login, subsequent backend operations travel inside the `Authenticated`
13//! envelope and are dispatched against the user's identity; the daemon gates
14//! each one per-tree against the target database's auth settings.
15
16use std::collections::{HashMap, HashSet, VecDeque};
17use std::path::Path;
18use std::sync::atomic::{AtomicBool, Ordering};
19use std::sync::{Arc, RwLock, Weak};
20
21use lru::LruCache;
22use tokio::io::{ReadHalf, WriteHalf};
23use tokio::net::UnixStream;
24use tokio::sync::{Mutex, Notify, mpsc, oneshot};
25
26use crate::auth::crypto::PrivateKey;
27use crate::auth::crypto::{PublicKey, create_challenge_response};
28use crate::auth::types::SigKey;
29use crate::backend::InstanceMetadata;
30use crate::entry::{Entry, ID};
31use crate::instance::WeakInstance;
32use crate::service::error::service_error_to_eidetica_error;
33use crate::service::protocol::{
34    AuthenticatedDbRequest, DatabaseOp, Handshake, HandshakeAck, MergeState, Notification,
35    PROTOCOL_VERSION, ReadScope, ServerFrame, ServiceRequest, ServiceResponse, TransactionContext,
36    WireCrdtValue, read_frame, write_frame,
37};
38use crate::snapshot::Snapshot;
39use crate::user::UserError;
40use crate::user::crypto::{decrypt_private_key, derive_encryption_key};
41use crate::user::types::{KeyStorage, UserInfo};
42
43/// Default cap on the client-side CRDT-state LRU. Matches `MAX_FRAME_SIZE`
44/// (64 MiB) so a single oversized cached blob can still ride the wire.
45const CLIENT_CACHE_CAPACITY_BYTES: usize = 64 * 1024 * 1024;
46
47/// How long an `Idle` per-tree subscription is kept warm before the
48/// sweep sends `UnsubscribeWrites`. A re-registration arriving inside
49/// this window transitions back to `Subscribed` without a wire call.
50///
51/// Sized for a "user is briefly between two react renders" case rather
52/// than "user closes the app, comes back tomorrow." 60s is plenty for
53/// the churn case and small enough that abandoned subscriptions don't
54/// linger.
55///
56/// Tests override this via the `EIDETICA_TEST_IDLE_GRACE_MS` env var
57/// (see [`idle_grace_window`]) so the lazy-unsubscribe path is
58/// exercisable without making test suites wait the full minute.
59const IDLE_GRACE_WINDOW: std::time::Duration = std::time::Duration::from_secs(60);
60
61/// How often the sweep task wakes up to check for expired `Idle`
62/// entries. Half the grace window so an entry that becomes Idle right
63/// after a sweep tick still gets unsubscribed within roughly one grace
64/// window's worth of clock time.
65///
66/// Tests override this via the `EIDETICA_TEST_SWEEP_INTERVAL_MS` env
67/// var (see [`sweep_interval`]).
68const SWEEP_INTERVAL: std::time::Duration = std::time::Duration::from_secs(30);
69
70/// Test-overridable grace window. Reads `EIDETICA_TEST_IDLE_GRACE_MS`
71/// (milliseconds) if set; otherwise [`IDLE_GRACE_WINDOW`].
72fn idle_grace_window() -> std::time::Duration {
73    std::env::var("EIDETICA_TEST_IDLE_GRACE_MS")
74        .ok()
75        .and_then(|v| v.parse::<u64>().ok())
76        .map(std::time::Duration::from_millis)
77        .unwrap_or(IDLE_GRACE_WINDOW)
78}
79
80/// Test-overridable sweep interval. Reads
81/// `EIDETICA_TEST_SWEEP_INTERVAL_MS` (milliseconds) if set; otherwise
82/// [`SWEEP_INTERVAL`].
83fn sweep_interval() -> std::time::Duration {
84    std::env::var("EIDETICA_TEST_SWEEP_INTERVAL_MS")
85        .ok()
86        .and_then(|v| v.parse::<u64>().ok())
87        .map(std::time::Duration::from_millis)
88        .unwrap_or(SWEEP_INTERVAL)
89}
90
91/// Process-lifetime LRU of materialized CRDT states for this connection.
92///
93/// Tier 1 of a two-level cache: local hits short-circuit any wire activity;
94/// misses fall through to `GetCachedCrdtState` against the daemon. Cleared
95/// on connection drop — durability across the daemon's lifetime is the
96/// unified [`crate::backend::CacheScope`]-keyed cache in the daemon's
97/// `BackendImpl`, not this one.
98///
99/// Keys are `(root_id, key, store_name)`; values are opaque bytes (cipher-
100/// or plaintext depending on the store, decided by the Transaction's
101/// `encryptors` map). The cache itself is byte-blind.
102struct ClientCrdtCache {
103    lru: LruCache<(ID, ID, String), Vec<u8>>,
104    current_bytes: usize,
105    capacity_bytes: usize,
106}
107
108impl ClientCrdtCache {
109    fn new(capacity_bytes: usize) -> Self {
110        Self {
111            lru: LruCache::unbounded(),
112            current_bytes: 0,
113            capacity_bytes,
114        }
115    }
116
117    fn get(&mut self, root_id: &ID, key: &ID, store: &str) -> Option<Vec<u8>> {
118        // `LruCache::get` promotes the entry to most-recently-used.
119        self.lru
120            .get(&(root_id.clone(), key.clone(), store.to_string()))
121            .cloned()
122    }
123
124    fn put(&mut self, root_id: ID, key: ID, store: String, blob: Vec<u8>) {
125        let blob_size = blob.len();
126        let cache_key = (root_id, key, store);
127        if let Some(prev) = self.lru.put(cache_key.clone(), blob) {
128            self.current_bytes = self.current_bytes.saturating_sub(prev.len());
129        }
130        self.current_bytes = self.current_bytes.saturating_add(blob_size);
131        // Evict LRU until under cap. Soft cap: a single oversized blob is
132        // allowed to exceed the limit alone rather than thrashing.
133        while self.current_bytes > self.capacity_bytes {
134            let Some((k, v)) = self.lru.pop_lru() else {
135                break;
136            };
137            if k == cache_key {
138                self.lru.put(k, v);
139                break;
140            }
141            self.current_bytes = self.current_bytes.saturating_sub(v.len());
142        }
143    }
144}
145
146/// Per-connection session state, populated by `trusted_login` on success.
147///
148/// Holds only the public key the daemon verified during challenge-response.
149/// The plaintext signing key is intentionally **not** stored here — it lives
150/// in the `User::key_manager` session that owns this connection. The
151/// daemon-side `ConnectionState::Authenticated` is what carries the
152/// `user_uuid` (for chunk 6's cache scoping); the client doesn't need it.
153#[derive(Clone, Debug)]
154struct SessionState {
155    session_pubkey: PublicKey,
156}
157
158/// Per-tree subscription state for [`RemoteConnectionInner::subscribed_trees`].
159///
160/// State machine:
161/// ```text
162///     (absent)
163///        │
164///        │ first `on_write` for tree
165///        ▼
166///   InFlight(notify) ──leader wire failure──> (absent)
167///        │
168///        │ leader wire success
169///        ▼
170///    Subscribed ───drop last cb───> Idle { since: Instant }
171///        ▲                              │
172///        │ new `on_write` arrives        │ sweep determines past
173///        │ (no wire call)                │ grace window
174///        └──────────────────────────────┘
175///                                       │
176///                                       ▼
177///                              UnsubscribeWrites on wire → (absent)
178/// ```
179///
180/// `InFlight` carries a `Notify` whose waiters are released exactly
181/// once when the leader finishes the wire round-trip (success or
182/// failure). Followers re-check the map after waking — success
183/// transitions to `Subscribed` (they return `Ok`), failure removes
184/// the entry (one of them becomes the next leader on retry).
185/// Defensive against a future shape change: under the current
186/// [`RemoteConnectionInner::subscription_locks`] fence,
187/// [`RemoteConnection::subscribe_writes`] is per-tree-serialized and
188/// no caller can observe `InFlight` for a tree it's about to subscribe
189/// to. Kept so a future relaxation of the fence (e.g. per-connection
190/// rather than per-tree) doesn't silently re-introduce the
191/// concurrent-leader race the `Notify` originally guarded.
192///
193/// `Idle` records the moment the last local callback for this tree
194/// was dropped. The daemon-side subscription is still alive — we
195/// haven't sent `UnsubscribeWrites` — so a re-registration before the
196/// grace window expires can transition straight back to `Subscribed`
197/// without a wire round-trip. A periodic sweep task removes Idle
198/// entries that have been quiet long enough, sending
199/// `UnsubscribeWrites` to the daemon at that point under the same
200/// per-tree fence the subscribe path uses, so a sweep's Unsubscribe
201/// is fully acked before any racing re-subscribe can send its
202/// Subscribe — no daemon-side `Sub → Unsub` inversion possible.
203enum SubState {
204    InFlight(Arc<Notify>),
205    Subscribed {
206        identity: SigKey,
207    },
208    Idle {
209        since: std::time::Instant,
210        identity: SigKey,
211    },
212}
213
214/// Role assignment for one entry into [`RemoteConnection::subscribe_writes`].
215/// Decided under the `subscribed_trees` mutex and consumed outside it so the
216/// std::Mutex is never held across an `await`.
217enum SubRole {
218    /// This task owns the wire round-trip and must transition the state +
219    /// `notify_waiters` when it finishes (success or failure).
220    Leader(Arc<Notify>),
221    /// Another task is already subscribing; await its notify and re-check.
222    Follower(Arc<Notify>),
223}
224
225/// Internal state for a remote connection, wrapped in Arc for Clone.
226struct RemoteConnectionInner {
227    /// Owns the write half of the socket. `tokio::sync::Mutex` because
228    /// `write_frame` is async (held across awaits). Only ever held for the
229    /// duration of one frame's write plus the FIFO push into [`Self::pending`];
230    /// the await on the response itself happens *after* the lock is released
231    /// so concurrent callers don't serialise on read-side latency.
232    writer: Mutex<WriteHalf<UnixStream>>,
233    /// FIFO of awaiting response slots. `request()` pushes one before
234    /// releasing the writer lock; the reader task pops the front on every
235    /// `ServerFrame::Response` so request and response order line up. The
236    /// VecDeque is guarded by a plain `std::sync::Mutex` — never held
237    /// across an await — so it can't deadlock with the writer lock.
238    pending: std::sync::Mutex<VecDeque<oneshot::Sender<ServiceResponse>>>,
239    /// Set once by [`RemoteConnection::attach_instance`] right after
240    /// `Instance::connect` has finished building the Instance. The reader
241    /// task reads (cheap clone of the inner `Weak`) on each
242    /// [`Notification::DatabaseWrite`] to dispatch into the instance's
243    /// callback registry. Stays `None` until attach; notifications can't
244    /// arrive in that window because the client subscribes lazily on the
245    /// first `Database::on_write` registration, which itself can't run
246    /// until the Instance exists.
247    weak_instance: std::sync::Mutex<Option<WeakInstance>>,
248    /// Set on successful `trusted_login`; read by `backend_request` to populate
249    /// the `Authenticated` envelope's identity field. `RwLock` because reads
250    /// are far more frequent than the one-shot login write.
251    ///
252    /// Accessed poison-tolerantly via [`RemoteConnectionInner::session_read`]
253    /// and [`RemoteConnectionInner::session_write`]: a panic in one task
254    /// while holding the guard must not promote itself to a permanent connection
255    /// outage. The worst observable case is a half-written session field, which
256    /// the caller already treats as "unauthenticated" (`session_identity`
257    /// returns `None` and the per-tree gate rejects the op).
258    session: RwLock<Option<SessionState>>,
259    /// Pubkeys this client has already proven possession of on this
260    /// connection (via `SessionKeyChallenge`/`SessionKeyRegister`), plus the
261    /// login pubkey added in `trusted_login`. Lets `register_session_key`
262    /// short-circuit when the key has already been registered, avoiding
263    /// per-request wire chatter for the common case where a single per-DB
264    /// key is reused across many ops.
265    registered_keys: Mutex<HashSet<PublicKey>>,
266    /// Per-tree subscription state. Entries are inserted on first call to
267    /// [`RemoteConnection::subscribe_writes`] and never removed on success
268    /// (subscriptions live for the connection's lifetime; the daemon scrubs
269    /// them on disconnect).
270    ///
271    /// The two states coordinate concurrent registrations against the same
272    /// tree: exactly one task is the "leader" that drives the wire round-trip;
273    /// other tasks observe `InFlight(notify)`, await the notify, and re-check
274    /// state. On leader success the state transitions to `Subscribed` and
275    /// followers return `Ok`; on leader failure the entry is removed so the
276    /// next waker can take leadership and retry.
277    subscribed_trees: std::sync::Mutex<HashMap<ID, SubState>>,
278    /// Per-tree async mutexes that fence wire-subscription state
279    /// transitions on this connection: held across the full
280    /// `SubscribeWrites` / `UnsubscribeWrites` request-response by the
281    /// leader path of [`RemoteConnection::subscribe_writes`] and by the
282    /// lazy-unsubscribe sweep ([`run_sweep_task`]).
283    ///
284    /// Closes the latent sweep-vs-resubscribe race in the gap between
285    /// the sweep removing an `Idle` entry from `subscribed_trees` and
286    /// its `UnsubscribeWrites` reaching the daemon: a racing
287    /// `subscribe_writes` could observe `None`, send `SubscribeWrites`,
288    /// and — if the daemon processed Subscribe before the in-flight
289    /// Unsubscribe — end Sub → Unsub (silent broken delivery).
290    ///
291    /// **Why the race is currently latent**: the daemon's per-connection
292    /// request loop is serial today (`server.rs` SubscribeWrites
293    /// handler comment), so Subscribe queues behind in-flight
294    /// Unsubscribe and gets processed after — end Subscribed. The
295    /// fence is structural future-proofing against a daemon shape
296    /// change to per-connection parallel dispatch. Cheap to maintain
297    /// (one async mutex per active tree, held only across sweep and
298    /// subscribe wire RTTs) and removes the dependency on the daemon
299    /// invariant entirely. Holding this lock across the daemon's ack
300    /// means a `subscribe_writes` arriving while a sweep is in flight
301    /// on the same tree blocks until the daemon has fully processed
302    /// the unsubscribe, regardless of dispatch shape.
303    ///
304    /// **Correctness contract this fence depends on**: the daemon
305    /// must serialize `SubscribeWrites` / `UnsubscribeWrites` *per
306    /// tree* within a single connection. Today this holds trivially
307    /// via per-connection serial dispatch. A future shape change to
308    /// per-connection+tree-parallel dispatch (the natural next step,
309    /// mirroring the client's `tree_workers`) also satisfies the
310    /// contract: within tree X the daemon would still order
311    /// Unsubscribe → Subscribe, while unrelated work on tree Y
312    /// proceeds in parallel. The fence stays correct under that
313    /// shape with no further work.
314    ///
315    /// What would break the fence: a daemon that *parallelizes
316    /// requests within a single tree* on one connection, freely
317    /// reordering Subscribe/Unsubscribe processing for the same
318    /// `root_id`. That shape would also break verify, settled-state
319    /// cursor advancement, and other invariants — it's not a
320    /// realistic future direction. If it ever becomes one, this
321    /// fence is insufficient and the design needs to revisit
322    /// ack-then-Subscribe vs. an `Unsubscribing { notify }` sub-state.
323    ///
324    /// Deliberately a separate lock from [`crate::instance::Instance`]'s
325    /// `tree_lock`: that lock serializes local `put_entry`/`verify`
326    /// against callback-dispatch coherence; reusing it here would
327    /// stall local writes on the same tree for an Unsubscribe RTT
328    /// for no correctness benefit.
329    ///
330    /// Shape mirrors `Instance::tree_lock`: std mutex around a hashmap
331    /// of `Arc<tokio::sync::Mutex<()>>` so the per-tree guard can be
332    /// cloned out and held across awaits.
333    subscription_locks: std::sync::Mutex<HashMap<ID, Arc<Mutex<()>>>>,
334    /// Process-lifetime CRDT-state LRU shared across every `Database` handle
335    /// (every `RemoteBackend`) on this connection. Tier 1 of the
336    /// two-level cache; tier 2 is the daemon's unified scope-keyed cache
337    /// (lives in `BackendImpl`), reached via `GetCachedCrdtState` /
338    /// `CacheCrdtState` RPCs.
339    ///
340    /// Accessed poison-tolerantly via [`Self::crdt_cache_lock`]: same
341    /// rationale as `session` — a panic in one task must not strand the
342    /// rest of the connection, since cache state is rebuildable.
343    crdt_cache: std::sync::Mutex<ClientCrdtCache>,
344    /// Set to `true` when the reader task exits (clean EOF, socket error,
345    /// or deserialization failure). Once set, [`RemoteConnection::request`]
346    /// short-circuits with `ConnectionAborted` instead of pushing a fresh
347    /// oneshot that would never be matched.
348    ///
349    /// Required because dropping the user-visible `RemoteConnection` does
350    /// not tear down the inner Arc (the reader task holds its own clone);
351    /// post-reader-exit calls would otherwise queue a sender into
352    /// [`Self::pending`] and `await` indefinitely on a `recv()` that no
353    /// one can fulfil. `pending` is cleared on reader exit, but a fresh
354    /// request landing *after* the clear would push a new sender into
355    /// the now-orphan queue.
356    ///
357    /// Ordering: the reader task sets this with `Release` ordering before
358    /// clearing `pending`, so any `Acquire` load that observes `true` is
359    /// guaranteed to also observe the empty queue.
360    closed: AtomicBool,
361    /// Per-tree dispatch lanes. The reader routes each incoming
362    /// `Notification::DatabaseWrite` by `root_id` into the matching
363    /// tree's `mpsc<Notification>` (lazily creating one + spawning a
364    /// per-tree worker on first notification for the tree). Each
365    /// worker pulls from its own channel and `await`s
366    /// `Instance::fire_write_callbacks` sequentially — sequential
367    /// within a tree (cursor advancement is well-defined), concurrent
368    /// across trees (a slow callback on one tree doesn't stall any
369    /// other tree's dispatches on this connection).
370    ///
371    /// **Why per-tree, not per-connection.** User-callback work is
372    /// per-tree; cursor advancement is per-tree; the only ordering
373    /// constraint we actually need is per-tree. The previous
374    /// single-drain-task model serialised across trees and could
375    /// stall an entire connection on one slow callback.
376    ///
377    /// **Why the reader doesn't await inline.** User callbacks may
378    /// issue wire calls (e.g. `Database::open` on a connected
379    /// instance) whose responses land through *this same reader*.
380    /// Awaiting a callback inline would deadlock the reader against
381    /// the response it needs to deliver. Routing to a separate worker
382    /// task keeps the reader free.
383    ///
384    /// Each worker holds `Weak<RemoteConnectionInner>` so it doesn't
385    /// keep `inner` alive. When `inner` drops (every user-facing
386    /// `RemoteConnection` released *and* the reader has exited),
387    /// every sender in this map drops, each worker's `recv()` returns
388    /// `None`, and workers exit cleanly without prolonging
389    /// `inner`'s lifetime.
390    tree_workers: std::sync::Mutex<HashMap<ID, mpsc::UnboundedSender<Notification>>>,
391    /// Abort handle for the background reader task, so [`Self::mark_dead`] can
392    /// force the reader out when the connection is torn down against a *wedged*
393    /// daemon — one that neither answers nor closes the socket. Without it the
394    /// reader blocks in `read_frame` forever, holding its own `Arc<inner>` clone
395    /// (leaking the task + socket, since the field docs on [`Self::closed`] note
396    /// dropping the user-facing `RemoteConnection` does not tear down `inner`)
397    /// and re-spawning per-tree workers on the next notification after
398    /// `mark_dead` cleared them. Set once, immediately after the reader is
399    /// spawned in [`RemoteConnection::connect`]; `None` only in the brief window
400    /// before that assignment, and after `mark_dead` has taken it.
401    reader_abort: std::sync::Mutex<Option<tokio::task::AbortHandle>>,
402}
403
404impl RemoteConnectionInner {
405    /// Acquire a read guard on `session`, tolerating poisoning.
406    ///
407    /// See the field-level doc on [`Self::session`] for the recovery rationale.
408    fn session_read(&self) -> std::sync::RwLockReadGuard<'_, Option<SessionState>> {
409        self.session
410            .read()
411            .unwrap_or_else(|poisoned| poisoned.into_inner())
412    }
413
414    /// Acquire a write guard on `session`, tolerating poisoning.
415    fn session_write(&self) -> std::sync::RwLockWriteGuard<'_, Option<SessionState>> {
416        self.session
417            .write()
418            .unwrap_or_else(|poisoned| poisoned.into_inner())
419    }
420
421    /// Acquire the CRDT cache lock, tolerating poisoning. See the field-level
422    /// doc on [`Self::crdt_cache`] for the recovery rationale.
423    fn crdt_cache_lock(&self) -> std::sync::MutexGuard<'_, ClientCrdtCache> {
424        self.crdt_cache
425            .lock()
426            .unwrap_or_else(|poisoned| poisoned.into_inner())
427    }
428
429    /// Acquire the pending-queue lock, tolerating poisoning.
430    fn pending_lock(
431        &self,
432    ) -> std::sync::MutexGuard<'_, VecDeque<oneshot::Sender<ServiceResponse>>> {
433        self.pending
434            .lock()
435            .unwrap_or_else(|poisoned| poisoned.into_inner())
436    }
437
438    /// Get-or-insert the per-tree subscription mutex. The returned
439    /// `Arc` is cheap to clone; the caller takes `lock().await` on it
440    /// outside the std mutex guard.
441    ///
442    /// See the field-level doc on [`Self::subscription_locks`] for the
443    /// race this fence closes.
444    fn subscription_lock(&self, tree_id: &ID) -> Arc<Mutex<()>> {
445        let mut locks = self
446            .subscription_locks
447            .lock()
448            .unwrap_or_else(|p| p.into_inner());
449        Arc::clone(
450            locks
451                .entry(tree_id.clone())
452                .or_insert_with(|| Arc::new(Mutex::new(()))),
453        )
454    }
455
456    /// Mark this connection dead and drop all dependent state. Used by
457    /// the reader task on its own exit path and by the sweep when an
458    /// `UnsubscribeWrites` times out (a daemon that can't ack a trivial
459    /// hashmap removal in seconds is broken; tear down and let the
460    /// caller reconnect).
461    ///
462    /// 1. Mark `closed` with `Release` ordering so future `request()`
463    ///    calls short-circuit before pushing senders into an orphan
464    ///    queue.
465    /// 2. Drain `pending` — every awaiting caller sees `RecvError`
466    ///    and surfaces `ConnectionAborted`.
467    /// 3. Drop every per-tree worker sender so workers exit cleanly.
468    /// 4. Abort the reader task. On the reader's *own* exit path this is a
469    ///    no-op (the task is already returning). When the sweep calls this on a
470    ///    wedged daemon it forces the reader out of its blocking `read_frame`,
471    ///    dropping the reader's `Arc<inner>` clone so `inner` can finally drop,
472    ///    and stopping it from re-spawning the workers step 3 just cleared.
473    ///    The handle is `take`n so a later `mark_dead` is a no-op.
474    fn mark_dead(&self) {
475        self.closed.store(true, Ordering::Release);
476        self.pending_lock().clear();
477        self.tree_workers
478            .lock()
479            .unwrap_or_else(|p| p.into_inner())
480            .clear();
481        if let Some(handle) = self
482            .reader_abort
483            .lock()
484            .unwrap_or_else(|p| p.into_inner())
485            .take()
486        {
487            handle.abort();
488        }
489    }
490}
491
492/// A connection to a remote Eidetica service server over a Unix domain socket.
493///
494/// `RemoteConnection` backs the `RemoteBackend` implementation of the `Backend`
495/// seam. It provides the storage operations as inherent methods, plus additional
496/// coordination methods like `notify_entry_written`.
497///
498/// Cloning is cheap (Arc-backed).
499///
500/// **Teardown.** The reader task holds a strong `Arc<RemoteConnectionInner>`
501/// and `inner` owns the socket's `WriteHalf`, so neither half of the split
502/// stream can drop on its own: the reader parks in `read_frame` until the
503/// daemon EOFs, and the daemon only EOFs once our socket closes. The
504/// `live` token breaks that cycle — when the last user-facing handle drops
505/// it calls `RemoteConnectionInner::mark_dead`, aborting the reader so
506/// its `Arc` is released, `inner` drops, and the `WriteHalf` closes. The
507/// daemon then sees EOF and scrubs the connection's subscriptions via its
508/// `ConnectionGuard`.
509#[derive(Clone)]
510pub struct RemoteConnection {
511    inner: Arc<RemoteConnectionInner>,
512    /// `Some` on every user-facing handle; `None` on internal handles
513    /// minted from a task that must not keep the connection alive (the
514    /// sweep). Cloning a user handle clones the token, so teardown fires
515    /// only when the last one goes.
516    _live: Option<Arc<ConnLiveness>>,
517}
518
519/// Drop token wired to `RemoteConnection::_live`. Holds a strong `inner`
520/// so `mark_dead` is always callable; that `Arc` is released immediately
521/// after, as part of this drop.
522struct ConnLiveness(Arc<RemoteConnectionInner>);
523
524impl Drop for ConnLiveness {
525    fn drop(&mut self) {
526        self.0.mark_dead();
527    }
528}
529
530impl std::fmt::Debug for RemoteConnection {
531    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
532        f.debug_struct("RemoteConnection").finish_non_exhaustive()
533    }
534}
535
536impl RemoteConnection {
537    /// Connect to a service server at the given socket path.
538    ///
539    /// Performs the protocol handshake, then spawns a background reader
540    /// task that demuxes [`ServerFrame`]s: `Response` frames pop the next
541    /// pending oneshot in FIFO order, `Notification` frames dispatch into
542    /// the attached `Instance`'s callback registry (after
543    /// [`Self::attach_instance`] has been called).
544    pub async fn connect(path: impl AsRef<Path>) -> crate::Result<Self> {
545        let stream = UnixStream::connect(path.as_ref()).await?;
546        let (mut reader, mut writer) = tokio::io::split(stream);
547
548        // Send handshake
549        let handshake = Handshake {
550            protocol_version: PROTOCOL_VERSION,
551        };
552        write_frame(&mut writer, &handshake).await?;
553
554        // Read ack
555        let ack: HandshakeAck = read_frame(&mut reader).await?.ok_or_else(|| {
556            crate::Error::Io(std::io::Error::new(
557                std::io::ErrorKind::ConnectionAborted,
558                "Server closed connection during handshake",
559            ))
560        })?;
561
562        if ack.protocol_version != PROTOCOL_VERSION {
563            return Err(crate::Error::Io(std::io::Error::new(
564                std::io::ErrorKind::InvalidData,
565                format!(
566                    "Protocol version mismatch: client={}, server={}",
567                    PROTOCOL_VERSION, ack.protocol_version
568                ),
569            )));
570        }
571
572        let inner = Arc::new(RemoteConnectionInner {
573            writer: Mutex::new(writer),
574            pending: std::sync::Mutex::new(VecDeque::new()),
575            weak_instance: std::sync::Mutex::new(None),
576            session: RwLock::new(None),
577            registered_keys: Mutex::new(HashSet::new()),
578            subscribed_trees: std::sync::Mutex::new(HashMap::new()),
579            subscription_locks: std::sync::Mutex::new(HashMap::new()),
580            crdt_cache: std::sync::Mutex::new(ClientCrdtCache::new(CLIENT_CACHE_CAPACITY_BYTES)),
581            closed: AtomicBool::new(false),
582            tree_workers: std::sync::Mutex::new(HashMap::new()),
583            reader_abort: std::sync::Mutex::new(None),
584        });
585
586        // Spawn the reader task. It holds an Arc clone of `inner` so the
587        // connection (and its pending queue) stay live as long as any
588        // request is in flight, and exits cleanly on EOF / read error /
589        // failure-to-deserialize, or on the abort the `live` token fires
590        // when the last user handle drops. On exit it drops the remaining oneshot
591        // senders (surfaces as `RecvError` on awaiting `request()`s) and
592        // also drops every per-tree worker channel, which causes those
593        // workers to exit. No separate dispatch task — per-tree workers
594        // are spawned lazily by the reader on first notification per
595        // tree.
596        let inner_for_reader = inner.clone();
597        let reader_handle = tokio::spawn(run_reader_task(reader, inner_for_reader));
598        *inner.reader_abort.lock().unwrap_or_else(|p| p.into_inner()) =
599            Some(reader_handle.abort_handle());
600
601        // Spawn the lazy-unsubscribe sweep. It holds a `Weak<inner>` so
602        // it doesn't extend `inner`'s lifetime; exits when `weak.upgrade()`
603        // returns `None` (the connection is being torn down).
604        let weak_for_sweep = Arc::downgrade(&inner);
605        tokio::spawn(run_sweep_task(weak_for_sweep));
606
607        let _live = Some(Arc::new(ConnLiveness(inner.clone())));
608        Ok(Self { inner, _live })
609    }
610
611    /// Attach an `Instance` to this connection so the reader task can
612    /// dispatch incoming [`Notification::DatabaseWrite`]s into the
613    /// instance's callback registry. Called exactly once by
614    /// `Instance::connect` after the Instance has been constructed.
615    /// Subsequent calls overwrite the previous reference, but no caller
616    /// does that today.
617    pub(crate) fn attach_instance(&self, weak: WeakInstance) {
618        *self
619            .inner
620            .weak_instance
621            .lock()
622            .unwrap_or_else(|poisoned| poisoned.into_inner()) = Some(weak);
623    }
624
625    /// Look up a cached materialized CRDT state in the connection-shared
626    /// process-lifetime LRU. Promotes the entry to most-recently-used.
627    pub(crate) fn cache_get(&self, root_id: &ID, key: &ID, store: &str) -> Option<Vec<u8>> {
628        self.inner.crdt_cache_lock().get(root_id, key, store)
629    }
630
631    /// Insert a materialized CRDT state into the connection-shared LRU.
632    /// Triggers byte-bounded eviction if over capacity.
633    pub(crate) fn cache_put(&self, root_id: ID, key: ID, store: String, blob: Vec<u8>) {
634        self.inner.crdt_cache_lock().put(root_id, key, store, blob);
635    }
636
637    /// Send a request and await its response.
638    ///
639    /// Take the writer lock; push a oneshot into the pending FIFO; write
640    /// the frame; release the lock; await the oneshot. Pushing the
641    /// oneshot *while still holding the writer lock* guarantees the FIFO
642    /// order in `pending` lines up with the on-wire order so the reader
643    /// task pairs each `ServerFrame::Response` with the right caller.
644    /// Concurrent `request()` calls do not serialise on the response
645    /// wait — only on the (cheap) frame write.
646    ///
647    /// Two `closed` checks gate the path: a cheap `Acquire` load before
648    /// acquiring the writer lock (the common-case fast path) and a second
649    /// re-check inside the lock *after* pushing the oneshot, in case the
650    /// reader exited concurrently between the first check and the push.
651    /// The reader sets `closed` with `Release` ordering *before* clearing
652    /// `pending`, so a load that sees `true` is guaranteed to see the
653    /// empty (or about-to-be-empty) queue. Without the post-push check,
654    /// a fresh request landing right after the reader clears could push
655    /// a sender into the orphan queue and `rx.await` forever.
656    async fn request(&self, req: ServiceRequest) -> crate::Result<ServiceResponse> {
657        if self.inner.closed.load(Ordering::Acquire) {
658            return Err(connection_aborted());
659        }
660        let (tx, rx) = oneshot::channel::<ServiceResponse>();
661        {
662            let mut writer = self.inner.writer.lock().await;
663            // Re-check under the writer lock to close the race where the
664            // reader exits and clears `pending` between our pre-check and
665            // this point. If we observe `closed` now, drop our oneshot on
666            // the floor without pushing — no reader means no response.
667            if self.inner.closed.load(Ordering::Acquire) {
668                return Err(connection_aborted());
669            }
670            // Push *before* writing the frame so the FIFO is consistent
671            // with the order frames hit the wire. If the write fails we
672            // pop the just-pushed sender so a future caller doesn't get
673            // matched to a response that never comes.
674            self.inner.pending_lock().push_back(tx);
675            if let Err(e) = write_frame(&mut *writer, &req).await {
676                let _ = self.inner.pending_lock().pop_back();
677                return Err(e);
678            }
679        }
680        rx.await.map_err(|_| connection_aborted())
681    }
682
683    /// Send a request and convert error responses to `crate::Error`.
684    pub(crate) async fn request_ok(&self, req: ServiceRequest) -> crate::Result<ServiceResponse> {
685        let resp = self.request(req).await?;
686        match resp {
687            ServiceResponse::Error(e) => Err(service_error_to_eidetica_error(e)),
688            other => Ok(other),
689        }
690    }
691
692    /// Wrap a `DatabaseOp` in the `AuthenticatedDb` envelope and send it.
693    ///
694    /// `(root_id, identity)` scope and `request_ok` error conversion, carrying
695    /// a `DatabaseOp` in an `AuthenticatedDbRequest`.
696    async fn db_request(
697        &self,
698        root_id: ID,
699        identity: SigKey,
700        op: DatabaseOp,
701    ) -> crate::Result<ServiceResponse> {
702        self.request_ok(ServiceRequest::AuthenticatedDb(Box::new(
703            AuthenticatedDbRequest {
704                root_id,
705                identity,
706                op,
707            },
708        )))
709        .await
710    }
711
712    /// Authenticate this connection as `username` by completing the
713    /// `TrustedLogin*` handshake against the daemon.
714    ///
715    /// Flow: send `TrustedLoginUser` → receive challenge + the user's full
716    /// `UserInfo` (encrypted credentials, user-database id, status) → derive
717    /// the password-encryption key locally (Argon2id) and decrypt the root
718    /// signing key in-process (or take it raw for passwordless users) → sign
719    /// the challenge → send `TrustedLoginProve` → expect `TrustedLoginOk`.
720    ///
721    /// The daemon never sees the password or the plaintext signing key; the
722    /// trust model for shipping the encrypted blob over the socket is captured
723    /// in the Service Architecture doc § Trusted login threat model.
724    ///
725    /// On success the connection's server-side state is `Authenticated`. The
726    /// caller receives the user's record and the decrypted root key so it can
727    /// build the `User` session without a second wire read of `_users` —
728    /// reads through the wire always travel as the authenticated user, which
729    /// with the per-tree gate means a fresh user without permissions on
730    /// `_users` would not be able to re-fetch it.
731    pub(crate) async fn trusted_login(
732        &self,
733        username: &str,
734        password: Option<&str>,
735    ) -> crate::Result<(String, UserInfo, PrivateKey)> {
736        // Step 1: name the user, receive challenge + user record.
737        let resp = self
738            .request_ok(ServiceRequest::TrustedLoginUser {
739                username: username.to_string(),
740            })
741            .await?;
742        let (challenge, user_uuid, user_info) = match resp {
743            ServiceResponse::TrustedLoginChallenge {
744                challenge,
745                user_uuid,
746                user_info,
747            } => (challenge, user_uuid, user_info),
748            other => return Err(unexpected_response("TrustedLoginChallenge", &other)),
749        };
750
751        // Step 2: decrypt the root signing key locally. Cross-check that the
752        // caller's password/no-password matches the credential's salt/no-salt;
753        // a mismatch is the same UX-level error as a wrong password.
754        let credentials = &user_info.credentials;
755        let is_passwordless = credentials.password_salt.is_none();
756        let signing_key = match (&credentials.root_key, password, is_passwordless) {
757            (KeyStorage::Unencrypted { key }, None, true) => key.clone(),
758            (
759                KeyStorage::Encrypted {
760                    ciphertext, nonce, ..
761                },
762                Some(pwd),
763                false,
764            ) => {
765                let salt = credentials.password_salt.as_deref().ok_or_else(|| {
766                    UserError::PasswordRequired {
767                        operation: "decrypt root key for remote login".to_string(),
768                    }
769                })?;
770                let kek = derive_encryption_key(pwd, salt)?;
771                decrypt_private_key(ciphertext, nonce, &kek)?
772            }
773            _ => return Err(UserError::InvalidPassword.into()),
774        };
775
776        // Step 3: sign the challenge and send the proof.
777        let signature = create_challenge_response(&challenge, &signing_key);
778        let resp = self
779            .request_ok(ServiceRequest::TrustedLoginProve { signature })
780            .await?;
781        match resp {
782            ServiceResponse::TrustedLoginOk => {
783                // Stash the verified session pubkey so subsequent
784                // `backend_request` calls can populate the `Authenticated`
785                // envelope's identity field.
786                *self.inner.session_write() = Some(SessionState {
787                    session_pubkey: credentials.root_key_id.clone(),
788                });
789                // The login pubkey is in the server-side session keyset by
790                // construction (the server seeds it there in
791                // `handle_trusted_login_prove`). Mirror that here so
792                // `register_session_key` short-circuits without a wire
793                // round-trip when called for the login key.
794                self.inner
795                    .registered_keys
796                    .lock()
797                    .await
798                    .insert(credentials.root_key_id.clone());
799                Ok((user_uuid, user_info, signing_key))
800            }
801            other => Err(unexpected_response("TrustedLoginOk", &other)),
802        }
803    }
804
805    /// Prove possession of `signing_key` and add its public key to the
806    /// connection's session keyset.
807    ///
808    /// Used by every `Database` handle whose `RemoteBackend` carries a
809    /// per-database identity (e.g. `Database::create` on a connected
810    /// instance, or `user.open_database_with_key` over the wire): the daemon
811    /// gates reads against the *acting* pubkey from the identity hint, and
812    /// the acting pubkey must be in the keyset, so we register the per-DB
813    /// key before the first read.
814    ///
815    /// Idempotent and cheap on repeated calls: a successful registration
816    /// caches the pubkey in `registered_keys`, and a follow-up call with the
817    /// same key returns `Ok(())` without touching the wire. The login pubkey
818    /// is seeded into the cache by `trusted_login`.
819    ///
820    /// Cryptographically a two-step proof of possession:
821    /// 1. `SessionKeyChallenge { pubkey }` → server returns a single-use,
822    ///    pubkey-bound random challenge.
823    /// 2. Client signs the challenge with `signing_key`; `SessionKeyRegister
824    ///    { pubkey, signature }` → server verifies and inserts the pubkey
825    ///    into its `session_keyset`.
826    pub(crate) async fn register_session_key(&self, signing_key: &PrivateKey) -> crate::Result<()> {
827        let pubkey = signing_key.public_key();
828        {
829            let cache = self.inner.registered_keys.lock().await;
830            if cache.contains(&pubkey) {
831                return Ok(());
832            }
833        }
834        // Step 1: ask for a challenge bound to this pubkey.
835        let resp = self
836            .request_ok(ServiceRequest::SessionKeyChallenge {
837                pubkey: pubkey.clone(),
838            })
839            .await?;
840        let challenge = match resp {
841            ServiceResponse::SessionKeyChallenge { challenge } => challenge,
842            other => return Err(unexpected_response("SessionKeyChallenge", &other)),
843        };
844        // Step 2: sign and submit. The daemon verifies and joins the pubkey
845        // into the connection's keyset on Ok.
846        let signature = create_challenge_response(&challenge, signing_key);
847        let resp = self
848            .request_ok(ServiceRequest::SessionKeyRegister {
849                pubkey: pubkey.clone(),
850                signature,
851            })
852            .await?;
853        Self::expect_ok(resp)?;
854        self.inner.registered_keys.lock().await.insert(pubkey);
855        Ok(())
856    }
857
858    // === Response extraction helpers ===
859
860    fn expect_ok(resp: ServiceResponse) -> crate::Result<()> {
861        match resp {
862            ServiceResponse::Ok => Ok(()),
863            other => Err(unexpected_response("Ok", &other)),
864        }
865    }
866
867    // === Instance-level operations ===
868
869    /// Build a `SigKey` from the session pubkey, when logged in.
870    pub fn session_identity(&self) -> Option<SigKey> {
871        self.inner
872            .session_read()
873            .as_ref()
874            .map(|s| SigKey::from_pubkey(&s.session_pubkey))
875    }
876
877    pub async fn get_instance_metadata(&self) -> crate::Result<Option<InstanceMetadata>> {
878        let resp = self.request_ok(ServiceRequest::GetInstanceMetadata).await?;
879        match resp {
880            ServiceResponse::InstanceMetadata(meta) => Ok(meta),
881            other => Err(unexpected_response("InstanceMetadata", &other)),
882        }
883    }
884
885    pub async fn set_instance_metadata(&self, metadata: &InstanceMetadata) -> crate::Result<()> {
886        // Gated server-side as Admin on `_databases`, not on `root_id`, so the
887        // scope's `root_id` is unused for this op (default is fine).
888        let identity = self.session_identity().unwrap_or_default();
889        let resp = self
890            .db_request(
891                ID::default(),
892                identity,
893                DatabaseOp::SetInstanceMetadata {
894                    metadata: Box::new(metadata.clone()),
895                },
896            )
897            .await?;
898        Self::expect_ok(resp)
899    }
900
901    /// Subscribe this connection to write notifications for `tree_id`.
902    ///
903    /// Safe to call concurrently from multiple tasks for the same
904    /// `tree_id`: serialized per-tree via
905    /// [`RemoteConnectionInner::subscription_locks`] so only one task
906    /// at a time decides state + runs the wire round-trip. The fence
907    /// also serializes against the lazy-unsubscribe sweep — if a
908    /// sweep is mid-`UnsubscribeWrites` for this tree, this call
909    /// blocks until the daemon has fully acked the unsubscribe, then
910    /// observes `None` and sends a fresh `SubscribeWrites`. Wire
911    /// order Unsubscribe → Subscribe is preserved end-to-end,
912    /// independent of the daemon's request-dispatch shape.
913    ///
914    /// Returns `Ok` only *after* the daemon has registered the
915    /// subscription, so an immediately-following commit on this
916    /// connection cannot race the subscribe and lose its
917    /// notification. On failure the local state is rolled back so a
918    /// subsequent call can retry.
919    ///
920    /// Idempotent across calls: a tree that is already `Subscribed`
921    /// returns `Ok` without any wire activity. The server's
922    /// `ConnectionRegistry` is also idempotent. Identity is gated
923    /// server-side as Read on `tree_id`.
924    pub(crate) async fn subscribe_writes(
925        &self,
926        tree_id: ID,
927        identity: SigKey,
928        tips: Snapshot,
929    ) -> crate::Result<()> {
930        // Per-tree subscription fence. Held across the entire state
931        // decision + (leader path's) wire round-trip so a concurrent
932        // sweep that's mid-`UnsubscribeWrites` for this tree can't
933        // interleave its frame between our state read and our Subscribe.
934        // See `RemoteConnectionInner::subscription_locks` for the race
935        // this closes.
936        let sub_lock = self.inner.subscription_lock(&tree_id);
937        let _sub_guard = sub_lock.lock().await;
938        loop {
939            // Decide our role under the std::Mutex without holding it across
940            // any await: leader inserts `InFlight(notify)` and proceeds to the
941            // wire call; followers clone the notify and `await` it below.
942            // `Idle` re-entries transition straight to `Subscribed` without
943            // a wire round-trip — the daemon-side subscription is still
944            // alive (the sweep hasn't unsubscribed it yet).
945            let role = {
946                let mut subs = self.subscribed_trees_lock();
947                match subs.get(&tree_id) {
948                    // Already subscribed: a no-op. A new `identity` on this
949                    // re-call is intentionally ignored — the daemon's live
950                    // subscription is keyed off the pubkey the original
951                    // subscribe succeeded with (same rationale as the `Idle`
952                    // arm below).
953                    Some(SubState::Subscribed { .. }) => return Ok(()),
954                    Some(SubState::Idle {
955                        identity: existing_identity,
956                        ..
957                    }) => {
958                        // Daemon-side subscription is still live; re-mark
959                        // as Subscribed locally without a wire call. Reuse
960                        // the identity the original subscribe succeeded
961                        // with — the daemon's subscription is keyed off
962                        // that pubkey, not whatever this caller now holds.
963                        let existing_identity = existing_identity.clone();
964                        subs.insert(
965                            tree_id.clone(),
966                            SubState::Subscribed {
967                                identity: existing_identity,
968                            },
969                        );
970                        return Ok(());
971                    }
972                    Some(SubState::InFlight(n)) => SubRole::Follower(n.clone()),
973                    None => {
974                        let n = Arc::new(Notify::new());
975                        subs.insert(tree_id.clone(), SubState::InFlight(n.clone()));
976                        SubRole::Leader(n)
977                    }
978                }
979            };
980
981            match role {
982                SubRole::Follower(notify) => {
983                    notify.notified().await;
984                    // Re-check: success → return Ok; failure (entry removed)
985                    // → loop, where this task may become the next leader.
986                    continue;
987                }
988                SubRole::Leader(notify) => {
989                    let result = self
990                        .db_request(
991                            tree_id.clone(),
992                            identity.clone(),
993                            DatabaseOp::SubscribeWrites { tips: tips.clone() },
994                        )
995                        .await
996                        .and_then(Self::expect_ok);
997                    {
998                        let mut subs = self.subscribed_trees_lock();
999                        match &result {
1000                            Ok(()) => {
1001                                subs.insert(
1002                                    tree_id.clone(),
1003                                    SubState::Subscribed {
1004                                        identity: identity.clone(),
1005                                    },
1006                                );
1007                            }
1008                            Err(_) => {
1009                                subs.remove(&tree_id);
1010                            }
1011                        }
1012                    }
1013                    // Wake everyone exactly once; new waiters that arrive
1014                    // after this point land in the post-transition state.
1015                    notify.notify_waiters();
1016                    return result;
1017                }
1018            }
1019        }
1020    }
1021
1022    fn subscribed_trees_lock(&self) -> std::sync::MutexGuard<'_, HashMap<ID, SubState>> {
1023        self.inner
1024            .subscribed_trees
1025            .lock()
1026            .unwrap_or_else(|p| p.into_inner())
1027    }
1028
1029    /// Whether the attached `Instance` still holds a per-database write
1030    /// callback for `tree_id`.
1031    ///
1032    /// `false` when no instance is attached or it has been dropped —
1033    /// nothing can be observing writes in either case, so the caller is
1034    /// free to release the subscription.
1035    ///
1036    /// Lock order: callers hold `subscribed_trees` across this, which
1037    /// takes the instance's `write_callbacks`. Nothing acquires those in
1038    /// the opposite order — the registry guard in `register_write_callback`,
1039    /// `remove_write_callback` and `spawn_write_callbacks` is released
1040    /// before any subscription-state access.
1041    fn tree_has_local_callbacks(&self, tree_id: &ID) -> bool {
1042        let weak = {
1043            let guard = self
1044                .inner
1045                .weak_instance
1046                .lock()
1047                .unwrap_or_else(|p| p.into_inner());
1048            guard.clone()
1049        };
1050        weak.and_then(|w| w.upgrade())
1051            .is_some_and(|instance| instance.has_write_callbacks(tree_id))
1052    }
1053
1054    /// Transition the subscription state for `tree_id` from `Subscribed`
1055    /// to `Idle`. Called from `WriteCallback::drop` when the last local
1056    /// callback for a tree on this connection is released.
1057    ///
1058    /// No wire call: the daemon-side subscription stays alive through
1059    /// the Idle grace window. If a new `on_write` registration arrives
1060    /// before the sweep, [`Self::subscribe_writes`] transitions back to
1061    /// `Subscribed` without touching the wire.
1062    ///
1063    /// If the state isn't `Subscribed` at the moment of the call —
1064    /// e.g. a concurrent re-registration already raced us, or the
1065    /// sweep already unsubscribed — this is a no-op.
1066    ///
1067    /// **Re-check under the state lock.** `WriteCallback::drop` removes
1068    /// the callback from the instance registry and calls this as two
1069    /// separate steps, so a registration can land in between: it inserts
1070    /// into the registry, then `subscribe_writes` observes `Subscribed`
1071    /// and returns without wire traffic. Flipping to `Idle` unconditionally
1072    /// would strand that live callback on an `Idle` subscription, and the
1073    /// sweep would silently unsubscribe it a grace window later. Probing
1074    /// the registry while holding `subscribed_trees` closes the window in
1075    /// both directions: a registration whose insert is already visible
1076    /// keeps us `Subscribed`, and one that isn't visible yet must take
1077    /// this same lock in `subscribe_writes`, where it observes `Idle` and
1078    /// transitions back.
1079    pub(crate) fn transition_to_idle(&self, tree_id: &ID) {
1080        let mut subs = self.subscribed_trees_lock();
1081        if self.tree_has_local_callbacks(tree_id) {
1082            return;
1083        }
1084        if let Some(SubState::Subscribed { identity }) = subs.get(tree_id) {
1085            let identity = identity.clone();
1086            subs.insert(
1087                tree_id.clone(),
1088                SubState::Idle {
1089                    since: std::time::Instant::now(),
1090                    identity,
1091                },
1092            );
1093        }
1094    }
1095
1096    /// Send `UnsubscribeWrites` to the daemon for `tree_id`. Called by
1097    /// the sweep task when an `Idle` entry's grace window has expired.
1098    pub(crate) async fn unsubscribe_writes(
1099        &self,
1100        tree_id: ID,
1101        identity: SigKey,
1102    ) -> crate::Result<()> {
1103        self.db_request(tree_id, identity, DatabaseOp::UnsubscribeWrites)
1104            .await
1105            .and_then(Self::expect_ok)
1106    }
1107
1108    // === Database operations (DatabaseOp via AuthenticatedDb envelope) ===
1109
1110    /// Acquire a [`TransactionContext`] for the given stores and scope.
1111    ///
1112    /// The returned context includes main-tree parents with heights,
1113    /// per-store subtree parents, `_settings` tips, and the merged
1114    /// `_settings` value — everything needed to build and sign an entry
1115    /// locally without further round-trips.
1116    pub async fn begin_transaction(
1117        &self,
1118        root_id: ID,
1119        identity: SigKey,
1120        stores: Vec<String>,
1121        scope: ReadScope,
1122    ) -> crate::Result<TransactionContext> {
1123        let resp = self
1124            .db_request(
1125                root_id,
1126                identity,
1127                DatabaseOp::BeginTransaction { stores, scope },
1128            )
1129            .await?;
1130        match resp {
1131            ServiceResponse::TransactionContext(ctx) => Ok(ctx),
1132            other => Err(unexpected_response("TransactionContext", &other)),
1133        }
1134    }
1135
1136    /// Fetch the server-materialized merged state of an unencrypted store.
1137    pub async fn get_store_state(
1138        &self,
1139        root_id: ID,
1140        identity: SigKey,
1141        store: String,
1142    ) -> crate::Result<WireCrdtValue> {
1143        let resp = self
1144            .db_request(root_id, identity, DatabaseOp::GetStoreState { store })
1145            .await?;
1146        match resp {
1147            ServiceResponse::CrdtValue(v) => Ok(v),
1148            other => Err(unexpected_response("CrdtValue", &other)),
1149        }
1150    }
1151
1152    /// Fetch ordered, verified, opaque store entries reachable from `tips`.
1153    ///
1154    /// Universal primitive — works for encrypted stores (client decrypts
1155    /// locally) as well as unencrypted ones.
1156    pub async fn get_store_entries(
1157        &self,
1158        root_id: ID,
1159        identity: SigKey,
1160        store: String,
1161        tips: Vec<ID>,
1162        scope: ReadScope,
1163    ) -> crate::Result<Vec<Entry>> {
1164        let resp = self
1165            .db_request(
1166                root_id,
1167                identity,
1168                DatabaseOp::GetStoreEntries { store, tips, scope },
1169            )
1170            .await?;
1171        match resp {
1172            ServiceResponse::Entries(entries) => Ok(entries),
1173            other => Err(unexpected_response("Entries", &other)),
1174        }
1175    }
1176
1177    /// Fetch the database's Verified-frontier tips.
1178    pub async fn get_verified_tips(
1179        &self,
1180        root_id: ID,
1181        identity: SigKey,
1182    ) -> crate::Result<Snapshot> {
1183        let resp = self
1184            .db_request(root_id, identity, DatabaseOp::GetVerifiedTips)
1185            .await?;
1186        match resp {
1187            ServiceResponse::Ids(ids) => Ok(ids),
1188            other => Err(unexpected_response("Ids", &other)),
1189        }
1190    }
1191
1192    /// Submit a client-signed entry to the server.
1193    ///
1194    /// The server stores the entry as `Unverified` and runs its own
1195    /// verification pass — it never trusts a submitted entry's claimed
1196    /// validity.
1197    pub async fn submit_signed_entry(
1198        &self,
1199        root_id: ID,
1200        identity: SigKey,
1201        entry: Entry,
1202    ) -> crate::Result<()> {
1203        let resp = self
1204            .db_request(
1205                root_id,
1206                identity,
1207                DatabaseOp::SubmitSignedEntry {
1208                    entry: Box::new(entry),
1209                },
1210            )
1211            .await?;
1212        match resp {
1213            ServiceResponse::Ok => Ok(()),
1214            other => Err(unexpected_response("Ok", &other)),
1215        }
1216    }
1217
1218    /// Fetch a single database entry by id.
1219    ///
1220    /// Gated post-fetch by the entry's owning tree, so the caller must hold
1221    /// at least `Read` on the database the entry belongs to.
1222    pub async fn db_get_entry(
1223        &self,
1224        root_id: ID,
1225        identity: SigKey,
1226        id: ID,
1227    ) -> crate::Result<Entry> {
1228        let resp = self
1229            .db_request(root_id, identity, DatabaseOp::GetEntry { id })
1230            .await?;
1231        match resp {
1232            ServiceResponse::Entry(entry) => Ok(entry),
1233            other => Err(unexpected_response("Entry", &other)),
1234        }
1235    }
1236
1237    /// Subtree tips reachable from given main-tree entries.
1238    pub async fn store_snapshot_at(
1239        &self,
1240        root_id: ID,
1241        identity: SigKey,
1242        store: String,
1243        up_to: Vec<ID>,
1244    ) -> crate::Result<Snapshot> {
1245        let resp = self
1246            .db_request(
1247                root_id,
1248                identity,
1249                DatabaseOp::GetStoreTipsUpToEntries { store, up_to },
1250            )
1251            .await?;
1252        match resp {
1253            ServiceResponse::Ids(ids) => Ok(ids),
1254            other => Err(unexpected_response("Ids", &other)),
1255        }
1256    }
1257
1258    /// Compute merge state: lowest common ancestor + path to tip entries.
1259    pub async fn compute_merge_state(
1260        &self,
1261        root_id: ID,
1262        identity: SigKey,
1263        store: String,
1264        entry_ids: Vec<ID>,
1265    ) -> crate::Result<MergeState> {
1266        let resp = self
1267            .db_request(
1268                root_id,
1269                identity,
1270                DatabaseOp::ComputeMergeState { store, entry_ids },
1271            )
1272            .await?;
1273        match resp {
1274            ServiceResponse::MergeState(state) => Ok(state),
1275            other => Err(unexpected_response("MergeState", &other)),
1276        }
1277    }
1278
1279    /// Tier 2 cache read: ask the daemon for a previously-stashed CRDT
1280    /// state blob. `None` on miss; the caller falls back to a full
1281    /// recompute from store entries.
1282    pub async fn get_cached_crdt_state_remote(
1283        &self,
1284        root_id: ID,
1285        identity: SigKey,
1286        store: String,
1287        key: ID,
1288    ) -> crate::Result<Option<Vec<u8>>> {
1289        let resp = self
1290            .db_request(
1291                root_id,
1292                identity,
1293                DatabaseOp::GetCachedCrdtState { store, key },
1294            )
1295            .await?;
1296        match resp {
1297            ServiceResponse::CachedCrdtState(blob) => Ok(blob),
1298            other => Err(unexpected_response("CachedCrdtState", &other)),
1299        }
1300    }
1301
1302    /// Tier 2 cache write: stash a client-computed CRDT state blob in the
1303    /// daemon's unified cache, scoped to the session user
1304    /// ([`crate::backend::CacheScope::User`]). Per-user trust; the daemon
1305    /// stores opaque bytes verbatim.
1306    pub async fn cache_crdt_state_remote(
1307        &self,
1308        root_id: ID,
1309        identity: SigKey,
1310        store: String,
1311        key: ID,
1312        blob: Vec<u8>,
1313    ) -> crate::Result<()> {
1314        let resp = self
1315            .db_request(
1316                root_id,
1317                identity,
1318                DatabaseOp::CacheCrdtState { store, key, blob },
1319            )
1320            .await?;
1321        match resp {
1322            ServiceResponse::Ok => Ok(()),
1323            other => Err(unexpected_response("Ok", &other)),
1324        }
1325    }
1326}
1327
1328fn unexpected_response(expected: &str, actual: &ServiceResponse) -> crate::Error {
1329    crate::Error::Io(std::io::Error::new(
1330        std::io::ErrorKind::InvalidData,
1331        format!("Expected {expected} response, got {actual:?}"),
1332    ))
1333}
1334
1335/// Canonical "connection torn down" error returned to any caller whose
1336/// request couldn't reach (or be answered by) the daemon.
1337fn connection_aborted() -> crate::Error {
1338    crate::Error::Io(std::io::Error::new(
1339        std::io::ErrorKind::ConnectionAborted,
1340        "Server closed connection unexpectedly",
1341    ))
1342}
1343
1344/// Background task driving the read half of the socket.
1345///
1346/// Loops on [`read_frame`] and demuxes by [`ServerFrame`] variant:
1347///
1348/// - `Response(r)`: pop the front of the pending FIFO and resolve its
1349///   oneshot. If the queue is empty something has gone badly wrong
1350///   (server sent more responses than the client issued requests) — log
1351///   and continue.
1352/// - `Notification(Notification::DatabaseWrite { … })`: upgrade the
1353///   attached `WeakInstance` and route the event into its callback
1354///   registry via [`crate::Instance::fire_write_callbacks`]. If no
1355///   instance is attached yet or the instance has been dropped, the
1356///   notification is silently dropped — both are expected end-states,
1357///   not errors.
1358///
1359/// Exit conditions: clean EOF (server closed), any read error, or any
1360/// deserialisation error. On exit the task drops its `Arc<inner>`, which
1361/// in turn drops every remaining oneshot sender in `pending`, surfacing
1362/// as a `RecvError` on each awaiting `request()` (translated to a
1363/// connection-closed `io::Error` there).
1364async fn run_reader_task(mut reader: ReadHalf<UnixStream>, inner: Arc<RemoteConnectionInner>) {
1365    loop {
1366        let frame_result: crate::Result<Option<ServerFrame>> = read_frame(&mut reader).await;
1367        let frame = match frame_result {
1368            Ok(Some(f)) => f,
1369            Ok(None) => break, // Clean EOF
1370            Err(e) => {
1371                tracing::debug!("RemoteConnection reader error: {e}");
1372                break;
1373            }
1374        };
1375
1376        match frame {
1377            ServerFrame::Response(resp) => {
1378                let next = inner.pending_lock().pop_front();
1379                match next {
1380                    Some(tx) => {
1381                        // Receiver dropped → the caller has already given
1382                        // up. Not an error worth logging.
1383                        let _ = tx.send(*resp);
1384                    }
1385                    None => {
1386                        tracing::warn!(
1387                            "RemoteConnection reader: response with no pending request; dropping"
1388                        );
1389                    }
1390                }
1391            }
1392            ServerFrame::Notification(notif) => {
1393                route_notification(&inner, notif);
1394            }
1395        }
1396    }
1397
1398    // Mark the connection dead with `Release` ordering paired against
1399    // `request()`'s `Acquire` load: any post-exit caller that observes
1400    // `true` is guaranteed to also see the drained `pending` queue and
1401    // bail with `ConnectionAborted` instead of pushing a sender no one
1402    // will ever pop. The helper also drops every per-tree worker
1403    // sender so workers exit cleanly without prolonging `inner`'s
1404    // lifetime.
1405    inner.mark_dead();
1406}
1407
1408/// Route a notification to its per-tree worker, spawning one if this is
1409/// the first notification for the tree on this connection.
1410///
1411/// Worker spawn is lazy: we don't create a worker for a tree until the
1412/// daemon actually pushes a notification for it. The map of per-tree
1413/// senders lives in `inner.tree_workers` (std mutex; the map is touched
1414/// for at most a single insert + clone per notification).
1415///
1416/// Sends are best-effort: if the worker has already exited (e.g. the
1417/// connection is winding down and `inner` is mid-drop), the send fails
1418/// and we silently drop. Same posture as the previous single-dispatch
1419/// shape.
1420///
1421/// TODO(dispatch-bound): per-tree channels are `unbounded`. Under
1422/// sustained write load on one tree, a slow user callback lets that
1423/// worker's queue grow without limit, holding all queued notifications
1424/// in client memory.
1425///
1426/// Under the cursor-only `Notification::DatabaseWrite` shape, drops are
1427/// *recoverable*: a worker that drops event N still receives event N+1
1428/// whose `post_tips` reflects the daemon's latest frontier, and the user
1429/// callback's next fire's `previous_tips = post_tips_of_N+1` lets
1430/// `ids_added` pick up any skipped IDs. So drop-oldest via
1431/// `Mutex<VecDeque<Notification>> + Notify` is the right v2 shape —
1432/// roughly a 30-line primitive isolated to this file. Bound size is the
1433/// tunable; `~256` is a reasonable starting point.
1434///
1435/// Deferred for its own PR (alongside server-side
1436/// [`TODO(backpressure)`]) so the drop-semantics tests get focused
1437/// review.
1438fn route_notification(inner: &Arc<RemoteConnectionInner>, notif: Notification) {
1439    let tree_id = match &notif {
1440        Notification::DatabaseWrite { root_id, .. } => root_id.clone(),
1441    };
1442    let tx = {
1443        let mut workers = inner.tree_workers.lock().unwrap_or_else(|p| p.into_inner());
1444        workers
1445            .entry(tree_id)
1446            .or_insert_with(|| {
1447                let (tx, rx) = mpsc::unbounded_channel::<Notification>();
1448                let weak = Arc::downgrade(inner);
1449                tokio::spawn(run_tree_worker(rx, weak));
1450                tx
1451            })
1452            .clone()
1453    };
1454    let _ = tx.send(notif);
1455}
1456
1457/// Drain one tree's notification queue, dispatching to the attached
1458/// `Instance`'s callback registry in arrival order.
1459///
1460/// **Ordering guarantee within the tree.** Notifications are processed
1461/// strictly one at a time — the next `recv()` doesn't run until the
1462/// previous callback's `fire_write_callbacks().await` has returned.
1463/// The reader pushes in the order frames hit the socket, so user
1464/// callbacks for this tree observe events in the daemon's canonical
1465/// order.
1466///
1467/// **No ordering guarantee across trees.** Different trees have their
1468/// own worker tasks; a slow callback on tree A doesn't stall tree B's
1469/// dispatches on the same connection. This is the load-bearing
1470/// difference from the previous single-drain-task shape.
1471///
1472/// **Why this can't be inline in the reader.** User callbacks may
1473/// issue wire ops (e.g. `Database::open` over the connected instance)
1474/// whose responses land through the same reader. Awaiting a callback
1475/// inline would deadlock the reader against the response it is
1476/// supposed to deliver. Per-tree workers keep the reader free.
1477///
1478/// **Lifecycle.** Holds `Weak<RemoteConnectionInner>` so it does not
1479/// extend `inner`'s lifetime. When the reader exits it clears
1480/// `tree_workers`, dropping every sender; `recv()` returns `None`;
1481/// this worker exits.
1482async fn run_tree_worker(
1483    mut rx: mpsc::UnboundedReceiver<Notification>,
1484    weak_inner: Weak<RemoteConnectionInner>,
1485) {
1486    while let Some(notif) = rx.recv().await {
1487        // Snapshot the attached `WeakInstance` per-notification under
1488        // the std mutex — never held across an await. Worst case is
1489        // `None`, which we treat as "instance not attached yet" (the
1490        // attach-vs-first-notification race is impossible in practice
1491        // because attach happens before `Instance::connect` returns,
1492        // and subscriptions only start after that).
1493        let weak_instance = {
1494            let Some(inner) = weak_inner.upgrade() else {
1495                tracing::debug!("RemoteConnection tree worker: inner gone; exiting");
1496                return;
1497            };
1498            let guard = inner
1499                .weak_instance
1500                .lock()
1501                .unwrap_or_else(|poisoned| poisoned.into_inner());
1502            guard.clone()
1503        };
1504        let Some(weak) = weak_instance else {
1505            tracing::debug!(
1506                "RemoteConnection tree worker: notification before attach_instance; dropping"
1507            );
1508            continue;
1509        };
1510        let Some(instance) = weak.upgrade() else {
1511            tracing::debug!(
1512                "RemoteConnection tree worker: instance dropped; ignoring notification"
1513            );
1514            continue;
1515        };
1516
1517        match notif {
1518            Notification::DatabaseWrite {
1519                root_id,
1520                previous_tips,
1521                post_tips,
1522                source,
1523            } => {
1524                instance
1525                    .fire_write_callbacks(&root_id, &previous_tips, &post_tips, source)
1526                    .await;
1527            }
1528        }
1529    }
1530}
1531
1532/// Bound on how long the sweep waits for the daemon's
1533/// `UnsubscribeWrites` ack before declaring the connection broken.
1534///
1535/// The daemon's handler is a single hashmap removal — well under a
1536/// millisecond on local transport, single-digit ms over loopback TCP.
1537/// Five seconds is a generous "is the daemon alive at all" bound; on
1538/// expiry we mark the connection dead via
1539/// [`RemoteConnectionInner::mark_dead`] and let callers reconnect.
1540const UNSUBSCRIBE_RTT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
1541
1542/// Periodically tear down `Idle` per-tree subscriptions whose grace
1543/// window has elapsed.
1544///
1545/// Wakes every [`SWEEP_INTERVAL`]; for each entry in
1546/// `subscribed_trees` that *appears* to be `Idle` past
1547/// [`IDLE_GRACE_WINDOW`], the sweep processes that tree under its
1548/// per-tree subscription fence (see
1549/// `RemoteConnectionInner::subscription_locks`):
1550///
1551/// 1. Acquire `subscription_lock(tree_id)`. This serializes with the
1552///    leader path in [`RemoteConnection::subscribe_writes`], which
1553///    must take the same lock before any mutation of
1554///    `subscribed_trees`. A racing re-registration that arrived after
1555///    the candidate snapshot blocks here until our unsubscribe is
1556///    fully acked.
1557/// 2. Re-check state under the `subscribed_trees` std mutex. If still
1558///    `Idle` past grace, remove the entry and capture its identity
1559///    for the wire call. If the state changed (re-registered, swept
1560///    by another path) skip.
1561/// 3. Send `UnsubscribeWrites` via [`RemoteConnection::unsubscribe_writes`]
1562///    wrapped in [`tokio::time::timeout`] of [`UNSUBSCRIBE_RTT_TIMEOUT`].
1563///    On timeout the daemon is wedged on a trivial op — mark the
1564///    connection dead and exit.
1565/// 4. Release `subscription_lock`. A pending `subscribe_writes` on
1566///    this tree can now proceed: it observes `None` in
1567///    `subscribed_trees`, takes the leader path, and sends a fresh
1568///    `SubscribeWrites` — guaranteed to land on the daemon *after*
1569///    our `UnsubscribeWrites` because we held the fence across the
1570///    ack, independent of the daemon's request-dispatch shape.
1571///
1572/// Holds `Weak<RemoteConnectionInner>` so it doesn't extend `inner`'s
1573/// lifetime. Exits when the upgrade fails (last `Arc<inner>` dropped →
1574/// connection torn down) or when the timeout path marks the connection
1575/// dead.
1576async fn run_sweep_task(weak_inner: Weak<RemoteConnectionInner>) {
1577    let grace = idle_grace_window();
1578    let mut ticker = tokio::time::interval(sweep_interval());
1579    // Skip the initial immediate tick so we don't sweep before any
1580    // subscription has had a chance to register.
1581    ticker.tick().await;
1582    loop {
1583        ticker.tick().await;
1584        let Some(inner) = weak_inner.upgrade() else {
1585            tracing::debug!("RemoteConnection sweep: inner gone; exiting");
1586            return;
1587        };
1588
1589        // Cheap snapshot: collect tree IDs that *appear* to be Idle
1590        // past grace. The per-tree fence below re-checks under the
1591        // std mutex so a re-registration that lands between snapshot
1592        // and fence acquisition is honored.
1593        let candidates: Vec<ID> = {
1594            let subs = inner
1595                .subscribed_trees
1596                .lock()
1597                .unwrap_or_else(|p| p.into_inner());
1598            let now = std::time::Instant::now();
1599            subs.iter()
1600                .filter_map(|(id, state)| match state {
1601                    SubState::Idle { since, .. } if now.duration_since(*since) >= grace => {
1602                        Some(id.clone())
1603                    }
1604                    _ => None,
1605                })
1606                .collect()
1607        };
1608
1609        // Resolve the connection handle once for the batch. `live: None`
1610        // is load-bearing: this is an internal handle, and minting a
1611        // token here would `mark_dead` the connection every time the
1612        // batch handle drops at the end of a tick. The strong `inner`
1613        // moved in here is released when `conn` drops at the end of the
1614        // tick, before the next `upgrade()`.
1615        let conn = RemoteConnection { inner, _live: None };
1616
1617        for tree_id in candidates {
1618            // Per-tree fence: hold across the full Unsubscribe RTT so
1619            // a concurrent `subscribe_writes` on the same tree must
1620            // wait for the daemon's ack before sending its Subscribe.
1621            // See `RemoteConnectionInner::subscription_locks`.
1622            let sub_lock = conn.inner.subscription_lock(&tree_id);
1623            let _sub_guard = sub_lock.lock().await;
1624
1625            // Re-check under the std mutex now that we hold the
1626            // fence. If a re-registration won the race before we got
1627            // the fence, state will be `Subscribed` (or `InFlight` /
1628            // absent on an unrelated race) and we skip — the next
1629            // sweep tick will pick it up if it goes Idle again.
1630            let identity = {
1631                let mut subs = conn
1632                    .inner
1633                    .subscribed_trees
1634                    .lock()
1635                    .unwrap_or_else(|p| p.into_inner());
1636                let now = std::time::Instant::now();
1637                match subs.get(&tree_id) {
1638                    Some(SubState::Idle { since, .. }) if now.duration_since(*since) >= grace => {
1639                        // Still due — pull the entry out so a racing
1640                        // `subscribe_writes` (queued behind our
1641                        // subscription_lock) will observe `None` when
1642                        // it finally proceeds and take the leader path.
1643                        match subs.remove(&tree_id) {
1644                            Some(SubState::Idle { identity, .. }) => Some(identity),
1645                            _ => unreachable!("just observed Idle under the same lock"),
1646                        }
1647                    }
1648                    _ => None,
1649                }
1650            };
1651
1652            let Some(identity) = identity else {
1653                continue;
1654            };
1655
1656            // Wire round-trip under the fence. Timeout-then-teardown
1657            // on hang: a daemon that can't ack a hashmap removal in
1658            // five seconds is broken; mark dead and let the next
1659            // caller reconnect. Don't release the fence and continue
1660            // — that re-opens the race we're fencing against.
1661            match tokio::time::timeout(
1662                UNSUBSCRIBE_RTT_TIMEOUT,
1663                conn.unsubscribe_writes(tree_id.clone(), identity),
1664            )
1665            .await
1666            {
1667                Ok(Ok(())) => tracing::debug!(?tree_id, "lazy unsubscribe complete"),
1668                Ok(Err(e)) => tracing::debug!(
1669                    ?tree_id,
1670                    "lazy unsubscribe failed (connection likely closing): {e}"
1671                ),
1672                Err(_elapsed) => {
1673                    tracing::error!(
1674                        ?tree_id,
1675                        "lazy unsubscribe timed out after {:?}; tearing down connection",
1676                        UNSUBSCRIBE_RTT_TIMEOUT,
1677                    );
1678                    conn.inner.mark_dead();
1679                    return;
1680                }
1681            }
1682        }
1683    }
1684}
1685
1686#[cfg(test)]
1687mod tests {
1688    use super::*;
1689
1690    fn eid(s: &str) -> ID {
1691        ID::from_bytes(s)
1692    }
1693
1694    fn root() -> ID {
1695        eid("root")
1696    }
1697
1698    #[test]
1699    fn client_cache_round_trip() {
1700        let mut c = ClientCrdtCache::new(1024);
1701        c.put(root(), eid("e1"), "store1".into(), b"hello".to_vec());
1702        assert_eq!(
1703            c.get(&root(), &eid("e1"), "store1"),
1704            Some(b"hello".to_vec())
1705        );
1706    }
1707
1708    #[test]
1709    fn client_cache_evicts_under_byte_pressure() {
1710        let mut c = ClientCrdtCache::new(100);
1711        c.put(root(), eid("e1"), "s".into(), vec![1u8; 50]);
1712        c.put(root(), eid("e2"), "s".into(), vec![2u8; 50]);
1713        assert_eq!(c.current_bytes, 100);
1714        c.put(root(), eid("e3"), "s".into(), vec![3u8; 50]);
1715        assert!(
1716            c.get(&root(), &eid("e1"), "s").is_none(),
1717            "least-recently-used entry must be evicted"
1718        );
1719        assert_eq!(c.get(&root(), &eid("e2"), "s"), Some(vec![2u8; 50]));
1720        assert_eq!(c.get(&root(), &eid("e3"), "s"), Some(vec![3u8; 50]));
1721    }
1722
1723    #[test]
1724    fn client_cache_get_promotes_to_most_recent() {
1725        let mut c = ClientCrdtCache::new(100);
1726        c.put(root(), eid("e1"), "s".into(), vec![1u8; 50]);
1727        c.put(root(), eid("e2"), "s".into(), vec![2u8; 50]);
1728        let _ = c.get(&root(), &eid("e1"), "s"); // promote e1
1729        c.put(root(), eid("e3"), "s".into(), vec![3u8; 50]);
1730        assert!(
1731            c.get(&root(), &eid("e1"), "s").is_some(),
1732            "promoted entry must survive eviction"
1733        );
1734        assert!(
1735            c.get(&root(), &eid("e2"), "s").is_none(),
1736            "older un-touched entry must be evicted"
1737        );
1738    }
1739
1740    /// Build a `RemoteConnection` over a socketpair — enough to exercise the
1741    /// subscription state machine without a daemon. No reader task is
1742    /// spawned and no wire traffic is sent; the peer end is returned so the
1743    /// caller keeps the socket open for the duration of the test.
1744    fn test_conn() -> (RemoteConnection, tokio::net::UnixStream) {
1745        let (client_side, peer) = tokio::net::UnixStream::pair().unwrap();
1746        let (_reader, writer) = tokio::io::split(client_side);
1747        let inner = Arc::new(RemoteConnectionInner {
1748            writer: Mutex::new(writer),
1749            pending: std::sync::Mutex::new(VecDeque::new()),
1750            weak_instance: std::sync::Mutex::new(None),
1751            session: RwLock::new(None),
1752            registered_keys: Mutex::new(HashSet::new()),
1753            subscribed_trees: std::sync::Mutex::new(HashMap::new()),
1754            subscription_locks: std::sync::Mutex::new(HashMap::new()),
1755            crdt_cache: std::sync::Mutex::new(ClientCrdtCache::new(CLIENT_CACHE_CAPACITY_BYTES)),
1756            closed: AtomicBool::new(false),
1757            tree_workers: std::sync::Mutex::new(HashMap::new()),
1758            reader_abort: std::sync::Mutex::new(None),
1759        });
1760        (RemoteConnection { inner, _live: None }, peer)
1761    }
1762
1763    /// Regression: `transition_to_idle` must not flip a tree to `Idle` while
1764    /// the attached instance still holds a per-database callback for it.
1765    ///
1766    /// `WriteCallback::drop` calls `remove_write_callback` and
1767    /// `transition_to_idle` as two separate synchronous steps, so a
1768    /// registration on another thread can land between them: it inserts into
1769    /// the registry, then `subscribe_writes` observes `Subscribed` and returns
1770    /// with no wire traffic. An unconditional flip strands that live callback
1771    /// on an `Idle` subscription, and the sweep sends `UnsubscribeWrites` a
1772    /// grace window later — after which the callback never fires again, with
1773    /// no error surfaced anywhere.
1774    #[tokio::test]
1775    async fn transition_to_idle_skips_while_a_callback_is_registered() {
1776        use crate::auth::crypto::generate_keypair;
1777        use crate::backend::database::InMemory;
1778        use crate::crdt::Doc;
1779
1780        let (conn, _peer) = test_conn();
1781        // A local instance suffices — the guard only reads the callback
1782        // registry, which is the same registry on connected instances.
1783        let (instance, _admin) = crate::Instance::create_backend(
1784            Box::new(InMemory::new()),
1785            crate::NewUser::passwordless("admin"),
1786        )
1787        .await
1788        .unwrap();
1789        conn.attach_instance(instance.downgrade());
1790
1791        let (signing_key, _) = generate_keypair();
1792        let db = crate::Database::create(&instance, signing_key, Doc::new())
1793            .await
1794            .unwrap();
1795        let tree_id = db.root_id().clone();
1796
1797        conn.subscribed_trees_lock().insert(
1798            tree_id.clone(),
1799            SubState::Subscribed {
1800                identity: SigKey::default(),
1801            },
1802        );
1803
1804        let cb = db.on_write(|_event, _db| async { Ok(()) }).await.unwrap();
1805
1806        conn.transition_to_idle(&tree_id);
1807        assert!(
1808            matches!(
1809                conn.subscribed_trees_lock().get(&tree_id),
1810                Some(SubState::Subscribed { .. })
1811            ),
1812            "a live callback must keep the wire subscription Subscribed"
1813        );
1814
1815        // Releasing the last callback makes the transition legitimate. The
1816        // handle's own Drop is a no-op for the wire state here (a local
1817        // instance has no `remote_connection`), so drive it explicitly.
1818        drop(cb);
1819        conn.transition_to_idle(&tree_id);
1820        assert!(
1821            matches!(
1822                conn.subscribed_trees_lock().get(&tree_id),
1823                Some(SubState::Idle { .. })
1824            ),
1825            "with no callbacks left the subscription must go Idle"
1826        );
1827    }
1828
1829    #[test]
1830    fn client_cache_replaces_in_place() {
1831        let mut c = ClientCrdtCache::new(1024);
1832        c.put(root(), eid("e1"), "s".into(), b"v1".to_vec());
1833        c.put(root(), eid("e1"), "s".into(), b"v2-different-len".to_vec());
1834        assert_eq!(
1835            c.get(&root(), &eid("e1"), "s"),
1836            Some(b"v2-different-len".to_vec())
1837        );
1838        // current_bytes should reflect only the replacement, not the sum.
1839        assert_eq!(c.current_bytes, b"v2-different-len".len());
1840    }
1841}