Skip to main content

smolvm_network/
queues.rs

1//! Shared queues and wake notifications for the virtio-net backend.
2//!
3//! Context
4//! =======
5//!
6//! The host-side virtio runtime has several independently blocked workers:
7//! - the Unix-stream reader thread
8//! - the Unix-stream writer thread
9//! - the smoltcp poll loop
10//! - TCP relay threads
11//!
12//! They need two kinds of coordination:
13//! 1. lock-free frame handoff between threads
14//! 2. a way to wake a thread that is blocked in `poll(2)` or waiting for work
15//!
16//! This module provides both:
17//! - `ArrayQueue<Vec<u8>>` for frame ownership transfer
18//! - `WakePipe` as a tiny readiness primitive built from `pipe(2)` + `poll(2)`
19//!
20//! Data flow:
21//!
22//! ```text
23//! guest_to_host queue : reader thread  -> smoltcp poll loop
24//! host_to_guest queue : smoltcp runtime -> writer thread
25//!
26//! guest_wake: reader thread / shutdown -> smoltcp poll loop
27//! host_wake : smoltcp runtime / shutdown -> Unix-stream writer
28//! relay_wake: TCP relay threads / shutdown -> smoltcp poll loop
29//! ```
30//!
31//! Thread interaction view:
32//!
33//! ```text
34//! FrameStream reader thread
35//!   -> guest_to_host.push(frame)
36//!   -> guest_wake.wake()
37//!
38//! smolvm-net-poll thread
39//!   -> guest_to_host.pop()
40//!   -> host_to_guest.push(frame)
41//!   -> host_wake.wake()
42//!   -> relay_wake.wait()/drain()
43//!
44//! FrameStream writer thread
45//!   -> host_wake.wait()
46//!   -> host_to_guest.pop()
47//!
48//! TCP relay thread
49//!   -> to_smoltcp.send(payload)
50//!   -> relay_wake.wake()
51//! ```
52
53use crossbeam_queue::ArrayQueue;
54use std::os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd};
55use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
56use std::sync::Arc;
57use std::time::Duration;
58
59/// Default queue capacity for guest/host ethernet frames.
60pub const DEFAULT_FRAME_QUEUE_CAPACITY: usize = 1024;
61
62/// Shared queues and wake handles for the host-side virtio-net runtime.
63///
64/// One `NetworkFrameQueues` is shared across all helper threads for a single
65/// guest NIC.
66///
67/// A useful mental model is:
68///
69/// ```text
70/// queues  = ownership transfer for frame bytes
71/// wakes   = "go look at the queue now"
72/// shutdown= sticky flag + wake all blocked waiters
73/// ```
74pub struct NetworkFrameQueues {
75    /// Raw ethernet frames emitted by the guest and waiting for smoltcp.
76    pub guest_to_host: ArrayQueue<Vec<u8>>,
77    /// Raw ethernet frames emitted by smoltcp and waiting for libkrun.
78    pub host_to_guest: ArrayQueue<Vec<u8>>,
79    /// Wake the smoltcp poll loop when a guest frame arrives.
80    pub guest_wake: WakePipe,
81    /// Wake the libkrun writer thread when a host frame is ready.
82    pub host_wake: WakePipe,
83    /// Wake the smoltcp poll loop when a TCP relay thread has new data.
84    pub relay_wake: WakePipe,
85    /// Signals that the helper process should shut down.
86    shutting_down: AtomicBool,
87    /// Cumulative guest-outbound (egress) bytes for this NIC since boot, at the
88    /// ethernet-frame level — every guest frame accepted into the stack is
89    /// counted. Used for per-machine egress billing/telemetry. Held behind an
90    /// `Arc` so the runtime owner can hand a cheap read handle to a flush thread
91    /// without exposing the rest of the queue set.
92    egress_bytes: Arc<AtomicU64>,
93}
94
95impl NetworkFrameQueues {
96    /// Create a new shared queue set wrapped in `Arc`.
97    pub fn shared(capacity: usize) -> Arc<Self> {
98        Arc::new(Self {
99            guest_to_host: ArrayQueue::new(capacity),
100            host_to_guest: ArrayQueue::new(capacity),
101            guest_wake: WakePipe::new(),
102            host_wake: WakePipe::new(),
103            relay_wake: WakePipe::new(),
104            shutting_down: AtomicBool::new(false),
105            egress_bytes: Arc::new(AtomicU64::new(0)),
106        })
107    }
108
109    /// Add `n` guest-outbound bytes to the egress counter. Relaxed ordering is
110    /// fine: the counter is a monotonic statistic, not a synchronization point.
111    pub fn add_egress_bytes(&self, n: u64) {
112        self.egress_bytes.fetch_add(n, Ordering::Relaxed);
113    }
114
115    /// Cumulative guest-outbound bytes for this NIC since boot.
116    pub fn egress_bytes(&self) -> u64 {
117        self.egress_bytes.load(Ordering::Relaxed)
118    }
119
120    /// A cheap, cloneable read handle to the egress counter, for a flush thread
121    /// owned by the launcher (the runtime itself is not `Clone`).
122    pub fn egress_counter(&self) -> Arc<AtomicU64> {
123        self.egress_bytes.clone()
124    }
125
126    /// Mark the runtime as shutting down and wake all waiters.
127    ///
128    /// The wakes are part of shutdown correctness. Without them, a thread
129    /// blocked in `poll(2)` could sleep indefinitely even though the shutdown
130    /// flag was already set.
131    pub fn begin_shutdown(&self) {
132        self.shutting_down.store(true, Ordering::SeqCst);
133        self.guest_wake.wake();
134        self.host_wake.wake();
135        self.relay_wake.wake();
136    }
137
138    /// Whether shutdown has been requested.
139    pub fn is_shutting_down(&self) -> bool {
140        self.shutting_down.load(Ordering::SeqCst)
141    }
142}
143
144/// Wake notification built on `pipe(2)`.
145///
146/// The pattern is:
147/// - one thread blocks on the read end with `poll(2)`
148/// - another thread writes one byte to the write end to signal "work exists"
149/// - the waiter drains pending bytes before going back to sleep
150///
151/// Why use a pipe here:
152/// - it gives us a real file descriptor that integrates with `poll(2)`
153/// - it works on the Unix platforms smolvm targets
154/// - it is simpler than building a custom condvar + timeout scheme around the
155///   smoltcp loop and Unix-stream writer
156#[derive(Debug)]
157pub struct WakePipe {
158    read_fd: OwnedFd,
159    write_fd: OwnedFd,
160}
161
162impl WakePipe {
163    /// Create a non-blocking wake pipe.
164    ///
165    /// Low-level steps:
166    ///
167    /// ```text
168    /// pipe()               -> create read/write fds
169    /// fcntl(F_SETFL)       -> add O_NONBLOCK
170    /// fcntl(F_SETFD)       -> add FD_CLOEXEC
171    /// wrap in OwnedFd      -> move fd lifetime into Rust ownership
172    /// ```
173    pub fn new() -> Self {
174        let mut fds = [0i32; 2];
175
176        // SAFETY: `pipe` initializes both file descriptors on success.
177        let result = unsafe { libc::pipe(fds.as_mut_ptr()) };
178        assert_eq!(
179            result,
180            0,
181            "pipe() failed: {}",
182            std::io::Error::last_os_error()
183        );
184
185        // SAFETY: both descriptors are valid after a successful `pipe`.
186        unsafe {
187            set_nonblock_cloexec(fds[0]);
188            set_nonblock_cloexec(fds[1]);
189        }
190
191        Self {
192            // SAFETY: ownership of the raw file descriptors transfers here.
193            read_fd: unsafe { OwnedFd::from_raw_fd(fds[0]) },
194            write_fd: unsafe { OwnedFd::from_raw_fd(fds[1]) },
195        }
196    }
197
198    /// Signal the waiting side.
199    ///
200    /// Writing one byte is enough. The byte value itself does not matter; only
201    /// readability of the pipe matters. Multiple writes coalesce naturally into
202    /// "there is pending wake state".
203    pub fn wake(&self) {
204        let byte = [1u8; 1];
205        // SAFETY: the write end is valid and non-blocking.
206        unsafe {
207            libc::write(self.write_fd.as_raw_fd(), byte.as_ptr().cast(), byte.len());
208        }
209    }
210
211    /// Drain all pending wake bytes.
212    ///
213    /// This resets the readiness state after a wake. Because the pipe is
214    /// non-blocking, `read <= 0` means "nothing more to drain right now".
215    pub fn drain(&self) {
216        let mut buf = [0u8; 256];
217        loop {
218            // SAFETY: the read end is valid and non-blocking.
219            let read =
220                unsafe { libc::read(self.read_fd.as_raw_fd(), buf.as_mut_ptr().cast(), buf.len()) };
221            if read <= 0 {
222                break;
223            }
224        }
225    }
226
227    /// Wait until the pipe is readable or the timeout elapses.
228    ///
229    /// This is the low-level equivalent of "sleep until another thread signals
230    /// me or the timeout expires", but implemented in file-descriptor space so
231    /// it composes with other polling logic.
232    pub fn wait(&self, timeout: Option<Duration>) -> std::io::Result<bool> {
233        let timeout_ms = timeout
234            .map(|duration| duration.as_millis().min(i32::MAX as u128) as i32)
235            .unwrap_or(-1);
236        let mut pollfd = libc::pollfd {
237            fd: self.read_fd.as_raw_fd(),
238            events: libc::POLLIN,
239            revents: 0,
240        };
241
242        // SAFETY: `pollfd` points to a valid descriptor and struct.
243        let result = unsafe { libc::poll(&mut pollfd, 1, timeout_ms) };
244        if result < 0 {
245            return Err(std::io::Error::last_os_error());
246        }
247
248        Ok(result > 0 && pollfd.revents & libc::POLLIN != 0)
249    }
250
251    /// File descriptor for `poll(2)`.
252    ///
253    /// Callers should treat this as a borrowed readiness handle, not as an fd
254    /// they own or may close.
255    pub fn as_raw_fd(&self) -> RawFd {
256        self.read_fd.as_raw_fd()
257    }
258}
259
260impl Clone for WakePipe {
261    /// Clone by duplicating both file descriptors.
262    ///
263    /// Each clone refers to the same underlying pipe objects, so waking or
264    /// draining from any clone affects the shared readiness state.
265    fn clone(&self) -> Self {
266        let read_fd = self
267            .read_fd
268            .try_clone()
269            .expect("wake pipe read fd should be clonable");
270        let write_fd = self
271            .write_fd
272            .try_clone()
273            .expect("wake pipe write fd should be clonable");
274        Self { read_fd, write_fd }
275    }
276}
277
278impl Default for WakePipe {
279    fn default() -> Self {
280        Self::new()
281    }
282}
283
284/// Set `O_NONBLOCK` and `FD_CLOEXEC` on a file descriptor.
285///
286/// # Safety
287///
288/// `fd` must be a valid open file descriptor.
289///
290/// Why these flags matter:
291/// - `O_NONBLOCK`: wake helpers should never hang the runtime on a read/write
292///   path that is supposed to be just a signal
293/// - `FD_CLOEXEC`: if smolvm later `exec`s another process, these internal
294///   coordination fds should not leak into that child
295unsafe fn set_nonblock_cloexec(fd: RawFd) {
296    // SAFETY: caller guarantees `fd` is valid.
297    let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
298    assert!(
299        flags >= 0,
300        "fcntl(F_GETFL) failed: {}",
301        std::io::Error::last_os_error()
302    );
303    // SAFETY: caller guarantees `fd` is valid.
304    let result = unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) };
305    assert!(
306        result >= 0,
307        "fcntl(F_SETFL) failed: {}",
308        std::io::Error::last_os_error()
309    );
310
311    // SAFETY: caller guarantees `fd` is valid.
312    let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
313    assert!(
314        flags >= 0,
315        "fcntl(F_GETFD) failed: {}",
316        std::io::Error::last_os_error()
317    );
318    // SAFETY: caller guarantees `fd` is valid.
319    let result = unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) };
320    assert!(
321        result >= 0,
322        "fcntl(F_SETFD) failed: {}",
323        std::io::Error::last_os_error()
324    );
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330
331    #[test]
332    fn wake_pipe_round_trip() {
333        let pipe = WakePipe::new();
334        pipe.wake();
335        assert!(pipe.wait(Some(Duration::from_millis(10))).unwrap());
336        pipe.drain();
337        assert!(!pipe.wait(Some(Duration::from_millis(1))).unwrap());
338    }
339
340    #[test]
341    fn queues_are_fifo() {
342        let queues = NetworkFrameQueues::shared(4);
343        queues.guest_to_host.push(vec![1, 2, 3]).unwrap();
344        queues.guest_to_host.push(vec![4, 5, 6]).unwrap();
345
346        assert_eq!(queues.guest_to_host.pop(), Some(vec![1, 2, 3]));
347        assert_eq!(queues.guest_to_host.pop(), Some(vec![4, 5, 6]));
348        assert_eq!(queues.guest_to_host.pop(), None);
349    }
350}