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::{
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#[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 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}