Skip to main content

dynamo_runtime/pipeline/network/
tcp.rs

1// SPDX-FileCopyrightText: Copyright (c) 2024-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4//! TCP Transport Module
5//!
6//! TODO: this design-and-implementation overview should eventually move into
7//! the architecture docs (`docs/design-docs/architecture.md`); kept here for
8//! now until there's a home for transport-level design notes.
9//!
10//! Brief overview of the request-response transport:
11//!
12//! The request plane (TCP, NATS, etc.) carries a two-part message whose header is a
13//! `RequestControlMessage` — embedding the [`ConnectionInfo`] that tells the worker where to call home
14//! — and whose data half is the serialized request body if request streaming is not needed.
15//! All subsequent streaming bytes (responses, and request-stream) flow over the TCP socket established afterwards.
16//!
17//! For simplicity, if request streaming is needed, the `RequestControlMessage` should not contain
18//! the request body. Instead, all requests of the stream should be sent over the TCP socket.
19//!
20//! The TCP transport is the implementation that produces and consumes [`ConnectionInfo`] and
21//! carries the streaming response (and, optionally, request-stream) bytes between two peers
22//! on separate sockets from the initial request.
23//!
24//! # Roles
25//!
26//! The TCP transport has two sides:
27//!
28//! - Request sender: The upstream that **initiates the transfer** runs [`server::TcpStreamServer`],
29//!   registers what it expects to receive, and listens. It publishes its address + a per-stream
30//!   subject UUID via [`TcpStreamConnectionInfo`], which is serialized into a [`ConnectionInfo`].
31//! - Request receiver: The downstream that **acknowledges the transfer** runs [`client::TcpClient`],
32//!   reads the connection info out of the request, dials the listener, and identifies itself with
33//!   a `CallHomeHandshake` to the request sender.
34//!
35//! Although TCP is bidirectional, we keep separate sockets for the request stream and the response
36//! stream to match Dynamo's design principles. To establish both, the request receiver must receive
37//! two [`ConnectionInfo`] objects and run two handshakes — each [`StreamType`] is its own TCP
38//! connection with its own subject UUID.
39//!
40//! # Server-Client Interaction
41//!
42//! See the test cases below for detailed examples. Note that the response stream expects the client
43//! to send a [`ResponseStreamPrologue`] in order to properly establish the stream.
44//!
45//! # Stream Types
46//!
47//! [`StreamType::Response`] — worker pushes engine output back to the upstream. Server side is
48//! `process_response_stream` (delivers a [`StreamReceiver`] to the awaiting registrant once the
49//! client has sent its [`ResponseStreamPrologue`]). Client side is
50//! [`client::TcpClient::create_response_stream`] (returns a [`StreamSender`]; spawns reader/writer
51//! tasks plus a connection monitor that waits for the server's FIN).
52//!
53//! [`StreamType::Request`] — upstream pushes the request body (or a stream of follow-up frames)
54//! into a downstream worker. Server side is `process_request_stream` (delivers a [`StreamSender`]
55//! immediately; there is no prologue today). Client side is
56//! [`client::TcpClient::create_request_stream`] (returns a [`StreamReceiver`]; spawns a single
57//! task that handles both directions on the socket).
58//!
59//! # Registration and Lifecycle
60//!
61//! [`ResponseService::register`] takes [`StreamOptions`] with `enable_request_stream` /
62//! `enable_response_stream` flags and returns [`PendingConnections`] holding zero, one, or two
63//! [`RegisteredStream`]s. Each [`RegisteredStream`] carries a [`ConnectionInfo`] and a oneshot
64//! that resolves to the [`StreamSender`] / [`StreamReceiver`] once the downstream dials in and the
65//! handshake completes. Once registered, the pending entry remains until the downstream successfully
66//! establishes the stream. Two mechanisms ensure the pending entry is removed when the downstream
67//! cannot be reached:
68//!
69//! 1. The returned [`RegisteredStream`] is RAII — dropping it without `into_parts()` removes the
70//!    pending entry from the server's subject tables. This is typically used by the request sender
71//!    up until the `RequestControlMessage` is sent and the stream is established.
72//! 2. The server tracks `subject UUID → oneshot` in `tx_subjects` / `rx_subjects`.
73//!    [`server::TcpStreamServer::associate_instance`] links one or both
74//!    subjects to a discovery instance so [`server::TcpStreamServer::cancel_instance_streams`] can
75//!    drop both halves' oneshots together when a worker disappears. Tombstones (`TOMBSTONE_TTL`)
76//!    are the safety net that closes the cancel-vs-register race.
77//!
78//! # CallHome Handshake
79//!
80//! The first message a [`client::TcpClient`] sends on a freshly-opened socket is a
81//! `CallHomeHandshake` header-only frame carrying `{ subject, stream_type }`. The server pops
82//! the matching entry out of `tx_subjects` (for [`StreamType::Request`]) or `rx_subjects` (for
83//! [`StreamType::Response`]) and resolves the registrant's oneshot. After that the socket carries
84//! framed [`TwoPartCodec`] messages: data frames in the natural direction, control frames
85//! (and, for response streams, the prologue) interleaved.
86//!
87//! # Control / Shutdown Protocol
88//!
89//! [`ControlMessage`] frames are header-only frames interleaved with data on either socket:
90//!
91//! - [`ControlMessage::Sentinel`] — per-direction clean end-of-stream; the producing side emits
92//!   it before closing the socket. Used on both the request and response sockets.
93//! - [`ControlMessage::Stop`] — sender asks the receiver to cancel; the receiving side calls
94//!   `context.stop()`.
95//! - [`ControlMessage::Kill`] — hard cancel; `context.kill()` and break out.
96//!
97//! `Stop` and `Kill` only flow from upstream to downstream (i.e. frontend → worker). This is an
98//! expected asymmetry: the upstream can cancel the downstream operation for various reasons,
99//! but the downstream cannot cancel the upstream operation. A downstream that cannot consume
100//! the stream simply drops its socket, which the upstream surfaces as a write error and
101//! interprets as a hint for recovery or failure propagation.
102//!
103//! The cancellation direction is fixed, but the two streams carry **data** in opposite
104//! directions, so the practical handling differs per stream:
105//!
106//! ## Response stream (downstream → upstream) — bidirectional
107//!
108//! - Upstream writes: `Stop` / `Kill` (any time, to cancel).
109//! - Downstream writes: data frames, then `Sentinel` on clean close (skipped on kill/stop).
110//!
111//! ## Request stream (upstream → downstream) — unidirectional after the handshake
112//!
113//! - Upstream writes: data frames, then exactly one closing frame — `Sentinel` (clean drain)
114//!   / `Stop` (`context.stopped()`) / `Kill` (`context.killed()`).
115//! - Downstream writes: nothing. Its TCP write half is closed right after the CallHome handshake.
116
117pub mod client;
118pub mod server;
119
120pub mod test_utils;
121
122use super::ControlMessage;
123use serde::{Deserialize, Serialize};
124
125#[allow(unused_imports)]
126use super::{
127    ConnectionInfo, PendingConnections, RegisteredStream, ResponseService, ResponseStreamPrologue,
128    StreamOptions, StreamReceiver, StreamSender, StreamType, codec::TwoPartCodec,
129};
130
131const TCP_TRANSPORT: &str = "tcp_server";
132
133/// Default listen backlog for the CallHome listener.
134///
135/// `tokio::net::TcpListener::bind` passes mio's `LISTEN_BACKLOG_SIZE`, which on Linux
136/// is a hardcoded `128` as of mio 1.2.0. Every response stream is its own CallHome
137/// connection ([`client::TcpClient::create_response_stream`] dials once per request,
138/// with no pooling), so this listener's accept rate scales with in-flight requests.
139///
140/// When the accept queue is full the kernel drops the client's final ACK and falls back
141/// to SYN-ACK retransmission (1s, 3s, 7s, ...), so the symptom is connection latency and,
142/// once `tcp_synack_retries` is exhausted, connection failure. A prompt RST is sent only
143/// when `net.ipv4.tcp_abort_on_overflow=1`, which is not the default.
144///
145/// The kernel caps the effective value at `net.core.somaxconn`.
146const DEFAULT_TCP_LISTEN_BACKLOG: u32 = 4096;
147
148// `listen(2)` takes an `int`; anything above `i32::MAX` would go negative.
149fn parse_tcp_listen_backlog(value: Result<String, std::env::VarError>) -> u32 {
150    value
151        .ok()
152        .and_then(|s| s.trim().parse::<u32>().ok())
153        .filter(|n| (1..=i32::MAX as u32).contains(n))
154        .unwrap_or(DEFAULT_TCP_LISTEN_BACKLOG)
155}
156
157fn tcp_listen_backlog() -> u32 {
158    parse_tcp_listen_backlog(std::env::var(
159        crate::config::environment_names::tcp_response_stream::DYN_TCP_LISTEN_BACKLOG,
160    ))
161}
162
163/// Bind the CallHome listener with a configurable backlog.
164///
165/// Mirrors what `tokio::net::TcpListener::bind` does for an already-resolved address
166/// (`SO_REUSEADDR`, bind, listen) except that the backlog comes from
167/// [`DYN_TCP_LISTEN_BACKLOG`] rather than mio's hardcoded value.
168///
169/// [`DYN_TCP_LISTEN_BACKLOG`]: crate::config::environment_names::tcp_response_stream::DYN_TCP_LISTEN_BACKLOG
170pub(crate) fn bind_listener(
171    addr: std::net::SocketAddr,
172) -> std::io::Result<tokio::net::TcpListener> {
173    let socket = if addr.is_ipv4() {
174        tokio::net::TcpSocket::new_v4()?
175    } else {
176        tokio::net::TcpSocket::new_v6()?
177    };
178    socket.set_reuseaddr(true)?;
179    socket.bind(addr)?;
180    socket.listen(tcp_listen_backlog())
181}
182
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct TcpStreamConnectionInfo {
185    pub address: String,
186    pub subject: String,
187    pub context: String,
188    pub stream_type: StreamType,
189}
190
191impl From<TcpStreamConnectionInfo> for ConnectionInfo {
192    fn from(info: TcpStreamConnectionInfo) -> Self {
193        // Need to consider the below. If failure should be fatal, keep the below with .expect()
194        // But if there is a default value, we can use:
195        // unwrap_or_else(|e| {
196        //     eprintln!("Failed to serialize TcpStreamConnectionInfo: {:?}", e);
197        //     "{}".to_string() // Provide a fallback empty JSON string or default value
198        ConnectionInfo {
199            transport: TCP_TRANSPORT.to_string(),
200            info: serde_json::to_string(&info)
201                .expect("Failed to serialize TcpStreamConnectionInfo"),
202        }
203    }
204}
205
206impl TryFrom<ConnectionInfo> for TcpStreamConnectionInfo {
207    type Error = anyhow::Error;
208
209    fn try_from(info: ConnectionInfo) -> Result<Self, Self::Error> {
210        if info.transport != TCP_TRANSPORT {
211            return Err(anyhow::anyhow!(
212                "Invalid transport; TcpClient requires the transport to be `tcp_server`; however {} was passed",
213                info.transport
214            ));
215        }
216
217        serde_json::from_str(&info.info)
218            .map_err(|e| anyhow::anyhow!("Failed parse ConnectionInfo: {:?}", e))
219    }
220}
221
222/// First message sent over a CallHome stream which will map the newly created socket to a specific
223/// response data stream which was registered with the same subject.
224///
225/// This is a transport specific message as part of forming/completing a CallHome TcpStream.
226#[derive(Debug, Clone, Serialize, Deserialize)]
227struct CallHomeHandshake {
228    subject: String,
229    stream_type: StreamType,
230}
231
232#[cfg(test)]
233mod tests {
234    use crate::engine::AsyncEngineContextProvider;
235
236    use super::*;
237    use crate::pipeline::Context;
238
239    #[derive(Debug, Clone, Serialize, Deserialize)]
240    struct TestMessage {
241        foo: String,
242    }
243
244    #[test]
245    fn test_tcp_listen_backlog_env_var() {
246        assert_eq!(
247            parse_tcp_listen_backlog(Err(std::env::VarError::NotPresent)),
248            DEFAULT_TCP_LISTEN_BACKLOG
249        );
250        assert_eq!(
251            parse_tcp_listen_backlog(Ok("0".to_string())),
252            DEFAULT_TCP_LISTEN_BACKLOG
253        );
254        assert_eq!(
255            parse_tcp_listen_backlog(Ok("invalid".to_string())),
256            DEFAULT_TCP_LISTEN_BACKLOG
257        );
258        assert_eq!(
259            parse_tcp_listen_backlog(Ok((i32::MAX as u32 + 1).to_string())),
260            DEFAULT_TCP_LISTEN_BACKLOG
261        );
262        assert_eq!(
263            parse_tcp_listen_backlog(Ok(i32::MAX.to_string())),
264            i32::MAX as u32
265        );
266        assert_eq!(parse_tcp_listen_backlog(Ok(" 8192 ".to_string())), 8192);
267    }
268
269    /// Round-trip a request-stream connection: register on the server with
270    /// `enable_request_stream(true)`, dial in via `TcpClient::create_request_stream`,
271    /// send a frame from the upstream `StreamSender`, and assert it arrives on the
272    /// downstream `StreamReceiver` returned by the client.
273    #[tokio::test]
274    async fn test_tcp_stream_request_stream_client_server() {
275        // [server] start the server and register the request stream
276        let options = server::ServerOptions::default();
277        let server = server::TcpStreamServer::new(options).await.unwrap();
278
279        let context_upstream = Context::new(());
280
281        let options = StreamOptions::builder()
282            .context(context_upstream.context())
283            .enable_request_stream(true)
284            .enable_response_stream(false)
285            .build()
286            .unwrap();
287
288        let pending_connection = server.register(options).await;
289
290        let connection_info = pending_connection
291            .send_stream
292            .as_ref()
293            .unwrap()
294            .connection_info
295            .clone();
296
297        // [client] Assume to receive the connection info from the server via the request plane,
298        // create the request stream and dial in to the server
299        let context_downstream = Context::with_id_and_metadata(
300            (),
301            context_upstream.id().to_string(),
302            Default::default(),
303        );
304
305        let mut recv_stream = client::TcpClient::create_request_stream(
306            context_downstream.context(),
307            connection_info,
308            None,
309        )
310        .await
311        .unwrap();
312
313        // [server] After client dials in, the server can pick up its `StreamSender` half.
314        let (_conn_info, stream_provider) = pending_connection.send_stream.unwrap().into_parts();
315        let send_stream = stream_provider.await.unwrap().unwrap();
316
317        let msg = TestMessage {
318            foo: "request-frame".to_string(),
319        };
320        let payload = serde_json::to_vec(&msg).unwrap();
321
322        send_stream.send(payload.into()).await.unwrap();
323
324        // [client] The client can now receive the response from the server
325        let data = recv_stream.rx.recv().await.unwrap();
326        let recv_msg = serde_json::from_slice::<TestMessage>(&data).unwrap();
327        assert_eq!(msg.foo, recv_msg.foo);
328
329        // Dropping the upstream `StreamSender` should cleanly close the request
330        // stream — the downstream receiver should observe `None`.
331        drop(send_stream);
332        assert!(recv_stream.rx.recv().await.is_none());
333    }
334
335    #[tokio::test]
336    async fn test_tcp_stream_client_server() {
337        // [server] start the server and register the response stream
338        let options = server::ServerOptions::builder().port(9124).build().unwrap();
339        let server = server::TcpStreamServer::new(options).await.unwrap();
340
341        let context_rank0 = Context::new(());
342
343        let options = StreamOptions::builder()
344            .context(context_rank0.context())
345            .enable_request_stream(false)
346            .enable_response_stream(true)
347            .build()
348            .unwrap();
349
350        let pending_connection = server.register(options).await;
351
352        let connection_info = pending_connection
353            .recv_stream
354            .as_ref()
355            .unwrap()
356            .connection_info
357            .clone();
358
359        // [client] set up the other rank and create the response stream
360        let context_rank1 =
361            Context::with_id_and_metadata((), context_rank0.id().to_string(), Default::default());
362
363        let mut send_stream = client::TcpClient::create_response_stream(
364            context_rank1.context(),
365            connection_info,
366            None,
367        )
368        .await
369        .unwrap();
370
371        // the client can now setup it's end of the stream and if it errors, it can send a message
372        // to the server to stop the stream
373        //
374        // this step must be done before the next step on the server can complete, i.e.
375        // the server's stream is now blocked on receiving the prologue message
376        //
377        // let's improve this and use an enum like Ok/Err; currently, None means good-to-go, and
378        // Some(String) means an error happened on this downstream node and we need to alert the
379        // upstream node that an error occurred
380        send_stream.send_prologue(None).await.unwrap();
381
382        // [server] After client sends the prologue, the server can pick up its `StreamReceiver` half.
383        let (_conn_info, stream_provider) = pending_connection.recv_stream.unwrap().into_parts();
384        let recv_stream = stream_provider.await.unwrap();
385
386        // [client] The client can now send the response message to the server
387        let msg = TestMessage {
388            foo: "bar".to_string(),
389        };
390
391        let payload = serde_json::to_vec(&msg).unwrap();
392
393        send_stream.send(payload.into()).await.unwrap();
394
395        // [server] The server can now receive the response message from the client
396
397        let data = recv_stream.unwrap().rx.recv().await.unwrap();
398
399        let recv_msg = serde_json::from_slice::<TestMessage>(&data).unwrap();
400
401        assert_eq!(msg.foo, recv_msg.foo);
402
403        drop(send_stream);
404
405        // let data = recv_stream.rx.recv().await;
406
407        // assert!(data.is_none());
408    }
409}