Skip to main content

khive_runtime/events_split/
forwarder.rs

1use super::*;
2
3/// Connect to the events socket and verify who answered. Pathname
4/// reachability is not identity: any process that can write the directory can
5/// pre-bind the path. The kernel-reported peer uid is the one identity the
6/// far side cannot choose, so every client connect — round-trips, the
7/// forwarder, the supervisor probe — refuses a socket answered by a foreign
8/// uid before a single frame is written.
9#[cfg(unix)]
10pub(super) async fn connect_verified(socket_path: &Path) -> std::io::Result<UnixStream> {
11    let stream = UnixStream::connect(socket_path).await?;
12    // SAFETY: `geteuid` is always successful and takes no arguments.
13    let own_euid = unsafe { libc::geteuid() } as u32;
14    let peer = crate::daemon::peer_uid(&stream)?;
15    if !crate::daemon::uid_is_permitted(peer, own_euid) {
16        return Err(std::io::Error::new(
17            std::io::ErrorKind::PermissionDenied,
18            format!(
19                "events socket {} answered by uid {peer}, not this process's uid {own_euid}; \
20                 refusing to exchange event frames with an unowned daemon",
21                socket_path.display()
22            ),
23        ));
24    }
25    Ok(stream)
26}
27
28/// Counters describing the fire-and-forget lane's degradation. Zero drops is
29/// the healthy state; any non-zero `dropped_batches` means the loss-tolerant
30/// contract was exercised and says so.
31#[cfg(unix)]
32#[derive(Debug, Clone, Copy, Serialize)]
33pub struct EventsForwardingMetrics {
34    pub forwarded_batches: u64,
35    pub forwarded_events: u64,
36    pub dropped_batches: u64,
37    pub dropped_events: u64,
38    /// Serialized request bytes reserved by queued and in-flight batches.
39    pub queued_bytes: usize,
40}
41
42#[cfg(unix)]
43#[derive(Debug, Default)]
44pub(super) struct ForwardingCounters {
45    pub(super) forwarded_batches: AtomicU64,
46    pub(super) forwarded_events: AtomicU64,
47    pub(super) dropped_batches: AtomicU64,
48    pub(super) dropped_events: AtomicU64,
49    pub(super) queued_bytes: AtomicUsize,
50}
51
52/// A reservation lives with the batch until delivery or a counted drop.
53/// Dropping a forwarder mid-delivery also returns its byte budget.
54#[cfg(unix)]
55#[derive(Debug)]
56pub(super) struct AppendByteReservation {
57    pub(super) counters: Arc<ForwardingCounters>,
58    pub(super) bytes: usize,
59}
60
61#[cfg(unix)]
62impl Drop for AppendByteReservation {
63    fn drop(&mut self) {
64        self.counters
65            .queued_bytes
66            .fetch_sub(self.bytes, Ordering::AcqRel);
67    }
68}
69
70#[cfg(unix)]
71#[derive(Debug)]
72pub(super) struct QueuedAppend {
73    pub(super) request: EventsRequest,
74    pub(super) count: u64,
75    pub(super) _reservation: AppendByteReservation,
76}
77
78#[cfg(all(test, unix))]
79impl QueuedAppend {
80    pub(super) fn unmetered(
81        namespace: &str,
82        events: Vec<Event>,
83        counters: Arc<ForwardingCounters>,
84    ) -> Self {
85        let count = events.len() as u64;
86        Self {
87            request: EventsRequest::AppendEvents {
88                protocol_version: EVENTS_PROTOCOL_VERSION,
89                namespace: namespace.to_string(),
90                events,
91            },
92            count,
93            _reservation: AppendByteReservation { counters, bytes: 0 },
94        }
95    }
96}
97
98/// Measure the actual wire request without retaining a second, potentially
99/// huge serialized copy of the caller's batch. Serde stops at the frame cap.
100#[cfg(unix)]
101#[derive(Default)]
102pub(super) struct BoundedFrameCounter {
103    pub(super) bytes: usize,
104    pub(super) exceeded: bool,
105}
106
107#[cfg(unix)]
108impl std::io::Write for BoundedFrameCounter {
109    fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
110        let next = self.bytes.saturating_add(buf.len());
111        if next > crate::daemon::MAX_FRAME_BYTES {
112            self.exceeded = true;
113            return Err(std::io::Error::new(
114                std::io::ErrorKind::InvalidData,
115                "events append request exceeds IPC frame cap",
116            ));
117        }
118        self.bytes = next;
119        Ok(buf.len())
120    }
121
122    fn flush(&mut self) -> std::io::Result<()> {
123        Ok(())
124    }
125}
126
127#[cfg(unix)]
128pub(super) fn reserve_append_bytes(
129    counters: &Arc<ForwardingCounters>,
130    bytes: usize,
131    budget: usize,
132) -> Option<AppendByteReservation> {
133    let mut used = counters.queued_bytes.load(Ordering::Acquire);
134    loop {
135        let next = used.checked_add(bytes)?;
136        if next > budget {
137            return None;
138        }
139        match counters.queued_bytes.compare_exchange_weak(
140            used,
141            next,
142            Ordering::AcqRel,
143            Ordering::Acquire,
144        ) {
145            Ok(_) => {
146                return Some(AppendByteReservation {
147                    counters: Arc::clone(counters),
148                    bytes,
149                });
150            }
151            Err(observed) => used = observed,
152        }
153    }
154}
155
156/// One per domain process: the connection to the events daemon plus the
157/// bounded fire-and-forget append queue.
158///
159/// Synchronous round-trips each own a fresh connection: there is no shared
160/// request connection and no lock in front of one, so concurrent reads and
161/// audit flushes never queue behind a single stalled call, and the round-trip
162/// timeout bounds each caller's whole attempt.
163#[cfg(unix)]
164pub struct EventsSplitClient {
165    pub(super) socket_path: PathBuf,
166    pub(super) append_tx: tokio::sync::mpsc::Sender<QueuedAppend>,
167    pub(super) append_queue_byte_budget: usize,
168    pub(super) counters: Arc<ForwardingCounters>,
169    /// Flipped by the forwarder while the daemon is unreachable so the drop
170    /// log fires once per outage, not once per batch.
171    pub(super) outage_logged: Arc<AtomicBool>,
172    /// In-memory validator backing `preflight_event` without I/O.
173    pub(super) preflight_store: Arc<dyn EventStore>,
174}
175
176#[cfg(unix)]
177impl std::fmt::Debug for EventsSplitClient {
178    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
179        f.debug_struct("EventsSplitClient")
180            .field("socket_path", &self.socket_path)
181            .finish_non_exhaustive()
182    }
183}
184
185#[cfg(unix)]
186impl EventsSplitClient {
187    /// Build the client and spawn its background forwarder.
188    pub fn new(socket_path: PathBuf) -> crate::error::RuntimeResult<Arc<Self>> {
189        Self::new_with_queue_depth(socket_path, DEFAULT_APPEND_QUEUE_BATCHES)
190    }
191
192    /// [`Self::new`] with an explicit fire-and-forget queue bound. Tests use a
193    /// tiny depth to exercise the overflow drop arm deterministically.
194    pub fn new_with_queue_depth(
195        socket_path: PathBuf,
196        queue_depth: usize,
197    ) -> crate::error::RuntimeResult<Arc<Self>> {
198        Self::new_with_queue_depth_and_delivery_timeout(socket_path, queue_depth, REQUEST_TIMEOUT)
199    }
200
201    /// [`Self::new_with_queue_depth`] with an explicit per-batch delivery
202    /// timeout, so tests can prove the forwarder abandons a hung peer without
203    /// waiting out the production clock.
204    pub(super) fn new_with_queue_depth_and_delivery_timeout(
205        socket_path: PathBuf,
206        queue_depth: usize,
207        delivery_timeout: Duration,
208    ) -> crate::error::RuntimeResult<Arc<Self>> {
209        Self::new_with_limits_and_delivery_timeout(
210            socket_path,
211            queue_depth,
212            DEFAULT_APPEND_QUEUE_BYTES,
213            delivery_timeout,
214        )
215    }
216
217    pub(super) fn new_with_limits_and_delivery_timeout(
218        socket_path: PathBuf,
219        queue_depth: usize,
220        byte_budget: usize,
221        delivery_timeout: Duration,
222    ) -> crate::error::RuntimeResult<Arc<Self>> {
223        validate_events_socket_path(&socket_path)?;
224        let preflight_backend = StorageBackend::memory()?;
225        let preflight_store = preflight_backend.events()?;
226        // The in-memory backend must outlive the store handle; the store holds
227        // the pool Arc internally, so dropping the backend wrapper here is fine.
228
229        let (append_tx, append_rx) = tokio::sync::mpsc::channel::<QueuedAppend>(queue_depth.max(1));
230        let counters = Arc::new(ForwardingCounters::default());
231        let outage_logged = Arc::new(AtomicBool::new(false));
232
233        let client = Arc::new(Self {
234            socket_path: socket_path.clone(),
235            append_tx,
236            append_queue_byte_budget: byte_budget,
237            counters: Arc::clone(&counters),
238            outage_logged: Arc::clone(&outage_logged),
239            preflight_store,
240        });
241
242        crate::daemon::spawn_named_tracked_task(
243            "events_forwarder",
244            run_forwarder(
245                socket_path,
246                append_rx,
247                counters,
248                outage_logged,
249                delivery_timeout,
250                crate::daemon::daemon_shutdown_token(),
251            ),
252        );
253        Ok(client)
254    }
255
256    /// Snapshot of the fire-and-forget lane's health.
257    pub fn metrics(&self) -> EventsForwardingMetrics {
258        EventsForwardingMetrics {
259            forwarded_batches: self.counters.forwarded_batches.load(Ordering::Relaxed),
260            forwarded_events: self.counters.forwarded_events.load(Ordering::Relaxed),
261            dropped_batches: self.counters.dropped_batches.load(Ordering::Relaxed),
262            dropped_events: self.counters.dropped_events.load(Ordering::Relaxed),
263            queued_bytes: self.counters.queued_bytes.load(Ordering::Acquire),
264        }
265    }
266
267    /// Enqueue a batch on the fire-and-forget lane. Never blocks; a full
268    /// queue is a counted, logged drop.
269    pub(super) fn enqueue(&self, namespace: &str, events: Vec<Event>) {
270        let count = events.len() as u64;
271        let request = EventsRequest::AppendEvents {
272            protocol_version: EVENTS_PROTOCOL_VERSION,
273            namespace: namespace.to_string(),
274            events,
275        };
276        let mut frame_size = BoundedFrameCounter::default();
277        if let Err(error) = serde_json::to_writer(&mut frame_size, &request) {
278            self.count_append_drop(
279                count,
280                if frame_size.exceeded {
281                    "frame cap"
282                } else {
283                    "serialization"
284                },
285            );
286            tracing::warn!(error = %error, "events append batch could not fit a wire frame");
287            return;
288        }
289        let Some(reservation) = reserve_append_bytes(
290            &self.counters,
291            frame_size.bytes,
292            self.append_queue_byte_budget,
293        ) else {
294            self.count_append_drop(count, "queue byte budget");
295            return;
296        };
297        let batch = QueuedAppend {
298            request,
299            count,
300            _reservation: reservation,
301        };
302        match self.append_tx.try_send(batch) {
303            Ok(()) => {}
304            Err(_) => {
305                // The rejected batch drops here and releases its reservation.
306                self.count_append_drop(count, "queue batch limit");
307            }
308        }
309    }
310
311    pub(super) fn count_append_drop(&self, count: u64, reason: &'static str) {
312        self.counters
313            .dropped_batches
314            .fetch_add(1, Ordering::Relaxed);
315        self.counters
316            .dropped_events
317            .fetch_add(count, Ordering::Relaxed);
318        if !self.outage_logged.swap(true, Ordering::Relaxed) {
319            tracing::warn!(
320                dropped_events = count,
321                reason,
322                "events append queue rejected loss-tolerant batch"
323            );
324        }
325    }
326
327    /// One synchronous framed round-trip with a bounded timeout, on its own
328    /// fresh connection. No connection is shared between concurrent callers,
329    /// so no caller ever waits on another's stall, and `REQUEST_TIMEOUT`
330    /// bounds the whole attempt — connect, peer verification, write, read.
331    pub(super) async fn round_trip(
332        &self,
333        request: &EventsRequest,
334    ) -> StorageResult<EventsResponse> {
335        let op = "events daemon round-trip";
336        let payload = serialize_events_request(request)?;
337        let attempt = events_round_trip_attempt(&self.socket_path, &payload);
338        let bytes = match tokio::time::timeout(REQUEST_TIMEOUT, attempt).await {
339            Ok(Ok(bytes)) => bytes,
340            Ok(Err(("read", error))) => {
341                return Err(StorageError::Serialization {
342                    capability: khive_storage::StorageCapability::Events,
343                    message: format!(
344                        "events daemon closed or broke the response after connection at {}: {error}",
345                        self.socket_path.display()
346                    ),
347                });
348            }
349            Ok(Err((stage, error))) => {
350                return Err(StorageError::Pool {
351                    operation: op.into(),
352                    message: format!(
353                        "events daemon {stage} failed at {}: {error}",
354                        self.socket_path.display()
355                    ),
356                });
357            }
358            Err(_elapsed) => {
359                return Err(StorageError::Timeout {
360                    operation: op.into(),
361                });
362            }
363        };
364
365        deserialize_events_response(&bytes)
366    }
367
368    pub(super) async fn round_trip_count(
369        &self,
370        request: &EventsRequest,
371        budget: &CountReadBudget,
372    ) -> StorageResult<EventsResponse> {
373        budget.scope(async {
374            let payload = serialize_events_request(request)?;
375            let attempt = events_round_trip_attempt(&self.socket_path, &payload);
376            let result = khive_storage::await_request_read_phase("events daemon count round-trip", attempt)
377                .await.map_err(|error| budget.attribute(error))?;
378            let bytes = match result {
379                Ok(bytes) => bytes,
380                Err(("read", error)) => return Err(StorageError::Serialization {
381                    capability: khive_storage::StorageCapability::Events,
382                    message: format!("events daemon closed or broke the response after connection at {}: {error}", self.socket_path.display()),
383                }),
384                Err((stage, error)) => return Err(StorageError::Pool {
385                    operation: "events daemon count round-trip".into(),
386                    message: format!("events daemon {stage} failed at {}: {error}", self.socket_path.display()),
387                }),
388            };
389            deserialize_events_response(&bytes)
390        }).await
391    }
392}
393
394#[cfg(unix)]
395pub(super) fn serialize_events_request(request: &EventsRequest) -> StorageResult<Vec<u8>> {
396    serde_json::to_vec(request).map_err(|error| StorageError::Serialization {
397        capability: khive_storage::StorageCapability::Events,
398        message: format!("events request serialization failed: {error}"),
399    })
400}
401
402#[cfg(unix)]
403pub(super) fn deserialize_events_response(bytes: &[u8]) -> StorageResult<EventsResponse> {
404    serde_json::from_slice(bytes).map_err(|error| StorageError::Serialization {
405        capability: khive_storage::StorageCapability::Events,
406        message: format!("events response deserialization failed: {error}"),
407    })
408}
409
410#[cfg(unix)]
411pub(super) async fn events_round_trip_attempt(
412    socket_path: &Path,
413    payload: &[u8],
414) -> Result<Vec<u8>, (&'static str, std::io::Error)> {
415    let mut stream = connect_verified(socket_path)
416        .await
417        .map_err(|error| ("connect", error))?;
418    write_frame(&mut stream, payload)
419        .await
420        .map_err(|error| ("write", error))?;
421    read_frame(&mut stream)
422        .await
423        .map_err(|error| ("read", error))
424}
425
426/// Drain every batch currently sitting in the fire-and-forget queue,
427/// counting each as a dropped batch/events and logging one summary line
428/// (never one line per batch — a full queue at shutdown is exactly the
429/// bursty case a per-batch log would flood). Non-blocking: `try_recv` only
430/// consumes what is already queued, so it terminates as soon as the queue
431/// (temporarily) empties even though the sender half is still live.
432#[cfg(unix)]
433pub(super) fn drain_dropped_queue(
434    rx: &mut tokio::sync::mpsc::Receiver<QueuedAppend>,
435    counters: &ForwardingCounters,
436) {
437    let mut dropped_batches = 0u64;
438    let mut dropped_events = 0u64;
439    while let Ok(batch) = rx.try_recv() {
440        dropped_batches += 1;
441        dropped_events += batch.count;
442        // `batch` drops its byte reservation at the end of this iteration.
443    }
444    if dropped_batches > 0 {
445        counters
446            .dropped_batches
447            .fetch_add(dropped_batches, Ordering::Relaxed);
448        counters
449            .dropped_events
450            .fetch_add(dropped_events, Ordering::Relaxed);
451        tracing::warn!(
452            dropped_batches,
453            dropped_events,
454            "events forwarder shutting down; dropping queued loss-tolerant batches"
455        );
456    }
457}
458
459/// The background fire-and-forget forwarder: drains the bounded queue into
460/// framed appends on its own connection, reconnecting with backoff. A batch
461/// that cannot be delivered is dropped and counted — never retried, never
462/// blocking the queue behind it.
463#[cfg(unix)]
464pub(super) async fn run_forwarder(
465    socket_path: PathBuf,
466    mut rx: tokio::sync::mpsc::Receiver<QueuedAppend>,
467    counters: Arc<ForwardingCounters>,
468    outage_logged: Arc<AtomicBool>,
469    delivery_timeout: Duration,
470    shutdown: tokio_util::sync::CancellationToken,
471) {
472    // The client lives in a process-global registry, so its sender is never
473    // dropped and `rx.recv()` alone would keep this tracked task alive
474    // through daemon shutdown, forcing `drain()` to its full timeout.
475    // Observe the shutdown token directly: queued batches at shutdown are a
476    // counted drop, exactly the lane's loss-tolerant contract — enforced by
477    // draining and counting `rx` below on every shutdown exit, not just
478    // implied by the comment.
479    let mut conn: Option<UnixStream> = None;
480    loop {
481        let batch = tokio::select! {
482            _ = shutdown.cancelled() => {
483                drain_dropped_queue(&mut rx, &counters);
484                break;
485            },
486            received = rx.recv() => match received {
487                Some(batch) => batch,
488                None => break,
489            },
490        };
491        let count = batch.count;
492        let payload = match serde_json::to_vec(&batch.request) {
493            Ok(payload) => payload,
494            Err(error) => {
495                counters.dropped_batches.fetch_add(1, Ordering::Relaxed);
496                counters.dropped_events.fetch_add(count, Ordering::Relaxed);
497                tracing::error!(error = %error, "events forwarder serialization failed; batch dropped");
498                continue;
499            }
500        };
501
502        // The delivery itself must stay both cancellable and bounded: a
503        // connected but non-responding daemon would otherwise park this
504        // tracked task inside write_frame/read_frame, beyond the reach of the
505        // recv-side shutdown select — and daemon drain would wait its full
506        // timeout on it. Shutdown mid-delivery abandons the batch (the
507        // lane's loss-tolerant contract); a timeout poisons the connection,
508        // since the peer may answer the abandoned frame later.
509        let delivered = tokio::select! {
510            _ = shutdown.cancelled() => {
511                counters.dropped_batches.fetch_add(1, Ordering::Relaxed);
512                counters.dropped_events.fetch_add(count, Ordering::Relaxed);
513                tracing::warn!(
514                    dropped_events = count,
515                    "events forwarder shutting down mid-delivery; in-flight batch dropped"
516                );
517                drain_dropped_queue(&mut rx, &counters);
518                break;
519            },
520            outcome = tokio::time::timeout(
521                delivery_timeout,
522                deliver_batch(&socket_path, &mut conn, &payload),
523            ) => match outcome {
524                Ok(delivered) => delivered,
525                Err(_elapsed) => {
526                    conn = None;
527                    false
528                }
529            },
530        };
531        if delivered {
532            counters.forwarded_batches.fetch_add(1, Ordering::Relaxed);
533            counters
534                .forwarded_events
535                .fetch_add(count, Ordering::Relaxed);
536            if outage_logged.swap(false, Ordering::Relaxed) {
537                tracing::info!("events daemon reachable again; forwarding resumed");
538            }
539        } else {
540            counters.dropped_batches.fetch_add(1, Ordering::Relaxed);
541            counters.dropped_events.fetch_add(count, Ordering::Relaxed);
542            if !outage_logged.swap(true, Ordering::Relaxed) {
543                tracing::warn!(
544                    socket = %socket_path.display(),
545                    "events daemon unreachable; dropping loss-tolerant events until it returns"
546                );
547            }
548            tokio::select! {
549                _ = shutdown.cancelled() => {
550                    drain_dropped_queue(&mut rx, &counters);
551                    break;
552                },
553                _ = tokio::time::sleep(FORWARDER_BACKOFF) => {}
554            }
555        }
556    }
557}
558
559/// Try to deliver one framed append over the forwarder connection,
560/// (re)connecting at most once. Returns whether the daemon acknowledged.
561/// Connections are peer-verified: a socket answered by a foreign uid is a
562/// failed connect, never a delivery target.
563#[cfg(unix)]
564pub(super) async fn deliver_batch(
565    socket_path: &Path,
566    conn: &mut Option<UnixStream>,
567    payload: &[u8],
568) -> bool {
569    for _attempt in 0..2u8 {
570        if conn.is_none() {
571            match connect_verified(socket_path).await {
572                Ok(stream) => *conn = Some(stream),
573                Err(_) => return false,
574            }
575        }
576        let stream = conn.as_mut().expect("connection populated above");
577        let ok = async {
578            write_frame(stream, payload).await?;
579            let bytes = read_frame(stream).await?;
580            std::io::Result::Ok(bytes)
581        }
582        .await;
583        match ok {
584            Ok(bytes) => {
585                return !matches!(
586                    serde_json::from_slice::<EventsResponse>(&bytes),
587                    Ok(EventsResponse::Error { .. }) | Err(_)
588                );
589            }
590            Err(_) => {
591                // Stale connection (daemon restarted): drop it and retry once
592                // with a fresh connect; a second failure is a real outage.
593                *conn = None;
594            }
595        }
596    }
597    false
598}