smolvm_network/device.rs
1//! smoltcp `phy::Device` adapter for the virtio-net backend.
2//!
3//! Context
4//! =======
5//!
6//! smoltcp talks to an implementation of its `phy::Device` trait:
7//! - `receive()` yields one incoming frame and a transmit token
8//! - `transmit()` yields a transmit token when space exists for an outgoing
9//! frame
10//! - `capabilities()` describes medium and MTU
11//!
12//! This module is the narrow adapter layer between:
13//! - smoltcp's abstract device API
14//! - the queue-based frame transport used by the rest of the virtio runtime
15//!
16//! Data flow:
17//!
18//! ```text
19//! guest_to_host queue --stage_next_frame--> smoltcp receive()
20//! smoltcp transmit() --host_to_guest queue--> frame_stream writer
21//! ```
22//!
23//! More concretely:
24//!
25//! ```text
26//! guest frame arrives in guest_to_host
27//! -> poll loop calls stage_next_frame()
28//! -> poll loop may inspect/classify frame first
29//! -> smoltcp calls receive()
30//! -> DeviceRxToken hands bytes to smoltcp
31//!
32//! smoltcp wants to emit a frame
33//! -> calls transmit()
34//! -> gets DeviceTxToken
35//! -> fills provided buffer
36//! -> token pushes frame into host_to_guest
37//! -> poll loop later wakes frame writer
38//! ```
39
40use crate::queues::NetworkFrameQueues;
41use smoltcp::phy::{self, DeviceCapabilities, Medium};
42use smoltcp::time::Instant;
43use std::sync::atomic::{AtomicBool, Ordering};
44use std::sync::Arc;
45
46/// smoltcp `Device` backed by shared frame queues.
47///
48/// `staged_guest_frame` exists because the poll loop sometimes needs to inspect
49/// a frame before handing it to smoltcp. In particular, the stack wants to
50/// classify guest TCP SYN and DNS packets before consumption so it can prepare
51/// relay/socket state.
52///
53/// The staging pattern looks like:
54///
55/// ```text
56/// queue -> staged_guest_frame -> RxToken -> smoltcp
57/// ```
58pub struct VirtioNetworkDevice {
59 queues: Arc<NetworkFrameQueues>,
60 mtu: usize,
61 staged_guest_frame: Option<Vec<u8>>,
62 /// Set when smoltcp emitted at least one frame for the guest.
63 pub(crate) frames_emitted: AtomicBool,
64}
65
66/// RX token representing one guest ethernet frame.
67///
68/// smoltcp consumes RX tokens immediately; the token just owns the frame bytes
69/// until the stack asks to inspect them.
70pub struct DeviceRxToken {
71 frame: Vec<u8>,
72}
73
74/// TX token representing one outgoing frame from smoltcp.
75///
76/// The token borrows the device so it can enqueue the produced frame when
77/// smoltcp finishes writing into the provided buffer.
78pub struct DeviceTxToken<'a> {
79 device: &'a mut VirtioNetworkDevice,
80}
81
82impl VirtioNetworkDevice {
83 /// Create a new device for the given queues and MTU.
84 ///
85 /// `mtu` here is the guest IP MTU, not the full Ethernet frame size.
86 /// `capabilities()` translates it to the Ethernet-frame convention expected
87 /// by smoltcp.
88 pub fn new(queues: Arc<NetworkFrameQueues>, mtu: usize) -> Self {
89 Self {
90 queues,
91 mtu,
92 staged_guest_frame: None,
93 frames_emitted: AtomicBool::new(false),
94 }
95 }
96
97 /// Stage one guest frame so the poll loop can inspect it before smoltcp consumes it.
98 ///
99 /// Why staging exists:
100 /// - the frame arrives first in `guest_to_host`
101 /// - the poll loop may need to classify it before calling
102 /// `Interface::poll_ingress_single`
103 /// - once smoltcp calls `receive()`, ownership moves into an RX token
104 ///
105 /// So staging gives the poll loop a temporary peek at the next frame
106 /// without losing the normal smoltcp `Device` flow.
107 ///
108 /// This is the key reason the adapter is not just a direct `queue.pop()`
109 /// inside `receive()`.
110 pub fn stage_next_frame(&mut self) -> Option<&[u8]> {
111 if self.staged_guest_frame.is_none() {
112 self.staged_guest_frame = self.queues.guest_to_host.pop();
113 }
114
115 self.staged_guest_frame.as_deref()
116 }
117
118 /// Drop the currently staged guest frame.
119 ///
120 /// This is used when the poll loop decides not to pass a frame into
121 /// smoltcp, for example when the MVP intentionally drops unsupported UDP.
122 pub fn drop_staged_frame(&mut self) {
123 self.staged_guest_frame = None;
124 }
125}
126
127impl phy::Device for VirtioNetworkDevice {
128 type RxToken<'a> = DeviceRxToken;
129 type TxToken<'a> = DeviceTxToken<'a>;
130
131 fn receive(&mut self, _timestamp: Instant) -> Option<(Self::RxToken<'_>, Self::TxToken<'_>)> {
132 // smoltcp asks for the next ingress frame only after the poll loop has
133 // already staged it. If nothing is staged, there is nothing to receive.
134 let frame = self.staged_guest_frame.take()?;
135 // This is the single point where a guest-outbound frame is accepted into
136 // the stack (frames the poll loop dropped never reach here), so it's the
137 // natural place to meter egress. Count the full ethernet frame.
138 self.queues.add_egress_bytes(frame.len() as u64);
139 Some((DeviceRxToken { frame }, DeviceTxToken { device: self }))
140 }
141
142 fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
143 // smoltcp may ask for a transmit token even when the downstream writer
144 // is temporarily behind. We only hand out a token if the host->guest
145 // queue still has room.
146 if self.queues.host_to_guest.len() < self.queues.host_to_guest.capacity() {
147 Some(DeviceTxToken { device: self })
148 } else {
149 None
150 }
151 }
152
153 fn capabilities(&self) -> DeviceCapabilities {
154 let mut capabilities = DeviceCapabilities::default();
155 capabilities.medium = Medium::Ethernet;
156 // smoltcp wants the maximum Ethernet frame size here, not the Linux IP
157 // MTU. For Ethernet devices that means "IP MTU + 14-byte Ethernet
158 // header"; see smoltcp's `DeviceCapabilities::max_transmission_unit`
159 // documentation.
160 capabilities.max_transmission_unit = self.mtu + 14;
161 capabilities
162 }
163}
164
165impl phy::RxToken for DeviceRxToken {
166 /// Hand the queued guest frame bytes to smoltcp.
167 fn consume<R, F>(self, f: F) -> R
168 where
169 F: FnOnce(&[u8]) -> R,
170 {
171 f(&self.frame)
172 }
173}
174
175impl<'a> phy::TxToken for DeviceTxToken<'a> {
176 /// Let smoltcp build one Ethernet frame and enqueue it for libkrun.
177 ///
178 /// Flow:
179 ///
180 /// ```text
181 /// smoltcp fills provided buffer
182 /// -> adapter enqueues frame into host_to_guest
183 /// -> sets frames_emitted
184 /// -> poll loop later wakes the Unix-stream writer
185 /// ```
186 ///
187 /// The queue push is the handoff point. After that, this adapter no longer
188 /// owns the frame bytes; the frame writer thread eventually serializes them
189 /// onto the libkrun Unix stream.
190 fn consume<R, F>(self, len: usize, f: F) -> R
191 where
192 F: FnOnce(&mut [u8]) -> R,
193 {
194 let mut frame = vec![0u8; len];
195 let result = f(&mut frame);
196 if self.device.queues.host_to_guest.push(frame).is_ok() {
197 self.device.frames_emitted.store(true, Ordering::Relaxed);
198 } else {
199 tracing::debug!("dropping outbound ethernet frame because the guest queue is full");
200 }
201 result
202 }
203}
204
205#[cfg(test)]
206mod tests {
207 use super::*;
208 use crate::queues::DEFAULT_FRAME_QUEUE_CAPACITY;
209 use smoltcp::phy::{Device, RxToken};
210
211 #[test]
212 fn receive_counts_guest_outbound_bytes_as_egress() {
213 let queues = NetworkFrameQueues::shared(DEFAULT_FRAME_QUEUE_CAPACITY);
214 let mut dev = VirtioNetworkDevice::new(queues.clone(), 1500);
215 assert_eq!(queues.egress_bytes(), 0, "starts at zero");
216
217 // A guest frame must be staged before smoltcp's receive() can take it.
218 queues.guest_to_host.push(vec![0u8; 100]).unwrap();
219 assert!(dev.stage_next_frame().is_some());
220 let (rx, _tx) = dev.receive(Instant::ZERO).expect("frame available");
221 rx.consume(|f| assert_eq!(f.len(), 100));
222 assert_eq!(queues.egress_bytes(), 100, "one frame metered");
223
224 // A second frame accumulates.
225 queues.guest_to_host.push(vec![0u8; 40]).unwrap();
226 assert!(dev.stage_next_frame().is_some());
227 let (rx, _tx) = dev.receive(Instant::ZERO).expect("frame available");
228 rx.consume(|_| {});
229 assert_eq!(queues.egress_bytes(), 140, "cumulative");
230 }
231
232 #[test]
233 fn dropped_staged_frame_is_not_metered() {
234 let queues = NetworkFrameQueues::shared(DEFAULT_FRAME_QUEUE_CAPACITY);
235 let mut dev = VirtioNetworkDevice::new(queues.clone(), 1500);
236 queues.guest_to_host.push(vec![0u8; 100]).unwrap();
237 assert!(dev.stage_next_frame().is_some());
238 // The poll loop drops frames it won't forward (e.g. unsupported UDP);
239 // those never reach the stack, so they must not bill egress.
240 dev.drop_staged_frame();
241 assert_eq!(queues.egress_bytes(), 0, "dropped frame not metered");
242 }
243}