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 Some((DeviceRxToken { frame }, DeviceTxToken { device: self }))
136 }
137
138 fn transmit(&mut self, _timestamp: Instant) -> Option<Self::TxToken<'_>> {
139 // smoltcp may ask for a transmit token even when the downstream writer
140 // is temporarily behind. We only hand out a token if the host->guest
141 // queue still has room.
142 if self.queues.host_to_guest.len() < self.queues.host_to_guest.capacity() {
143 Some(DeviceTxToken { device: self })
144 } else {
145 None
146 }
147 }
148
149 fn capabilities(&self) -> DeviceCapabilities {
150 let mut capabilities = DeviceCapabilities::default();
151 capabilities.medium = Medium::Ethernet;
152 // smoltcp wants the maximum Ethernet frame size here, not the Linux IP
153 // MTU. For Ethernet devices that means "IP MTU + 14-byte Ethernet
154 // header"; see smoltcp's `DeviceCapabilities::max_transmission_unit`
155 // documentation.
156 capabilities.max_transmission_unit = self.mtu + 14;
157 capabilities
158 }
159}
160
161impl phy::RxToken for DeviceRxToken {
162 /// Hand the queued guest frame bytes to smoltcp.
163 fn consume<R, F>(self, f: F) -> R
164 where
165 F: FnOnce(&[u8]) -> R,
166 {
167 f(&self.frame)
168 }
169}
170
171impl<'a> phy::TxToken for DeviceTxToken<'a> {
172 /// Let smoltcp build one Ethernet frame and enqueue it for libkrun.
173 ///
174 /// Flow:
175 ///
176 /// ```text
177 /// smoltcp fills provided buffer
178 /// -> adapter enqueues frame into host_to_guest
179 /// -> sets frames_emitted
180 /// -> poll loop later wakes the Unix-stream writer
181 /// ```
182 ///
183 /// The queue push is the handoff point. After that, this adapter no longer
184 /// owns the frame bytes; the frame writer thread eventually serializes them
185 /// onto the libkrun Unix stream.
186 fn consume<R, F>(self, len: usize, f: F) -> R
187 where
188 F: FnOnce(&mut [u8]) -> R,
189 {
190 let mut frame = vec![0u8; len];
191 let result = f(&mut frame);
192 if self.device.queues.host_to_guest.push(frame).is_ok() {
193 self.device.frames_emitted.store(true, Ordering::Relaxed);
194 } else {
195 tracing::debug!("dropping outbound ethernet frame because the guest queue is full");
196 }
197 result
198 }
199}