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#[derive(Debug, Clone, Serialize, Deserialize)]
134pub struct TcpStreamConnectionInfo {
135    pub address: String,
136    pub subject: String,
137    pub context: String,
138    pub stream_type: StreamType,
139}
140
141impl From<TcpStreamConnectionInfo> for ConnectionInfo {
142    fn from(info: TcpStreamConnectionInfo) -> Self {
143        // Need to consider the below. If failure should be fatal, keep the below with .expect()
144        // But if there is a default value, we can use:
145        // unwrap_or_else(|e| {
146        //     eprintln!("Failed to serialize TcpStreamConnectionInfo: {:?}", e);
147        //     "{}".to_string() // Provide a fallback empty JSON string or default value
148        ConnectionInfo {
149            transport: TCP_TRANSPORT.to_string(),
150            info: serde_json::to_string(&info)
151                .expect("Failed to serialize TcpStreamConnectionInfo"),
152        }
153    }
154}
155
156impl TryFrom<ConnectionInfo> for TcpStreamConnectionInfo {
157    type Error = anyhow::Error;
158
159    fn try_from(info: ConnectionInfo) -> Result<Self, Self::Error> {
160        if info.transport != TCP_TRANSPORT {
161            return Err(anyhow::anyhow!(
162                "Invalid transport; TcpClient requires the transport to be `tcp_server`; however {} was passed",
163                info.transport
164            ));
165        }
166
167        serde_json::from_str(&info.info)
168            .map_err(|e| anyhow::anyhow!("Failed parse ConnectionInfo: {:?}", e))
169    }
170}
171
172/// First message sent over a CallHome stream which will map the newly created socket to a specific
173/// response data stream which was registered with the same subject.
174///
175/// This is a transport specific message as part of forming/completing a CallHome TcpStream.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177struct CallHomeHandshake {
178    subject: String,
179    stream_type: StreamType,
180}
181
182#[cfg(test)]
183mod tests {
184    use crate::engine::AsyncEngineContextProvider;
185
186    use super::*;
187    use crate::pipeline::Context;
188
189    #[derive(Debug, Clone, Serialize, Deserialize)]
190    struct TestMessage {
191        foo: String,
192    }
193
194    /// Round-trip a request-stream connection: register on the server with
195    /// `enable_request_stream(true)`, dial in via `TcpClient::create_request_stream`,
196    /// send a frame from the upstream `StreamSender`, and assert it arrives on the
197    /// downstream `StreamReceiver` returned by the client.
198    #[tokio::test]
199    async fn test_tcp_stream_request_stream_client_server() {
200        // [server] start the server and register the request stream
201        let options = server::ServerOptions::default();
202        let server = server::TcpStreamServer::new(options).await.unwrap();
203
204        let context_upstream = Context::new(());
205
206        let options = StreamOptions::builder()
207            .context(context_upstream.context())
208            .enable_request_stream(true)
209            .enable_response_stream(false)
210            .build()
211            .unwrap();
212
213        let pending_connection = server.register(options).await;
214
215        let connection_info = pending_connection
216            .send_stream
217            .as_ref()
218            .unwrap()
219            .connection_info
220            .clone();
221
222        // [client] Assume to receive the connection info from the server via the request plane,
223        // create the request stream and dial in to the server
224        let context_downstream = Context::with_id_and_metadata(
225            (),
226            context_upstream.id().to_string(),
227            Default::default(),
228        );
229
230        let mut recv_stream = client::TcpClient::create_request_stream(
231            context_downstream.context(),
232            connection_info,
233            None,
234        )
235        .await
236        .unwrap();
237
238        // [server] After client dials in, the server can pick up its `StreamSender` half.
239        let (_conn_info, stream_provider) = pending_connection.send_stream.unwrap().into_parts();
240        let send_stream = stream_provider.await.unwrap().unwrap();
241
242        let msg = TestMessage {
243            foo: "request-frame".to_string(),
244        };
245        let payload = serde_json::to_vec(&msg).unwrap();
246
247        send_stream.send(payload.into()).await.unwrap();
248
249        // [client] The client can now receive the response from the server
250        let data = recv_stream.rx.recv().await.unwrap();
251        let recv_msg = serde_json::from_slice::<TestMessage>(&data).unwrap();
252        assert_eq!(msg.foo, recv_msg.foo);
253
254        // Dropping the upstream `StreamSender` should cleanly close the request
255        // stream — the downstream receiver should observe `None`.
256        drop(send_stream);
257        assert!(recv_stream.rx.recv().await.is_none());
258    }
259
260    #[tokio::test]
261    async fn test_tcp_stream_client_server() {
262        // [server] start the server and register the response stream
263        let options = server::ServerOptions::builder().port(9124).build().unwrap();
264        let server = server::TcpStreamServer::new(options).await.unwrap();
265
266        let context_rank0 = Context::new(());
267
268        let options = StreamOptions::builder()
269            .context(context_rank0.context())
270            .enable_request_stream(false)
271            .enable_response_stream(true)
272            .build()
273            .unwrap();
274
275        let pending_connection = server.register(options).await;
276
277        let connection_info = pending_connection
278            .recv_stream
279            .as_ref()
280            .unwrap()
281            .connection_info
282            .clone();
283
284        // [client] set up the other rank and create the response stream
285        let context_rank1 =
286            Context::with_id_and_metadata((), context_rank0.id().to_string(), Default::default());
287
288        let mut send_stream = client::TcpClient::create_response_stream(
289            context_rank1.context(),
290            connection_info,
291            None,
292        )
293        .await
294        .unwrap();
295
296        // the client can now setup it's end of the stream and if it errors, it can send a message
297        // to the server to stop the stream
298        //
299        // this step must be done before the next step on the server can complete, i.e.
300        // the server's stream is now blocked on receiving the prologue message
301        //
302        // let's improve this and use an enum like Ok/Err; currently, None means good-to-go, and
303        // Some(String) means an error happened on this downstream node and we need to alert the
304        // upstream node that an error occurred
305        send_stream.send_prologue(None).await.unwrap();
306
307        // [server] After client sends the prologue, the server can pick up its `StreamReceiver` half.
308        let (_conn_info, stream_provider) = pending_connection.recv_stream.unwrap().into_parts();
309        let recv_stream = stream_provider.await.unwrap();
310
311        // [client] The client can now send the response message to the server
312        let msg = TestMessage {
313            foo: "bar".to_string(),
314        };
315
316        let payload = serde_json::to_vec(&msg).unwrap();
317
318        send_stream.send(payload.into()).await.unwrap();
319
320        // [server] The server can now receive the response message from the client
321
322        let data = recv_stream.unwrap().rx.recv().await.unwrap();
323
324        let recv_msg = serde_json::from_slice::<TestMessage>(&data).unwrap();
325
326        assert_eq!(msg.foo, recv_msg.foo);
327
328        drop(send_stream);
329
330        // let data = recv_stream.rx.recv().await;
331
332        // assert!(data.is_none());
333    }
334}