duende_platform/
native.rs

1//! Native process adapter (fallback).
2//!
3//! Spawns daemons as native OS processes without systemd/launchd integration.
4
5use async_trait::async_trait;
6use std::process::Stdio;
7use tokio::process::Command;
8
9use duende_core::{Daemon, DaemonStatus, Signal};
10
11use crate::adapter::{DaemonHandle, PlatformAdapter, TracerHandle};
12use crate::detect::Platform;
13use crate::error::{PlatformError, Result};
14
15#[cfg(unix)]
16use nix::sys::signal::{Signal as NixSignal, kill as nix_kill};
17#[cfg(unix)]
18use nix::unistd::Pid;
19
20/// Native process adapter.
21///
22/// This is the fallback adapter when no platform-specific service manager
23/// is available. Daemons are spawned as regular OS processes.
24pub struct NativeAdapter {
25    // Future: could hold process handles for management
26}
27
28impl NativeAdapter {
29    /// Creates a new native adapter.
30    #[must_use]
31    pub const fn new() -> Self {
32        Self {}
33    }
34}
35
36impl Default for NativeAdapter {
37    fn default() -> Self {
38        Self::new()
39    }
40}
41
42#[async_trait]
43impl PlatformAdapter for NativeAdapter {
44    fn platform(&self) -> Platform {
45        Platform::Native
46    }
47
48    async fn spawn(&self, daemon: Box<dyn Daemon>) -> Result<DaemonHandle> {
49        let config = duende_core::DaemonConfig::new(daemon.name(), "/bin/sh");
50
51        // Build command
52        let mut cmd = Command::new(&config.binary_path);
53        cmd.args(&config.args)
54            .envs(&config.env)
55            .stdin(Stdio::null())
56            .stdout(Stdio::null())
57            .stderr(Stdio::null());
58
59        if let Some(ref cwd) = config.working_dir {
60            cmd.current_dir(cwd);
61        }
62
63        // Spawn process
64        let child = cmd
65            .spawn()
66            .map_err(|e| PlatformError::spawn(format!("failed to spawn process: {e}")))?;
67
68        let pid = child
69            .id()
70            .ok_or_else(|| PlatformError::spawn("process has no PID"))?;
71
72        tracing::info!(pid = pid, name = daemon.name(), "spawned native daemon");
73
74        Ok(DaemonHandle::native(pid))
75    }
76
77    async fn signal(&self, handle: &DaemonHandle, sig: Signal) -> Result<()> {
78        let pid = handle
79            .pid
80            .ok_or_else(|| PlatformError::signal("no PID available"))?;
81
82        #[cfg(unix)]
83        {
84            let nix_signal = match sig {
85                Signal::Hup => NixSignal::SIGHUP,
86                Signal::Int => NixSignal::SIGINT,
87                Signal::Quit => NixSignal::SIGQUIT,
88                Signal::Term => NixSignal::SIGTERM,
89                Signal::Kill => NixSignal::SIGKILL,
90                Signal::Usr1 => NixSignal::SIGUSR1,
91                Signal::Usr2 => NixSignal::SIGUSR2,
92                Signal::Stop => NixSignal::SIGSTOP,
93                Signal::Cont => NixSignal::SIGCONT,
94            };
95
96            #[allow(clippy::cast_possible_wrap)] // PID from u32 fits in i32 range
97            nix_kill(Pid::from_raw(pid as i32), nix_signal)
98                .map_err(|e| PlatformError::signal(format!("kill({pid}, {sig:?}) failed: {e}")))?;
99        }
100
101        #[cfg(not(unix))]
102        {
103            let _ = (pid, sig); // Suppress unused warnings
104            return Err(PlatformError::not_supported(
105                "signals not supported on this platform",
106            ));
107        }
108
109        tracing::debug!(pid = pid, signal = ?sig, "sent signal to native daemon");
110        Ok(())
111    }
112
113    async fn status(&self, handle: &DaemonHandle) -> Result<DaemonStatus> {
114        let pid = handle
115            .pid
116            .ok_or_else(|| PlatformError::Status("no PID available".to_string()))?;
117
118        #[cfg(unix)]
119        {
120            // Check if process exists by sending signal 0 (null signal)
121            #[allow(clippy::cast_possible_wrap)] // PID from u32 fits in i32 range
122            match nix_kill(Pid::from_raw(pid as i32), None) {
123                Ok(()) => Ok(DaemonStatus::Running),
124                Err(nix::errno::Errno::ESRCH) => Ok(DaemonStatus::Stopped),
125                Err(e) => Err(PlatformError::Status(format!(
126                    "failed to check process: {e}"
127                ))),
128            }
129        }
130
131        #[cfg(not(unix))]
132        {
133            let _ = pid; // Suppress unused warning
134            Err(PlatformError::not_supported(
135                "process status not available on this platform",
136            ))
137        }
138    }
139
140    async fn attach_tracer(&self, handle: &DaemonHandle) -> Result<TracerHandle> {
141        let pid = handle
142            .pid
143            .ok_or_else(|| PlatformError::Tracer("no PID available".to_string()))?;
144
145        // Verify process exists before attempting tracer attachment
146        #[cfg(unix)]
147        {
148            #[allow(clippy::cast_possible_wrap)]
149            match nix_kill(Pid::from_raw(pid as i32), None) {
150                Ok(()) => {
151                    // Process exists, tracer can be attached
152                    tracing::info!(pid = pid, "tracer ready for attachment");
153                }
154                Err(nix::errno::Errno::ESRCH) => {
155                    return Err(PlatformError::Tracer(format!(
156                        "process {pid} does not exist"
157                    )));
158                }
159                Err(e) => {
160                    return Err(PlatformError::Tracer(format!(
161                        "cannot verify process {pid}: {e}"
162                    )));
163                }
164            }
165        }
166
167        #[cfg(not(unix))]
168        tracing::warn!(
169            pid = pid,
170            "tracer attachment not supported on this platform"
171        );
172
173        // Return tracer handle for renacer integration
174        // Full ptrace attachment is deferred to when tracing is actually needed
175        // since it requires CAP_SYS_PTRACE capability
176        Ok(TracerHandle {
177            platform: Platform::Native,
178            id: format!("renacer:{pid}"),
179        })
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn test_native_adapter_creation() {
189        let adapter = NativeAdapter::new();
190        assert_eq!(adapter.platform(), Platform::Native);
191    }
192
193    #[test]
194    fn test_native_adapter_default() {
195        let adapter = NativeAdapter::default();
196        assert_eq!(adapter.platform(), Platform::Native);
197    }
198
199    #[test]
200    fn test_daemon_handle_native() {
201        let handle = DaemonHandle::native(1234);
202        assert_eq!(handle.platform, Platform::Native);
203        assert_eq!(handle.pid, Some(1234));
204    }
205
206    #[tokio::test]
207    async fn test_signal_no_pid() {
208        let adapter = NativeAdapter::new();
209        let handle = DaemonHandle {
210            platform: Platform::Native,
211            pid: None,
212            id: "test".to_string(),
213        };
214
215        let result = adapter.signal(&handle, Signal::Term).await;
216        assert!(result.is_err());
217    }
218
219    #[tokio::test]
220    async fn test_status_no_pid() {
221        let adapter = NativeAdapter::new();
222        let handle = DaemonHandle {
223            platform: Platform::Native,
224            pid: None,
225            id: "test".to_string(),
226        };
227
228        let result = adapter.status(&handle).await;
229        assert!(result.is_err());
230    }
231
232    #[tokio::test]
233    async fn test_attach_tracer_no_pid() {
234        let adapter = NativeAdapter::new();
235        let handle = DaemonHandle {
236            platform: Platform::Native,
237            pid: None,
238            id: "test".to_string(),
239        };
240
241        let result = adapter.attach_tracer(&handle).await;
242        assert!(result.is_err());
243    }
244
245    #[tokio::test]
246    async fn test_attach_tracer_with_valid_pid() {
247        let adapter = NativeAdapter::new();
248        // Use our own PID which always exists
249        let pid = std::process::id();
250        let handle = DaemonHandle::native(pid);
251
252        let result = adapter.attach_tracer(&handle).await;
253        assert!(
254            result.is_ok(),
255            "attach_tracer should succeed for existing process"
256        );
257        let tracer = result.expect("tracer should succeed");
258        assert_eq!(tracer.platform, Platform::Native);
259        assert!(tracer.id.contains(&pid.to_string()));
260    }
261
262    #[cfg(unix)]
263    #[tokio::test]
264    async fn test_attach_tracer_nonexistent_pid() {
265        let adapter = NativeAdapter::new();
266        // Use a very high PID that should not exist
267        let handle = DaemonHandle::native(4000000);
268
269        let result = adapter.attach_tracer(&handle).await;
270        assert!(
271            result.is_err(),
272            "attach_tracer should fail for non-existent process"
273        );
274    }
275
276    #[cfg(unix)]
277    #[tokio::test]
278    async fn test_status_nonexistent_process() {
279        let adapter = NativeAdapter::new();
280        // Use a very high PID that should not exist
281        let handle = DaemonHandle::native(4000000);
282
283        let result = adapter.status(&handle).await;
284        assert!(result.is_ok());
285        assert_eq!(result.expect("status"), DaemonStatus::Stopped);
286    }
287
288    #[cfg(unix)]
289    #[tokio::test]
290    async fn test_status_current_process() {
291        let adapter = NativeAdapter::new();
292        // Check status of our own process (should be running)
293        let pid = std::process::id();
294        let handle = DaemonHandle::native(pid);
295
296        let result = adapter.status(&handle).await;
297        assert!(result.is_ok());
298        assert_eq!(result.expect("status"), DaemonStatus::Running);
299    }
300}