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}