use std::collections::HashMap;
use std::io::Read;
use std::path::PathBuf;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use portable_pty::{native_pty_system, CommandBuilder, PtySize};
use thiserror::Error;
use tokio::sync::{mpsc, oneshot};
use tokio::task;
use crate::beholders::{registry_with_user_beholders, BeholderSelect};
use crate::store::{RunFilter, StoreError, TaskStore};
use crate::types::{BeholderStatus, Initiator, OutputChunk, RunStatus, Stream, TaskRunId, TaskRunMeta};
const DEFAULT_GRACE: Duration = Duration::from_secs(5);
const READ_BUF_SIZE: usize = 4096;
const SIGTERM: i32 = 15;
const SIGKILL: i32 = 9;
#[derive(Debug, Error)]
pub enum DriverError {
#[error("store: {0}")]
Store(#[from] StoreError),
#[error("pty: {0}")]
Pty(String),
#[error("run not found: {0}")]
NotFound(String),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Clone)]
pub struct SpawnOpts {
pub cwd: PathBuf,
pub env: Vec<(String, String)>,
pub label: Option<String>,
pub initiator: Initiator,
pub pty_cols: u16,
pub pty_rows: u16,
pub stdin_enabled: bool,
pub pin: bool,
pub beholder_select: BeholderSelect,
pub tty_attached: bool,
pub log_fd_enabled: bool,
pub origin: Option<String>,
}
impl Default for SpawnOpts {
fn default() -> Self {
Self {
cwd: std::env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
env: vec![],
label: None,
initiator: Initiator::Human { camp: "local".to_string() },
pty_cols: 80,
pty_rows: 24,
stdin_enabled: false,
pin: false,
beholder_select: BeholderSelect::Auto,
tty_attached: false,
log_fd_enabled: true,
origin: None,
}
}
}
struct RunControl {
kill_tx: mpsc::Sender<KillRequest>,
stdin_tx: Option<mpsc::Sender<Vec<u8>>>,
}
#[derive(Debug)]
struct KillRequest {
signal: i32,
}
#[cfg(unix)]
#[derive(serde::Deserialize)]
struct ShimRecord {
level: String,
target: String,
msg: String,
#[serde(default)]
fields: serde_json::Value,
#[serde(rename = "_lib", default)]
lib: Option<String>,
#[serde(rename = "_lib_ver", default)]
lib_version: Option<String>,
}
#[cfg(unix)]
struct FdCloser(libc::c_int);
#[cfg(unix)]
impl Drop for FdCloser {
fn drop(&mut self) {
unsafe { libc::close(self.0) };
}
}
#[cfg(unix)]
unsafe impl Send for FdCloser {}
pub struct TaskDriver {
store: Arc<TaskStore>,
active: Arc<Mutex<HashMap<String, RunControl>>>,
completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
}
impl TaskDriver {
pub async fn new(store: Arc<TaskStore>) -> Result<Self, DriverError> {
Self::new_with_completion(store, None).await
}
pub async fn new_with_completion(
store: Arc<TaskStore>,
completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
) -> Result<Self, DriverError> {
let stale = store.list_runs(&RunFilter {
status: Some("running".to_string()),
..Default::default()
}).await?;
for meta in stale {
store.update_status(
&meta.id,
&RunStatus::Lost {
reason: "daemon restarted while run was in-flight".to_string(),
},
).await?;
}
Ok(Self {
store,
active: Arc::new(Mutex::new(HashMap::new())),
completion_tx,
})
}
pub async fn spawn_run(&self, cmd: &str, opts: SpawnOpts) -> Result<TaskRunId, DriverError> {
let id = TaskRunId::new();
let started_at = unix_now_secs();
let started_at_ms: u64 = started_at.saturating_mul(1000);
let user_dir = std::env::var_os("YAH_BEHOLDERS_DIR")
.map(std::path::PathBuf::from)
.or_else(|| {
std::env::var_os("HOME")
.map(|h| std::path::PathBuf::from(h).join(".yah/beholders"))
});
let registry = registry_with_user_beholders(user_dir.as_deref());
let attach = registry.attach(cmd, &opts.beholder_select, opts.tty_attached);
let effective_cmd = if attach.argv.is_empty() {
cmd.to_string()
} else {
attach.argv.join(" ")
};
self.store.insert_run(&TaskRunMeta {
id: id.clone(),
command: cmd.to_string(),
cwd: opts.cwd.clone(),
env: opts.env.clone(),
started_at,
status: RunStatus::Running,
label: opts.label.clone(),
initiator: opts.initiator.clone(),
beholder_status: Some(attach.status),
pinned: opts.pin,
origin: opts.origin.clone(),
}).await?;
let pty_sys = native_pty_system();
let pair = pty_sys
.openpty(PtySize {
rows: opts.pty_rows,
cols: opts.pty_cols,
pixel_width: 0,
pixel_height: 0,
})
.map_err(|e| DriverError::Pty(e.to_string()))?;
let pty_reader = pair
.master
.try_clone_reader()
.map_err(|e| DriverError::Pty(e.to_string()))?;
let stdin_tx: Option<mpsc::Sender<Vec<u8>>> = if opts.stdin_enabled {
let mut writer = pair
.master
.take_writer()
.map_err(|e| DriverError::Pty(e.to_string()))?;
let (tx, mut rx) = mpsc::channel::<Vec<u8>>(64);
task::spawn(async move {
use std::io::Write;
while let Some(bytes) = rx.recv().await {
let _ = writer.write_all(&bytes);
let _ = writer.flush();
}
});
Some(tx)
} else {
None
};
#[cfg(unix)]
let log_fifo: Option<(libc::c_int, FdCloser, std::path::PathBuf)> = if opts.log_fd_enabled {
let fifo_path = std::env::temp_dir().join(format!("yah-log-{}.fifo", id));
let path_cstr = match std::ffi::CString::new(fifo_path.to_string_lossy().as_bytes()) {
Ok(s) => s,
Err(_) => {
return Err(DriverError::Io(std::io::Error::new(
std::io::ErrorKind::InvalidInput,
"log FIFO path contained nul byte",
)));
}
};
let mkfifo_ret = unsafe { libc::mkfifo(path_cstr.as_ptr(), 0o600) };
if mkfifo_ret != 0 {
None } else {
let rfd = unsafe {
libc::open(path_cstr.as_ptr(), libc::O_RDONLY | libc::O_NONBLOCK)
};
if rfd < 0 {
let _ = unsafe { libc::unlink(path_cstr.as_ptr()) };
None
} else {
unsafe { libc::fcntl(rfd, libc::F_SETFL, 0) };
let wfd = unsafe {
libc::open(path_cstr.as_ptr(), libc::O_WRONLY)
};
if wfd < 0 {
unsafe { libc::close(rfd) };
let _ = unsafe { libc::unlink(path_cstr.as_ptr()) };
None
} else {
Some((rfd, FdCloser(wfd), fifo_path))
}
}
}
} else {
None
};
let mut cb = CommandBuilder::new("sh");
cb.args(["-c", &effective_cmd]);
cb.cwd(&opts.cwd);
for (k, v) in &opts.env {
cb.env(k, v);
}
cb.env("TERM", "xterm-256color");
#[cfg(unix)]
if let Some((_, _, ref fifo_path)) = log_fifo {
cb.env("YAH_TASK_RUN", id.to_string());
cb.env("YAH_LOG_PIPE", fifo_path.to_string_lossy().as_ref());
}
let child = pair
.slave
.spawn_command(cb)
.map_err(|e| DriverError::Pty(e.to_string()))?;
drop(pair.slave);
let pid = child.process_id().unwrap_or(0);
#[cfg(unix)]
let log_wfd_holder: Option<FdCloser> = if let Some((rfd, wfd, fifo_path)) = log_fifo {
let store_log = Arc::clone(&self.store);
let id_log = id.clone();
let rt = tokio::runtime::Handle::current();
tokio::task::spawn_blocking(move || {
run_log_receiver(rt, store_log, id_log, rfd, fifo_path, started_at_ms);
});
Some(wfd)
} else {
None
};
let (kill_tx, kill_rx) = mpsc::channel::<KillRequest>(4);
let (reader_done_tx, reader_done_rx) = oneshot::channel::<()>();
{
let store_r = Arc::clone(&self.store);
let id_r = id.clone();
let mut beholder = attach.beholder;
let rt = tokio::runtime::Handle::current();
tokio::task::spawn_blocking(move || {
let mut buf = [0u8; READ_BUF_SIZE];
let mut reader = pty_reader;
loop {
match reader.read(&mut buf) {
Ok(0) | Err(_) => break,
Ok(n) => {
let offset = elapsed_ms(started_at_ms);
let append_res = rt.block_on(store_r.append_chunk(
&id_r,
offset,
Stream::Stdout,
&buf[..n],
));
if let Ok(seq) = append_res {
let mut detach_beholder = false;
if let Some(ref mut b) = beholder {
let chunk = OutputChunk {
run_id: id_r.clone(),
seq,
offset_ms: offset,
stream: Stream::Stdout,
bytes: buf[..n].to_vec(),
};
for ev in b.parse_chunk(&chunk) {
let _ = rt.block_on(store_r.append_event(
&ev.run_id,
ev.offset_ms,
ev.level,
&ev.target,
&ev.msg,
&ev.fields,
ev.anchor.as_ref().map(|a| a.seq),
&ev.source,
));
}
if let Some(reason) = b.unknown_format_reason() {
let new_status = BeholderStatus::unknown_format_with_reason(
b.name(),
reason,
);
let _ = rt.block_on(
store_r.update_beholder_status(&id_r, &new_status),
);
detach_beholder = true;
}
}
if detach_beholder {
beholder = None;
}
}
}
}
}
if let Some(ref mut b) = beholder {
let final_offset = elapsed_ms(started_at_ms);
for ev in b.on_done(&id_r, final_offset) {
let _ = rt.block_on(store_r.append_event(
&ev.run_id,
ev.offset_ms,
ev.level,
&ev.target,
&ev.msg,
&ev.fields,
ev.anchor.as_ref().map(|a| a.seq),
&ev.source,
));
}
if let Some(reason) = b.unknown_format_reason() {
let new_status = BeholderStatus::unknown_format_with_reason(b.name(), reason);
let _ = rt.block_on(store_r.update_beholder_status(&id_r, &new_status));
}
}
let _ = reader_done_tx.send(());
});
}
{
let store_l = Arc::clone(&self.store);
let active_l = Arc::clone(&self.active);
let id_l = id.clone();
let master = pair.master;
let completion_tx_l = self.completion_tx.clone();
#[cfg(unix)]
let wfd_l = log_wfd_holder;
task::spawn(async move {
run_lifecycle(
store_l,
active_l,
id_l,
pid,
child,
master,
kill_rx,
reader_done_rx,
completion_tx_l,
#[cfg(unix)]
wfd_l,
)
.await;
});
}
self.active
.lock()
.unwrap()
.insert(id.to_string(), RunControl { kill_tx, stdin_tx });
Ok(id)
}
pub async fn kill_run(&self, id: &TaskRunId, signal: Option<i32>) -> Result<(), DriverError> {
let kill_tx = self
.active
.lock()
.unwrap()
.get(&id.to_string())
.map(|c| c.kill_tx.clone());
match kill_tx {
Some(tx) => tx
.send(KillRequest { signal: signal.unwrap_or(SIGTERM) })
.await
.map_err(|_| DriverError::NotFound(id.to_string())),
None => Err(DriverError::NotFound(id.to_string())),
}
}
pub async fn send_stdin(&self, id: &TaskRunId, bytes: Vec<u8>) -> Result<(), DriverError> {
let stdin_tx = self
.active
.lock()
.unwrap()
.get(&id.to_string())
.and_then(|c| c.stdin_tx.clone());
match stdin_tx {
Some(tx) => tx
.send(bytes)
.await
.map_err(|_| DriverError::NotFound(id.to_string())),
None => Err(DriverError::NotFound(id.to_string())),
}
}
}
#[cfg(unix)]
fn run_log_receiver(
rt: tokio::runtime::Handle,
store: Arc<TaskStore>,
run_id: TaskRunId,
read_fd: libc::c_int,
fifo_path: std::path::PathBuf,
started_at_ms: u64,
) {
use std::io::BufRead;
use std::os::unix::io::FromRawFd;
let file = unsafe { std::fs::File::from_raw_fd(read_fd) };
let reader = std::io::BufReader::new(file);
for line in reader.lines() {
let line = match line {
Ok(l) => l,
Err(_) => break,
};
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let rec: ShimRecord = match serde_json::from_str(trimmed) {
Ok(r) => r,
Err(_) => continue, };
let level = rec.level.parse::<crate::types::Level>().unwrap_or(crate::types::Level::Info);
let source = crate::types::EventSource::Shim {
lib: rec.lib.unwrap_or_else(|| "unknown".to_string()),
version: rec.lib_version.unwrap_or_else(|| "0.0.0".to_string()),
};
let fields = if rec.fields.is_object() {
rec.fields
} else {
serde_json::Value::Object(Default::default())
};
let offset = elapsed_ms(started_at_ms);
let _ = rt.block_on(store.append_event(
&run_id,
offset,
level,
&rec.target,
&rec.msg,
&fields,
None,
&source,
));
}
let _ = std::fs::remove_file(&fifo_path);
}
async fn run_lifecycle(
store: Arc<TaskStore>,
active: Arc<Mutex<HashMap<String, RunControl>>>,
id: TaskRunId,
pid: u32,
child: Box<dyn portable_pty::Child + Send>,
master: Box<dyn portable_pty::MasterPty + Send>,
mut kill_rx: mpsc::Receiver<KillRequest>,
reader_done_rx: oneshot::Receiver<()>,
completion_tx: Option<tokio::sync::mpsc::UnboundedSender<(TaskRunId, RunStatus)>>,
#[cfg(unix)]
_log_wfd: Option<FdCloser>,
) {
let reader_done = async { reader_done_rx.await.ok(); };
tokio::pin!(reader_done);
let sent_signal: Option<i32>;
tokio::select! {
req = kill_rx.recv() => {
match req {
Some(KillRequest { signal }) => {
send_unix_signal(pid, signal);
if signal == SIGKILL {
sent_signal = Some(SIGKILL);
} else {
tokio::select! {
_ = &mut reader_done => {
sent_signal = Some(signal);
}
_ = tokio::time::sleep(DEFAULT_GRACE) => {
send_unix_signal(pid, SIGKILL);
sent_signal = Some(SIGKILL);
}
}
}
}
None => {
send_unix_signal(pid, SIGKILL);
sent_signal = Some(SIGKILL);
}
}
}
_ = &mut reader_done => {
sent_signal = None;
}
}
let exit_code = task::spawn_blocking(move || {
let mut c = child;
let _m = master; c.wait().ok().map(|s| s.exit_code())
})
.await
.ok()
.flatten();
let ended_at = unix_now_secs();
let status = match sent_signal {
Some(sig) => RunStatus::Killed { signal: sig, ended_at },
None => match exit_code {
Some(code) => RunStatus::Done { exit_code: code as i32, ended_at },
None => RunStatus::Lost {
reason: "process exited without an exit code".to_string(),
},
},
};
let _ = store.update_status(&id, &status).await;
if let Some(ref tx) = completion_tx {
let _ = tx.send((id.clone(), status));
}
active.lock().unwrap().remove(&id.to_string());
}
fn send_unix_signal(pid: u32, signal: i32) {
#[cfg(unix)]
unsafe {
libc::kill(pid as libc::pid_t, signal);
}
}
fn unix_now_secs() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
fn elapsed_ms(started_at_ms: u64) -> u32 {
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64;
now_ms.saturating_sub(started_at_ms).min(u32::MAX as u64) as u32
}
#[cfg(test)]
mod tests {
use super::*;
use crate::store::ChunkFilter;
async fn open_store(dir: &tempfile::TempDir) -> Arc<TaskStore> {
Arc::new(TaskStore::open(&dir.path().join("tr.turso")).await.unwrap())
}
#[tokio::test]
async fn lost_on_disappear_marks_stale_running_runs() {
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let stale_id = TaskRunId::new();
store
.insert_run(&TaskRunMeta {
id: stale_id.clone(),
command: "sleep 9999".to_string(),
cwd: "/tmp".into(),
env: vec![],
started_at: unix_now_secs() - 60,
status: RunStatus::Running,
label: None,
initiator: Initiator::Human { camp: "test".to_string() },
beholder_status: None,
pinned: false,
origin: None,
})
.await
.unwrap();
let _driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
let meta = store.get_run(&stale_id).await.unwrap().unwrap();
assert!(
matches!(meta.status, RunStatus::Lost { .. }),
"stale run should be Lost, got {:?}",
meta.status
);
}
#[tokio::test]
async fn new_driver_does_not_touch_completed_runs() {
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let done_id = TaskRunId::new();
store
.insert_run(&TaskRunMeta {
id: done_id.clone(),
command: "true".to_string(),
cwd: "/tmp".into(),
env: vec![],
started_at: unix_now_secs() - 10,
status: RunStatus::Running,
label: None,
initiator: Initiator::Human { camp: "test".to_string() },
beholder_status: None,
pinned: false,
origin: None,
})
.await
.unwrap();
store
.update_status(&done_id, &RunStatus::Done { exit_code: 0, ended_at: unix_now_secs() })
.await
.unwrap();
let _driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
let meta = store.get_run(&done_id).await.unwrap().unwrap();
assert!(
matches!(meta.status, RunStatus::Done { .. }),
"completed run must not be touched"
);
}
#[tokio::test]
async fn spawn_echo_and_read_chunks() {
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
let id = driver
.spawn_run(
"echo hello_world",
SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
)
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let meta = store.get_run(&id).await.unwrap().unwrap();
if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
break;
}
if std::time::Instant::now() > deadline {
panic!("run did not complete in time, status={:?}", meta.status);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let chunks = store
.get_chunks(&id, &ChunkFilter::default())
.await
.unwrap();
let output: Vec<u8> = chunks.into_iter().flat_map(|c| c.bytes).collect();
let text = String::from_utf8_lossy(&output);
assert!(
text.contains("hello_world"),
"expected 'hello_world' in output, got: {text:?}"
);
let meta = store.get_run(&id).await.unwrap().unwrap();
assert!(
matches!(meta.status, RunStatus::Done { exit_code: 0, .. }),
"expected Done(0), got {:?}",
meta.status
);
}
#[tokio::test]
async fn spawn_failing_command_records_nonzero_exit() {
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let driver = TaskDriver::new(Arc::clone(&store)).await.unwrap();
let id = driver
.spawn_run(
"exit 42",
SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
)
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let meta = store.get_run(&id).await.unwrap().unwrap();
if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
match meta.status {
RunStatus::Done { exit_code, .. } => {
assert_ne!(exit_code, 0, "exit 42 should produce a non-zero exit code");
}
other => panic!("unexpected status: {other:?}"),
}
break;
}
if std::time::Instant::now() > deadline {
panic!("run did not complete in time");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(unix)]
#[tokio::test]
async fn kill_with_sigterm_transitions_to_killed() {
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
let id = driver
.spawn_run(
"sleep 60",
SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(100)).await;
driver.kill_run(&id, Some(SIGTERM)).await.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(10);
loop {
let meta = store.get_run(&id).await.unwrap().unwrap();
if matches!(meta.status, RunStatus::Killed { .. } | RunStatus::Lost { .. }) {
assert!(
matches!(meta.status, RunStatus::Killed { .. }),
"expected Killed, got {:?}",
meta.status
);
break;
}
if std::time::Instant::now() > deadline {
panic!("run did not become Killed in time, status={:?}", meta.status);
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
#[cfg(unix)]
#[tokio::test]
async fn kill_run_returns_not_found_after_exit() {
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
let id = driver
.spawn_run(
"echo done",
SpawnOpts { cwd: "/tmp".into(), ..Default::default() },
)
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let meta = store.get_run(&id).await.unwrap().unwrap();
if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
break;
}
if std::time::Instant::now() > deadline {
panic!("run did not complete");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let result = driver.kill_run(&id, None).await;
assert!(
matches!(result, Err(DriverError::NotFound(_))),
"expected NotFound, got {result:?}"
);
}
#[cfg(unix)]
#[tokio::test]
async fn stdin_send_reaches_child() {
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
let id = driver
.spawn_run(
"read line && echo got_$line",
SpawnOpts {
cwd: "/tmp".into(),
stdin_enabled: true,
..Default::default()
},
)
.await
.unwrap();
tokio::time::sleep(Duration::from_millis(150)).await;
driver.send_stdin(&id, b"hello\n".to_vec()).await.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let meta = store.get_run(&id).await.unwrap().unwrap();
if !matches!(meta.status, RunStatus::Running | RunStatus::Pending) {
break;
}
if std::time::Instant::now() > deadline {
panic!("run did not complete after stdin input");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
let chunks = store.get_chunks(&id, &ChunkFilter::default()).await.unwrap();
let raw: Vec<u8> = chunks.into_iter().flat_map(|c| c.bytes).collect();
let text = String::from_utf8_lossy(&raw);
assert!(
text.contains("got_hello"),
"expected 'got_hello' in output, got: {text:?}"
);
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn log_pipe_events_land_in_store() {
use crate::store::EventFilter;
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
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""#;
let id = driver
.spawn_run(cmd, SpawnOpts { cwd: "/tmp".into(), ..Default::default() })
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(20);
loop {
let meta = store.get_run(&id).await.unwrap().unwrap();
if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
break;
}
if std::time::Instant::now() > deadline {
panic!("run did not complete in time");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
tokio::time::sleep(Duration::from_millis(500)).await;
let events = store.query_events(&id, &EventFilter::default()).await.unwrap();
assert!(
!events.is_empty(),
"expected at least one shim event, got none"
);
let ev = events.iter().find(|e| e.target == "test.shim");
let ev = ev.expect("event with target 'test.shim' not found");
assert_eq!(ev.msg, "hello-from-pipe");
assert_eq!(ev.level, crate::types::Level::Warn);
assert!(
matches!(&ev.source, crate::types::EventSource::Shim { lib, .. } if lib == "test-shim"),
"unexpected source: {:?}",
ev.source
);
assert_eq!(ev.fields.get("x"), Some(&serde_json::json!(42)));
}
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn log_pipe_disabled_produces_no_events() {
use crate::store::EventFilter;
let dir = tempfile::tempdir().unwrap();
let store = open_store(&dir).await;
let driver = Arc::new(TaskDriver::new(Arc::clone(&store)).await.unwrap());
let cmd = r#"[ -n "$YAH_LOG_PIPE" ] && printf '{"level":"info","target":"t","msg":"m","fields":{}}\n' >> "$YAH_LOG_PIPE" || true"#;
let id = driver
.spawn_run(
cmd,
SpawnOpts { cwd: "/tmp".into(), log_fd_enabled: false, ..Default::default() },
)
.await
.unwrap();
let deadline = std::time::Instant::now() + Duration::from_secs(5);
loop {
let meta = store.get_run(&id).await.unwrap().unwrap();
if matches!(meta.status, RunStatus::Done { .. } | RunStatus::Lost { .. }) {
break;
}
if std::time::Instant::now() > deadline {
panic!("run did not complete");
}
tokio::time::sleep(Duration::from_millis(50)).await;
}
tokio::time::sleep(Duration::from_millis(100)).await;
let events = store.query_events(&id, &EventFilter::default()).await.unwrap();
assert!(
events.is_empty(),
"expected no shim events when log_fd_enabled=false, got {}",
events.len()
);
}
}