Skip to main content

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}