1use std::fmt;
13use std::io;
14use std::sync::Arc;
15use std::sync::Mutex as StdMutex;
16use std::sync::atomic::{AtomicBool, Ordering};
17
18use bytes::Bytes;
19use tokio::sync::{broadcast, mpsc, oneshot};
20use tokio::task::{AbortHandle, JoinHandle};
21
22const POST_EXIT_DRAIN_QUIET_MS: u64 = 50;
23const POST_EXIT_DRAIN_MAX_MS: u64 = 500;
24
25pub trait ChildTerminator: Send + Sync {
29 fn kill(&mut self) -> io::Result<()>;
31}
32
33pub trait PtyHandle: Send {}
53
54impl<T: Send> PtyHandle for T {}
55
56pub struct PtyHandles {
61 pub _slave: Option<Box<dyn PtyHandle>>,
63 pub _master: Box<dyn PtyHandle>,
65}
66
67impl fmt::Debug for PtyHandles {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 f.debug_struct("PtyHandles").finish()
70 }
71}
72
73pub struct ProcessHandle {
81 writer_tx: mpsc::Sender<Vec<u8>>,
82 output_tx: broadcast::Sender<Bytes>,
83 killer: StdMutex<Option<Box<dyn ChildTerminator>>>,
84 reader_handle: StdMutex<Option<JoinHandle<()>>>,
85 reader_abort_handles: StdMutex<Vec<AbortHandle>>,
86 writer_handle: StdMutex<Option<JoinHandle<()>>>,
87 wait_handle: StdMutex<Option<JoinHandle<()>>>,
88 exit_status: Arc<AtomicBool>,
89 exit_observed: Arc<AtomicBool>,
90 exit_code: Arc<StdMutex<Option<i32>>>,
91 _pty_handles: StdMutex<Option<PtyHandles>>,
93}
94
95impl fmt::Debug for ProcessHandle {
96 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97 f.debug_struct("ProcessHandle")
98 .field("has_exited", &self.has_exited())
99 .field("exit_code", &self.exit_code())
100 .finish()
101 }
102}
103
104impl ProcessHandle {
105 #[allow(
107 clippy::too_many_arguments,
108 reason = "Intentional compatibility, platform, or test-only suppression."
109 )]
110 pub(crate) fn new(
111 writer_tx: mpsc::Sender<Vec<u8>>,
112 output_tx: broadcast::Sender<Bytes>,
113 initial_output_rx: broadcast::Receiver<Bytes>,
114 killer: Box<dyn ChildTerminator>,
115 reader_handle: JoinHandle<()>,
116 reader_abort_handles: Vec<AbortHandle>,
117 writer_handle: JoinHandle<()>,
118 wait_handle: JoinHandle<()>,
119 exit_status: Arc<AtomicBool>,
120 exit_observed: Arc<AtomicBool>,
121 exit_code: Arc<StdMutex<Option<i32>>>,
122 pty_handles: Option<PtyHandles>,
123 ) -> (Self, broadcast::Receiver<Bytes>) {
124 (
125 Self {
126 writer_tx,
127 output_tx,
128 killer: StdMutex::new(Some(killer)),
129 reader_handle: StdMutex::new(Some(reader_handle)),
130 reader_abort_handles: StdMutex::new(reader_abort_handles),
131 writer_handle: StdMutex::new(Some(writer_handle)),
132 wait_handle: StdMutex::new(Some(wait_handle)),
133 exit_status,
134 exit_observed,
135 exit_code,
136 _pty_handles: StdMutex::new(pty_handles),
137 },
138 initial_output_rx,
139 )
140 }
141
142 #[inline]
150 pub fn writer_sender(&self) -> mpsc::Sender<Vec<u8>> {
151 self.writer_tx.clone()
152 }
153
154 #[inline]
159 pub fn output_receiver(&self) -> broadcast::Receiver<Bytes> {
160 self.output_tx.subscribe()
161 }
162
163 #[inline]
166 pub fn has_exited(&self) -> bool {
167 self.exit_status.load(Ordering::SeqCst)
168 }
169
170 #[inline]
173 pub fn has_observed_exit(&self) -> bool {
174 self.exit_observed.load(Ordering::SeqCst)
175 }
176
177 #[inline]
179 pub fn exit_code(&self) -> Option<i32> {
180 *self.exit_code.lock().unwrap_or_else(|e| e.into_inner())
181 }
182
183 #[inline]
185 pub fn is_output_drained(&self) -> bool {
186 self.reader_handle
187 .lock()
188 .ok()
189 .and_then(|guard| guard.as_ref().map(JoinHandle::is_finished))
190 .unwrap_or(true)
191 }
192
193 pub fn terminate(&self) {
197 self.terminate_internal();
198 }
199
200 pub fn terminate_process(&self) {
206 let _ = self.try_terminate_process();
207 }
208
209 pub fn try_terminate_process(&self) -> io::Result<()> {
215 let mut killer = self
216 .killer
217 .lock()
218 .map_err(|_error| io::Error::other("process termination lock poisoned"))?;
219 if let Some(terminator) = killer.as_mut() {
220 terminator.kill()?;
221 *killer = None;
222 }
223 Ok(())
224 }
225
226 fn terminate_internal(&self) {
228 if let Ok(mut killer_opt) = self.killer.lock()
230 && let Some(mut killer) = killer_opt.take()
231 {
232 let _ = killer.kill();
233 }
234
235 self.abort_tasks();
236 }
237
238 fn abort_tasks(&self) {
240 if let Ok(mut h) = self.reader_handle.lock()
242 && let Some(handle) = h.take()
243 {
244 handle.abort();
245 }
246
247 if let Ok(mut handles) = self.reader_abort_handles.lock() {
249 for handle in handles.drain(..) {
250 handle.abort();
251 }
252 }
253
254 if let Ok(mut h) = self.writer_handle.lock()
256 && let Some(handle) = h.take()
257 {
258 handle.abort();
259 }
260
261 if let Ok(mut h) = self.wait_handle.lock()
263 && let Some(handle) = h.take()
264 {
265 handle.abort();
266 }
267 }
268
269 #[inline]
271 pub fn is_running(&self) -> bool {
272 !self.has_exited() && !self.is_writer_closed()
273 }
274
275 pub async fn write(&self, bytes: impl Into<Vec<u8>>) -> Result<(), mpsc::error::SendError<Vec<u8>>> {
279 self.writer_tx.send(bytes.into()).await
280 }
281
282 #[inline]
284 pub fn is_writer_closed(&self) -> bool {
285 self.writer_tx.is_closed()
286 }
287}
288
289impl Drop for ProcessHandle {
290 fn drop(&mut self) {
291 self.terminate_internal();
301 }
302}
303
304#[derive(Debug)]
308pub struct SpawnedProcess {
309 pub session: ProcessHandle,
311 pub process_id: u32,
313 pub output_rx: broadcast::Receiver<Bytes>,
315 pub reliable_output_rx: mpsc::Receiver<Bytes>,
319 pub(crate) reliable_output_enabled: bool,
321 pub exit_rx: oneshot::Receiver<i32>,
323}
324
325impl SpawnedProcess {
326 pub async fn wait_with_output(self, timeout_ms: u64) -> (Vec<u8>, i32) {
330 if self.reliable_output_enabled {
331 collect_reliable_output_until_exit(self.reliable_output_rx, self.exit_rx, timeout_ms).await
332 } else {
333 collect_output_until_exit(self.output_rx, self.exit_rx, timeout_ms).await
334 }
335 }
336}
337
338async fn collect_reliable_output_until_exit(
340 mut output_rx: mpsc::Receiver<Bytes>,
341 exit_rx: oneshot::Receiver<i32>,
342 timeout_ms: u64,
343) -> (Vec<u8>, i32) {
344 let mut collected = Vec::new();
345 let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
346 tokio::pin!(exit_rx);
347
348 loop {
349 tokio::select! {
350 chunk = output_rx.recv() => {
351 if let Some(chunk) = chunk {
352 collected.extend_from_slice(&chunk);
353 } else {
354 return (collected, exit_rx.await.unwrap_or(-1));
355 }
356 }
357 res = &mut exit_rx => {
358 let code = res.unwrap_or(-1);
359 let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
364 let max_deadline = tokio::time::Instant::now()
365 + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
366 while tokio::time::Instant::now() < max_deadline {
367 match tokio::time::timeout(quiet, output_rx.recv()).await {
368 Ok(Some(chunk)) => collected.extend_from_slice(&chunk),
369 Ok(None) | Err(_) => break,
370 }
371 }
372 return (collected, code);
373 }
374 _ = tokio::time::sleep_until(deadline) => {
375 return (collected, -1);
376 }
377 }
378 }
379}
380
381pub async fn collect_output_until_exit(
385 mut output_rx: broadcast::Receiver<Bytes>,
386 exit_rx: oneshot::Receiver<i32>,
387 timeout_ms: u64,
388) -> (Vec<u8>, i32) {
389 let mut collected = Vec::new();
390 let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_millis(timeout_ms);
391 tokio::pin!(exit_rx);
392
393 loop {
394 tokio::select! {
395 res = output_rx.recv() => {
396 if let Ok(chunk) = res {
397 collected.extend_from_slice(&chunk);
398 }
399 }
400 res = &mut exit_rx => {
401 let code = res.unwrap_or(-1);
402 let quiet = tokio::time::Duration::from_millis(POST_EXIT_DRAIN_QUIET_MS);
404 let max_deadline = tokio::time::Instant::now()
405 + tokio::time::Duration::from_millis(POST_EXIT_DRAIN_MAX_MS);
406
407 while tokio::time::Instant::now() < max_deadline {
408 match tokio::time::timeout(quiet, output_rx.recv()).await {
409 Ok(Ok(chunk)) => collected.extend_from_slice(&chunk),
410 Ok(Err(broadcast::error::RecvError::Lagged(count))) => {
411 vtcode_commons::diagnostics::stderr(format_args!("Warning: output stream lagged ({count} dropped)"));
412 continue;
413 }
414 Ok(Err(broadcast::error::RecvError::Closed)) => break,
415 Err(_) => break, }
417 }
418 return (collected, code);
419 }
420 _ = tokio::time::sleep_until(deadline) => {
421 return (collected, -1);
422 }
423 }
424 }
425}
426
427pub type ExecCommandSession = ProcessHandle;
429
430pub type SpawnedPty = SpawnedProcess;
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436
437 struct NoopTerminator;
438 impl ChildTerminator for NoopTerminator {
439 fn kill(&mut self) -> io::Result<()> {
440 Ok(())
441 }
442 }
443
444 #[tokio::test]
445 async fn test_process_handle_debug() {
446 let exit_status = Arc::new(AtomicBool::new(false));
448 let exit_code = Arc::new(StdMutex::new(None));
449
450 let (writer_tx, _) = mpsc::channel(1);
451 let (output_tx, initial_rx) = broadcast::channel(1);
452
453 let (handle, _) = ProcessHandle::new(
454 writer_tx,
455 output_tx,
456 initial_rx,
457 Box::new(NoopTerminator),
458 tokio::spawn(async {}),
459 vec![],
460 tokio::spawn(async {}),
461 tokio::spawn(async {}),
462 exit_status,
463 Arc::new(AtomicBool::new(false)),
464 exit_code,
465 None,
466 );
467
468 let debug_str = format!("{handle:?}");
469 assert!(debug_str.contains("ProcessHandle"));
470 }
471
472 #[tokio::test]
473 async fn test_has_exited() {
474 let exit_status = Arc::new(AtomicBool::new(false));
475 let exit_code = Arc::new(StdMutex::new(None));
476
477 let (writer_tx, _) = mpsc::channel(1);
478 let (output_tx, initial_rx) = broadcast::channel(1);
479
480 let (handle, _) = ProcessHandle::new(
481 writer_tx,
482 output_tx,
483 initial_rx,
484 Box::new(NoopTerminator),
485 tokio::spawn(async {}),
486 vec![],
487 tokio::spawn(async {}),
488 tokio::spawn(async {}),
489 Arc::clone(&exit_status),
490 Arc::new(AtomicBool::new(false)),
491 exit_code,
492 None,
493 );
494
495 assert!(!handle.has_exited());
496 exit_status.store(true, Ordering::SeqCst);
497 assert!(handle.has_exited());
498 assert!(!handle.has_observed_exit(), "a completed wait is not necessarily a successful wait");
499 }
500
501 struct RecordingTerminator(Arc<AtomicBool>);
502 impl ChildTerminator for RecordingTerminator {
503 fn kill(&mut self) -> io::Result<()> {
504 self.0.store(true, Ordering::SeqCst);
505 Ok(())
506 }
507 }
508
509 struct FailOnceTerminator(Arc<std::sync::atomic::AtomicUsize>);
510 impl ChildTerminator for FailOnceTerminator {
511 fn kill(&mut self) -> io::Result<()> {
512 if self.0.fetch_add(1, Ordering::SeqCst) == 0 {
513 Err(io::Error::other("simulated kill failure"))
514 } else {
515 Ok(())
516 }
517 }
518 }
519
520 #[tokio::test]
521 async fn failed_termination_retains_the_handle_for_retry() {
522 let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0));
523 let (writer_tx, _) = mpsc::channel(1);
524 let (output_tx, initial_rx) = broadcast::channel(1);
525 let (handle, _) = ProcessHandle::new(
526 writer_tx,
527 output_tx,
528 initial_rx,
529 Box::new(FailOnceTerminator(Arc::clone(&attempts))),
530 tokio::spawn(async {}),
531 vec![],
532 tokio::spawn(async {}),
533 tokio::spawn(async {}),
534 Arc::new(AtomicBool::new(false)),
535 Arc::new(AtomicBool::new(false)),
536 Arc::new(StdMutex::new(None)),
537 None,
538 );
539 assert!(handle.try_terminate_process().is_err());
540 assert!(handle.try_terminate_process().is_ok());
541 assert!(handle.try_terminate_process().is_ok());
542 assert_eq!(attempts.load(Ordering::SeqCst), 2);
543 }
544
545 #[tokio::test]
550 async fn drop_kills_child_synchronously_without_bridging_runtime() {
551 let killed = Arc::new(AtomicBool::new(false));
552 let exit_status = Arc::new(AtomicBool::new(false));
553 let exit_code = Arc::new(StdMutex::new(None));
554 let (writer_tx, _) = mpsc::channel(1);
555 let (output_tx, initial_rx) = broadcast::channel(1);
556
557 let (handle, _) = ProcessHandle::new(
559 writer_tx,
560 output_tx,
561 initial_rx,
562 Box::new(RecordingTerminator(Arc::clone(&killed))),
563 tokio::spawn(std::future::pending()),
564 vec![],
565 tokio::spawn(std::future::pending()),
566 tokio::spawn(std::future::pending()),
567 exit_status,
568 Arc::new(AtomicBool::new(false)),
569 exit_code,
570 None,
571 );
572
573 let started = std::time::Instant::now();
574 drop(handle);
575 assert!(killed.load(Ordering::SeqCst), "Drop must kill the child");
576 assert!(started.elapsed() < std::time::Duration::from_millis(250), "Drop must not block on async cleanup");
577 }
578}