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}