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::{
9    AppError, AppResult, ErrorCode, ProcessResult, SignalPolicy,
10    process_group::{kill_target, terminate_target},
11};
12
13use super::{
14    cancel::CancelThread,
15    io::{Capture, ReaderThread, StdinThread, join_reader, join_stdin, 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        if !terminate_target(pid, terminate_group(self.signal)) {
95            let _ = self.child.kill();
96        }
97        let status = wait_for_shutdown(
98            &mut self.child,
99            pid,
100            self.signal,
101            self.shutdown_grace_period,
102        )?;
103        self.stopped = true;
104        let cancelled = self.cancelled.load(Ordering::SeqCst);
105        self.completed_result(status, false, cancelled)
106            .map(ShutdownOutcome::Stopped)
107    }
108
109    fn completed_result(
110        &mut self,
111        status: ExitStatus,
112        timed_out: bool,
113        cancelled: bool,
114    ) -> AppResult<ProcessResult> {
115        join_stdin(self.stdin_thread.take())?;
116        join_reader(self.stdout_thread.take())?;
117        join_reader(self.stderr_thread.take())?;
118        let stdout = take_capture(&self.stdout);
119        let stderr = take_capture(&self.stderr);
120        Ok(ProcessResult::completed(
121            status.code(),
122            stdout.bytes,
123            stderr.bytes,
124            stdout.truncated,
125            stderr.truncated,
126            self.start.elapsed(),
127            timed_out,
128            cancelled,
129        ))
130    }
131
132    fn stop_cancel_thread(&mut self) -> AppResult<()> {
133        if let Some(thread) = self.cancel_thread.take() {
134            thread.stop()
135        } else {
136            Ok(())
137        }
138    }
139}
140
141impl Drop for PersistentProcess {
142    fn drop(&mut self) {
143        if !self.stopped {
144            let _ = self.shutdown_inner();
145        }
146    }
147}
148
149#[allow(clippy::too_many_arguments)]
150pub(in crate::persistent) fn new_process(
151    child: Child,
152    stdin_thread: StdinThread,
153    stdout_thread: ReaderThread,
154    stderr_thread: ReaderThread,
155    cancel_thread: Option<CancelThread>,
156    cancelled: Arc<AtomicBool>,
157    stdout: Capture,
158    stderr: Capture,
159    start: Instant,
160    signal: SignalPolicy,
161    shutdown_grace_period: Duration,
162) -> PersistentProcess {
163    PersistentProcess {
164        child,
165        stdin_thread,
166        stdout_thread,
167        stderr_thread,
168        cancel_thread,
169        cancelled,
170        stdout,
171        stderr,
172        start,
173        signal,
174        shutdown_grace_period,
175        stopped: false,
176    }
177}
178
179pub(in crate::persistent) fn cleanup_spawned_child(
180    child: &mut Child,
181    signal: SignalPolicy,
182    grace_period: Duration,
183) -> AppResult<()> {
184    let pid = child.id();
185    if !terminate_target(pid, terminate_group(signal)) {
186        let _ = child.kill();
187    }
188    wait_for_shutdown(child, pid, signal, grace_period).map(|_| ())
189}
190
191fn wait_for_shutdown(
192    child: &mut Child,
193    pid: u32,
194    signal: SignalPolicy,
195    grace_period: Duration,
196) -> AppResult<ExitStatus> {
197    let deadline = Instant::now() + grace_period;
198    loop {
199        if let Some(status) = child.try_wait().map_err(AppError::internal)? {
200            return Ok(status);
201        }
202        if Instant::now() >= deadline {
203            if !kill_target(pid, terminate_group(signal)) {
204                let _ = child.kill();
205            }
206            return child.wait().map_err(AppError::internal);
207        }
208        thread::sleep(Duration::from_millis(10));
209    }
210}
211
212fn wait_for_exit(
213    child: &mut Child,
214    cancel_thread: &Option<CancelThread>,
215    signal: SignalPolicy,
216    grace_period: Duration,
217) -> AppResult<ExitStatus> {
218    loop {
219        if let Some(status) = child.try_wait().map_err(AppError::internal)? {
220            return Ok(status);
221        }
222        if cancel_thread
223            .as_ref()
224            .is_some_and(CancelThread::is_cancel_requested)
225        {
226            let pid = child.id();
227            if !terminate_target(pid, terminate_group(signal)) {
228                let _ = child.kill();
229            }
230            return wait_for_shutdown(child, pid, signal, grace_period);
231        }
232        thread::sleep(Duration::from_millis(10));
233    }
234}
235
236fn terminate_group(signal: SignalPolicy) -> bool {
237    signal.create_process_group && signal.terminate_descendants
238}
239
240#[cfg(all(test, unix))]
241mod tests {
242    use std::time::Duration;
243
244    use tokio_util::sync::CancellationToken;
245
246    use super::*;
247    use crate::{
248        ErrorCode, PersistentConfig, PersistentReadiness, ProcessConfig, ProcessSpec,
249        start_persistent_with_cancel,
250    };
251
252    fn start_ready_process(command: &str) -> PersistentProcess {
253        let spec = ProcessSpec::new("/bin/sh").arg("-c").arg(command);
254        let config = PersistentConfig::default()
255            .with_readiness(PersistentReadiness::OutputContains("ready".to_string()))
256            .with_readiness_timeout(Duration::from_secs(2))
257            .with_shutdown_grace_period(Duration::from_millis(50));
258
259        start_persistent_with_cancel(
260            &spec,
261            &ProcessConfig::default(),
262            &config,
263            CancellationToken::new(),
264        )
265        .expect("persistent process starts")
266        .process
267    }
268
269    #[test]
270    fn wait_inner_rejects_reuse_after_shutdown() {
271        let mut process = start_ready_process("printf ready; while :; do sleep 1; done");
272        let _ = process.shutdown_inner().expect("initial shutdown succeeds");
273
274        let error = process
275            .wait_inner()
276            .expect_err("wait after shutdown should fail");
277
278        assert_eq!(error.code(), ErrorCode::Conflict);
279    }
280
281    #[test]
282    fn shutdown_inner_rejects_reuse_after_wait() {
283        let mut process = start_ready_process("printf ready; exit 0");
284        let _ = process.wait_inner().expect("initial wait succeeds");
285
286        let error = process
287            .shutdown_inner()
288            .expect_err("shutdown after wait should fail");
289
290        assert_eq!(error.code(), ErrorCode::Conflict);
291    }
292
293    #[test]
294    fn shutdown_inner_handles_missing_cancel_thread() {
295        let mut process = start_ready_process("printf ready; while :; do sleep 1; done");
296        process.cancel_thread = None;
297
298        let outcome = process
299            .shutdown_inner()
300            .expect("shutdown should work without cancel thread handle");
301
302        assert!(matches!(outcome, ShutdownOutcome::Stopped(_)));
303    }
304
305    #[test]
306    fn cleanup_spawned_child_terminates_process() {
307        let mut child = std::process::Command::new("/bin/sh")
308            .arg("-c")
309            .arg("while :; do sleep 1; done")
310            .stdout(std::process::Stdio::null())
311            .stderr(std::process::Stdio::null())
312            .spawn()
313            .expect("child starts");
314
315        cleanup_spawned_child(
316            &mut child,
317            SignalPolicy::default()
318                .with_create_process_group(false)
319                .with_terminate_descendants(false),
320            Duration::from_millis(20),
321        )
322        .expect("cleanup should stop spawned child");
323    }
324}