1use std::collections::HashMap;
41use std::io::Read;
42use std::path::PathBuf;
43use std::sync::{Arc, Mutex};
44use std::time::{Duration, SystemTime, UNIX_EPOCH};
45
46use portable_pty::{native_pty_system, CommandBuilder, PtySize};
47use thiserror::Error;
48use tokio::sync::{mpsc, oneshot};
49use tokio::task;
50
51use crate::beholders::{registry_with_user_beholders, BeholderSelect};
52use crate::store::{RunFilter, StoreError, TaskStore};
53use crate::types::{BeholderStatus, Initiator, OutputChunk, RunStatus, Stream, TaskRunId, TaskRunMeta};
54
55const DEFAULT_GRACE: Duration = Duration::from_secs(5);
56const READ_BUF_SIZE: usize = 4096;
57const SIGTERM: i32 = 15;
58const SIGKILL: i32 = 9;
59
60#[derive(Debug, Error)]
63pub enum DriverError {
64 #[error("store: {0}")]
65 Store(#[from] StoreError),
66 #[error("pty: {0}")]
67 Pty(String),
68 #[error("run not found: {0}")]
69 NotFound(String),
70 #[error("io: {0}")]
71 Io(#[from] std::io::Error),
72}
73
74#[derive(Debug, Clone)]
78pub struct SpawnOpts {
79 pub cwd: PathBuf,
80 pub env: Vec<(String, String)>,
82 pub label: Option<String>,
83 pub initiator: Initiator,
84 pub pty_cols: u16,
86 pub pty_rows: u16,
88 pub stdin_enabled: bool,
90 pub pin: bool,
92 pub beholder_select: BeholderSelect,
94 pub tty_attached: bool,
97 pub log_fd_enabled: bool,
101 pub origin: Option<String>,
104}
105
106impl Default for SpawnOpts {
107 fn default() -> Self {
108 Self {
109 cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
110 env: vec![],
111 label: None,
112 initiator: Initiator::Human { camp: "local".to_string() },
113 pty_cols: 80,
114 pty_rows: 24,
115 stdin_enabled: false,
116 pin: false,
117 beholder_select: BeholderSelect::Auto,
118 tty_attached: false,
119 log_fd_enabled: true,
120 origin: None,
121 }
122 }
123}
124
125struct RunControl {
128 kill_tx: mpsc::Sender<KillRequest>,
129 stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
130}
131
132#[derive(Debug)]
133struct KillRequest {
134 signal: i32,
135}
136
137#[cfg(unix)]
144#[derive(serde::Deserialize)]
145struct ShimRecord {
146 level: String,
147 target: String,
148 msg: String,
149 #[serde(default)]
150 fields: serde_json::Value,
151 #[serde(rename = "_lib", default)]
154 lib: Option<String>,
155 #[serde(rename = "_lib_ver", default)]
157 lib_version: Option<String>,
158}
159
160#[cfg(unix)]
167struct FdCloser(libc::c_int);
168
169#[cfg(unix)]
170impl Drop for FdCloser {
171 fn drop(&mut self) {
172 unsafe { libc::close(self.0) };
173 }
174}
175
176#[cfg(unix)]
179unsafe impl Send for FdCloser {}
180
181pub struct TaskDriver {
187 store: Arc<TaskStore>,
188 active: Arc<Mutex<HashMap<String, RunControl>>>,
189 completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
192}
193
194impl TaskDriver {
195 pub async fn new(store: Arc<TaskStore>) -> Result<Self, DriverError> {
200 Self::new_with_completion(store, None).await
201 }
202
203 pub async fn new_with_completion(
208 store: Arc<TaskStore>,
209 completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
210 ) -> Result<Self, DriverError> {
211 let stale = store.list_runs(&RunFilter {
212 status: Some("running".to_string()),
213 ..Default::default()
214 }).await?;
215 for meta in stale {
216 store.update_status(
217 &meta.id,
218 &RunStatus::Lost {
219 reason: "daemon restarted while run was in-flight".to_string(),
220 },
221 ).await?;
222 }
223 Ok(Self {
224 store,
225 active: Arc::new(Mutex::new(HashMap::new())),
226 completion_tx,
227 })
228 }
229
230 pub async fn spawn_run(&self, cmd: &str, opts: SpawnOpts) -> Result<TaskRunId, DriverError> {
243 let id = TaskRunId::new();
244 let started_at = unix_now_secs();
245 let started_at_ms: u64 = started_at.saturating_mul(1000);
246
247 let user_dir = std::env::var_os("YAH_BEHOLDERS_DIR")
250 .map(std::path::PathBuf::from)
251 .or_else(|| {
252 std::env::var_os("HOME")
253 .map(|h| std::path::PathBuf::from(h).join(".yah/beholders"))
254 });
255 let registry = registry_with_user_beholders(user_dir.as_deref());
256 let attach = registry.attach(cmd, &opts.beholder_select, opts.tty_attached);
257 let effective_cmd = if attach.argv.is_empty() {
259 cmd.to_string()
260 } else {
261 attach.argv.join(" ")
262 };
263
264 self.store.insert_run(&TaskRunMeta {
265 id: id.clone(),
266 command: cmd.to_string(),
267 cwd: opts.cwd.clone(),
268 env: opts.env.clone(),
269 started_at,
270 status: RunStatus::Running,
271 label: opts.label.clone(),
272 initiator: opts.initiator.clone(),
273 beholder_status: Some(attach.status),
274 pinned: opts.pin,
275 origin: opts.origin.clone(),
276 }).await?;
277
278 let pty_sys = native_pty_system();
280 let pair = pty_sys
281 .openpty(PtySize {
282 rows: opts.pty_rows,
283 cols: opts.pty_cols,
284 pixel_width: 0,
285 pixel_height: 0,
286 })
287 .map_err(|e| DriverError::Pty(e.to_string()))?;
288
289 let pty_reader = pair
291 .master
292 .try_clone_reader()
293 .map_err(|e| DriverError::Pty(e.to_string()))?;
294
295 let stdin_tx: Option<mpsc::Sender<Vec<u8>>> = if opts.stdin_enabled {
297 let mut writer = pair
298 .master
299 .take_writer()
300 .map_err(|e| DriverError::Pty(e.to_string()))?;
301 let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64);
302 task::spawn(async move {
303 use std::io::Write;
304 while let Some(bytes) = rx.recv().await {
305 let _ = writer.write_all(&bytes);
306 let _ = writer.flush();
307 }
308 });
309 Some(tx)
310 } else {
311 None
312 };
313
314 #[cfg(unix)]
327 let log_fifo: Option<(libc::c_int, FdCloser, std::path::PathBuf)> = if opts.log_fd_enabled {
328 let fifo_path = std::env::temp_dir().join(format!("yah-log-{}.fifo", id));
329 let path_cstr = match std::ffi::CString::new(fifo_path.to_string_lossy().as_bytes()) {
330 Ok(s) => s,
331 Err(_) => {
332 return Err(DriverError::Io(std::io::Error::new(
334 std::io::ErrorKind::InvalidInput,
335 "log FIFO path contained nul byte",
336 )));
337 }
338 };
339 let mkfifo_ret = unsafe { libc::mkfifo(path_cstr.as_ptr(), 0o600) };
340 if mkfifo_ret != 0 {
341 None } else {
343 let rfd = unsafe {
345 libc::open(path_cstr.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK)
346 };
347 if rfd < 0 {
348 let _ = unsafe { libc::unlink(path_cstr.as_ptr()) };
349 None
350 } else {
351 unsafe { libc::fcntl(rfd, libc::F_SETFL, 0) };
353 let wfd = unsafe {
355 libc::open(path_cstr.as_ptr(), libc::O_WRONLY)
356 };
357 if wfd < 0 {
358 unsafe { libc::close(rfd) };
359 let _ = unsafe { libc::unlink(path_cstr.as_ptr()) };
360 None
361 } else {
362 Some((rfd, FdCloser(wfd), fifo_path))
363 }
364 }
365 }
366 } else {
367 None
368 };
369
370 let mut cb = CommandBuilder::new("sh");
372 cb.args(["-c", &effective_cmd]);
373 cb.cwd(&opts.cwd);
374 for (k, v) in &opts.env {
375 cb.env(k, v);
376 }
377 cb.env("TERM", "xterm-256color");
378
379 #[cfg(unix)]
381 if let Some((_, _, ref fifo_path)) = log_fifo {
382 cb.env("YAH_TASK_RUN", id.to_string());
383 cb.env("YAH_LOG_PIPE", fifo_path.to_string_lossy().as_ref());
384 }
385
386 let child = pair
387 .slave
388 .spawn_command(cb)
389 .map_err(|e| DriverError::Pty(e.to_string()))?;
390 drop(pair.slave);
392
393 let pid = child.process_id().unwrap_or(0);
394
395 #[cfg(unix)]
403 let log_wfd_holder: Option<FdCloser> = if let Some((rfd, wfd, fifo_path)) = log_fifo {
404 let store_log = Arc::clone(&self.store);
405 let id_log = id.clone();
406 let rt = tokio::runtime::Handle::current();
407 tokio::task::spawn_blocking(move || {
410 run_log_receiver(rt, store_log, id_log, rfd, fifo_path, started_at_ms);
411 });
412 Some(wfd)
413 } else {
414 None
415 };
416
417 let (kill_tx, kill_rx) = mpsc::channel::<KillRequest>(4);
419 let (reader_done_tx, reader_done_rx) = oneshot::channel::<()>();
420
421 {
424 let store_r = Arc::clone(&self.store);
425 let id_r = id.clone();
426 let mut beholder = attach.beholder;
427 let rt = tokio::runtime::Handle::current();
428 tokio::task::spawn_blocking(move || {
429 let mut buf = [0u8; READ_BUF_SIZE];
430 let mut reader = pty_reader;
431 loop {
432 match reader.read(&mut buf) {
433 Ok(0) | Err(_) => break,
434 Ok(n) => {
435 let offset = elapsed_ms(started_at_ms);
436 let append_res = rt.block_on(store_r.append_chunk(
437 &id_r,
438 offset,
439 Stream::Stdout,
440 &buf[..n],
441 ));
442 if let Ok(seq) = append_res {
443 let mut detach_beholder = false;
444 if let Some(ref mut b) = beholder {
445 let chunk = OutputChunk {
446 run_id: id_r.clone(),
447 seq,
448 offset_ms: offset,
449 stream: Stream::Stdout,
450 bytes: buf[..n].to_vec(),
451 };
452 for ev in b.parse_chunk(&chunk) {
453 let _ = rt.block_on(store_r.append_event(
454 &ev.run_id,
455 ev.offset_ms,
456 ev.level,
457 &ev.target,
458 &ev.msg,
459 &ev.fields,
460 ev.anchor.as_ref().map(|a| a.seq),
461 &ev.source,
462 ));
463 }
464 if let Some(reason) = b.unknown_format_reason() {
465 let new_status = BeholderStatus::unknown_format_with_reason(
466 b.name(),
467 reason,
468 );
469 let _ = rt.block_on(
470 store_r.update_beholder_status(&id_r, &new_status),
471 );
472 detach_beholder = true;
473 }
474 }
475 if detach_beholder {
476 beholder = None;
477 }
478 }
479 }
480 }
481 }
482 if let Some(ref mut b) = beholder {
483 let final_offset = elapsed_ms(started_at_ms);
484 for ev in b.on_done(&id_r, final_offset) {
485 let _ = rt.block_on(store_r.append_event(
486 &ev.run_id,
487 ev.offset_ms,
488 ev.level,
489 &ev.target,
490 &ev.msg,
491 &ev.fields,
492 ev.anchor.as_ref().map(|a| a.seq),
493 &ev.source,
494 ));
495 }
496 if let Some(reason) = b.unknown_format_reason() {
497 let new_status = BeholderStatus::unknown_format_with_reason(b.name(), reason);
498 let _ = rt.block_on(store_r.update_beholder_status(&id_r, &new_status));
499 }
500 }
501 let _ = reader_done_tx.send(());
502 });
503 }
504
505 {
509 let store_l = Arc::clone(&self.store);
510 let active_l = Arc::clone(&self.active);
511 let id_l = id.clone();
512 let master = pair.master;
513 let completion_tx_l = self.completion_tx.clone();
514 #[cfg(unix)]
515 let wfd_l = log_wfd_holder;
516 task::spawn(async move {
517 run_lifecycle(
518 store_l,
519 active_l,
520 id_l,
521 pid,
522 child,
523 master,
524 kill_rx,
525 reader_done_rx,
526 completion_tx_l,
527 #[cfg(unix)]
528 wfd_l,
529 )
530 .await;
531 });
532 }
533
534 self.active
535 .lock()
536 .unwrap()
537 .insert(id.to_string(), RunControl { kill_tx, stdin_tx });
538
539 Ok(id)
540 }
541
542 pub async fn kill_run(&self, id: &TaskRunId, signal: Option<i32>) -> Result<(), DriverError> {
549 let kill_tx = self
550 .active
551 .lock()
552 .unwrap()
553 .get(&id.to_string())
554 .map(|c| c.kill_tx.clone());
555
556 match kill_tx {
557 Some(tx) => tx
558 .send(KillRequest { signal: signal.unwrap_or(SIGTERM) })
559 .await
560 .map_err(|_| DriverError::NotFound(id.to_string())),
561 None => Err(DriverError::NotFound(id.to_string())),
562 }
563 }
564
565 pub async fn send_stdin(&self, id: &TaskRunId, bytes: Vec<u8>) -> Result<(), DriverError> {
567 let stdin_tx = self
568 .active
569 .lock()
570 .unwrap()
571 .get(&id.to_string())
572 .and_then(|c| c.stdin_tx.clone());
573
574 match stdin_tx {
575 Some(tx) => tx
576 .send(bytes)
577 .await
578 .map_err(|_| DriverError::NotFound(id.to_string())),
579 None => Err(DriverError::NotFound(id.to_string())),
580 }
581 }
582}
583
584#[cfg(unix)]
593fn run_log_receiver(
594 rt: tokio::runtime::Handle,
595 store: Arc<TaskStore>,
596 run_id: TaskRunId,
597 read_fd: libc::c_int,
598 fifo_path: std::path::PathBuf,
599 started_at_ms: u64,
600) {
601 use std::io::BufRead;
602 use std::os::unix::io::FromRawFd;
603
604 let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
607 let reader = std::io::BufReader::new(file);
608
609 for line in reader.lines() {
610 let line = match line {
611 Ok(l) => l,
612 Err(_) => break,
613 };
614 let trimmed = line.trim();
615 if trimmed.is_empty() {
616 continue;
617 }
618 let rec: ShimRecord = match serde_json::from_str(trimmed) {
619 Ok(r) => r,
620 Err(_) => continue, };
622 let level = rec.level.parse::<crate::types::Level>().unwrap_or(crate::types::Level::Info);
623 let source = crate::types::EventSource::Shim {
624 lib: rec.lib.unwrap_or_else(|| "unknown".to_string()),
625 version: rec.lib_version.unwrap_or_else(|| "0.0.0".to_string()),
626 };
627 let fields = if rec.fields.is_object() {
628 rec.fields
629 } else {
630 serde_json::Value::Object(Default::default())
631 };
632 let offset = elapsed_ms(started_at_ms);
633 let _ = rt.block_on(store.append_event(
634 &run_id,
635 offset,
636 level,
637 &rec.target,
638 &rec.msg,
639 &fields,
640 None,
641 &source,
642 ));
643 }
644
645 let _ = std::fs::remove_file(&fifo_path);
647}
648
649async fn run_lifecycle(
652 store: Arc<TaskStore>,
653 active: Arc<Mutex<HashMap<String, RunControl>>>,
654 id: TaskRunId,
655 pid: u32,
656 child: Box<dyn portable_pty::Child + Send>,
657 master: Box<dyn portable_pty::MasterPty + Send>,
658 mut kill_rx: mpsc::Receiver<KillRequest>,
659 reader_done_rx: oneshot::Receiver<()>,
660 completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
661 #[cfg(unix)]
665 _log_wfd: Option<FdCloser>,
666) {
667 let reader_done = async { reader_done_rx.await.ok(); };
670 tokio::pin!(reader_done);
671
672 let sent_signal: Option<i32>;
673
674 tokio::select! {
675 req = kill_rx.recv() => {
676 match req {
677 Some(KillRequest { signal }) => {
678 send_unix_signal(pid, signal);
679 if signal == SIGKILL {
680 sent_signal = Some(SIGKILL);
681 } else {
682 tokio::select! {
684 _ = &mut reader_done => {
685 sent_signal = Some(signal);
687 }
688 _ = tokio::time::sleep(DEFAULT_GRACE) => {
689 send_unix_signal(pid, SIGKILL);
691 sent_signal = Some(SIGKILL);
692 }
693 }
694 }
695 }
696 None => {
698 send_unix_signal(pid, SIGKILL);
699 sent_signal = Some(SIGKILL);
700 }
701 }
702 }
703 _ = &mut reader_done => {
704 sent_signal = None;
705 }
706 }
707
708 let exit_code = task::spawn_blocking(move || {
711 let mut c = child;
712 let _m = master; c.wait().ok().map(|s| s.exit_code())
714 })
715 .await
716 .ok()
717 .flatten();
718
719 let ended_at = unix_now_secs();
720 let status = match sent_signal {
721 Some(sig) => RunStatus::Killed { signal: sig, ended_at },
722 None => match exit_code {
723 Some(code) => RunStatus::Done { exit_code: code as i32, ended_at },
724 None => RunStatus::Lost {
725 reason: "process exited without an exit code".to_string(),
726 },
727 },
728 };
729
730 let _ = store.update_status(&id, &status).await;
731 if let Some(ref tx) = completion_tx {
732 let _ = tx.send((id.clone(), status));
733 }
734 active.lock().unwrap().remove(&id.to_string());
735}
736
737fn send_unix_signal(pid: u32, signal: i32) {
740 #[cfg(unix)]
741 unsafe {
742 libc::kill(pid as libc::pid_t, signal);
743 }
744 }
746
747fn unix_now_secs() -> u64 {
748 SystemTime::now()
749 .duration_since(UNIX_EPOCH)
750 .unwrap_or_default()
751 .as_secs()
752}
753
754fn elapsed_ms(started_at_ms: u64) -> u32 {
755 let now_ms = SystemTime::now()
756 .duration_since(UNIX_EPOCH)
757 .unwrap_or_default()
758 .as_millis() as u64;
759 now_ms.saturating_sub(started_at_ms).min(u32::MAX as u64) as u32
760}
761
762#[cfg(test)]
765mod tests {
766 use super::*;
767 use crate::store::ChunkFilter;
768
769 async fn open_store(dir: &tempfile::TempDir) -> Arc<TaskStore> {
770 Arc::new(TaskStore::open(&dir.path().join("tr.turso")).await.unwrap())
771 }
772
773 #[tokio::test]
776 async fn lost_on_disappear_marks_stale_running_runs() {
777 let dir = tempfile::tempdir().unwrap();
778 let store = open_store(&dir).await;
779
780 let stale_id = TaskRunId::new();
782 store
783 .insert_run(&TaskRunMeta {
784 id: stale_id.clone(),
785 command: "sleep 9999".to_string(),
786 cwd: "/tmp".into(),
787 env: vec![],
788 started_at: unix_now_secs() - 60,
789 status: RunStatus::Running,
790 label: None,
791 initiator: Initiator::Human { camp: "test".to_string() },
792 beholder_status: None,
793 pinned: false,
794 origin: None,
795 })
796 .await
797 .unwrap();
798
799 let _driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
801
802 let meta = store.get_run(&stale_id).await.unwrap().unwrap();
803 assert!(
804 matches!(meta.status, RunStatus::Lost { .. }),
805 "stale run should be Lost, got {:?}",
806 meta.status
807 );
808 }
809
810 #[tokio::test]
811 async fn new_driver_does_not_touch_completed_runs() {
812 let dir = tempfile::tempdir().unwrap();
813 let store = open_store(&dir).await;
814
815 let done_id = TaskRunId::new();
816 store
817 .insert_run(&TaskRunMeta {
818 id: done_id.clone(),
819 command: "true".to_string(),
820 cwd: "/tmp".into(),
821 env: vec![],
822 started_at: unix_now_secs() - 10,
823 status: RunStatus::Running,
824 label: None,
825 initiator: Initiator::Human { camp: "test".to_string() },
826 beholder_status: None,
827 pinned: false,
828 origin: None,
829 })
830 .await
831 .unwrap();
832 store
833 .update_status(&done_id, &RunStatus::Done { exit_code: 0, ended_at: unix_now_secs() })
834 .await
835 .unwrap();
836
837 let _driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
838
839 let meta = store.get_run(&done_id).await.unwrap().unwrap();
840 assert!(
841 matches!(meta.status, RunStatus::Done { .. }),
842 "completed run must not be touched"
843 );
844 }
845
846 #[tokio::test]
849 async fn spawn_echo_and_read_chunks() {
850 let dir = tempfile::tempdir().unwrap();
851 let store = open_store(&dir).await;
852 let driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
853
854 let id = driver
855 .spawn_run(
856 "echo hello_world",
857 SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
858 )
859 .await
860 .unwrap();
861
862 let deadline = std::time::Instant::now() + Duration::from_secs(5);
864 loop {
865 let meta = store.get_run(&id).await.unwrap().unwrap();
866 if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
867 break;
868 }
869 if std::time::Instant::now() > deadline {
870 panic!("run did not complete in time, status={:?}", meta.status);
871 }
872 tokio::time::sleep(Duration::from_millis(50)).await;
873 }
874
875 let chunks = store
877 .get_chunks(&id, &ChunkFilter::default())
878 .await
879 .unwrap();
880 let output: Vec<u8> = chunks.into_iter().flat_map(|c| c.bytes).collect();
881 let text = String::from_utf8_lossy(&output);
882 assert!(
883 text.contains("hello_world"),
884 "expected 'hello_world' in output, got: {text:?}"
885 );
886
887 let meta = store.get_run(&id).await.unwrap().unwrap();
888 assert!(
889 matches!(meta.status, RunStatus::Done { exit_code: 0, .. }),
890 "expected Done(0), got {:?}",
891 meta.status
892 );
893 }
894
895 #[tokio::test]
896 async fn spawn_failing_command_records_nonzero_exit() {
897 let dir = tempfile::tempdir().unwrap();
898 let store = open_store(&dir).await;
899 let driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
900
901 let id = driver
902 .spawn_run(
903 "exit 42",
904 SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
905 )
906 .await
907 .unwrap();
908
909 let deadline = std::time::Instant::now() + Duration::from_secs(5);
910 loop {
911 let meta = store.get_run(&id).await.unwrap().unwrap();
912 if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
913 match meta.status {
914 RunStatus::Done { exit_code, .. } => {
915 assert_ne!(exit_code, 0, "exit 42 should produce a non-zero exit code");
916 }
917 other => panic!("unexpected status: {other:?}"),
918 }
919 break;
920 }
921 if std::time::Instant::now() > deadline {
922 panic!("run did not complete in time");
923 }
924 tokio::time::sleep(Duration::from_millis(50)).await;
925 }
926 }
927
928 #[cfg(unix)]
931 #[tokio::test]
932 async fn kill_with_sigterm_transitions_to_killed() {
933 let dir = tempfile::tempdir().unwrap();
934 let store = open_store(&dir).await;
935 let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
936
937 let id = driver
938 .spawn_run(
939 "sleep 60",
940 SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
941 )
942 .await
943 .unwrap();
944
945 tokio::time::sleep(Duration::from_millis(100)).await;
947
948 driver.kill_run(&id, Some(SIGTERM)).await.unwrap();
949
950 let deadline = std::time::Instant::now() + Duration::from_secs(10);
951 loop {
952 let meta = store.get_run(&id).await.unwrap().unwrap();
953 if matches!(meta.status, RunStatus::Killed { .. } | RunStatus::Lost { .. }) {
954 assert!(
955 matches!(meta.status, RunStatus::Killed { .. }),
956 "expected Killed, got {:?}",
957 meta.status
958 );
959 break;
960 }
961 if std::time::Instant::now() > deadline {
962 panic!("run did not become Killed in time, status={:?}", meta.status);
963 }
964 tokio::time::sleep(Duration::from_millis(50)).await;
965 }
966 }
967
968 #[cfg(unix)]
969 #[tokio::test]
970 async fn kill_run_returns_not_found_after_exit() {
971 let dir = tempfile::tempdir().unwrap();
972 let store = open_store(&dir).await;
973 let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
974
975 let id = driver
976 .spawn_run(
977 "echo done",
978 SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
979 )
980 .await
981 .unwrap();
982
983 let deadline = std::time::Instant::now() + Duration::from_secs(5);
985 loop {
986 let meta = store.get_run(&id).await.unwrap().unwrap();
987 if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
988 break;
989 }
990 if std::time::Instant::now() > deadline {
991 panic!("run did not complete");
992 }
993 tokio::time::sleep(Duration::from_millis(50)).await;
994 }
995
996 let result = driver.kill_run(&id, None).await;
998 assert!(
999 matches!(result, Err(DriverError::NotFound(_))),
1000 "expected NotFound, got {result:?}"
1001 );
1002 }
1003
1004 #[cfg(unix)]
1007 #[tokio::test]
1008 async fn stdin_send_reaches_child() {
1009 let dir = tempfile::tempdir().unwrap();
1010 let store = open_store(&dir).await;
1011 let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
1012
1013 let id = driver
1015 .spawn_run(
1016 "read line && echo got_$line",
1017 SpawnOpts {
1018 cwd: "/tmp".into(),
1019 stdin_enabled: true,
1020 ..Default::default()
1021 },
1022 )
1023 .await
1024 .unwrap();
1025
1026 tokio::time::sleep(Duration::from_millis(150)).await;
1027 driver.send_stdin(&id, b"hello\n".to_vec()).await.unwrap();
1028
1029 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1030 loop {
1031 let meta = store.get_run(&id).await.unwrap().unwrap();
1032 if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
1033 break;
1034 }
1035 if std::time::Instant::now() > deadline {
1036 panic!("run did not complete after stdin input");
1037 }
1038 tokio::time::sleep(Duration::from_millis(50)).await;
1039 }
1040
1041 let chunks = store.get_chunks(&id, &ChunkFilter::default()).await.unwrap();
1042 let raw: Vec<u8> = chunks.into_iter().flat_map(|c| c.bytes).collect();
1043 let text = String::from_utf8_lossy(&raw);
1044 assert!(
1045 text.contains("got_hello"),
1046 "expected 'got_hello' in output, got: {text:?}"
1047 );
1048 }
1049
1050 #[cfg(unix)]
1058 #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
1059 async fn log_pipe_events_land_in_store() {
1060 use crate::store::EventFilter;
1061
1062 let dir = tempfile::tempdir().unwrap();
1063 let store = open_store(&dir).await;
1064 let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
1065
1066 let cmd = r#"printf '{"level":"warn","target":"test.shim","msg":"hello-from-pipe","fields":{"x":42},"_lib":"test-shim","_lib_ver":"0.1.0"}\n' >> "$YAH_LOG_PIPE""#;
1069
1070 let id = driver
1071 .spawn_run(cmd, SpawnOpts { cwd: "/tmp".into(), ..Default::default() })
1072 .await
1073 .unwrap();
1074
1075 let deadline = std::time::Instant::now() + Duration::from_secs(20);
1079 loop {
1080 let meta = store.get_run(&id).await.unwrap().unwrap();
1081 if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
1082 break;
1083 }
1084 if std::time::Instant::now() > deadline {
1085 panic!("run did not complete in time");
1086 }
1087 tokio::time::sleep(Duration::from_millis(50)).await;
1088 }
1089
1090 tokio::time::sleep(Duration::from_millis(500)).await;
1093
1094 let events = store.query_events(&id, &EventFilter::default()).await.unwrap();
1095 assert!(
1096 !events.is_empty(),
1097 "expected at least one shim event, got none"
1098 );
1099 let ev = events.iter().find(|e| e.target == "test.shim");
1100 let ev = ev.expect("event with target 'test.shim' not found");
1101 assert_eq!(ev.msg, "hello-from-pipe");
1102 assert_eq!(ev.level, crate::types::Level::Warn);
1103 assert!(
1104 matches!(&ev.source, crate::types::EventSource::Shim { lib, .. } if lib == "test-shim"),
1105 "unexpected source: {:?}",
1106 ev.source
1107 );
1108 assert_eq!(ev.fields.get("x"), Some(&serde_json::json!(42)));
1109 }
1110
1111 #[cfg(unix)]
1114 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1115 async fn log_pipe_disabled_produces_no_events() {
1116 use crate::store::EventFilter;
1117
1118 let dir = tempfile::tempdir().unwrap();
1119 let store = open_store(&dir).await;
1120 let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
1121
1122 let cmd = r#"[ -n "$YAH_LOG_PIPE" ] && printf '{"level":"info","target":"t","msg":"m","fields":{}}\n' >> "$YAH_LOG_PIPE" || true"#;
1125
1126 let id = driver
1127 .spawn_run(
1128 cmd,
1129 SpawnOpts { cwd: "/tmp".into(), log_fd_enabled: false, ..Default::default() },
1130 )
1131 .await
1132 .unwrap();
1133
1134 let deadline = std::time::Instant::now() + Duration::from_secs(5);
1135 loop {
1136 let meta = store.get_run(&id).await.unwrap().unwrap();
1137 if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
1138 break;
1139 }
1140 if std::time::Instant::now() > deadline {
1141 panic!("run did not complete");
1142 }
1143 tokio::time::sleep(Duration::from_millis(50)).await;
1144 }
1145
1146 tokio::time::sleep(Duration::from_millis(100)).await;
1147
1148 let events = store.query_events(&id, &EventFilter::default()).await.unwrap();
1149 assert!(
1150 events.is_empty(),
1151 "expected no shim events when log_fd_enabled=false, got {}",
1152 events.len()
1153 );
1154 }
1155}