Skip to main content

smolvm_network/
tcp_relay.rs

1//! TCP relay support for the virtio-net backend.
2//!
3//! Context
4//! =======
5//!
6//! In the Phase 1 virtio-net design, guest TCP does not flow directly from the
7//! guest to the outside network through the host kernel. Instead, the host-side
8//! smoltcp runtime terminates the guest-visible TCP connection in userspace and
9//! relays payloads to a normal host `TcpStream`.
10//!
11//! Conceptually:
12//!
13//! ```text
14//! guest app
15//!   -> guest kernel TCP
16//!   -> Ethernet frame
17//!   -> smoltcp TCP socket (inside smolvm)
18//!   -> channel
19//!   -> host TcpStream
20//!   -> remote server
21//! ```
22//!
23//! That means:
24//! - the host runtime can observe every guest TCP byte stream on this NIC
25//! - smoltcp owns the guest-facing TCP state machine
26//! - the relay thread owns the host-facing TCP socket
27//! - channels bridge payloads between them
28
29use crate::egress::EgressPolicy;
30use crate::queues::WakePipe;
31use crate::virtio_net_log;
32use smoltcp::iface::{Interface, SocketHandle, SocketSet};
33use smoltcp::socket::tcp;
34use smoltcp::wire::IpListenEndpoint;
35use std::collections::{HashMap, HashSet};
36use std::io::{self, Read, Write};
37use std::net::{Ipv4Addr, Shutdown, SocketAddr, TcpStream};
38use std::sync::atomic::{AtomicU8, Ordering};
39use std::sync::mpsc::{self, Receiver, SyncSender, TryRecvError, TrySendError};
40use std::sync::Arc;
41use std::thread;
42use std::time::Duration;
43
44const TCP_RX_BUFFER_BYTES: usize = 64 * 1024;
45const TCP_TX_BUFFER_BYTES: usize = 64 * 1024;
46const MAX_CONNECTIONS: usize = 256;
47const CHANNEL_CAPACITY: usize = 32;
48const RELAY_BUFFER_BYTES: usize = 16 * 1024;
49const CLOSE_RETRY_LIMIT: u16 = 64;
50const PROXY_IDLE_SLEEP: Duration = Duration::from_millis(10);
51const PUBLISHED_PORT_START: u16 = 49_152;
52const PUBLISHED_PORT_END: u16 = 65_535;
53
54/// Track all active guest TCP connections bridged through host sockets.
55///
56/// One entry corresponds to one `(guest source, destination)` tuple. The table
57/// lives in the smoltcp poll thread and owns all guest-facing socket handles.
58pub struct TcpRelayTable {
59    connections: HashMap<SocketHandle, TrackedConnection>,
60    connection_keys: HashSet<(SocketAddr, SocketAddr)>,
61    used_published_ports: HashSet<u16>,
62    next_published_port: u16,
63    max_connections: usize,
64    /// Outbound allow-list applied before opening a host connection for a
65    /// guest-initiated flow. Inbound published-port connections bypass it.
66    egress: EgressPolicy,
67}
68
69/// Newly established guest connection ready for a host relay thread.
70///
71/// The poll loop emits these once the guest-side smoltcp socket reaches
72/// `Established`. At that point we can safely create the host-side relay
73/// thread and give it channel endpoints for payload exchange.
74pub struct NewTcpConnection {
75    /// Destination originally requested by the guest.
76    pub destination: SocketAddr,
77    /// How the host-side relay should be started.
78    pub relay_target: RelayTarget,
79    /// Guest-to-host payloads read from the smoltcp socket.
80    pub from_smoltcp: Receiver<Vec<u8>>,
81    /// Host-to-guest payloads written back into the smoltcp socket.
82    pub to_smoltcp: SyncSender<Vec<u8>>,
83    /// Shared relay exit state.
84    pub exit_state: RelayExitState,
85}
86
87#[derive(Debug)]
88struct TrackedConnection {
89    // `source` and `destination` identify the guest-side flow.
90    source: SocketAddr,
91    destination: SocketAddr,
92    // guest -> host relay payloads
93    to_proxy: SyncSender<Vec<u8>>,
94    // host -> guest relay payloads
95    from_proxy: Receiver<Vec<u8>>,
96    // endpoints are held here until the guest-side handshake completes
97    pending_proxy_endpoints: Option<PendingProxyEndpoints>,
98    // once true, a dedicated host relay thread exists
99    relay_spawned: bool,
100    // partial guest->host payload already consumed from smoltcp but not yet
101    // accepted by the relay thread channel
102    buffered_guest_data: Option<Vec<u8>>,
103    // partial host->guest payload not yet fully accepted by smoltcp
104    buffered_proxy_data: Option<(Vec<u8>, usize)>,
105    // bounded retry count for closing with unsent buffered data
106    close_attempts: u16,
107    // set once we've sent FIN to the guest after the host half-closed, so the
108    // HalfClosed handling closes the guest send-half exactly once
109    guest_send_closed: bool,
110    // relay thread termination mode observed by the poll loop
111    exit_state: RelayExitState,
112    // reserved local source port for published inbound connections
113    reserved_published_port: Option<u16>,
114}
115
116#[derive(Debug)]
117struct PendingProxyEndpoints {
118    from_smoltcp: Receiver<Vec<u8>>,
119    to_smoltcp: SyncSender<Vec<u8>>,
120    relay_target: RelayTarget,
121}
122
123/// How a host-side TCP relay should obtain its remote socket.
124#[derive(Debug)]
125pub enum RelayTarget {
126    /// Open a new outbound host `TcpStream` to the destination.
127    Connect(SocketAddr),
128    /// Use an already-accepted host `TcpStream` from a published port listener.
129    Attached(TcpStream),
130}
131
132/// Host relay termination state shared between the poll loop and the relay thread.
133///
134/// The relay thread cannot mutate smoltcp sockets directly because those sockets
135/// are owned by the poll loop thread. Instead it reports how it finished, and
136/// the poll loop interprets that into guest-side socket actions:
137/// - `Graceful`   -> close guest socket cleanly
138/// - `HalfClosed` -> host closed its send half; send FIN to the guest but keep
139///   the guest socket open so guest->host output still drains
140/// - `Abort`      -> abort/reset guest socket
141#[derive(Clone, Debug)]
142pub struct RelayExitState {
143    inner: Arc<AtomicU8>,
144}
145
146/// How a host TCP relay thread terminated.
147#[derive(Clone, Copy, Debug, PartialEq, Eq)]
148#[repr(u8)]
149pub enum RelayExitMode {
150    /// Relay thread is still running.
151    Running = 0,
152    /// Remote side closed normally; send FIN toward the guest.
153    Graceful = 1,
154    /// Remote connect or I/O failed; abort the guest TCP socket.
155    Abort = 2,
156    /// Host closed its send half (host-read EOF) but the guest may still be
157    /// sending. Mirror the FIN toward the guest and keep pumping guest->host
158    /// until the guest closes too. Without this a hijacked-attach response is
159    /// dropped: the docker CLI half-closes (`shutdown(SHUT_WR)`) after
160    /// `101 UPGRADED` when it has no stdin, while the daemon is still streaming.
161    HalfClosed = 3,
162}
163
164impl RelayExitState {
165    fn new() -> Self {
166        Self {
167            inner: Arc::new(AtomicU8::new(RelayExitMode::Running as u8)),
168        }
169    }
170
171    fn load(&self) -> RelayExitMode {
172        match self.inner.load(Ordering::Relaxed) {
173            1 => RelayExitMode::Graceful,
174            2 => RelayExitMode::Abort,
175            3 => RelayExitMode::HalfClosed,
176            _ => RelayExitMode::Running,
177        }
178    }
179
180    fn store(&self, mode: RelayExitMode) {
181        self.inner.store(mode as u8, Ordering::Relaxed);
182    }
183}
184
185impl TcpRelayTable {
186    /// Create a new relay table.
187    pub fn new(max_connections: Option<usize>, egress: EgressPolicy) -> Self {
188        Self {
189            connections: HashMap::new(),
190            connection_keys: HashSet::new(),
191            used_published_ports: HashSet::new(),
192            next_published_port: PUBLISHED_PORT_START,
193            max_connections: max_connections.unwrap_or(MAX_CONNECTIONS),
194            egress,
195        }
196    }
197
198    /// Whether a relay socket already exists for the same guest source and destination.
199    pub fn has_socket_for(&self, source: &SocketAddr, destination: &SocketAddr) -> bool {
200        self.connection_keys.contains(&(*source, *destination))
201    }
202
203    /// Create a smoltcp TCP socket for a guest SYN.
204    ///
205    /// Why this happens before full ingress processing:
206    /// - when the first guest SYN arrives, smoltcp needs a matching socket to
207    ///   receive it
208    /// - the poll loop therefore pre-creates a listening socket keyed to the
209    ///   destination the guest is trying to reach
210    /// - only after the guest-facing connection reaches `Established` do we
211    ///   spawn the host relay thread
212    ///
213    /// Data path after creation:
214    ///
215    /// ```text
216    /// smoltcp socket --to_proxy channel--> host relay thread
217    /// host relay thread --from_proxy channel--> smoltcp socket
218    /// ```
219    pub fn create_tcp_socket(
220        &mut self,
221        source: SocketAddr,
222        destination: SocketAddr,
223        sockets: &mut SocketSet<'_>,
224    ) -> bool {
225        if self.connections.len() >= self.max_connections {
226            tracing::warn!("dropping TCP connection because the relay table is full");
227            return false;
228        }
229
230        // Egress policy: drop the guest SYN before any socket is created when the
231        // destination isn't allowed, so the guest just sees the connection fail.
232        // Inbound published-port flows take a separate path and are unaffected.
233        if !self.egress.allows(destination.ip()) {
234            tracing::debug!(
235                %destination,
236                "virtio-net: blocking outbound connection by egress policy"
237            );
238            return false;
239        }
240
241        let rx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUFFER_BYTES]);
242        let tx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUFFER_BYTES]);
243        let mut socket = tcp::Socket::new(rx_buffer, tx_buffer);
244
245        let listen_endpoint = IpListenEndpoint {
246            addr: Some(destination.ip().into()),
247            port: destination.port(),
248        };
249        if socket.listen(listen_endpoint).is_err() {
250            return false;
251        }
252
253        let handle = sockets.add(socket);
254
255        let (to_proxy_tx, to_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
256        let (from_proxy_tx, from_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
257        let exit_state = RelayExitState::new();
258
259        self.connection_keys.insert((source, destination));
260        self.connections.insert(
261            handle,
262            TrackedConnection {
263                source,
264                destination,
265                to_proxy: to_proxy_tx,
266                from_proxy: from_proxy_rx,
267                pending_proxy_endpoints: Some(PendingProxyEndpoints {
268                    from_smoltcp: to_proxy_rx,
269                    to_smoltcp: from_proxy_tx,
270                    relay_target: RelayTarget::Connect(destination),
271                }),
272                relay_spawned: false,
273                buffered_guest_data: None,
274                buffered_proxy_data: None,
275                close_attempts: 0,
276                guest_send_closed: false,
277                exit_state,
278                reserved_published_port: None,
279            },
280        );
281
282        true
283    }
284
285    /// Create a guest-facing TCP connection for a published host socket.
286    ///
287    /// This is the host->guest mirror of `create_tcp_socket`:
288    ///
289    /// ```text
290    /// host client connects to published port
291    ///   -> host listener accepts TcpStream
292    ///   -> poll loop creates smoltcp TCP socket from gateway_ip:ephemeral
293    ///      to guest_ip:guest_port
294    ///   -> guest kernel sees a normal inbound TCP connection on guest_port
295    /// ```
296    ///
297    /// The guest-visible source address is the gateway IP, not the original
298    /// host peer address. That keeps the first version simple and matches the
299    /// fact that this runtime is acting as a userspace gateway/proxy.
300    pub fn create_published_socket(
301        &mut self,
302        interface: &mut Interface,
303        gateway_ip: Ipv4Addr,
304        destination: SocketAddr,
305        host_stream: TcpStream,
306        sockets: &mut SocketSet<'_>,
307    ) -> bool {
308        if self.connections.len() >= self.max_connections {
309            tracing::warn!("dropping published TCP connection because the relay table is full");
310            return false;
311        }
312
313        let Some(local_port) = self.allocate_published_port() else {
314            tracing::warn!(
315                "dropping published TCP connection because no gateway source port is available"
316            );
317            return false;
318        };
319
320        // Inbound published connections always target the guest's IPv4 on the
321        // internal link (the host listener family is independent of this).
322        let std::net::IpAddr::V4(destination_ip) = destination.ip() else {
323            self.used_published_ports.remove(&local_port);
324            return false;
325        };
326
327        let rx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_RX_BUFFER_BYTES]);
328        let tx_buffer = tcp::SocketBuffer::new(vec![0u8; TCP_TX_BUFFER_BYTES]);
329        let mut socket = tcp::Socket::new(rx_buffer, tx_buffer);
330        let local_endpoint = IpListenEndpoint {
331            addr: Some(gateway_ip.into()),
332            port: local_port,
333        };
334        if socket
335            .connect(
336                interface.context(),
337                (destination_ip, destination.port()),
338                local_endpoint,
339            )
340            .is_err()
341        {
342            self.used_published_ports.remove(&local_port);
343            return false;
344        }
345
346        let handle = sockets.add(socket);
347        let source = SocketAddr::new(std::net::IpAddr::V4(gateway_ip), local_port);
348
349        let (to_proxy_tx, to_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
350        let (from_proxy_tx, from_proxy_rx) = mpsc::sync_channel(CHANNEL_CAPACITY);
351        let exit_state = RelayExitState::new();
352
353        self.connection_keys.insert((source, destination));
354        self.connections.insert(
355            handle,
356            TrackedConnection {
357                source,
358                destination,
359                to_proxy: to_proxy_tx,
360                from_proxy: from_proxy_rx,
361                pending_proxy_endpoints: Some(PendingProxyEndpoints {
362                    from_smoltcp: to_proxy_rx,
363                    to_smoltcp: from_proxy_tx,
364                    relay_target: RelayTarget::Attached(host_stream),
365                }),
366                relay_spawned: false,
367                buffered_guest_data: None,
368                buffered_proxy_data: None,
369                close_attempts: 0,
370                guest_send_closed: false,
371                exit_state,
372                reserved_published_port: Some(local_port),
373            },
374        );
375
376        true
377    }
378
379    /// Relay TCP payloads between smoltcp sockets and host relay threads.
380    ///
381    /// This runs in the poll thread. It is responsible for:
382    /// - draining bytes received from the guest-facing smoltcp socket and
383    ///   pushing them toward the host relay thread
384    /// - draining bytes received from the host relay thread and writing them
385    ///   back into the smoltcp socket
386    /// - interpreting relay exit state into guest-side `close()` or `abort()`
387    pub fn relay_data(&mut self, sockets: &mut SocketSet<'_>) {
388        let mut read_buffer = [0u8; RELAY_BUFFER_BYTES];
389
390        for (&handle, connection) in &mut self.connections {
391            if !connection.relay_spawned {
392                continue;
393            }
394
395            let socket = sockets.get_mut::<tcp::Socket>(handle);
396
397            match connection.exit_state.load() {
398                RelayExitMode::Abort => {
399                    socket.abort();
400                    continue;
401                }
402                RelayExitMode::Graceful => {
403                    flush_proxy_data(socket, connection);
404                    if connection.buffered_proxy_data.is_none() {
405                        socket.close();
406                    } else {
407                        connection.close_attempts += 1;
408                        if connection.close_attempts >= CLOSE_RETRY_LIMIT {
409                            socket.abort();
410                        }
411                    }
412                    continue;
413                }
414                RelayExitMode::HalfClosed => {
415                    // Host closed its send half: flush any remaining host->guest
416                    // bytes, then send FIN to the guest exactly once. Crucially
417                    // we do NOT `continue` — fall through to the guest->host
418                    // drain below so the guest's in-flight response still
419                    // reaches the host. The thread flips to Graceful once the
420                    // guest closes too, and the connection is torn down then.
421                    flush_proxy_data(socket, connection);
422                    if connection.buffered_proxy_data.is_none() && !connection.guest_send_closed {
423                        socket.close();
424                        connection.guest_send_closed = true;
425                    }
426                }
427                RelayExitMode::Running => {}
428            }
429
430            flush_guest_data(connection);
431            while connection.buffered_guest_data.is_none() && socket.can_recv() {
432                match socket.recv_slice(&mut read_buffer) {
433                    Ok(bytes_read) if bytes_read > 0 => {
434                        let payload = read_buffer[..bytes_read].to_vec();
435                        if !send_guest_payload(connection, payload) {
436                            break;
437                        }
438                    }
439                    _ => break,
440                }
441            }
442
443            flush_proxy_data(socket, connection);
444        }
445    }
446
447    /// Collect connections that reached ESTABLISHED and need a host relay thread.
448    ///
449    /// The separation between `create_tcp_socket` and this method is important:
450    /// the guest TCP handshake is accepted first on the smoltcp side, and only
451    /// once that succeeds do we commit to opening the host-side `TcpStream`.
452    pub fn take_new_connections(&mut self, sockets: &mut SocketSet<'_>) -> Vec<NewTcpConnection> {
453        let mut new_connections = Vec::new();
454
455        for (&handle, connection) in &mut self.connections {
456            if connection.relay_spawned {
457                continue;
458            }
459
460            let socket = sockets.get::<tcp::Socket>(handle);
461            if socket.state() == tcp::State::Established {
462                connection.relay_spawned = true;
463
464                if let Some(endpoints) = connection.pending_proxy_endpoints.take() {
465                    new_connections.push(NewTcpConnection {
466                        destination: connection.destination,
467                        relay_target: endpoints.relay_target,
468                        from_smoltcp: endpoints.from_smoltcp,
469                        to_smoltcp: endpoints.to_smoltcp,
470                        exit_state: connection.exit_state.clone(),
471                    });
472                }
473            }
474        }
475
476        new_connections
477    }
478
479    /// Remove closed sockets and drop their relay endpoints.
480    ///
481    /// This is the final ownership cleanup step for a guest TCP flow.
482    pub fn cleanup_closed(&mut self, sockets: &mut SocketSet<'_>) {
483        let keys = &mut self.connection_keys;
484        let published_ports = &mut self.used_published_ports;
485        self.connections.retain(|&handle, connection| {
486            let socket = sockets.get::<tcp::Socket>(handle);
487            if socket.state() == tcp::State::Closed {
488                keys.remove(&(connection.source, connection.destination));
489                if let Some(port) = connection.reserved_published_port {
490                    published_ports.remove(&port);
491                }
492                sockets.remove(handle);
493                false
494            } else {
495                true
496            }
497        });
498    }
499
500    fn allocate_published_port(&mut self) -> Option<u16> {
501        let start = self.next_published_port;
502
503        loop {
504            let candidate = self.next_published_port;
505            self.next_published_port = if candidate == PUBLISHED_PORT_END {
506                PUBLISHED_PORT_START
507            } else {
508                candidate + 1
509            };
510
511            if self.used_published_ports.insert(candidate) {
512                return Some(candidate);
513            }
514
515            if self.next_published_port == start {
516                return None;
517            }
518        }
519    }
520}
521
522/// Spawn one host TCP relay thread for an established guest connection.
523///
524/// Thread responsibilities:
525/// - connect a host `TcpStream` to the guest-requested destination
526/// - copy bytes guest->host from `from_smoltcp`
527/// - copy bytes host->guest into `to_smoltcp`
528/// - wake the poll loop when host->guest data arrives or guest->host backpressure eases
529/// - report termination mode through `exit_state`
530pub fn spawn_tcp_relay(
531    destination: SocketAddr,
532    relay_target: RelayTarget,
533    from_smoltcp: Receiver<Vec<u8>>,
534    to_smoltcp: SyncSender<Vec<u8>>,
535    relay_wake: Arc<WakePipe>,
536    exit_state: RelayExitState,
537) {
538    let thread_name = format!("smolvm-tcp-{}", destination.port());
539    virtio_net_log!(
540        "virtio-net: spawning host TCP relay thread destination={} thread={}",
541        destination,
542        thread_name
543    );
544    let _ = thread::Builder::new().name(thread_name).spawn(move || {
545        run_tcp_relay(
546            destination,
547            relay_target,
548            from_smoltcp,
549            to_smoltcp,
550            relay_wake,
551            exit_state,
552        )
553    });
554}
555
556fn run_tcp_relay(
557    destination: SocketAddr,
558    relay_target: RelayTarget,
559    from_smoltcp: Receiver<Vec<u8>>,
560    to_smoltcp: SyncSender<Vec<u8>>,
561    relay_wake: Arc<WakePipe>,
562    exit_state: RelayExitState,
563) {
564    // The relay thread is intentionally isolated from smoltcp internals. Its
565    // contract is just channels in, channels out, and an exit code back.
566    virtio_net_log!(
567        "virtio-net: host TCP relay thread started destination={}",
568        destination
569    );
570    match tcp_relay_loop(
571        destination,
572        relay_target,
573        from_smoltcp,
574        to_smoltcp,
575        relay_wake,
576        &exit_state,
577    ) {
578        Ok(mode) => {
579            virtio_net_log!(
580                "virtio-net: host TCP relay thread exited destination={} mode={:?}",
581                destination,
582                mode
583            );
584            exit_state.store(mode)
585        }
586        Err(err) => {
587            virtio_net_log!(
588                "virtio-net: host TCP relay failed destination={} error={}",
589                destination,
590                err
591            );
592            exit_state.store(RelayExitMode::Abort);
593        }
594    }
595}
596
597fn tcp_relay_loop(
598    destination: SocketAddr,
599    relay_target: RelayTarget,
600    from_smoltcp: Receiver<Vec<u8>>,
601    to_smoltcp: SyncSender<Vec<u8>>,
602    relay_wake: Arc<WakePipe>,
603    exit_state: &RelayExitState,
604) -> io::Result<RelayExitMode> {
605    // Host-side flow:
606    //
607    // 1. Connect a normal host TcpStream to the destination.
608    // 2. Non-blockingly drain guest payloads from the channel into the socket.
609    // 3. Non-blockingly read remote payloads from the socket into the channel.
610    // 4. If neither side made progress, sleep briefly to avoid a hot spin loop.
611    let mut stream = match relay_target {
612        RelayTarget::Connect(destination) => {
613            virtio_net_log!(
614                "virtio-net: connecting host TCP relay socket destination={}",
615                destination
616            );
617            let stream = TcpStream::connect(destination)?;
618            virtio_net_log!(
619                "virtio-net: host TCP relay socket connected destination={}",
620                destination
621            );
622            stream
623        }
624        RelayTarget::Attached(stream) => {
625            virtio_net_log!(
626                "virtio-net: using accepted host TCP socket for published port guest_destination={} peer_addr={:?} local_addr={:?}",
627                destination,
628                stream.peer_addr().ok(),
629                stream.local_addr().ok()
630            );
631            stream
632        }
633    };
634    stream.set_nonblocking(true)?;
635
636    let mut guest_write_closed = false;
637    let mut guest_channel_closed = false;
638    let mut host_read_closed = false;
639    let mut pending_guest_data: Option<(Vec<u8>, usize)> = None;
640    let mut read_buffer = [0u8; RELAY_BUFFER_BYTES];
641
642    loop {
643        let mut did_work = false;
644
645        if pending_guest_data.is_none() && !guest_channel_closed {
646            match from_smoltcp.try_recv() {
647                Ok(payload) => {
648                    pending_guest_data = Some((payload, 0));
649                    // Consuming from the bounded guest->host channel may free
650                    // capacity for a payload buffered in the smoltcp poll
651                    // thread. Wake it so backpressure clears promptly even for
652                    // one-way guest->host streams.
653                    relay_wake.wake();
654                    did_work = true;
655                }
656                Err(TryRecvError::Empty) => {}
657                Err(TryRecvError::Disconnected) => {
658                    guest_channel_closed = true;
659                }
660            }
661        }
662
663        if let Some((payload, offset)) = &mut pending_guest_data {
664            while *offset < payload.len() {
665                match stream.write(&payload[*offset..]) {
666                    Ok(0) => {
667                        return Err(io::Error::new(
668                            io::ErrorKind::WriteZero,
669                            "host TCP relay wrote zero bytes",
670                        ));
671                    }
672                    Ok(bytes_written) => {
673                        *offset += bytes_written;
674                        did_work = true;
675                    }
676                    Err(err) if err.kind() == io::ErrorKind::WouldBlock => break,
677                    Err(err) if err.kind() == io::ErrorKind::Interrupted => continue,
678                    Err(err) => return Err(err),
679                }
680            }
681
682            if *offset >= payload.len() {
683                pending_guest_data = None;
684            }
685        }
686
687        if guest_channel_closed && pending_guest_data.is_none() && !guest_write_closed {
688            // The guest side closed its write half. Mirror that toward the
689            // remote peer only after all buffered guest bytes were written.
690            let _ = stream.shutdown(Shutdown::Write);
691            guest_write_closed = true;
692        }
693
694        // Both directions are done — the host stopped sending (host_read_closed)
695        // and the guest stopped sending and was fully flushed. Finish with a
696        // clean close; the poll loop tears the guest socket down.
697        if host_read_closed && guest_channel_closed && pending_guest_data.is_none() {
698            return Ok(RelayExitMode::Graceful);
699        }
700
701        // host -> guest, only while the host's send half is still open. On host
702        // read-EOF we do NOT tear the relay down: signal HalfClosed so the poll
703        // loop mirrors the FIN to the guest, then keep draining guest->host. A
704        // hijacked docker attach half-closes here (`shutdown(SHUT_WR)` with no
705        // stdin) while the daemon is still streaming its response back.
706        if !host_read_closed {
707            match stream.read(&mut read_buffer) {
708                Ok(0) => {
709                    host_read_closed = true;
710                    exit_state.store(RelayExitMode::HalfClosed);
711                    relay_wake.wake();
712                    did_work = true;
713                }
714                Ok(bytes_read) => {
715                    if to_smoltcp.send(read_buffer[..bytes_read].to_vec()).is_err() {
716                        return Ok(RelayExitMode::Graceful);
717                    }
718                    relay_wake.wake();
719                    did_work = true;
720                }
721                Err(err) if err.kind() == io::ErrorKind::WouldBlock => {}
722                Err(err) => return Err(err),
723            }
724        }
725
726        if !did_work {
727            thread::sleep(PROXY_IDLE_SLEEP);
728        }
729    }
730}
731
732fn flush_guest_data(connection: &mut TrackedConnection) {
733    let Some(payload) = connection.buffered_guest_data.take() else {
734        return;
735    };
736    send_guest_payload(connection, payload);
737}
738
739fn send_guest_payload(connection: &mut TrackedConnection, payload: Vec<u8>) -> bool {
740    match connection.to_proxy.try_send(payload) {
741        Ok(()) => true,
742        Err(TrySendError::Full(payload)) => {
743            connection.buffered_guest_data = Some(payload);
744            false
745        }
746        Err(TrySendError::Disconnected(_)) => false,
747    }
748}
749
750fn flush_proxy_data(socket: &mut tcp::Socket<'_>, connection: &mut TrackedConnection) {
751    // smoltcp send windows may accept only part of an inbound host payload.
752    // `buffered_proxy_data` remembers the unwritten remainder so the next poll
753    // iteration can continue where it left off instead of dropping bytes.
754    if let Some((data, offset)) = &mut connection.buffered_proxy_data {
755        if socket.can_send() {
756            match socket.send_slice(&data[*offset..]) {
757                Ok(written) => {
758                    *offset += written;
759                    if *offset >= data.len() {
760                        connection.buffered_proxy_data = None;
761                    }
762                }
763                Err(_) => return,
764            }
765        } else {
766            return;
767        }
768    }
769
770    while connection.buffered_proxy_data.is_none() {
771        match connection.from_proxy.try_recv() {
772            Ok(payload) => {
773                if socket.can_send() {
774                    match socket.send_slice(&payload) {
775                        Ok(written) if written < payload.len() => {
776                            connection.buffered_proxy_data = Some((payload, written));
777                        }
778                        Err(_) => {
779                            connection.buffered_proxy_data = Some((payload, 0));
780                        }
781                        _ => {}
782                    }
783                } else {
784                    connection.buffered_proxy_data = Some((payload, 0));
785                }
786            }
787            Err(TryRecvError::Empty | TryRecvError::Disconnected) => break,
788        }
789    }
790}
791
792#[cfg(test)]
793mod tests {
794    use super::*;
795
796    fn test_connection(to_proxy: SyncSender<Vec<u8>>) -> TrackedConnection {
797        let (_from_proxy_tx, from_proxy) = mpsc::sync_channel(CHANNEL_CAPACITY);
798        TrackedConnection {
799            source: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 12_345),
800            destination: SocketAddr::new(Ipv4Addr::LOCALHOST.into(), 80),
801            to_proxy,
802            from_proxy,
803            pending_proxy_endpoints: None,
804            relay_spawned: true,
805            buffered_guest_data: None,
806            buffered_proxy_data: None,
807            close_attempts: 0,
808            guest_send_closed: false,
809            exit_state: RelayExitState::new(),
810            reserved_published_port: None,
811        }
812    }
813
814    #[test]
815    fn guest_payload_is_buffered_when_relay_channel_is_full() {
816        let (to_proxy, from_smoltcp) = mpsc::sync_channel(1);
817        to_proxy.send(vec![1]).unwrap();
818        let mut connection = test_connection(to_proxy);
819
820        assert!(!send_guest_payload(&mut connection, vec![2]));
821        assert_eq!(connection.buffered_guest_data.as_deref(), Some(&[2][..]));
822
823        assert_eq!(from_smoltcp.recv().unwrap(), vec![1]);
824        flush_guest_data(&mut connection);
825
826        assert!(connection.buffered_guest_data.is_none());
827        assert_eq!(from_smoltcp.recv().unwrap(), vec![2]);
828    }
829}