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 ¬if {
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}