Skip to main content

client_core/sync/
writer.rs

1//! Long-lived WS-driven sync writer.
2//!
3//! `Writer::start` acquires the advisory lockfile so only one writer runs per
4//! machine, performs an initial REST backfill, then subscribes to the relay
5//! WebSocket.  Incoming `new_clip` events are decrypted (when an encryption key
6//! is available) and inserted into the local store.  On disconnect the loop
7//! receives `WsStatus::Disconnected` and `ws::run` reconnects automatically
8//! with exponential backoff.
9//!
10//! `Writer::shutdown` signals the background task to stop and awaits it.
11
12use std::path::PathBuf;
13use std::sync::Arc;
14
15use tokio::sync::{mpsc, Notify};
16use tokio::task::JoinHandle;
17use tracing::warn;
18
19use super::lockfile::{LockKind, Lockfile};
20use super::{map, reader};
21use crate::http::RestClient;
22use crate::protocol::Clip;
23use crate::store::{queries, Store};
24use crate::ws::{self, DecryptFailReason, WsConfig, WsEvent, WsStatus};
25
26/// Callback invoked after a remote `new_clip` event has been successfully
27/// decrypted and inserted into the local store. The closure receives the
28/// already-decrypted wire `Clip` so callers can extract metadata (id, source,
29/// content_type) without touching the store.
30///
31/// Wrapped in `Arc` so the same callback can be shared across the writer's
32/// background task and any future re-spawns. `Send + Sync` lets the closure
33/// cross task boundaries.
34pub type OnNewClipCallback = Arc<dyn Fn(&Clip) + Send + Sync>;
35
36/// Callback invoked every time `WsStatus::Connected` is observed on the relay
37/// WebSocket stream (i.e., on initial connection and on every reconnect).
38///
39/// The callback is executed on a separate `tokio::spawn` task so a slow
40/// handler cannot stall the WS event loop.  It must therefore be
41/// `Send + Sync + 'static`.
42pub type OnConnectedCallback = Arc<dyn Fn() + Send + Sync>;
43
44/// Long-lived WS-driven sync writer.  One per machine, coordinated by the
45/// `~/.cinch/sync.lock` advisory lockfile.
46pub struct Writer {
47    handle: Option<JoinHandle<()>>,
48    stop: Arc<Notify>,
49    _lock: Lockfile, // released on Drop
50    on_connected: Option<OnConnectedCallback>,
51}
52
53impl Writer {
54    /// Try to start a writer.  Returns `Ok(None)` if another writer already
55    /// holds the lock.
56    ///
57    /// The caller must supply a `WsConfig` (relay URL + bearer token +
58    /// optional 32-byte AES key).  The same `RestClient` is used for the
59    /// initial REST backfill.
60    ///
61    /// Use [`Writer::with_on_connected`] on the returned value to register a
62    /// reconnect callback before the first `WsStatus::Connected` event fires.
63    pub async fn start(
64        store: Arc<Store>,
65        client: Arc<RestClient>,
66        ws_cfg: WsConfig,
67        lock_path: PathBuf,
68        kind: LockKind,
69        on_new_clip: Option<OnNewClipCallback>,
70        on_connected: Option<OnConnectedCallback>,
71    ) -> std::io::Result<Option<Self>> {
72        let lock = match Lockfile::try_acquire(&lock_path, kind)? {
73            Some(l) => l,
74            None => return Ok(None),
75        };
76
77        let stop = Arc::new(Notify::new());
78        let stop_clone = stop.clone();
79        let store_clone = store.clone();
80        let client_clone = client.clone();
81        let enc_key = ws_cfg.encryption_key;
82
83        // Clone the callback Arc for the background task.  The Writer also
84        // stores a copy so callers can inspect it, but the task holds the
85        // authoritative reference used to fire on every Connected event.
86        let on_connected_for_task = on_connected.clone();
87
88        let handle = tokio::spawn(async move {
89            // Initial REST backfill — bring the store up to date before
90            // the WebSocket stream takes over.
91            let _ = reader::backfill_once(
92                &store_clone,
93                &client_clone,
94                reader::BackfillBudget::default(),
95                enc_key.as_ref(),
96            )
97            .await;
98
99            // Channel for WsEvent from the background ws::run task.
100            let (tx, mut rx) = mpsc::channel::<WsEvent>(64);
101
102            // ws::run already reconnects internally with exponential backoff,
103            // so we just run it once and read from the channel until stop.
104            let _ws_handle = tokio::spawn(ws::run(ws_cfg, tx));
105
106            loop {
107                tokio::select! {
108                    _ = stop_clone.notified() => return,
109                    maybe = rx.recv() => {
110                        match maybe {
111                            None => {
112                                // Channel closed — ws::run exited (should not
113                                // happen while the sender is alive).
114                                return;
115                            }
116                            Some(event) => {
117                                handle_ws_event(
118                                    &store_clone,
119                                    &client_clone,
120                                    event,
121                                    enc_key,
122                                    on_new_clip.as_ref(),
123                                    on_connected_for_task.as_ref(),
124                                )
125                                .await;
126                            }
127                        }
128                    }
129                }
130            }
131        });
132
133        Ok(Some(Self {
134            handle: Some(handle),
135            stop,
136            _lock: lock,
137            on_connected,
138        }))
139    }
140
141    /// Register (or replace) the callback that fires on every
142    /// `WsStatus::Connected` event.
143    ///
144    /// This is a builder-style setter intended for use with a `Writer` that
145    /// was constructed without a connected callback.  Note that if the writer
146    /// background task has already started, changing this field has no effect
147    /// on the running task — use the `on_connected` parameter of
148    /// [`Writer::start`] to supply the callback at construction time.
149    pub fn with_on_connected(mut self, cb: OnConnectedCallback) -> Self {
150        self.on_connected = Some(cb);
151        self
152    }
153
154    /// Signal the writer to stop and wait for it to finish.
155    pub async fn shutdown(mut self) {
156        self.stop.notify_waiters();
157        if let Some(h) = self.handle.take() {
158            let _ = h.await;
159        }
160    }
161}
162
163/// Fire the `on_connected` callback on a separate task so a slow handler
164/// cannot stall the WS event loop.
165///
166/// Extracted as a free function so it can be unit-tested without a running
167/// WebSocket connection.
168pub(crate) fn dispatch_on_connected(cb: &OnConnectedCallback) {
169    let cb = cb.clone();
170    tokio::spawn(async move { cb() });
171}
172
173/// Process a single [`WsEvent`] from the relay subscription.
174///
175/// `NewClip` results in a store write. `KeyExchangeRequested` triggers
176/// the ECDH bearer responder (when this device holds the encryption
177/// key). Status changes are logged at debug level; decrypt failures
178/// emit a warning. All other event kinds are no-ops pending Phase 5
179/// handling.
180async fn handle_ws_event(
181    store: &Store,
182    client: &RestClient,
183    event: WsEvent,
184    enc_key: Option<[u8; 32]>,
185    on_new_clip: Option<&OnNewClipCallback>,
186    on_connected: Option<&OnConnectedCallback>,
187) {
188    match event {
189        WsEvent::NewClip { clip, plaintext: _ } => {
190            // `ws::run` has already attempted decryption using the key supplied
191            // in `WsConfig`.  After a successful decrypt `clip.encrypted` is
192            // `false` and `clip.content` holds the decoded text (or base64 for
193            // binary clips).  The `enc_key` was passed to `WsConfig` upstream so
194            // decryption happens before the event reaches us here.
195            let _ = enc_key; // consumed upstream by ws::decode_message via WsConfig
196            let clip = *clip; // unbox
197                              // After a successful decrypt, ws::run sets clip.encrypted = false
198                              // and clip.content to the decoded string. If the clip is still
199                              // marked encrypted here, the decrypt failed and we should not store
200                              // ciphertext — the event type would be ClipDecryptFailed, not
201                              // NewClip. So any NewClip with encrypted=false is safe to store.
202            if clip.encrypted {
203                // Should not happen: ws::run converts decrypt-failed clips to
204                // WsEvent::ClipDecryptFailed. Guard here anyway.
205                warn!(
206                    clip_id = %clip.clip_id,
207                    "writer: received NewClip with encrypted=true — skipping"
208                );
209                return;
210            }
211            // Use plaintext bytes for the content so binary clips (which have
212            // base64 in clip.content after re-encoding) are stored as raw bytes.
213            // The map function accepts clip.content as a String — for text clips
214            // plaintext == clip.content.as_bytes(), so the lossy conversion is
215            // a no-op.  For binary clips the store already accepts the base64
216            // form (same as the REST path).
217            match map::clip_wire_to_stored(&clip) {
218                Ok(Some(stored)) => {
219                    let inserted = match queries::insert_clip(store, &stored) {
220                        Ok(()) => true,
221                        Err(e) => {
222                            warn!(clip_id = %stored.id, error = %e, "writer: insert_clip failed");
223                            false
224                        }
225                    };
226                    if inserted {
227                        if let Err(e) = queries::set_watermark(store, &stored.id) {
228                            warn!(clip_id = %stored.id, error = %e, "writer: set_watermark failed");
229                        }
230                        if let Some(cb) = on_new_clip {
231                            cb(&clip);
232                        }
233                    }
234                }
235                Ok(None) => {
236                    // Empty clip_id — silently skip.
237                }
238                Err(e) => {
239                    warn!(clip_id = %clip.clip_id, error = %e, "writer: map_wire_to_stored failed");
240                }
241            }
242        }
243
244        WsEvent::ClipDecryptFailed { clip_id, reason } => {
245            let reason_str = match reason {
246                DecryptFailReason::MissingKey => "no encryption key available".into(),
247                DecryptFailReason::TagFailed(e) => format!("key mismatch: {e}"),
248            };
249            warn!(clip_id = %clip_id, reason = %reason_str, "writer: skipping encrypted clip — decrypt failed");
250        }
251
252        WsEvent::ClipDeleted { clip_id: _ } => {
253            // TODO(phase 5): propagate deletion to local store.
254        }
255
256        WsEvent::Revoked { reason } => {
257            warn!(
258                reason = ?reason,
259                "writer: device revoked by relay — writer will stop receiving events"
260            );
261        }
262
263        WsEvent::TokenRotated {
264            token: _,
265            device_id: _,
266        } => {
267            // TODO(phase 5): persist the rotated token via credstore.
268        }
269
270        WsEvent::KeyExchangeRequested { device_id } => {
271            log::info!(
272                "writer: received KeyExchangeRequested device_id={:?}",
273                device_id
274            );
275            let Some(did) = device_id else {
276                log::info!("writer: key_exchange_requested missing device_id — skipping");
277                return;
278            };
279            let Some(key) = enc_key else {
280                log::info!(
281                    "writer: cannot bear key — no encryption key on this device (target_device_id={})",
282                    did
283                );
284                return;
285            };
286            log::info!("writer: bearing key for target_device_id={}", did);
287            if let Err(e) = crate::key_exchange::handle_event(client, &did, &key).await {
288                log::warn!(
289                    "writer: key_exchange responder failed: target_device_id={}, error={}",
290                    did,
291                    e
292                );
293            } else {
294                log::info!(
295                    "writer: posted encrypted key bundle to peer target_device_id={}",
296                    did
297                );
298            }
299        }
300
301        WsEvent::Status(WsStatus::Connected) => {
302            tracing::debug!("writer: WS connected");
303            if let Some(cb) = on_connected {
304                dispatch_on_connected(cb);
305            }
306        }
307        WsEvent::Status(WsStatus::Disconnected) => {
308            tracing::debug!("writer: WS disconnected — ws::run will reconnect");
309        }
310        WsEvent::Status(WsStatus::Connecting) => {
311            tracing::debug!("writer: WS connecting");
312        }
313    }
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use std::path::Path;
320    use std::sync::atomic::{AtomicUsize, Ordering};
321
322    fn make_test_store() -> Arc<crate::store::Store> {
323        Arc::new(crate::store::Store::open(Path::new(":memory:")).expect("in-memory store"))
324    }
325
326    fn make_test_client() -> Arc<crate::http::RestClient> {
327        Arc::new(crate::http::RestClient::for_test_offline())
328    }
329
330    #[tokio::test]
331    async fn writer_invokes_on_connected_on_status_connected() {
332        let counter = Arc::new(AtomicUsize::new(0));
333        let counter_c = counter.clone();
334        let cb: OnConnectedCallback = Arc::new(move || {
335            counter_c.fetch_add(1, Ordering::SeqCst);
336        });
337
338        let store = make_test_store();
339        let client = make_test_client();
340
341        // Fire Connected twice and Disconnected once in between.
342        handle_ws_event(
343            &store,
344            &client,
345            WsEvent::Status(WsStatus::Connected),
346            None,
347            None,
348            Some(&cb),
349        )
350        .await;
351
352        handle_ws_event(
353            &store,
354            &client,
355            WsEvent::Status(WsStatus::Disconnected),
356            None,
357            None,
358            Some(&cb),
359        )
360        .await;
361
362        handle_ws_event(
363            &store,
364            &client,
365            WsEvent::Status(WsStatus::Connected),
366            None,
367            None,
368            Some(&cb),
369        )
370        .await;
371
372        // The callback fires on a tokio::spawn — yield to let those tasks run.
373        tokio::time::sleep(std::time::Duration::from_millis(50)).await;
374        assert_eq!(
375            counter.load(Ordering::SeqCst),
376            2,
377            "callback should fire exactly once per Connected event"
378        );
379    }
380
381    #[tokio::test]
382    async fn writer_skips_callback_when_none_set() {
383        let store = make_test_store();
384        let client = make_test_client();
385
386        // Should not panic when no on_connected callback is registered.
387        handle_ws_event(
388            &store,
389            &client,
390            WsEvent::Status(WsStatus::Connected),
391            None,
392            None,
393            None,
394        )
395        .await;
396    }
397}