arcly_stream/inbound.rs
1//! The **multi-protocol ingestion architecture** — the public seam for teaching
2//! the engine new inbound wire protocols (RTSP, SRT, WebRTC WHIP/WHEP, …)
3//! without touching the kernel.
4//!
5//! # The three pieces
6//!
7//! | Type | Role |
8//! |------|------|
9//! | [`InboundProtocol`] | The worker contract you implement: own a listener, accept connections, bridge frames onto the bus. |
10//! | [`IngestContext`] | The ergonomic, cloneable handle a worker uses to reach the engine bus — hands out publish sessions. |
11//! | [`PublishSession`] | An RAII token for one live stream: every frame lands in the GOP cache + live QoS; releasing it frees the publish slot. |
12//!
13//! ```text
14//! your crate arcly-stream kernel
15//! ┌────────────────────────┐ ┌───────────────────────────────┐
16//! │ struct MyRtspHandler │ │ Engine (lock-free bus) │
17//! │ impl InboundProtocol { │ serve │ • broadcast fan-out │
18//! │ async fn serve(ctx) { │◀─────────│ • GOP cache (instant start) │
19//! │ ctx.open_publish()──┼────────▶ │ • live QoS counters │
20//! │ .publish_frame() │ frames │ PublishRegistry │
21//! │ } } │ │ │
22//! └────────────────────────┘ └───────────────────────────────┘
23//! ```
24//!
25//! # Design pattern: a worker owns its transport
26//!
27//! [`InboundProtocol::serve`] is intentionally a *single, long-lived call* that
28//! owns the listener for the protocol's whole lifetime, rather than a set of
29//! per-connection lifecycle callbacks. This keeps the contract minimal and lets
30//! each protocol model its own connection state machine (RTMP chunk streams,
31//! RTSP sessions, SRT handshakes) however it needs — the kernel never assumes a
32//! shape. The reusable [`run_tcp_ingest_server`](crate::protocol::run_tcp_ingest_server)
33//! accept-loop (feature `ingest`) covers the common TCP case so most workers are
34//! a thin per-connection handler over it.
35//!
36//! # Thread-safety & runtime requirements
37//!
38//! - A worker is `Send + Sync + 'static`: the engine shares one instance across
39//! tasks and may run it for the entire process lifetime.
40//! - [`serve`](InboundProtocol::serve) must return promptly once `shutdown` is
41//! cancelled — the engine's coordinated teardown waits on every worker.
42//! - [`PublishSession`] is **not** `Clone`: it models exclusive ownership of one
43//! publish slot. Frames may be published from any task that holds it (or holds
44//! its [`StreamHandle`](PublishSession::handle)).
45//!
46//! # Graceful teardown
47//!
48//! When an ingest connection drops, ending its [`PublishSession`] (via
49//! [`finish`](PublishSession::finish), or best-effort on `Drop`) frees the
50//! publish slot *and* closes the stream's lock-free broadcast bus. Active
51//! playback subscribers are therefore **notified seamlessly** — a resilient
52//! subscriber's `recv` yields `None` — with no handler reaching across to each
53//! one. A host that drives a recorder ([`RecordingSink`](crate::record::RecordingSink))
54//! off the same subscription flushes it on that signal, so recordings land in
55//! the [`StorageBackend`](crate::traits::StorageBackend) on disconnect. The
56//! engine's [idle reaper](crate::Engine::reap_idle) provides the same teardown
57//! for connections that wedge without closing cleanly.
58//!
59//! # Minimal worker
60//!
61//! ```no_run
62//! use arcly_stream::prelude::*;
63//! use arcly_stream::inbound::{InboundProtocol, IngestContext};
64//! use arcly_stream::bytes::Bytes;
65//!
66//! struct LoopbackProtocol;
67//!
68//! #[async_trait]
69//! impl InboundProtocol for LoopbackProtocol {
70//! fn name(&self) -> &'static str { "loopback" }
71//!
72//! async fn serve(&self, ctx: IngestContext, shutdown: CancellationToken) -> Result<()> {
73//! // Claim a stream; the session releases it on drop.
74//! let session = ctx.open_publish(StreamKey::new("live", "demo")).await?;
75//! let kf = MediaFrame::new_video(0, 0, Bytes::from_static(b"idr"), CodecId::H264, true);
76//! session.publish_frame(kf)?;
77//! shutdown.cancelled().await; // run until told to stop
78//! session.finish().await
79//! }
80//! }
81//! ```
82
83use crate::bus::{PublishRegistry, StreamHandle};
84use crate::{MediaFrame, Result, StreamKey};
85use async_trait::async_trait;
86use std::sync::Arc;
87use tokio_util::sync::CancellationToken;
88
89/// A pluggable inbound wire-protocol worker — the unit the engine runs to ingest
90/// a transport (RTMP, RTSP, SRT, WHIP, …).
91///
92/// Implement this in your own crate and register it with
93/// [`EngineBuilder::protocol`](crate::EngineBuilder::protocol) (or pass it to
94/// [`Engine::serve`](crate::Engine::serve)); the engine never needs to know the
95/// concrete type. A worker:
96///
97/// 1. **binds** its listener(s) inside [`serve`](Self::serve),
98/// 2. **accepts** connections and performs each protocol **handshake**,
99/// 3. resolves a [`StreamKey`] and opens a [`PublishSession`] via the
100/// [`IngestContext`],
101/// 4. **bridges** decoded access units to [`MediaFrame`]s and publishes them,
102/// 5. **tears down** cleanly when a connection closes or `shutdown` fires.
103///
104/// Any type implementing the legacy [`ProtocolHandler`](crate::ProtocolHandler)
105/// is automatically an `InboundProtocol` via a blanket bridge, so existing
106/// handlers keep working unchanged.
107#[async_trait]
108pub trait InboundProtocol: Send + Sync + 'static {
109 /// Stable, human-readable protocol name (`"rtmp"`, `"rtsp"`, …). Used in logs
110 /// and the engine's per-worker lifecycle tracing.
111 fn name(&self) -> &'static str;
112
113 /// Run the protocol's listener until `shutdown` is cancelled.
114 ///
115 /// Return `Ok(())` on a clean shutdown. Returning `Err` signals a fatal fault
116 /// (e.g. the listener could not bind) and trips the engine's coordinated
117 /// teardown, winding down sibling workers too.
118 ///
119 /// Implementations **must** observe `shutdown` and return promptly once it is
120 /// cancelled; the engine awaits every worker during drain.
121 async fn serve(&self, ctx: IngestContext, shutdown: CancellationToken) -> Result<()>;
122}
123
124/// Blanket bridge: every legacy [`ProtocolHandler`](crate::ProtocolHandler) is an
125/// [`InboundProtocol`]. New protocols should implement `InboundProtocol` directly
126/// for the ergonomic [`IngestContext`]; this keeps pre-existing handlers working.
127#[async_trait]
128impl<T: crate::traits::ProtocolHandler + 'static> InboundProtocol for T {
129 fn name(&self) -> &'static str {
130 crate::traits::ProtocolHandler::name(self)
131 }
132
133 async fn serve(&self, ctx: IngestContext, shutdown: CancellationToken) -> Result<()> {
134 crate::traits::ProtocolHandler::run(self, Arc::clone(ctx.registry()), shutdown).await
135 }
136}
137
138/// The ergonomic, cloneable handle a protocol worker uses to reach the engine
139/// bus, handed to every [`InboundProtocol::serve`] call.
140///
141/// It hides the [`PublishRegistry`] trait object behind a small, stable surface:
142/// claim a stream for publishing ([`open_publish`](Self::open_publish)), or reach
143/// the underlying registry for advanced flows ([`registry`](Self::registry)).
144/// Cloning is cheap (an `Arc` bump) — share it freely across per-connection tasks.
145#[derive(Clone)]
146pub struct IngestContext {
147 registry: Arc<dyn PublishRegistry>,
148}
149
150impl IngestContext {
151 /// Wrap a publish registry. The engine constructs this for you; tests and
152 /// embedders can build one directly from any [`PublishRegistry`] (e.g. an
153 /// `Arc<Engine>`).
154 pub fn new(registry: Arc<dyn PublishRegistry>) -> Self {
155 Self { registry }
156 }
157
158 /// Claim `key` for publishing, returning an RAII [`PublishSession`].
159 ///
160 /// Frames published through the returned session flow into the lock-free
161 /// broadcast fan-out, the keyframe-anchored GOP cache (for instant-start
162 /// replay), and the live QoS counters — exactly as a native handler's frames
163 /// do. Fails with [`StreamAlreadyPublishing`] on a live duplicate or
164 /// [`PublisherLimitReached`] at capacity.
165 ///
166 /// [`StreamAlreadyPublishing`]: crate::StreamError::StreamAlreadyPublishing
167 /// [`PublisherLimitReached`]: crate::StreamError::PublisherLimitReached
168 pub async fn open_publish(&self, key: StreamKey) -> Result<PublishSession> {
169 let handle = self.registry.start_publish(&key).await?;
170 Ok(PublishSession {
171 handle,
172 registry: Arc::clone(&self.registry),
173 key,
174 released: false,
175 })
176 }
177
178 /// Like [`open_publish`](Self::open_publish), but enforces the registry's
179 /// admission policy ([`PublishRegistry::start_publish_checked`]) against
180 /// `creds` first — protocol handlers that carry a credential (SRT passphrase/
181 /// streamid token, a publish token) should use this so the publish is gated
182 /// before any media is accepted.
183 pub async fn open_publish_checked(
184 &self,
185 key: StreamKey,
186 creds: &crate::auth::Credentials,
187 ) -> Result<PublishSession> {
188 let handle = self.registry.start_publish_checked(&key, creds).await?;
189 Ok(PublishSession {
190 handle,
191 registry: Arc::clone(&self.registry),
192 key,
193 released: false,
194 })
195 }
196
197 /// The underlying publish registry, for flows not covered by
198 /// [`open_publish`](Self::open_publish).
199 pub fn registry(&self) -> &Arc<dyn PublishRegistry> {
200 &self.registry
201 }
202}
203
204/// An RAII publish session — a protocol worker's exclusive token for one live
205/// stream.
206///
207/// Wraps the engine's [`StreamHandle`]: every [`publish_frame`](Self::publish_frame)
208/// lands in the broadcast fan-out, GOP cache, and live QoS counters. Dropping the
209/// session releases the publish slot (best-effort, on the current Tokio runtime),
210/// so a worker that returns early or panics never leaks a stream. Prefer
211/// [`finish`](Self::finish) for deterministic, awaited teardown.
212pub struct PublishSession {
213 handle: StreamHandle,
214 registry: Arc<dyn PublishRegistry>,
215 key: StreamKey,
216 released: bool,
217}
218
219impl PublishSession {
220 /// The stream key this session publishes to.
221 pub fn key(&self) -> &StreamKey {
222 &self.key
223 }
224
225 /// The underlying engine [`StreamHandle`] — GOP cache, QoS, subscriber count,
226 /// metadata, and direct `subscribe`/replay access.
227 pub fn handle(&self) -> &StreamHandle {
228 &self.handle
229 }
230
231 /// Publish one decoded frame to all subscribers; returns the live subscriber
232 /// count (`0` when nobody is watching yet).
233 pub fn publish_frame(&self, frame: MediaFrame) -> Result<usize> {
234 self.handle.publish_frame(frame)
235 }
236
237 /// Release the publish slot deterministically. Prefer this over relying on
238 /// [`Drop`] whenever you can `await` — it surfaces the teardown error and
239 /// completes before the next stream can reuse the key.
240 pub async fn finish(mut self) -> Result<()> {
241 self.released = true;
242 self.registry.end_publish(&self.key).await
243 }
244}
245
246impl Drop for PublishSession {
247 fn drop(&mut self) {
248 if self.released {
249 return;
250 }
251 // Best-effort async release. `end_publish` is async; if a Tokio runtime is
252 // available (the normal case inside a worker), spawn the teardown so the
253 // slot is freed even on an early return or panic.
254 if let Ok(rt) = tokio::runtime::Handle::try_current() {
255 let registry = Arc::clone(&self.registry);
256 let key = self.key.clone();
257 rt.spawn(async move {
258 let _ = registry.end_publish(&key).await;
259 });
260 }
261 }
262}
263
264#[cfg(test)]
265mod tests {
266 use super::*;
267 use crate::bus::PlaybackRegistry;
268 use crate::{AppSpec, CodecId, Engine, FrameFlags};
269 use bytes::Bytes;
270
271 /// A tiny custom protocol that publishes a config + keyframe then idles until
272 /// shutdown — exercising the full `InboundProtocol` → `IngestContext` →
273 /// `PublishSession` path against a real engine.
274 struct DemoProtocol {
275 key: StreamKey,
276 }
277
278 #[async_trait]
279 impl InboundProtocol for DemoProtocol {
280 fn name(&self) -> &'static str {
281 "demo"
282 }
283
284 async fn serve(&self, ctx: IngestContext, shutdown: CancellationToken) -> Result<()> {
285 let session = ctx.open_publish(self.key.clone()).await?;
286 let mut cfg =
287 MediaFrame::new_video(0, 0, Bytes::from_static(b"sps"), CodecId::H264, false);
288 cfg.flags |= FrameFlags::CONFIG;
289 session.publish_frame(cfg)?;
290 session.publish_frame(MediaFrame::new_video(
291 0,
292 0,
293 Bytes::from_static(b"idr"),
294 CodecId::H264,
295 true,
296 ))?;
297 shutdown.cancelled().await;
298 session.finish().await
299 }
300 }
301
302 #[tokio::test]
303 async fn custom_protocol_publishes_through_ingest_context() {
304 let engine = Engine::builder()
305 .application(AppSpec::new("live").gop_cache(8))
306 .build();
307 let key = StreamKey::new("live", "cam");
308 let ctx = IngestContext::new(engine.clone());
309
310 let proto = DemoProtocol { key: key.clone() };
311 let shutdown = CancellationToken::new();
312
313 // Run the worker; cancel once it has had a chance to publish.
314 let worker = {
315 let shutdown = shutdown.clone();
316 tokio::spawn(async move { proto.serve(ctx, shutdown).await })
317 };
318
319 // Wait until the stream is live and its GOP cache holds the keyframe.
320 let handle = loop {
321 if let Ok(h) = engine.get_stream(&key) {
322 if h.replay_buffer().iter().any(|f| f.is_keyframe()) {
323 break h;
324 }
325 }
326 tokio::task::yield_now().await;
327 };
328 let (vcfg, _) = handle.cached_configs();
329 assert!(vcfg.is_some(), "config frame cached via PublishSession");
330
331 shutdown.cancel();
332 worker.await.unwrap().unwrap();
333 // After finish(), the publish slot is released.
334 assert!(
335 engine.get_stream(&key).is_err(),
336 "session released on finish"
337 );
338 }
339
340 #[tokio::test]
341 async fn dropping_session_releases_the_slot() {
342 let engine = Engine::builder().application(AppSpec::new("live")).build();
343 let key = StreamKey::new("live", "drop-test");
344 let ctx = IngestContext::new(engine.clone());
345
346 {
347 let _session = ctx.open_publish(key.clone()).await.unwrap();
348 assert!(engine.get_stream(&key).is_ok(), "stream live while held");
349 } // dropped here → best-effort async end_publish spawned
350
351 // Yield so the spawned teardown runs.
352 for _ in 0..16 {
353 if engine.get_stream(&key).is_err() {
354 break;
355 }
356 tokio::task::yield_now().await;
357 }
358 assert!(engine.get_stream(&key).is_err(), "slot released on drop");
359 }
360
361 #[tokio::test]
362 async fn finishing_a_session_notifies_subscribers_via_the_bus() {
363 // Graceful teardown: ending a publish closes the lock-free bus, so an
364 // active playback subscriber is seamlessly notified (its stream ends)
365 // without the protocol handler reaching across to each subscriber.
366 let engine = Engine::builder()
367 .application(AppSpec::new("live").gop_cache(4))
368 .build();
369 let key = StreamKey::new("live", "cam");
370 let ctx = IngestContext::new(engine.clone());
371
372 let session = ctx.open_publish(key.clone()).await.unwrap();
373 let handle = engine.get_stream(&key).unwrap();
374 let mut sub = handle.subscribe_resilient();
375
376 // The subscriber sees the live frame.
377 session
378 .publish_frame(MediaFrame::new_video(
379 0,
380 0,
381 Bytes::from_static(b"idr"),
382 CodecId::H264,
383 true,
384 ))
385 .unwrap();
386 assert!(sub.recv().await.is_some(), "subscriber receives live frame");
387
388 // Release every handle clone, then tear the session down.
389 drop(handle);
390 session.finish().await.unwrap();
391
392 // The subscriber is notified of teardown: the closed bus yields `None`.
393 assert!(
394 sub.recv().await.is_none(),
395 "subscriber notified that the stream ended"
396 );
397 assert!(engine.get_stream(&key).is_err(), "slot released on finish");
398 }
399}