rskit_process/persistent/
process.rs1use 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#[derive(Debug, Clone)]
20#[non_exhaustive]
21pub enum ShutdownOutcome {
22 AlreadyExited(ProcessResult),
24 Stopped(ProcessResult),
26}
27
28#[derive(Debug)]
30pub struct PersistentProcess {
31 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 pub fn wait(mut self) -> AppResult<ProcessResult> {
49 self.wait_inner()
50 }
51
52 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}