Skip to main content

rns_net/interface/
pipe.rs

1//! Pipe interface: subprocess stdin/stdout with HDLC framing.
2//!
3//! Matches Python `PipeInterface.py`. Spawns a subprocess, communicates
4//! via piped stdin/stdout using HDLC framing for packet boundaries.
5//! Auto-respawns subprocess on failure.
6
7use std::collections::HashMap;
8use std::io::{self, Read, Write};
9use std::os::fd::AsRawFd;
10use std::process::{Command, Stdio};
11use std::sync::{Arc, Mutex};
12use std::thread;
13use std::time::Duration;
14
15use rns_core::transport::types::{InterfaceId, InterfaceInfo};
16
17use super::{InterfaceConfigData, InterfaceFactory, StartContext, StartResult};
18use crate::event::{Event, EventSender, LoopContext, LoopSource, SourceStatus};
19use crate::hdlc;
20use crate::interface::{lock_or_recover, Writer};
21
22/// Configuration for a pipe interface.
23#[derive(Debug, Clone)]
24pub struct PipeConfig {
25    pub name: String,
26    pub command: String,
27    pub respawn_delay: Duration,
28    pub interface_id: InterfaceId,
29    pub runtime: Arc<Mutex<PipeRuntime>>,
30}
31
32#[derive(Debug, Clone)]
33pub struct PipeRuntime {
34    pub respawn_delay: Duration,
35}
36
37impl PipeRuntime {
38    pub fn from_config(config: &PipeConfig) -> Self {
39        Self {
40            respawn_delay: config.respawn_delay,
41        }
42    }
43}
44
45#[derive(Debug, Clone)]
46pub struct PipeRuntimeConfigHandle {
47    pub interface_name: String,
48    pub runtime: Arc<Mutex<PipeRuntime>>,
49    pub startup: PipeRuntime,
50}
51
52impl Default for PipeConfig {
53    fn default() -> Self {
54        let mut config = PipeConfig {
55            name: String::new(),
56            command: String::new(),
57            respawn_delay: Duration::from_secs(5),
58            interface_id: InterfaceId(0),
59            runtime: Arc::new(Mutex::new(PipeRuntime {
60                respawn_delay: Duration::from_secs(5),
61            })),
62        };
63        let startup = PipeRuntime::from_config(&config);
64        config.runtime = Arc::new(Mutex::new(startup));
65        config
66    }
67}
68
69/// Writer that sends HDLC-framed data to a subprocess stdin.
70struct PipeWriter {
71    stdin: std::process::ChildStdin,
72}
73
74impl Writer for PipeWriter {
75    fn send_frame(&mut self, data: &[u8]) -> io::Result<()> {
76        self.stdin.write_all(&hdlc::frame(data))
77    }
78
79    fn loop_socket(&self) -> Option<std::os::fd::RawFd> {
80        // With interface I/O folded the driver writes the pipe itself.
81        crate::engine::FOLD_IO.then(|| self.stdin.as_raw_fd())
82    }
83}
84
85/// Start the pipe interface. Spawns subprocess, returns writer.
86pub fn start(config: PipeConfig, tx: EventSender) -> io::Result<Box<dyn Writer>> {
87    let id = config.interface_id;
88    {
89        let startup = PipeRuntime::from_config(&config);
90        *lock_or_recover(&config.runtime, "pipe runtime") = startup;
91    }
92
93    let mut child = spawn_child(&config.command)?;
94
95    let stdout = child
96        .stdout
97        .take()
98        .ok_or_else(|| io::Error::other("no stdout from child"))?;
99    let stdin = child
100        .stdin
101        .take()
102        .ok_or_else(|| io::Error::other("no stdin from child"))?;
103
104    log::info!(
105        "[{}] pipe interface started: {}",
106        config.name,
107        config.command
108    );
109
110    // Signal interface up
111    let _ = tx.send(Event::InterfaceUp(id, None, None));
112
113    if crate::engine::FOLD_IO {
114        Arc::new(PipeLink { config, tx }).serve(stdout, child)?;
115        return Ok(Box::new(PipeWriter { stdin }));
116    }
117
118    // Spawn reader thread
119    thread::Builder::new()
120        .name(format!("pipe-reader-{}", id.0))
121        .spawn(move || {
122            reader_loop(stdout, child, id, config, tx);
123        })?;
124
125    Ok(Box::new(PipeWriter { stdin }))
126}
127
128fn spawn_child(command: &str) -> io::Result<std::process::Child> {
129    Command::new("sh")
130        .args(["-c", command])
131        .stdin(Stdio::piped())
132        .stdout(Stdio::piped())
133        .stderr(Stdio::null())
134        .spawn()
135}
136
137/// Reader loop: reads from subprocess stdout, HDLC-decodes, dispatches events.
138/// On subprocess exit, attempts respawn.
139fn reader_loop(
140    mut stdout: std::process::ChildStdout,
141    mut child: std::process::Child,
142    id: InterfaceId,
143    config: PipeConfig,
144    tx: EventSender,
145) {
146    let mut decoder = hdlc::Decoder::new();
147    let mut buf = [0u8; 4096];
148
149    loop {
150        match stdout.read(&mut buf) {
151            Ok(0) => {
152                // EOF — subprocess exited
153                let _ = child.wait();
154                log::warn!("[{}] subprocess terminated", config.name);
155                let _ = tx.send(Event::InterfaceDown(id));
156                match respawn(&config, &tx) {
157                    Some((new_stdout, new_child)) => {
158                        stdout = new_stdout;
159                        child = new_child;
160                        decoder = hdlc::Decoder::new();
161                        continue;
162                    }
163                    None => return,
164                }
165            }
166            Ok(n) => {
167                for frame in decoder.feed(&buf[..n]) {
168                    if tx
169                        .send(Event::Frame {
170                            interface_id: id,
171                            data: frame.into(),
172                            rssi: None,
173                            snr: None,
174                        })
175                        .is_err()
176                    {
177                        // Driver shut down
178                        let _ = child.kill();
179                        return;
180                    }
181                }
182            }
183            Err(e) => {
184                log::warn!("[{}] pipe read error: {}", config.name, e);
185                let _ = child.kill();
186                let _ = child.wait();
187                let _ = tx.send(Event::InterfaceDown(id));
188                match respawn(&config, &tx) {
189                    Some((new_stdout, new_child)) => {
190                        stdout = new_stdout;
191                        child = new_child;
192                        decoder = hdlc::Decoder::new();
193                        continue;
194                    }
195                    None => return,
196                }
197            }
198        }
199    }
200}
201
202/// Attempt to respawn the subprocess after a delay.
203fn respawn(
204    config: &PipeConfig,
205    tx: &EventSender,
206) -> Option<(std::process::ChildStdout, std::process::Child)> {
207    loop {
208        let respawn_delay = lock_or_recover(&config.runtime, "pipe runtime").respawn_delay;
209        thread::sleep(respawn_delay);
210        log::info!(
211            "[{}] attempting to respawn subprocess: {}",
212            config.name,
213            config.command
214        );
215
216        match spawn_child(&config.command) {
217            Ok(mut child) => {
218                let stdout = match child.stdout.take() {
219                    Some(s) => s,
220                    None => {
221                        let _ = child.kill();
222                        let _ = child.wait();
223                        continue;
224                    }
225                };
226                let stdin = match child.stdin.take() {
227                    Some(s) => s,
228                    None => {
229                        let _ = child.kill();
230                        let _ = child.wait();
231                        continue;
232                    }
233                };
234
235                let new_writer: Box<dyn Writer> = Box::new(PipeWriter { stdin });
236                if tx
237                    .send(Event::InterfaceUp(
238                        config.interface_id,
239                        Some(new_writer),
240                        None,
241                    ))
242                    .is_err()
243                {
244                    return None; // Driver shut down
245                }
246                log::info!("[{}] subprocess respawned", config.name);
247                return Some((stdout, child));
248            }
249            Err(e) => {
250                log::warn!("[{}] respawn failed: {}", config.name, e);
251            }
252        }
253    }
254}
255
256/// What reading a subprocess on the driver loop needs across respawns (see
257/// [`crate::engine::FOLD_IO`]).
258struct PipeLink {
259    config: PipeConfig,
260    tx: EventSender,
261}
262
263impl PipeLink {
264    /// Read `stdout` on the driver thread from now on.
265    fn serve(
266        self: &Arc<Self>,
267        stdout: std::process::ChildStdout,
268        child: std::process::Child,
269    ) -> io::Result<()> {
270        crate::interface::stream_read::set_nonblocking(stdout.as_raw_fd())?;
271        self.tx.register_source(
272            crate::interface::loop_writer::read_key(self.config.interface_id),
273            Box::new(PipeSource {
274                stdout,
275                child: Some(child),
276                decoder: hdlc::Decoder::new(),
277                link: Arc::clone(self),
278            }),
279        )
280    }
281
282    /// Report the interface down, and on a thread of its own wait for the
283    /// subprocess, respawn it, and serve the new one on the driver.
284    fn subprocess_lost(self: &Arc<Self>, mut child: std::process::Child, kill: bool) {
285        let id = self.config.interface_id;
286        let _ = self.tx.push_from_loop([Event::InterfaceDown(id)]);
287        let link = Arc::clone(self);
288        let spawned = thread::Builder::new()
289            .name(format!("pipe-respawn-{}", id.0))
290            .spawn(move || {
291                if kill {
292                    let _ = child.kill();
293                }
294                let _ = child.wait();
295                while let Some((stdout, child)) = respawn(&link.config, &link.tx) {
296                    match link.serve(stdout, child) {
297                        Ok(()) => return,
298                        Err(e) => {
299                            log::warn!("[{}] cannot read the subprocess: {}", link.config.name, e);
300                            let _ = link.tx.send(Event::InterfaceDown(id));
301                        }
302                    }
303                }
304            });
305        if let Err(e) = spawned {
306            log::error!(
307                "[{}] cannot start respawning, giving up: {}",
308                self.config.name,
309                e
310            );
311        }
312    }
313}
314
315/// A subprocess's stdout, read on the driver thread.
316struct PipeSource {
317    stdout: std::process::ChildStdout,
318    /// Taken when the subprocess is handed on: to the respawn thread, or
319    /// killed when the source goes away.
320    child: Option<std::process::Child>,
321    decoder: hdlc::Decoder,
322    link: Arc<PipeLink>,
323}
324
325impl LoopSource for PipeSource {
326    fn fd(&self) -> std::os::fd::RawFd {
327        self.stdout.as_raw_fd()
328    }
329
330    fn on_ready(&mut self, cx: &mut LoopContext<'_>) -> SourceStatus {
331        use crate::interface::stream_read::{read_device_frames, StreamRead};
332        let link = &self.link;
333        let name = &link.config.name;
334        let kill = match read_device_frames(
335            self.stdout.as_raw_fd(),
336            &mut self.decoder,
337            cx,
338            &link.tx,
339            link.config.interface_id,
340            name,
341            |_, _| {},
342        ) {
343            StreamRead::Rearm => return SourceStatus::Rearm,
344            StreamRead::Suspend => return SourceStatus::Suspend,
345            StreamRead::DriverGone => return SourceStatus::Remove,
346            StreamRead::Closed => {
347                log::warn!("[{}] subprocess terminated", name);
348                false
349            }
350            StreamRead::Failed(e) => {
351                log::warn!("[{}] pipe read error: {}", name, e);
352                true
353            }
354        };
355        if let Some(child) = self.child.take() {
356            link.subprocess_lost(child, kill);
357        }
358        SourceStatus::Remove
359    }
360
361    fn on_removed(&mut self) {
362        // The driver is going away: so does the subprocess, as with the
363        // reader thread.
364        if let Some(mut child) = self.child.take() {
365            let _ = child.kill();
366            let _ = child.wait();
367        }
368    }
369}
370
371/// Factory for [`PipeInterface`] instances.
372pub struct PipeFactory;
373
374impl InterfaceFactory for PipeFactory {
375    fn type_name(&self) -> &str {
376        "PipeInterface"
377    }
378
379    fn parse_config(
380        &self,
381        name: &str,
382        id: InterfaceId,
383        params: &HashMap<String, String>,
384    ) -> Result<Box<dyn InterfaceConfigData>, String> {
385        let command = params
386            .get("command")
387            .ok_or_else(|| "PipeInterface requires 'command'".to_string())?
388            .clone();
389
390        let respawn_delay = match params.get("respawn_delay") {
391            Some(v) => {
392                let ms: u64 = v
393                    .parse()
394                    .map_err(|_| format!("invalid respawn_delay: {}", v))?;
395                Duration::from_millis(ms)
396            }
397            None => Duration::from_secs(5),
398        };
399
400        Ok(Box::new(PipeConfig {
401            name: name.to_string(),
402            command,
403            respawn_delay,
404            interface_id: id,
405            runtime: Arc::new(Mutex::new(PipeRuntime { respawn_delay })),
406        }))
407    }
408
409    fn start(
410        &self,
411        config: Box<dyn InterfaceConfigData>,
412        ctx: StartContext,
413    ) -> io::Result<StartResult> {
414        let pipe_config = *config
415            .into_any()
416            .downcast::<PipeConfig>()
417            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "wrong config type"))?;
418
419        let id = pipe_config.interface_id;
420        let info = InterfaceInfo {
421            stamp_costs: Default::default(),
422            id,
423            name: pipe_config.name.clone(),
424            mode: ctx.mode,
425            gravity: ctx.gravity,
426            recursive_prs: ctx.recursive_prs,
427            announces_from_internal: ctx.announces_from_internal,
428            announces_to_internal: ctx.announces_to_internal,
429            out_capable: true,
430            in_capable: true,
431            bitrate: Some(1_000_000),
432            airtime_profile: None,
433            announce_rate_target: None,
434            announce_rate_grace: 0,
435            announce_rate_penalty: 0.0,
436            announce_cap: rns_core::constants::ANNOUNCE_CAP,
437            is_local_client: false,
438            wants_tunnel: false,
439            tunnel_id: None,
440            mtu: rns_core::constants::MTU as u32,
441            ingress_control: rns_core::transport::types::IngressControlConfig::disabled(),
442            ia_freq: 0.0,
443            ip_freq: 0.0,
444            op_freq: 0.0,
445            op_samples: 0,
446            started: crate::time::now(),
447        };
448
449        let writer = start(pipe_config, ctx.tx)?;
450
451        Ok(StartResult::Simple {
452            control: None,
453            id,
454            info,
455            writer,
456            interface_type_name: "PipeInterface".to_string(),
457        })
458    }
459}
460
461pub(crate) fn pipe_runtime_handle_from_config(config: &PipeConfig) -> PipeRuntimeConfigHandle {
462    PipeRuntimeConfigHandle {
463        interface_name: config.name.clone(),
464        runtime: Arc::clone(&config.runtime),
465        startup: PipeRuntime::from_config(config),
466    }
467}
468
469#[cfg(test)]
470mod tests {
471    use super::*;
472
473    #[test]
474    fn pipe_start_and_receive() {
475        // Use `cat` as a loopback subprocess
476        let (tx, rx) = crate::event::channel();
477        let config = PipeConfig {
478            name: "test-pipe".into(),
479            command: "cat".into(),
480            respawn_delay: Duration::from_secs(1),
481            interface_id: InterfaceId(100),
482            runtime: Arc::new(Mutex::new(PipeRuntime {
483                respawn_delay: Duration::from_secs(1),
484            })),
485        };
486
487        let mut writer = start(config, tx).unwrap();
488
489        // Drain InterfaceUp
490        let event = rx.recv_timeout(Duration::from_secs(2)).unwrap();
491        assert!(matches!(
492            event,
493            Event::InterfaceUp(InterfaceId(100), None, None)
494        ));
495
496        // Send a packet (>= 19 bytes for HDLC minimum)
497        let payload: Vec<u8> = (0..32).collect();
498        writer.send_frame(&payload).unwrap();
499
500        // Should receive Frame event (cat echos back the HDLC frame)
501        let event = rx.recv_timeout(Duration::from_secs(2)).unwrap();
502        match event {
503            Event::Frame {
504                interface_id,
505                data,
506                rssi: _,
507                snr: _,
508            } => {
509                assert_eq!(interface_id, InterfaceId(100));
510                assert_eq!(data, payload.into());
511            }
512            other => panic!("expected Frame, got {:?}", other),
513        }
514    }
515
516    #[test]
517    fn pipe_writer_sends() {
518        // Verify the writer wraps data in HDLC
519        let (tx, rx) = crate::event::channel();
520        let config = PipeConfig {
521            name: "test-pipe-writer".into(),
522            command: "cat".into(),
523            respawn_delay: Duration::from_secs(1),
524            interface_id: InterfaceId(101),
525            runtime: Arc::new(Mutex::new(PipeRuntime {
526                respawn_delay: Duration::from_secs(1),
527            })),
528        };
529
530        let mut writer = start(config, tx).unwrap();
531        let _ = rx.recv_timeout(Duration::from_secs(2)).unwrap(); // drain InterfaceUp
532
533        // Write data and verify we get it back as HDLC frame
534        let payload: Vec<u8> = (10..42).collect();
535        writer.send_frame(&payload).unwrap();
536
537        let event = rx.recv_timeout(Duration::from_secs(2)).unwrap();
538        match event {
539            Event::Frame { data, .. } => {
540                assert_eq!(data, payload.into());
541            }
542            other => panic!("expected Frame, got {:?}", other),
543        }
544    }
545
546    #[test]
547    fn pipe_subprocess_exit() {
548        // Use a command that exits immediately
549        let (tx, rx) = crate::event::channel();
550        let config = PipeConfig {
551            name: "test-pipe-exit".into(),
552            command: "true".into(),                 // exits immediately with 0
553            respawn_delay: Duration::from_secs(60), // long delay so we catch InterfaceDown
554            interface_id: InterfaceId(102),
555            runtime: Arc::new(Mutex::new(PipeRuntime {
556                respawn_delay: Duration::from_secs(60),
557            })),
558        };
559
560        let _writer = start(config, tx).unwrap();
561
562        // Should get InterfaceUp then InterfaceDown
563        let mut got_down = false;
564        for _ in 0..5 {
565            match rx.recv_timeout(Duration::from_secs(2)) {
566                Ok(Event::InterfaceDown(InterfaceId(102))) => {
567                    got_down = true;
568                    break;
569                }
570                Ok(_) => continue,
571                Err(_) => break,
572            }
573        }
574        assert!(
575            got_down,
576            "should receive InterfaceDown after subprocess exits"
577        );
578    }
579
580    #[test]
581    fn folded_pipe_runs_on_the_driver_loop_and_respawns() {
582        if !crate::engine::FOLD_IO {
583            return;
584        }
585        let (tx, rx) = crate::event::channel();
586        let config = PipeConfig {
587            name: "test-pipe-folded".into(),
588            // Echoes one framed 32-byte packet, then exits.
589            command: "head -c 34".into(),
590            respawn_delay: Duration::from_millis(100),
591            interface_id: InterfaceId(104),
592            runtime: Arc::new(Mutex::new(PipeRuntime {
593                respawn_delay: Duration::from_millis(100),
594            })),
595        };
596        let writer = start(config, tx.clone()).unwrap();
597        assert!(writer.loop_socket().is_some());
598        let (mut writer, _) =
599            crate::interface::wrap_async_writer(writer, InterfaceId(104), "pipe", tx.clone(), 16);
600        assert!(matches!(
601            rx.recv_timeout(Duration::from_secs(2)).unwrap(),
602            Event::InterfaceUp(InterfaceId(104), None, None)
603        ));
604        let threads = || {
605            std::fs::read_dir("/proc/self/task")
606                .unwrap()
607                .filter_map(|task| std::fs::read_to_string(task.unwrap().path().join("comm")).ok())
608                .collect::<Vec<_>>()
609        };
610        assert!(!threads().iter().any(|t| t.starts_with("pipe-reader-104")));
611
612        // Written and read back by the driver.
613        let payload: Vec<u8> = (0..32).collect();
614        writer.send_frame(&payload).unwrap();
615        writer.flush_pending();
616        match rx.recv_timeout(Duration::from_secs(2)).unwrap() {
617            Event::Frame { data, .. } => assert_eq!(data, payload.clone().into()),
618            other => panic!("expected Frame, got {other:?}"),
619        }
620
621        // The subprocess exits after one packet: the interface goes down,
622        // and a new one is spawned and served.
623        let mut down = false;
624        let mut new_writer = None;
625        let deadline = std::time::Instant::now() + Duration::from_secs(5);
626        while new_writer.is_none() && std::time::Instant::now() < deadline {
627            match rx.recv_timeout(Duration::from_millis(500)) {
628                Ok(Event::InterfaceDown(InterfaceId(104))) => down = true,
629                Ok(Event::InterfaceUp(InterfaceId(104), Some(writer), None)) => {
630                    new_writer = Some(writer)
631                }
632                _ => {}
633            }
634        }
635        assert!(down);
636        let (mut writer, _) = crate::interface::wrap_async_writer(
637            new_writer.expect("respawned"),
638            InterfaceId(104),
639            "pipe",
640            tx,
641            16,
642        );
643        writer.send_frame(&payload).unwrap();
644        writer.flush_pending();
645        match rx.recv_timeout(Duration::from_secs(2)).unwrap() {
646            Event::Frame { data, .. } => assert_eq!(data, payload.into()),
647            other => panic!("expected Frame, got {other:?}"),
648        }
649    }
650
651    #[test]
652    fn pipe_config_defaults() {
653        let config = PipeConfig::default();
654        assert_eq!(config.respawn_delay, Duration::from_secs(5));
655        assert_eq!(config.interface_id, InterfaceId(0));
656        assert!(config.command.is_empty());
657    }
658
659    #[test]
660    fn pipe_invalid_command() {
661        let (tx, _rx) = crate::event::channel();
662        let config = PipeConfig {
663            name: "test-pipe-bad".into(),
664            command: "/nonexistent_rns_test_binary_that_does_not_exist_xyz".into(),
665            respawn_delay: Duration::from_secs(60),
666            interface_id: InterfaceId(103),
667            runtime: Arc::new(Mutex::new(PipeRuntime {
668                respawn_delay: Duration::from_secs(60),
669            })),
670        };
671
672        // sh -c <nonexistent> will start sh successfully but the child exits immediately
673        // For a truly invalid binary we need to check that the process fails
674        // Actually, sh -c "<nonexistent>" will still spawn sh successfully
675        // Let's test with a different approach: verify it doesn't panic
676        let result = start(config, tx);
677        // sh will spawn successfully even if the inner command fails
678        // The real test is that it doesn't panic
679        assert!(result.is_ok());
680    }
681}