Skip to main content

rskit_process/persistent/
process.rs

1use std::{
2    process::{Child, ExitStatus},
3    sync::{Arc, atomic::AtomicBool, atomic::Ordering},
4    thread,
5    time::{Duration, Instant},
6};
7
8use crate::worker::join_within;
9use crate::{
10    AppError, AppResult, ErrorCode, ProcessResult, SignalPolicy, terminate::terminate_and_reap,
11};
12
13use super::{
14    cancel::CancelThread,
15    io::{Capture, ReaderThread, StdinThread, take_capture},
16};
17
18/// Outcome of requesting persistent process shutdown.
19#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub enum ShutdownOutcome {
22    /// The process had already exited before shutdown was requested.
23    AlreadyExited(ProcessResult),
24    /// The process was stopped by the shutdown request.
25    Stopped(ProcessResult),
26}
27
28/// Running persistent process.
29#[derive(Debug)]
30pub struct PersistentProcess {
31    // Keep field order stable: drop order is part of the lifecycle safety story.
32    child: Child,
33    stdin_thread: StdinThread,
34    stdout_thread: ReaderThread,
35    stderr_thread: ReaderThread,
36    cancel_thread: Option<CancelThread>,
37    cancelled: Arc<AtomicBool>,
38    stdout: Capture,
39    stderr: Capture,
40    start: Instant,
41    signal: SignalPolicy,
42    shutdown_grace_period: Duration,
43    stopped: bool,
44}
45
46impl PersistentProcess {
47    /// Wait for the persistent process to exit naturally.
48    pub fn wait(mut self) -> AppResult<ProcessResult> {
49        self.wait_inner()
50    }
51
52    /// Gracefully stop the persistent process.
53    pub fn shutdown(mut self) -> AppResult<ShutdownOutcome> {
54        self.shutdown_inner()
55    }
56
57    fn wait_inner(&mut self) -> AppResult<ProcessResult> {
58        if self.stopped {
59            return Err(AppError::new(
60                ErrorCode::Conflict,
61                "persistent process already stopped",
62            ));
63        }
64        let status = wait_for_exit(
65            &mut self.child,
66            &self.cancel_thread,
67            self.signal,
68            self.shutdown_grace_period,
69        )?;
70        self.stopped = true;
71        self.stop_cancel_thread()?;
72        let cancelled = self.cancelled.load(Ordering::SeqCst);
73        self.completed_result(status, false, cancelled)
74    }
75
76    pub(in crate::persistent) fn shutdown_inner(&mut self) -> AppResult<ShutdownOutcome> {
77        if self.stopped {
78            return Err(AppError::new(
79                ErrorCode::Conflict,
80                "persistent process already stopped",
81            ));
82        }
83        if let Some(status) = self.child.try_wait().map_err(AppError::internal)? {
84            self.stopped = true;
85            self.stop_cancel_thread()?;
86            let cancelled = self.cancelled.load(Ordering::SeqCst);
87            return self
88                .completed_result(status, false, cancelled)
89                .map(ShutdownOutcome::AlreadyExited);
90        }
91
92        self.stop_cancel_thread()?;
93        let pid = self.child.id();
94        let (status, _escalated) = terminate_and_reap(
95            &mut self.child,
96            pid,
97            self.signal,
98            self.shutdown_grace_period,
99        )?;
100        self.stopped = true;
101        let cancelled = self.cancelled.load(Ordering::SeqCst);
102        self.completed_result(status, false, cancelled)
103            .map(ShutdownOutcome::Stopped)
104    }
105
106    fn completed_result(
107        &mut self,
108        status: ExitStatus,
109        timed_out: bool,
110        cancelled: bool,
111    ) -> AppResult<ProcessResult> {
112        let grace = self.shutdown_grace_period;
113        join_within(self.stdin_thread.take(), grace)?;
114        join_within(self.stdout_thread.take(), grace)?;
115        join_within(self.stderr_thread.take(), grace)?;
116        let stdout = take_capture(&self.stdout);
117        let stderr = take_capture(&self.stderr);
118        Ok(ProcessResult::completed(
119            status.code(),
120            stdout.bytes,
121            stderr.bytes,
122            stdout.truncated,
123            stderr.truncated,
124            self.start.elapsed(),
125            timed_out,
126            cancelled,
127        ))
128    }
129
130    fn stop_cancel_thread(&mut self) -> AppResult<()> {
131        if let Some(thread) = self.cancel_thread.take() {
132            thread.stop()
133        } else {
134            Ok(())
135        }
136    }
137}
138
139impl Drop for PersistentProcess {
140    fn drop(&mut self) {
141        if !self.stopped {
142            let _ = self.shutdown_inner();
143        }
144    }
145}
146
147#[allow(clippy::too_many_arguments)]
148pub(in crate::persistent) fn new_process(
149    child: Child,
150    stdin_thread: StdinThread,
151    stdout_thread: ReaderThread,
152    stderr_thread: ReaderThread,
153    cancel_thread: Option<CancelThread>,
154    cancelled: Arc<AtomicBool>,
155    stdout: Capture,
156    stderr: Capture,
157    start: Instant,
158    signal: SignalPolicy,
159    shutdown_grace_period: Duration,
160) -> PersistentProcess {
161    PersistentProcess {
162        child,
163        stdin_thread,
164        stdout_thread,
165        stderr_thread,
166        cancel_thread,
167        cancelled,
168        stdout,
169        stderr,
170        start,
171        signal,
172        shutdown_grace_period,
173        stopped: false,
174    }
175}
176
177pub(in crate::persistent) fn cleanup_spawned_child(
178    child: &mut Child,
179    signal: SignalPolicy,
180    grace_period: Duration,
181) -> AppResult<()> {
182    let pid = child.id();
183    terminate_and_reap(child, pid, signal, grace_period).map(|_| ())
184}
185
186fn wait_for_exit(
187    child: &mut Child,
188    cancel_thread: &Option<CancelThread>,
189    signal: SignalPolicy,
190    grace_period: Duration,
191) -> AppResult<ExitStatus> {
192    loop {
193        if let Some(status) = child.try_wait().map_err(AppError::internal)? {
194            return Ok(status);
195        }
196        if cancel_thread
197            .as_ref()
198            .is_some_and(CancelThread::is_cancel_requested)
199        {
200            let pid = child.id();
201            return terminate_and_reap(child, pid, signal, grace_period).map(|(status, _)| status);
202        }
203        thread::sleep(Duration::from_millis(10));
204    }
205}
206
207#[cfg(all(test, unix))]
208mod tests {
209    use std::time::Duration;
210
211    use tokio_util::sync::CancellationToken;
212
213    use super::*;
214    use crate::{
215        ErrorCode, PersistentConfig, PersistentReadiness, ProcessConfig, ProcessSpec,
216        start_persistent_with_cancel,
217    };
218
219    fn start_ready_process(command: &str) -> PersistentProcess {
220        let spec = ProcessSpec::new("/bin/sh").arg("-c").arg(command);
221        let config = PersistentConfig::default()
222            .with_readiness(PersistentReadiness::OutputContains("ready".to_string()))
223            .with_readiness_timeout(Duration::from_secs(2))
224            .with_shutdown_grace_period(Duration::from_millis(50));
225
226        start_persistent_with_cancel(
227            &spec,
228            &ProcessConfig::default(),
229            &config,
230            CancellationToken::new(),
231        )
232        .expect("persistent process starts")
233        .process
234    }
235
236    #[test]
237    fn wait_inner_rejects_reuse_after_shutdown() {
238        let mut process = start_ready_process("printf ready; while :; do sleep 1; done");
239        let _ = process.shutdown_inner().expect("initial shutdown succeeds");
240
241        let error = process
242            .wait_inner()
243            .expect_err("wait after shutdown should fail");
244
245        assert_eq!(error.code(), ErrorCode::Conflict);
246    }
247
248    #[test]
249    fn shutdown_inner_rejects_reuse_after_wait() {
250        let mut process = start_ready_process("printf ready; exit 0");
251        let _ = process.wait_inner().expect("initial wait succeeds");
252
253        let error = process
254            .shutdown_inner()
255            .expect_err("shutdown after wait should fail");
256
257        assert_eq!(error.code(), ErrorCode::Conflict);
258    }
259
260    #[test]
261    fn shutdown_inner_handles_missing_cancel_thread() {
262        let mut process = start_ready_process("printf ready; while :; do sleep 1; done");
263        process.cancel_thread = None;
264
265        let outcome = process
266            .shutdown_inner()
267            .expect("shutdown should work without cancel thread handle");
268
269        assert!(matches!(outcome, ShutdownOutcome::Stopped(_)));
270    }
271
272    #[test]
273    fn cleanup_spawned_child_terminates_process() {
274        let mut child = std::process::Command::new("/bin/sh")
275            .arg("-c")
276            .arg("while :; do sleep 1; done")
277            .stdout(std::process::Stdio::null())
278            .stderr(std::process::Stdio::null())
279            .spawn()
280            .expect("child starts");
281
282        cleanup_spawned_child(
283            &mut child,
284            SignalPolicy::default()
285                .with_create_process_group(false)
286                .with_terminate_descendants(false),
287            Duration::from_millis(20),
288        )
289        .expect("cleanup should stop spawned child");
290    }
291}