rtmp_runtime/io.rs
1//! Async `tokio` socket adapter driving the sans-IO ingest server session
2//! over a real `tokio::net::TcpStream` — the Layer-2 adapter (see
3//! [`docs/rtmp.md`](../docs/rtmp.md) § Layer-2 adapter; mirrors the
4//! `rtsp_runtime::io` tokio adapter shape in this same workspace).
5//!
6//! [`ServerSession`] never touches a socket: it turns inbound bytes into
7//! `(reply bytes, [`ServerEvent`]s)`. This module is the thin layer that
8//! actually owns a [`TcpStream`], reads whatever bytes are available, feeds
9//! them to [`ServerSession::handle_data`], writes the reply bytes back, and
10//! returns the events — no business logic beyond that plumbing.
11//!
12//! [`ServerSession::handle_data`] buffers partial handshake/chunk input
13//! internally (see its doc comment), so unlike an RTSP or HTTP adapter this
14//! one does not need to detect message boundaries itself: any chunk size,
15//! split anywhere, is fine to feed straight through.
16
17use std::io;
18use std::net::SocketAddr;
19
20use tokio::io::{AsyncReadExt, AsyncWriteExt};
21use tokio::net::{TcpListener, TcpStream, ToSocketAddrs};
22
23use crate::server::{ServerConfig, ServerEvent, ServerSession};
24
25/// Size of one socket read chunk. Reads are handed to
26/// [`ServerSession::handle_data`] as soon as they arrive, so this only bounds
27/// a single `read` syscall, never a message.
28const READ_CHUNK: usize = 8192;
29
30/// Maps an [`RtmpError`](crate::RtmpError) from the sans-IO session into an
31/// [`io::Error`] so callers only deal with one error type at this layer.
32fn io_err(e: crate::RtmpError) -> io::Error {
33 io::Error::new(io::ErrorKind::InvalidData, e)
34}
35
36/// A `tokio::net::TcpListener` that accepts inbound RTMP publishers and hands
37/// back a driven [`RtmpConnection`] per connection.
38#[derive(Debug)]
39pub struct AsyncRtmpServer {
40 listener: TcpListener,
41 config: ServerConfig,
42}
43
44impl AsyncRtmpServer {
45 /// Binds a listen address (e.g. `"0.0.0.0:1935"`, the IANA-assigned RTMP
46 /// port). `config` is cloned into a fresh [`ServerSession`] for each
47 /// accepted connection.
48 pub async fn bind<A: ToSocketAddrs>(addr: A, config: ServerConfig) -> io::Result<Self> {
49 let listener = TcpListener::bind(addr).await?;
50 Ok(Self { listener, config })
51 }
52
53 /// Accepts the next inbound connection and wraps it with a fresh
54 /// [`ServerSession`] built from this server's [`ServerConfig`].
55 pub async fn accept(&self) -> io::Result<RtmpConnection> {
56 let (stream, _peer) = self.listener.accept().await?;
57 Ok(RtmpConnection::new(
58 stream,
59 ServerSession::new(self.config.clone()),
60 ))
61 }
62
63 /// The address this server is actually bound to (useful for `":0"`
64 /// ephemeral-port binds).
65 pub fn local_addr(&self) -> io::Result<SocketAddr> {
66 self.listener.local_addr()
67 }
68}
69
70/// One accepted RTMP connection: a [`TcpStream`] driving a [`ServerSession`].
71///
72/// [`next_events`](Self::next_events) is the whole surface: read a chunk,
73/// drive the session, write the reply, return the events.
74#[derive(Debug)]
75pub struct RtmpConnection {
76 stream: TcpStream,
77 session: ServerSession,
78 /// Once set, the connection is done (clean EOF, `ServerEvent::Eof`, or a
79 /// prior `RtmpError`) and further calls to `next_events` return `None`
80 /// without touching the socket again.
81 closed: bool,
82}
83
84impl RtmpConnection {
85 fn new(stream: TcpStream, session: ServerSession) -> Self {
86 Self {
87 stream,
88 session,
89 closed: false,
90 }
91 }
92
93 /// The remote address of the connected publisher.
94 pub fn peer_addr(&self) -> io::Result<SocketAddr> {
95 self.stream.peer_addr()
96 }
97
98 /// Reads one chunk from the socket, drives [`ServerSession::handle_data`],
99 /// writes the reply bytes back (all of them), and returns the resulting
100 /// events.
101 ///
102 /// Returns `Ok(None)` when the connection is finished: the peer closed
103 /// the socket (clean EOF), or the most recent batch of events included
104 /// [`ServerEvent::Eof`] (so the caller sees that final batch once, then
105 /// `None` on the next call). Once a call returns `None`, every
106 /// subsequent call also returns `None` without reading the socket again.
107 ///
108 /// # Errors
109 /// An [`io::Error`] from the underlying socket read/write, or a mapped
110 /// [`RtmpError`](crate::RtmpError) (kind [`io::ErrorKind::InvalidData`])
111 /// from [`ServerSession::handle_data`]. On an `RtmpError` the session is
112 /// unrecoverable (per `handle_data`'s own doc): this connection is torn
113 /// down immediately — marked closed and not driven further, even if the
114 /// caller keeps calling `next_events`.
115 pub async fn next_events(&mut self) -> io::Result<Option<Vec<ServerEvent>>> {
116 if self.closed {
117 return Ok(None);
118 }
119
120 let mut chunk = [0u8; READ_CHUNK];
121 let n = self.stream.read(&mut chunk).await?;
122 if n == 0 {
123 self.closed = true;
124 return Ok(None);
125 }
126
127 let (reply, events) = match self.session.handle_data(&chunk[..n]) {
128 Ok(v) => v,
129 Err(e) => {
130 // Unrecoverable: tear down rather than keep driving a session
131 // whose internal state may have partially advanced.
132 self.closed = true;
133 return Err(io_err(e));
134 }
135 };
136
137 if !reply.is_empty() {
138 self.stream.write_all(&reply).await?;
139 }
140
141 if events.iter().any(|e| matches!(e, ServerEvent::Eof)) {
142 self.closed = true;
143 }
144
145 Ok(Some(events))
146 }
147}
148
149#[cfg(test)]
150mod tests {
151 use super::*;
152
153 const FIXTURE: &str = concat!(
154 env!("CARGO_MANIFEST_DIR"),
155 "/tests/fixtures/obs-publish.bin"
156 );
157
158 /// Replays the real captured `ffmpeg` publish (T8's `obs-publish.bin`)
159 /// over an actual loopback TCP socket, driving `AsyncRtmpServer`/
160 /// `RtmpConnection` end to end: a spawned client task writes the fixture
161 /// bytes and drains the server's replies, while the server side accepts
162 /// the connection and loops `next_events` until the connection closes.
163 ///
164 /// This is the online counterpart of `tests/ingest_fixture.rs`'s offline
165 /// replay — it proves the tokio adapter (not just the sans-IO session)
166 /// actually drives a real publish to `Connected` -> `Publish` -> `Media`.
167 #[tokio::test]
168 async fn loopback_replay_of_real_publish_reaches_connected_publish_media() {
169 let fixture = std::fs::read(FIXTURE).expect("read tests/fixtures/obs-publish.bin");
170
171 let server = AsyncRtmpServer::bind("127.0.0.1:0", ServerConfig::default())
172 .await
173 .expect("bind ephemeral loopback port");
174 let addr = server.local_addr().expect("local_addr");
175
176 // Client task: play back the captured publisher's raw bytes, and
177 // drain whatever the server writes back (so the server's writes
178 // never block on an unread socket buffer), counting the bytes
179 // received — this is what proves `next_events` actually wrote the
180 // session's reply bytes to the socket, not just decoded events.
181 let client = tokio::spawn(async move {
182 let mut stream = TcpStream::connect(addr).await.expect("connect loopback");
183 stream
184 .write_all(&fixture)
185 .await
186 .expect("write fixture bytes");
187 let mut sink = [0u8; READ_CHUNK];
188 let mut replied_bytes = 0usize;
189 loop {
190 match stream.read(&mut sink).await {
191 Ok(0) | Err(_) => break,
192 Ok(n) => replied_bytes += n,
193 }
194 }
195 replied_bytes
196 });
197
198 let mut conn = server.accept().await.expect("accept the client connection");
199 let mut events = Vec::new();
200 while let Some(batch) = conn
201 .next_events()
202 .await
203 .expect("next_events must not error")
204 {
205 events.extend(batch);
206 }
207 // Close the server-side socket so the client's drain loop observes
208 // EOF and the spawned task actually finishes.
209 drop(conn);
210 let replied_bytes = client.await.expect("client task must not panic");
211
212 // The handshake alone (S0 + S1 + S2, §5.2) is 1 + 1536 + 1536 = 3073
213 // bytes; `connect`/`createStream`/`publish` each add a reply on top.
214 // If `next_events` failed to write the reply bytes back, the client
215 // would observe a bare EOF and this would be 0.
216 const HANDSHAKE_REPLY_LEN: usize = 1 + 1536 + 1536;
217 assert!(
218 replied_bytes >= HANDSHAKE_REPLY_LEN,
219 "next_events must write the session's reply bytes back to the socket \
220 (expected at least the {HANDSHAKE_REPLY_LEN}-byte S0+S1+S2 handshake reply), \
221 got {replied_bytes} bytes"
222 );
223
224 assert!(
225 events
226 .iter()
227 .any(|e| matches!(e, ServerEvent::Connected { app } if app == "live")),
228 "must emit Connected{{app: \"live\"}} over the real socket; got {events:?}"
229 );
230 assert!(
231 events.iter().any(
232 |e| matches!(e, ServerEvent::Publish { stream_key, .. } if stream_key == "testkey")
233 ),
234 "must emit Publish{{stream_key: \"testkey\", ..}} over the real socket; got {events:?}"
235 );
236 let media_count = events
237 .iter()
238 .filter(|e| matches!(e, ServerEvent::Media { .. }))
239 .count();
240 assert!(
241 media_count >= 1,
242 "must emit at least one Media event over the real socket, got {media_count}"
243 );
244 }
245}