Skip to main content

pixelflux/
recording_sink.rs

1//! Unix-socket H.264 fan-out for external recording.
2//!
3//! Frames are intercepted at the delivery layer (not inside each encoder), so the tap works
4//! uniformly for every full-frame encoder. The 10-byte pixelflux wire header is skipped so
5//! consumers receive a plain Annex-B elementary stream that is directly muxable.
6//!
7//! The tap must never perturb the live viewer transport, and it never copies frame bytes:
8//! stripe payloads are `Arc`-shared, so the encode thread only clones a handle into a bounded
9//! per-client channel drained by a dedicated writer thread. A slow or stalled recorder blocks
10//! nothing but itself and is dropped once its queue overflows. A newly connected client arms
11//! [`RecordingSink::should_force_idr`] so the next encode emits an IDR it can decode from.
12
13use std::fs;
14use std::io::{ErrorKind, Write};
15use std::os::unix::net::UnixListener;
16use std::sync::atomic::{AtomicBool, Ordering};
17use std::sync::{Arc, Mutex};
18use std::thread;
19use std::time::Duration;
20
21use crossbeam_channel::{bounded, Sender, TrySendError};
22
23use crate::encoders::software::EncodedStripe;
24
25/// Per-write timeout on a client stream; a stalled write surfaces as a soft error that
26/// [`write_all_frame`] retries, keeping the writer thread responsive to teardown.
27const WRITE_TIMEOUT: Duration = Duration::from_millis(100);
28
29/// How often the non-blocking accept loop retries when no client is waiting.
30const ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(50);
31
32/// Per-client backlog bound. A recorder that falls this far behind is dropped rather than
33/// allowed to grow memory or push back on the shared encode thread.
34const CLIENT_QUEUE_CAP: usize = 256;
35
36/// A queued frame: the `Arc`-shared payload plus the byte offset where the recordable
37/// Annex-B stream starts (past the wire header, or `0` when the payload is bare).
38type QueuedFrame = (Arc<Vec<u8>>, usize);
39
40/// The sink's handle to one connected recorder: the feed end of its bounded queue and a kill
41/// switch for its writer thread. The socket itself is owned solely by that writer thread.
42struct ClientHandle {
43    tx: Sender<QueuedFrame>,
44    stop: Arc<AtomicBool>,
45}
46
47/// Unix-socket fan-out that broadcasts every encoded H.264 frame to connected consumers.
48///
49/// A listener thread accepts connections and gives each its own bounded queue and writer thread
50/// (see [`ClientHandle`]) so one slow reader cannot stall the others or the encode thread.
51pub struct RecordingSink {
52    /// Filesystem path of the Unix socket; removed on drop.
53    path: String,
54    /// Feed handles for the connected clients, shared with the accept thread.
55    clients: Arc<Mutex<Vec<ClientHandle>>>,
56    /// Signals the accept thread to exit; set in [`Drop`].
57    shutdown: Arc<AtomicBool>,
58    /// Flipped to `true` each time a new client connects; consumed by [`should_force_idr`].
59    ///
60    /// [`should_force_idr`]: RecordingSink::should_force_idr
61    client_connected: Arc<AtomicBool>,
62    /// One-time notice that the session's H.264 frames are striped and unrecordable.
63    warned_unrecordable: AtomicBool,
64}
65
66impl RecordingSink {
67    /// Bind a Unix socket at `settings_path`, or return `None` when no path is configured or the
68    /// bind fails. Recording is an optional tap that must never take the pipeline down, so a bind
69    /// error is logged and swallowed.
70    pub fn try_bind(settings_path: &str) -> Option<Arc<Self>> {
71        if settings_path.is_empty() {
72            return None;
73        }
74        match Self::bind(settings_path.to_string()) {
75            Ok(sink) => Some(Arc::new(sink)),
76            Err(e) => {
77                eprintln!("[recording_sink] bind failed: {:?}", e);
78                None
79            }
80        }
81    }
82
83    /// Create the socket and spawn the accept thread. Each accepted connection gets a write
84    /// timeout, a bounded queue, and a writer thread; the sink keeps only the feed handle.
85    fn bind(path: String) -> std::io::Result<Self> {
86        let _ = fs::remove_file(&path);
87        let listener = UnixListener::bind(&path)?;
88        listener.set_nonblocking(true)?;
89
90        let clients: Arc<Mutex<Vec<ClientHandle>>> = Arc::new(Mutex::new(Vec::new()));
91        let shutdown = Arc::new(AtomicBool::new(false));
92        let client_connected = Arc::new(AtomicBool::new(false));
93
94        let clients_acc = clients.clone();
95        let shutdown_acc = shutdown.clone();
96        let client_connected_acc = client_connected.clone();
97        let path_log = path.clone();
98
99        thread::spawn(move || {
100            eprintln!("[recording_sink] listening on {}", path_log);
101            while !shutdown_acc.load(Ordering::Relaxed) {
102                match listener.accept() {
103                    Ok((stream, _)) => {
104                        if let Err(e) = stream.set_write_timeout(Some(WRITE_TIMEOUT)) {
105                            eprintln!("[recording_sink] set_write_timeout failed: {:?}", e);
106                            continue;
107                        }
108
109                        let (tx, rx) = bounded::<QueuedFrame>(CLIENT_QUEUE_CAP);
110                        let stop = Arc::new(AtomicBool::new(false));
111                        let stop_writer = stop.clone();
112                        thread::spawn(move || {
113                            let mut stream = stream;
114                            for (buf, offset) in rx.iter() {
115                                if stop_writer.load(Ordering::Relaxed) {
116                                    break;
117                                }
118                                if let Err(e) =
119                                    write_all_frame(&mut stream, &buf[offset..], &stop_writer)
120                                {
121                                    eprintln!(
122                                        "[recording_sink] writer thread exiting; write failed: {:?}",
123                                        e
124                                    );
125                                    break;
126                                }
127                            }
128                        });
129
130                        let mut guard = clients_acc.lock().unwrap();
131                        guard.push(ClientHandle { tx, stop });
132                        client_connected_acc.store(true, Ordering::Relaxed);
133                        eprintln!("[recording_sink] client connected; total {}", guard.len());
134                    }
135                    Err(e) if e.kind() == ErrorKind::WouldBlock => {
136                        thread::sleep(ACCEPT_POLL_INTERVAL);
137                    }
138                    Err(e) => {
139                        eprintln!("[recording_sink] accept error: {:?}", e);
140                        thread::sleep(Duration::from_millis(500));
141                    }
142                }
143            }
144            eprintln!("[recording_sink] listener thread exiting");
145        });
146
147        Ok(Self {
148            path,
149            clients,
150            shutdown,
151            client_connected,
152            warned_unrecordable: AtomicBool::new(false),
153        })
154    }
155
156    /// Returns `true` exactly once after a new client connects, signalling that the next encode
157    /// should produce an IDR so the consumer starts from a clean reference frame.
158    pub fn should_force_idr(&self) -> bool {
159        self.client_connected.swap(false, Ordering::Relaxed)
160    }
161
162    /// Delivery-layer tap for one encoded frame. The socket carries a single H.264
163    /// elementary stream, so only a lone full-height stripe (`data_type == 2`) is
164    /// recordable: striped CPU encodes are N independent per-stripe streams, and
165    /// interleaving them would produce an undecodable file — those are skipped with a
166    /// one-time notice (live streaming is unaffected). The 10-byte wire header
167    /// (`0x04` tag) is skipped via the queued offset so consumers receive plain
168    /// Annex-B.
169    ///
170    /// Never blocks and never copies: the `Arc` payload is cloned into each client's
171    /// bounded queue with `try_send`, and a client whose queue is full or whose
172    /// writer died is dropped.
173    pub fn write_frame(&self, stripes: &[EncodedStripe], full_height: i32) {
174        let mut h264 = stripes
175            .iter()
176            .filter(|s| s.data_type == 2 && !s.data.is_empty());
177        let Some(stripe) = h264.next() else { return };
178        if h264.next().is_some() || stripe.stripe_y_start != 0 || stripe.stripe_height != full_height
179        {
180            if !self.warned_unrecordable.swap(true, Ordering::Relaxed) {
181                eprintln!(
182                    "[recording_sink] WARNING: striped H.264 frames are not recordable \
183                     (the socket carries one elementary stream); use a full-frame encoder \
184                     to record this session"
185                );
186            }
187            return;
188        }
189        let offset = if stripe.data.len() >= 10 && stripe.data[0] == 0x04 {
190            10
191        } else {
192            0
193        };
194        if stripe.data.len() == offset {
195            return;
196        }
197
198        let mut clients = self.clients.lock().unwrap();
199        if clients.is_empty() {
200            return;
201        }
202        let mut to_remove: Vec<usize> = Vec::new();
203        for (idx, client) in clients.iter().enumerate() {
204            match client.tx.try_send((stripe.data.clone(), offset)) {
205                Ok(()) => {}
206                Err(TrySendError::Full(_)) => {
207                    eprintln!("[recording_sink] dropping slow client (idx {})", idx);
208                    to_remove.push(idx);
209                }
210                Err(TrySendError::Disconnected(_)) => {
211                    to_remove.push(idx);
212                }
213            }
214        }
215        for idx in to_remove.into_iter().rev() {
216            let removed = clients.swap_remove(idx);
217            removed.stop.store(true, Ordering::Relaxed);
218        }
219    }
220}
221
222impl Drop for RecordingSink {
223    /// Stop accepting, release every writer thread (set each `stop`, then drop its sender so an
224    /// idle writer parked on `rx.iter()` wakes), and remove the socket file.
225    fn drop(&mut self) {
226        self.shutdown.store(true, Ordering::Relaxed);
227        if let Ok(mut clients) = self.clients.lock() {
228            for client in clients.iter() {
229                client.stop.store(true, Ordering::Relaxed);
230            }
231            clients.clear();
232        }
233        let _ = fs::remove_file(&self.path);
234    }
235}
236
237/// Write one whole frame to a recorder's socket, resuming across the soft timeouts a slow reader
238/// induces so a partial Annex-B NAL is never left behind. Aborts if `stop` is set (the client was
239/// dropped by [`RecordingSink::write_encoded_frame`]) or a hard error occurs.
240fn write_all_frame<W: Write>(stream: &mut W, buf: &[u8], stop: &AtomicBool) -> std::io::Result<()> {
241    let mut written = 0usize;
242    while written < buf.len() {
243        if stop.load(Ordering::Relaxed) {
244            return Err(std::io::Error::other("writer stopped (client dropped)"));
245        }
246        match stream.write(&buf[written..]) {
247            Ok(0) => {
248                return Err(std::io::Error::new(
249                    ErrorKind::WriteZero,
250                    "failed to write whole frame",
251                ));
252            }
253            Ok(n) => written += n,
254            Err(ref e) if e.kind() == ErrorKind::TimedOut => {}
255            Err(ref e) if e.kind() == ErrorKind::WouldBlock => {
256                thread::sleep(Duration::from_millis(5));
257            }
258            Err(ref e) if e.kind() == ErrorKind::Interrupted => {}
259            Err(e) => return Err(e),
260        }
261    }
262    Ok(())
263}
264
265#[cfg(test)]
266mod cost_tests {
267    //! The sink's isolation contract, measured: feeding a frame must cost nothing when no
268    //! recorder is connected (empty-clients early return), microseconds when a healthy
269    //! recorder drains its queue, and stay bounded (a lock + `try_send`, never a blocking
270    //! write) when a recorder stalls completely — until the bounded queue overflows and the
271    //! client is dropped, returning the tap to idle cost.
272
273    use super::*;
274    use std::io::Read;
275    use std::os::unix::net::UnixStream;
276    use std::time::Instant;
277
278    fn frame(len: usize) -> EncodedStripe {
279        let mut data = vec![0u8; len];
280        data[0] = 0x04; // wire-header tag so the 10-byte strip path runs
281        EncodedStripe {
282            data: Arc::new(data),
283            data_type: 2,
284            stripe_y_start: 0,
285            stripe_height: 720,
286            frame_id: 0,
287        }
288    }
289
290    fn feed_timed(sink: &RecordingSink, n: usize, len: usize) -> (f64, f64) {
291        let f = frame(len);
292        let mut max_us = 0f64;
293        let mut total_us = 0f64;
294        for _ in 0..n {
295            let t = Instant::now();
296            sink.write_frame(std::slice::from_ref(&f), 720);
297            let us = t.elapsed().as_secs_f64() * 1e6;
298            total_us += us;
299            max_us = max_us.max(us);
300            thread::sleep(Duration::from_micros(200));
301        }
302        (total_us / n as f64, max_us)
303    }
304
305    #[test]
306    fn stalled_recorder_isolation_cost() {
307        let path = format!("/tmp/pf-sink-cost-{}.sock", std::process::id());
308        let sink = RecordingSink::try_bind(&path).expect("bind");
309
310        // Idle: no client connected.
311        let (idle_mean, idle_max) = feed_timed(&sink, 500, 100_000);
312
313        // Healthy: a client draining as fast as it can.
314        let mut healthy = UnixStream::connect(&path).expect("connect");
315        thread::sleep(Duration::from_millis(200));
316        let drain = thread::spawn(move || {
317            let mut buf = vec![0u8; 1 << 20];
318            while healthy.read(&mut buf).map(|n| n > 0).unwrap_or(false) {}
319        });
320        let (healthy_mean, healthy_max) = feed_timed(&sink, 500, 100_000);
321
322        // Stalled: a connected client that never reads. The socket buffer fills, then the
323        // bounded queue fills, then the client is dropped (~256 frames later).
324        let stalled = UnixStream::connect(&path).expect("connect");
325        thread::sleep(Duration::from_millis(200));
326        let (stalled_mean, stalled_max) = feed_timed(&sink, 500, 100_000);
327        drop(stalled);
328
329        println!(
330            "[sink-cost] idle    mean {idle_mean:.3}us max {idle_max:.3}us\n\
331             [sink-cost] healthy mean {healthy_mean:.3}us max {healthy_max:.3}us\n\
332             [sink-cost] stalled mean {stalled_mean:.3}us max {stalled_max:.3}us"
333        );
334        drop(sink);
335        let _ = drain.join();
336
337        assert!(idle_mean < 5.0, "idle feed should be sub-5us, was {idle_mean:.3}us");
338        assert!(healthy_mean < 100.0, "healthy feed should be tens of us, was {healthy_mean:.3}us");
339        assert!(
340            stalled_max < 10_000.0,
341            "a stalled recorder must never block the tap >10ms, was {stalled_max:.3}us"
342        );
343    }
344}