Skip to main content

rskit_process/persistent/
mod.rs

1//! Persistent subprocess lifecycle support.
2
3use std::{
4    process::{Child, Command as StdCommand, Stdio},
5    sync::{Arc, atomic::AtomicBool, mpsc},
6    time::{Duration, Instant},
7};
8
9use parking_lot::Mutex;
10use tokio_util::sync::CancellationToken;
11
12use crate::{
13    AppError, AppResult, EnvPolicy, ErrorCode, InputPolicy, ProcessConfig, ProcessIo, ProcessSpec,
14    SignalPolicy, process_group::isolate,
15};
16
17mod cancel;
18mod config;
19mod error;
20mod io;
21mod process;
22mod readiness;
23
24#[cfg(all(test, unix))]
25mod tests;
26
27pub use config::{PersistentConfig, PersistentOutput, PersistentOutputStream, PersistentReadiness};
28pub use error::{PersistentStartErrorKind, persistent_start_error_kind};
29pub use process::{PersistentProcess, ShutdownOutcome};
30
31use cancel::spawn_cancel_thread;
32use config::PersistentReadiness::{Command as CommandReadiness, OutputContains, Started};
33use error::persistent_start_error;
34use io::{
35    CapturedOutput, ReaderThread, StdinThread, spawn_output_readers, spawn_stdin_writer,
36    take_capture,
37};
38use process::{cleanup_spawned_child, new_process};
39use readiness::{
40    readiness_wait_error, run_readiness_command, validate_readiness, wait_for_readiness,
41};
42
43/// Captured output retained while waiting for persistent readiness.
44#[derive(Debug, Clone)]
45pub struct PersistentStartup {
46    /// Captured stdout at the moment readiness completed.
47    pub stdout: String,
48    /// Captured stdout bytes at the moment readiness completed.
49    pub stdout_bytes: Vec<u8>,
50    /// Captured stderr at the moment readiness completed.
51    pub stderr: String,
52    /// Captured stderr bytes at the moment readiness completed.
53    pub stderr_bytes: Vec<u8>,
54    /// Whether stdout capture exceeded the configured limit before readiness.
55    pub stdout_truncated: bool,
56    /// Whether stderr capture exceeded the configured limit before readiness.
57    pub stderr_truncated: bool,
58    /// Time elapsed from spawn until readiness completed.
59    pub duration: Duration,
60}
61
62/// Result of starting a persistent process.
63#[derive(Debug)]
64pub struct PersistentRun {
65    /// Startup output captured while waiting for readiness.
66    pub startup: PersistentStartup,
67    /// Running persistent process handle.
68    pub process: PersistentProcess,
69}
70
71/// Start a persistent process and wait for its readiness policy.
72pub fn start_persistent_with_cancel(
73    spec: &ProcessSpec,
74    process_config: &ProcessConfig,
75    persistent_config: &PersistentConfig,
76    cancel: CancellationToken,
77) -> AppResult<PersistentRun> {
78    if spec.program.as_os_str().is_empty() {
79        return Err(AppError::invalid_input("program", "must not be empty"));
80    }
81    if cancel.is_cancelled() {
82        return Err(AppError::cancelled("persistent process startup"));
83    }
84    validate_readiness(&persistent_config.readiness)?;
85
86    let start = Instant::now();
87    let input = process_input(process_config)?;
88    let mut child = spawn_child(spec, process_config, input)?;
89    let stdout = Arc::new(Mutex::new(CapturedOutput::default()));
90    let stderr = Arc::new(Mutex::new(CapturedOutput::default()));
91    let cancelled = Arc::new(AtomicBool::new(false));
92    let cancel_thread = match spawn_cancel_thread(
93        child.id(),
94        cancel.clone(),
95        Arc::clone(&cancelled),
96        process_config.signal,
97        persistent_config.shutdown_grace_period,
98    ) {
99        Ok(thread) => Some(thread),
100        Err(error) => {
101            let _ = cleanup_spawned_child(
102                &mut child,
103                process_config.signal,
104                persistent_config.shutdown_grace_period,
105            );
106            return Err(error);
107        }
108    };
109    let (ready_tx, ready_rx) = mpsc::channel();
110    let (stdout_thread, stderr_thread) = spawn_output_readers(
111        &mut child,
112        &stdout,
113        &stderr,
114        &ready_tx,
115        &persistent_config.readiness,
116        persistent_config.output,
117        persistent_config.max_capture_bytes,
118    );
119    let stdin_thread = spawn_stdin_writer(&mut child, predefined_stdin(input));
120
121    match &persistent_config.readiness {
122        Started => {
123            let _ = ready_tx.send(());
124        }
125        CommandReadiness(command) => {
126            if let Err(error) = run_readiness_command(
127                command,
128                process_config,
129                persistent_config.readiness_timeout,
130                persistent_config.shutdown_grace_period,
131                cancel.clone(),
132            ) {
133                let mut process = persistent_process(
134                    child,
135                    stdin_thread,
136                    stdout_thread,
137                    stderr_thread,
138                    cancel_thread,
139                    cancelled,
140                    stdout,
141                    stderr,
142                    start,
143                    process_config.signal,
144                    persistent_config,
145                );
146                // Reuse the normal lifecycle cleanup so partially-started processes
147                // are terminated the same way as explicit shutdown.
148                let _ = process.shutdown_inner();
149                return Err(error);
150            }
151            let _ = ready_tx.send(());
152        }
153        OutputContains(_) => {}
154    }
155    drop(ready_tx);
156
157    if let Err(error) = wait_for_readiness(
158        &ready_rx,
159        persistent_config.readiness_timeout,
160        &cancel,
161        &cancelled,
162    ) {
163        let readiness_error = readiness_wait_error(&mut child, error, &cancelled)?;
164        let mut process = persistent_process(
165            child,
166            stdin_thread,
167            stdout_thread,
168            stderr_thread,
169            cancel_thread,
170            cancelled,
171            stdout,
172            stderr,
173            start,
174            process_config.signal,
175            persistent_config,
176        );
177        // Reuse the normal lifecycle cleanup so readiness failures do not leak the child.
178        let _ = process.shutdown_inner();
179        return Err(readiness_error);
180    }
181
182    let stdout_startup = take_capture(&stdout);
183    let stderr_startup = take_capture(&stderr);
184    let process = persistent_process(
185        child,
186        stdin_thread,
187        stdout_thread,
188        stderr_thread,
189        cancel_thread,
190        cancelled,
191        stdout,
192        stderr,
193        start,
194        process_config.signal,
195        persistent_config,
196    );
197
198    Ok(PersistentRun {
199        startup: PersistentStartup {
200            stdout: String::from_utf8_lossy(&stdout_startup.bytes).into_owned(),
201            stdout_bytes: stdout_startup.bytes,
202            stderr: String::from_utf8_lossy(&stderr_startup.bytes).into_owned(),
203            stderr_bytes: stderr_startup.bytes,
204            stdout_truncated: stdout_startup.truncated,
205            stderr_truncated: stderr_startup.truncated,
206            duration: start.elapsed(),
207        },
208        process,
209    })
210}
211
212fn process_input(config: &ProcessConfig) -> AppResult<&InputPolicy> {
213    match &config.io {
214        ProcessIo::Captured(io)
215            if matches!(io.input, InputPolicy::Closed | InputPolicy::Bytes(_)) =>
216        {
217            Ok(&io.input)
218        }
219        ProcessIo::Captured(_) => Err(AppError::invalid_input(
220            "process.io.input",
221            "persistent processes support only closed stdin or predefined stdin bytes",
222        )),
223        ProcessIo::Inherited(_) => Err(AppError::invalid_input(
224            "process.io",
225            "persistent processes use PersistentOutput for output handling; inherited mode is not supported",
226        )),
227        ProcessIo::Observed(_) => Err(AppError::invalid_input(
228            "process.io",
229            "persistent processes use PersistentOutput for observation; observed mode is not supported",
230        )),
231    }
232}
233
234fn predefined_stdin(input: &InputPolicy) -> Option<Vec<u8>> {
235    match input {
236        InputPolicy::Bytes(bytes) => Some(bytes.clone()),
237        InputPolicy::Closed | InputPolicy::Inherit => None,
238    }
239}
240
241fn spawn_child(
242    spec: &ProcessSpec,
243    config: &ProcessConfig,
244    input: &InputPolicy,
245) -> AppResult<Child> {
246    let mut cmd = StdCommand::new(&spec.program);
247    cmd.args(&spec.args)
248        .stdin(if matches!(input, InputPolicy::Bytes(_)) {
249            Stdio::piped()
250        } else {
251            Stdio::null()
252        })
253        .stdout(Stdio::piped())
254        .stderr(Stdio::piped());
255
256    if let Some(dir) = &spec.dir {
257        cmd.current_dir(dir);
258    }
259    if matches!(spec.env_policy, EnvPolicy::Empty) {
260        cmd.env_clear();
261    }
262    for (key, value) in &spec.env {
263        cmd.env(key, value);
264    }
265    if config.signal.create_process_group {
266        isolate(&mut cmd);
267    }
268
269    cmd.spawn().map_err(|error| {
270        persistent_start_error(
271            PersistentStartErrorKind::SpawnFailed,
272            ErrorCode::Internal,
273            format!("failed to spawn persistent process: {error}"),
274        )
275        .with_cause(error)
276    })
277}
278
279#[allow(clippy::too_many_arguments)]
280fn persistent_process(
281    child: Child,
282    stdin_thread: StdinThread,
283    stdout_thread: ReaderThread,
284    stderr_thread: ReaderThread,
285    cancel_thread: Option<cancel::CancelThread>,
286    cancelled: Arc<AtomicBool>,
287    stdout: io::Capture,
288    stderr: io::Capture,
289    start: Instant,
290    signal: SignalPolicy,
291    config: &PersistentConfig,
292) -> PersistentProcess {
293    new_process(
294        child,
295        stdin_thread,
296        stdout_thread,
297        stderr_thread,
298        cancel_thread,
299        cancelled,
300        stdout,
301        stderr,
302        start,
303        signal,
304        config.shutdown_grace_period,
305    )
306}