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!(pid = pid, "tracer attachment not supported on this platform");
169
170        // Return tracer handle for renacer integration
171        // Full ptrace attachment is deferred to when tracing is actually needed
172        // since it requires CAP_SYS_PTRACE capability
173        Ok(TracerHandle {
174            platform: Platform::Native,
175            id: format!("renacer:{pid}"),
176        })
177    }
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn test_native_adapter_creation() {
186        let adapter = NativeAdapter::new();
187        assert_eq!(adapter.platform(), Platform::Native);
188    }
189
190    #[test]
191    fn test_native_adapter_default() {
192        let adapter = NativeAdapter::default();
193        assert_eq!(adapter.platform(), Platform::Native);
194    }
195
196    #[test]
197    fn test_daemon_handle_native() {
198        let handle = DaemonHandle::native(1234);
199        assert_eq!(handle.platform, Platform::Native);
200        assert_eq!(handle.pid, Some(1234));
201    }
202
203    #[tokio::test]
204    async fn test_signal_no_pid() {
205        let adapter = NativeAdapter::new();
206        let handle = DaemonHandle {
207            platform: Platform::Native,
208            pid: None,
209            id: "test".to_string(),
210        };
211
212        let result = adapter.signal(&handle, Signal::Term).await;
213        assert!(result.is_err());
214    }
215
216    #[tokio::test]
217    async fn test_status_no_pid() {
218        let adapter = NativeAdapter::new();
219        let handle = DaemonHandle {
220            platform: Platform::Native,
221            pid: None,
222            id: "test".to_string(),
223        };
224
225        let result = adapter.status(&handle).await;
226        assert!(result.is_err());
227    }
228
229    #[tokio::test]
230    async fn test_attach_tracer_no_pid() {
231        let adapter = NativeAdapter::new();
232        let handle = DaemonHandle {
233            platform: Platform::Native,
234            pid: None,
235            id: "test".to_string(),
236        };
237
238        let result = adapter.attach_tracer(&handle).await;
239        assert!(result.is_err());
240    }
241
242    #[tokio::test]
243    async fn test_attach_tracer_with_valid_pid() {
244        let adapter = NativeAdapter::new();
245        // Use our own PID which always exists
246        let pid = std::process::id();
247        let handle = DaemonHandle::native(pid);
248
249        let result = adapter.attach_tracer(&handle).await;
250        assert!(result.is_ok(), "attach_tracer should succeed for existing process");
251        let tracer = result.expect("tracer should succeed");
252        assert_eq!(tracer.platform, Platform::Native);
253        assert!(tracer.id.contains(&pid.to_string()));
254    }
255
256    #[cfg(unix)]
257    #[tokio::test]
258    async fn test_attach_tracer_nonexistent_pid() {
259        let adapter = NativeAdapter::new();
260        // Use a very high PID that should not exist
261        let handle = DaemonHandle::native(4000000);
262
263        let result = adapter.attach_tracer(&handle).await;
264        assert!(result.is_err(), "attach_tracer should fail for non-existent process");
265    }
266
267    #[cfg(unix)]
268    #[tokio::test]
269    async fn test_status_nonexistent_process() {
270        let adapter = NativeAdapter::new();
271        // Use a very high PID that should not exist
272        let handle = DaemonHandle::native(4000000);
273
274        let result = adapter.status(&handle).await;
275        assert!(result.is_ok());
276        assert_eq!(result.expect("status"), DaemonStatus::Stopped);
277    }
278
279    #[cfg(unix)]
280    #[tokio::test]
281    async fn test_status_current_process() {
282        let adapter = NativeAdapter::new();
283        // Check status of our own process (should be running)
284        let pid = std::process::id();
285        let handle = DaemonHandle::native(pid);
286
287        let result = adapter.status(&handle).await;
288        assert!(result.is_ok());
289        assert_eq!(result.expect("status"), DaemonStatus::Running);
290    }
291}