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
147pub(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}