Skip to main content

parallax_runtime/
worker.rs

1//! Subprocess worker speaking NDJSON on stdin/stdout.
2
3use parallax_core::{ErrorCode, ParallaxError, Remediation, RuntimeKind};
4use parallax_protocol::Envelope;
5use std::path::PathBuf;
6use std::process::Stdio;
7use std::sync::Arc;
8use std::time::Duration;
9use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
10use tokio::process::{Child, ChildStdin, ChildStdout, Command};
11use tokio::sync::Mutex;
12use tokio::time::timeout;
13
14/// Default max NDJSON line size when callers do not supply a tighter bound.
15const DEFAULT_MAX_MESSAGE_BYTES: u64 = 16_777_216;
16
17/// Managed worker subprocess.
18pub struct WorkerProcess {
19    child: Child,
20    stdin: ChildStdin,
21    stdout: BufReader<ChildStdout>,
22    runtime: RuntimeKind,
23    /// Bounded stderr capture (drained on a background task to avoid pipe deadlock).
24    stderr_buf: Arc<Mutex<String>>,
25    stderr_task: Option<tokio::task::JoinHandle<()>>,
26}
27
28impl WorkerProcess {
29    /// Spawn a worker.
30    pub async fn spawn(
31        program: PathBuf,
32        args: &[String],
33        runtime: RuntimeKind,
34    ) -> parallax_core::Result<Self> {
35        let mut cmd = Command::new(&program);
36        cmd.args(args)
37            .stdin(Stdio::piped())
38            .stdout(Stdio::piped())
39            .stderr(Stdio::piped())
40            .kill_on_drop(true);
41        #[cfg(windows)]
42        {
43            // Prevent console window flashes when possible.
44            const CREATE_NO_WINDOW: u32 = 0x08000000;
45            cmd.creation_flags(CREATE_NO_WINDOW);
46        }
47        let mut child = cmd.spawn().map_err(|e| {
48            ParallaxError::new(
49                ErrorCode::RuntimeUnavailable,
50                format!("failed to spawn {:?} worker: {e}", runtime),
51            )
52            .with_runtime(runtime.clone())
53            .with_source("parallax-runtime")
54            .with_operation("WorkerProcess::spawn")
55            .remediate(Remediation::new(
56                "Install the runtime or ensure it is on PATH",
57            ))
58        })?;
59        let stdin = child.stdin.take().ok_or_else(|| {
60            ParallaxError::new(ErrorCode::AdapterCrashed, "worker stdin missing")
61                .with_runtime(runtime.clone())
62        })?;
63        let stdout = child.stdout.take().ok_or_else(|| {
64            ParallaxError::new(ErrorCode::AdapterCrashed, "worker stdout missing")
65                .with_runtime(runtime.clone())
66        })?;
67        let stderr = child.stderr.take().ok_or_else(|| {
68            ParallaxError::new(ErrorCode::AdapterCrashed, "worker stderr missing")
69                .with_runtime(runtime.clone())
70        })?;
71
72        // Drain stderr so a chatty/crashing worker cannot fill the OS pipe and deadlock.
73        let stderr_buf = Arc::new(Mutex::new(String::new()));
74        let stderr_buf_task = Arc::clone(&stderr_buf);
75        let stderr_task = tokio::spawn(async move {
76            let mut reader = BufReader::new(stderr);
77            let mut chunk = [0u8; 4096];
78            const MAX_STDERR: usize = 64 * 1024;
79            loop {
80                match reader.read(&mut chunk).await {
81                    Ok(0) => break,
82                    Ok(n) => {
83                        let mut guard = stderr_buf_task.lock().await;
84                        if guard.len() < MAX_STDERR {
85                            let take = (MAX_STDERR - guard.len()).min(n);
86                            guard.push_str(&String::from_utf8_lossy(&chunk[..take]));
87                        }
88                    }
89                    Err(_) => break,
90                }
91            }
92        });
93
94        Ok(Self {
95            child,
96            stdin,
97            stdout: BufReader::new(stdout),
98            runtime,
99            stderr_buf,
100            stderr_task: Some(stderr_task),
101        })
102    }
103
104    /// Send a request and wait for a matching response.
105    pub async fn request(
106        &mut self,
107        env: Envelope,
108        wait: Duration,
109    ) -> parallax_core::Result<Envelope> {
110        self.request_bounded(env, wait, DEFAULT_MAX_MESSAGE_BYTES)
111            .await
112    }
113
114    /// Send a request with an explicit max response line size.
115    pub async fn request_bounded(
116        &mut self,
117        env: Envelope,
118        wait: Duration,
119        max_message_bytes: u64,
120    ) -> parallax_core::Result<Envelope> {
121        let id = env.id;
122        let line = env.to_ndjson_line()?;
123        if line.len() as u64 > max_message_bytes {
124            return Err(ParallaxError::new(
125                ErrorCode::ResourceLimitExceeded,
126                format!(
127                    "outbound protocol message exceeds max_message_bytes ({})",
128                    max_message_bytes
129                ),
130            )
131            .with_runtime(self.runtime.clone())
132            .with_operation("WorkerProcess::request"));
133        }
134        self.stdin.write_all(line.as_bytes()).await.map_err(|e| {
135            ParallaxError::new(ErrorCode::AdapterCrashed, format!("write failed: {e}"))
136                .with_runtime(self.runtime.clone())
137                .with_source("parallax-runtime")
138                .with_operation("WorkerProcess::request")
139        })?;
140        self.stdin.flush().await.map_err(|e| {
141            ParallaxError::new(ErrorCode::AdapterCrashed, format!("flush failed: {e}"))
142                .with_runtime(self.runtime.clone())
143        })?;
144
145        let runtime = self.runtime.clone();
146        let max_message_bytes = max_message_bytes as usize;
147        let read_fut = async {
148            loop {
149                let mut buf = String::new();
150                let n = self.stdout.read_line(&mut buf).await.map_err(|e| {
151                    ParallaxError::new(ErrorCode::AdapterCrashed, format!("read failed: {e}"))
152                        .with_runtime(runtime.clone())
153                })?;
154                if n == 0 {
155                    let stderr = self.stderr_buf.lock().await.clone();
156                    return Err(ParallaxError::new(
157                        ErrorCode::AdapterCrashed,
158                        "worker closed stdout",
159                    )
160                    .with_runtime(runtime.clone())
161                    .with_diagnostic(stderr));
162                }
163                if buf.len() > max_message_bytes {
164                    return Err(ParallaxError::new(
165                        ErrorCode::ResourceLimitExceeded,
166                        format!(
167                            "inbound protocol message exceeds max_message_bytes ({max_message_bytes})"
168                        ),
169                    )
170                    .with_runtime(runtime.clone())
171                    .with_operation("WorkerProcess::request"));
172                }
173                let line = buf.trim();
174                if line.is_empty() {
175                    continue;
176                }
177                let resp = Envelope::from_ndjson_line(line)?;
178                if resp.id == id {
179                    return Ok(resp);
180                }
181                // Mismatched ids are ignored until timeout; a hostile/buggy worker
182                // cannot permanently desync a single request beyond `wait`.
183            }
184        };
185
186        match timeout(wait, read_fut).await {
187            Ok(r) => r,
188            Err(_) => {
189                let _ = self.child.kill().await;
190                let stderr = self.stderr_buf.lock().await.clone();
191                Err(ParallaxError::new(
192                    ErrorCode::ExecutionTimeout,
193                    format!("worker request timed out after {:?}", wait),
194                )
195                .with_runtime(self.runtime.clone())
196                .with_operation("WorkerProcess::request")
197                .with_diagnostic(stderr))
198            }
199        }
200    }
201
202    /// Graceful shutdown.
203    pub async fn shutdown(&mut self) {
204        let env = Envelope::request("shutdown", serde_json::json!({}));
205        let _ = self.request(env, Duration::from_secs(2)).await;
206        let _ = self.child.kill().await;
207        if let Some(task) = self.stderr_task.take() {
208            let _ = task.await;
209        }
210    }
211}