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/// The live handles and captured state produced by spawning a persistent process.
148///
149/// These pieces are assembled into a [`PersistentProcess`] once it satisfies its readiness policy,
150/// or cleaned up through the normal lifecycle if it fails one.
151pub(in crate::persistent) struct SpawnedProcess {
152    pub(in crate::persistent) child: Child,
153    pub(in crate::persistent) stdin_thread: StdinThread,
154    pub(in crate::persistent) stdout_thread: ReaderThread,
155    pub(in crate::persistent) stderr_thread: ReaderThread,
156    pub(in crate::persistent) cancel_thread: Option<CancelThread>,
157    pub(in crate::persistent) cancelled: Arc<AtomicBool>,
158    pub(in crate::persistent) stdout: Capture,
159    pub(in crate::persistent) stderr: Capture,
160    pub(in crate::persistent) start: Instant,
161}
162
163pub(in crate::persistent) fn new_process(
164    spawned: SpawnedProcess,
165    signal: SignalPolicy,
166    shutdown_grace_period: Duration,
167) -> PersistentProcess {
168    PersistentProcess {
169        child: spawned.child,
170        stdin_thread: spawned.stdin_thread,
171        stdout_thread: spawned.stdout_thread,
172        stderr_thread: spawned.stderr_thread,
173        cancel_thread: spawned.cancel_thread,
174        cancelled: spawned.cancelled,
175        stdout: spawned.stdout,
176        stderr: spawned.stderr,
177        start: spawned.start,
178        signal,
179        shutdown_grace_period,
180        stopped: false,
181    }
182}
183
184pub(in crate::persistent) fn cleanup_spawned_child(
185    child: &mut Child,
186    signal: SignalPolicy,
187    grace_period: Duration,
188) -> AppResult<()> {
189    let pid = child.id();
190    terminate_and_reap(child, pid, signal, grace_period).map(|_| ())
191}
192
193fn wait_for_exit(
194    child: &mut Child,
195    cancel_thread: &Option<CancelThread>,
196    signal: SignalPolicy,
197    grace_period: Duration,
198) -> AppResult<ExitStatus> {
199    loop {
200        if let Some(status) = child.try_wait().map_err(AppError::internal)? {
201            return Ok(status);
202        }
203        if cancel_thread
204            .as_ref()
205            .is_some_and(CancelThread::is_cancel_requested)
206        {
207            let pid = child.id();
208            return terminate_and_reap(child, pid, signal, grace_period).map(|(status, _)| status);
209        }
210        thread::sleep(Duration::from_millis(10));
211    }
212}
213
214#[cfg(all(test, unix))]
215mod tests {
216    use std::time::Duration;
217
218    use tokio_util::sync::CancellationToken;
219
220    use super::*;
221    use crate::{
222        ErrorCode, PersistentConfig, PersistentReadiness, ProcessConfig, ProcessSpec,
223        start_persistent_with_cancel,
224    };
225
226    fn start_ready_process(command: &str) -> PersistentProcess {
227        let spec = ProcessSpec::new("/bin/sh").arg("-c").arg(command);
228        let config = PersistentConfig::default()
229            .with_readiness(PersistentReadiness::OutputContains("ready".to_string()))
230            .with_readiness_timeout(Duration::from_secs(2))
231            .with_shutdown_grace_period(Duration::from_millis(50));
232
233        start_persistent_with_cancel(
234            &spec,
235            &ProcessConfig::default(),
236            &config,
237            CancellationToken::new(),
238        )
239        .expect("persistent process starts")
240        .process
241    }
242
243    #[test]
244    fn wait_inner_rejects_reuse_after_shutdown() {
245        let mut process = start_ready_process("printf ready; while :; do sleep 1; done");
246        let _ = process.shutdown_inner().expect("initial shutdown succeeds");
247
248        let error = process
249            .wait_inner()
250            .expect_err("wait after shutdown should fail");
251
252        assert_eq!(error.code(), ErrorCode::Conflict);
253    }
254
255    #[test]
256    fn shutdown_inner_rejects_reuse_after_wait() {
257        let mut process = start_ready_process("printf ready; exit 0");
258        let _ = process.wait_inner().expect("initial wait succeeds");
259
260        let error = process
261            .shutdown_inner()
262            .expect_err("shutdown after wait should fail");
263
264        assert_eq!(error.code(), ErrorCode::Conflict);
265    }
266
267    #[test]
268    fn shutdown_inner_handles_missing_cancel_thread() {
269        let mut process = start_ready_process("printf ready; while :; do sleep 1; done");
270        process.cancel_thread = None;
271
272        let outcome = process
273            .shutdown_inner()
274            .expect("shutdown should work without cancel thread handle");
275
276        assert!(matches!(outcome, ShutdownOutcome::Stopped(_)));
277    }
278
279    #[test]
280    fn cleanup_spawned_child_terminates_process() {
281        let mut child = std::process::Command::new("/bin/sh")
282            .arg("-c")
283            .arg("while :; do sleep 1; done")
284            .stdout(std::process::Stdio::null())
285            .stderr(std::process::Stdio::null())
286            .spawn()
287            .expect("child starts");
288
289        cleanup_spawned_child(
290            &mut child,
291            SignalPolicy::default()
292                .with_create_process_group(false)
293                .with_terminate_descendants(false),
294            Duration::from_millis(20),
295        )
296        .expect("cleanup should stop spawned child");
297    }
298}