Skip to main content

lumen_core/
net_capture.rs

1//! Process-wide, opt-in HTTP capture sink for dev tooling.
2//!
3//! The scripting HTTP layer (`lumen-script`'s `fetch()` / `http()`
4//! builtins) unconditionally reports request/response lifecycle events to
5//! [`record`]. When no sink has been installed - the default in a release
6//! build, and always when `lumen-devtools` is not compiled in - [`record`]
7//! is a single atomic load plus branch and drops the event on the floor, so
8//! there is zero capture cost and no unbounded buffer growth.
9//!
10//! A dev-only consumer (the devtools Network tab) calls [`init_net_capture`]
11//! once at startup to install the sink, then drains accumulated events each
12//! tick via [`drain`] into its own bounded ring. This mirrors the
13//! [`crate::signals`] external-mutation channel pattern: one global
14//! `OnceLock` sender, one `OnceLock<Mutex<Receiver>>`, both idempotent.
15
16use std::sync::{Mutex, OnceLock};
17
18use crossbeam_channel::{Receiver, Sender, unbounded};
19
20/// One HTTP lifecycle event emitted by the scripting fetch/http layer.
21///
22/// A request produces a [`NetEvent::Started`] when it is dispatched and a
23/// matching [`NetEvent::Completed`] (correlated by `tag`) when the worker
24/// thread's reply lands. The devtools Network tab pairs them by `tag`, the
25/// same identifier scripts pass to `fetch(url, tag)` / `http(#{...})`.
26#[derive(Clone, Debug)]
27pub enum NetEvent {
28    /// A request was dispatched to the off-thread worker.
29    Started {
30        /// Script-supplied correlation tag (`fetch(url, tag)`).
31        tag: String,
32        /// HTTP method (`"GET"`, `"POST"`, ...).
33        method: String,
34        /// Target URL.
35        url: String,
36    },
37    /// A previously-[`NetEvent::Started`] request's reply arrived.
38    Completed {
39        /// Correlation tag matching the [`NetEvent::Started`] event.
40        tag: String,
41        /// `true` when the transport succeeded (any HTTP status), `false`
42        /// on a transport error (DNS, connect, timeout, bad method/url).
43        ok: bool,
44        /// HTTP status code when `ok`; `0` on a transport error.
45        status: u16,
46        /// Error string when `!ok`; empty otherwise.
47        error: String,
48    },
49}
50
51static NET_TX: OnceLock<Sender<NetEvent>> = OnceLock::new();
52static NET_RX: OnceLock<Mutex<Receiver<NetEvent>>> = OnceLock::new();
53
54/// Idempotently install the capture sink. Safe to call multiple times; only
55/// the first call creates the channel. After this returns, [`record`] starts
56/// forwarding events for [`drain`] to collect.
57pub fn init_net_capture() {
58    NET_TX.get_or_init(|| {
59        let (tx, rx) = unbounded();
60        let _ = NET_RX.set(Mutex::new(rx));
61        tx
62    });
63}
64
65/// Report an HTTP lifecycle event. No-op (one atomic load + branch) until
66/// [`init_net_capture`] has run, so release builds without dev tooling pay
67/// nothing and never accumulate an unbounded buffer.
68pub fn record(event: NetEvent) {
69    if let Some(tx) = NET_TX.get() {
70        let _ = tx.send(event);
71    }
72}
73
74/// Drain up to `max` pending events (oldest first). Returns empty when the
75/// sink was never installed or nothing is queued.
76pub fn drain(max: usize) -> Vec<NetEvent> {
77    let Some(rx_cell) = NET_RX.get() else {
78        return Vec::new();
79    };
80    let Ok(rx) = rx_cell.lock() else {
81        return Vec::new();
82    };
83    let mut out = Vec::new();
84    while out.len() < max {
85        match rx.try_recv() {
86            Ok(ev) => out.push(ev),
87            Err(_) => break,
88        }
89    }
90    out
91}