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}