use std::collections::HashMap;
use std::sync::Arc;
use tokio::io::AsyncReadExt as _;
use tokio::process::Command;
use tokio::sync::broadcast;
use tokio::task::JoinHandle;
use tokio_util::sync::CancellationToken;
use crate::diagnostics_extractor::DiagnosticsExtractor;
use crate::log_matcher::{MatcherEngine, MatcherRegistry};
use crate::log_store::LogStore;
use crate::operation::Operation;
use crate::task_registry::{TaskId, TaskStatus};
use crate::vt_parser::{StyledLine, TerminalParser};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum OutputStream {
Stdout,
Stderr,
}
#[derive(Clone, Debug)]
pub struct TaskOutputEvent {
pub task_id: TaskId,
pub stream: OutputStream,
pub chunk: Vec<u8>,
}
#[derive(Clone, Debug)]
pub struct ParsedOutputEvent {
pub task_id: TaskId,
pub stream: OutputStream,
pub lines: Vec<StyledLine>,
}
#[derive(Clone, Debug)]
pub enum TaskEvent {
Started(TaskId),
ParsedOutput(ParsedOutputEvent),
Finished(TaskId, TaskStatus),
}
pub struct TaskExecutor {
event_tx: broadcast::Sender<TaskEvent>,
handles: HashMap<TaskId, JoinHandle<()>>,
matcher_registry: std::sync::Arc<MatcherRegistry>,
}
impl TaskExecutor {
const BROADCAST_CAPACITY: usize = 256;
pub fn new() -> Self {
let (event_tx, _) = broadcast::channel(Self::BROADCAST_CAPACITY);
Self {
event_tx,
handles: HashMap::new(),
matcher_registry: std::sync::Arc::new(MatcherRegistry::new()),
}
}
pub fn with_matcher_registry(matcher_registry: std::sync::Arc<MatcherRegistry>) -> Self {
let (event_tx, _) = broadcast::channel(Self::BROADCAST_CAPACITY);
Self {
event_tx,
handles: HashMap::new(),
matcher_registry,
}
}
pub fn subscribe(&self) -> broadcast::Receiver<TaskEvent> {
self.event_tx.subscribe()
}
#[allow(clippy::too_many_arguments)]
pub fn spawn(
&mut self,
task_id: TaskId,
command: String,
cancellation_token: CancellationToken,
marker: String,
log_store: Option<LogStore>,
op_tx: &tokio::sync::mpsc::UnboundedSender<Vec<Operation>>,
) {
if let Some(old) = self.handles.remove(&task_id) {
old.abort();
}
let event_tx = self.event_tx.clone();
let op_tx = op_tx.clone();
let log_rx = log_store.as_ref().map(|_| event_tx.subscribe());
let source = marker
.strip_prefix("task:")
.and_then(|s| s.split(':').next())
.unwrap_or("task")
.to_string();
let extractor = Arc::new(DiagnosticsExtractor::new(marker.clone(), source));
let matchers = self.matcher_registry.iter_active();
let stdout_engine = if matchers.is_empty() {
None
} else {
Some(MatcherEngine::new(matchers.clone(), marker.clone()))
};
let stderr_engine = if matchers.is_empty() {
None
} else {
Some(MatcherEngine::new(matchers, marker))
};
let handle = tokio::spawn(async move {
if let (Some(ls), Some(mut rx)) = (log_store, log_rx) {
tokio::spawn(async move {
let mut writer = match ls.open_writer(task_id).await {
Ok(w) => w,
Err(e) => {
log::warn!(
"log_store: failed to open writer for task {}: {e}",
task_id.0
);
return;
}
};
let mut finished_normally = false;
loop {
match rx.recv().await {
Ok(TaskEvent::ParsedOutput(ev)) if ev.task_id == task_id => {
for line in &ev.lines {
if let Err(e) = writer.append_line(&line.text).await {
log::warn!("log_store: write error: {e}");
}
}
}
Ok(TaskEvent::Finished(id, _)) if id == task_id => {
finished_normally = true;
break;
}
Err(broadcast::error::RecvError::Closed) => break,
Err(broadcast::error::RecvError::Lagged(n)) => {
log::warn!("log_store: receiver lagged by {n} messages");
}
_ => {}
}
}
if finished_normally {
if let Err(e) = writer.close_and_compress(&ls).await {
log::warn!(
"log_store: compress error for task {}: {e}",
task_id.0
);
let _ = ls.log_path(task_id); }
} else {
if let Err(e) = writer.close().await {
log::warn!(
"log_store: close error for task {}: {e}",
task_id.0
);
}
}
});
}
let _ = event_tx.send(TaskEvent::Started(task_id));
let _ = op_tx.send(vec![Operation::TaskStarted(task_id)]);
let child_result = build_command(&command)
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn();
let mut child = match child_result {
Ok(c) => c,
Err(_) => {
let _ = event_tx.send(TaskEvent::Finished(task_id, TaskStatus::Error));
let _ = op_tx.send(vec![Operation::TaskFinished {
id: task_id,
status: TaskStatus::Error,
}]);
return;
}
};
let stdout = child.stdout.take().expect("stdout was piped");
let stderr = child.stderr.take().expect("stderr was piped");
let stdout_tx = event_tx.clone();
let stdout_op_tx = op_tx.clone();
let stdout_extractor = Arc::clone(&extractor);
let stdout_task = tokio::spawn(async move {
stream_output_parsed(
task_id,
OutputStream::Stdout,
stdout,
TerminalParser::new(),
&stdout_extractor,
stdout_engine,
&stdout_tx,
&stdout_op_tx,
)
.await;
});
let stderr_tx = event_tx.clone();
let stderr_op_tx = op_tx.clone();
let stderr_extractor = Arc::clone(&extractor);
let stderr_task = tokio::spawn(async move {
stream_output_parsed(
task_id,
OutputStream::Stderr,
stderr,
TerminalParser::new(),
&stderr_extractor,
stderr_engine,
&stderr_tx,
&stderr_op_tx,
)
.await;
});
let final_status = tokio::select! {
_ = cancellation_token.cancelled() => {
let _ = child.kill().await;
stdout_task.abort();
stderr_task.abort();
TaskStatus::Cancelled
}
result = child.wait() => {
let _ = tokio::join!(stdout_task, stderr_task);
match result {
Ok(exit) if exit.success() => TaskStatus::Success,
_ => TaskStatus::Error,
}
}
};
let _ = event_tx.send(TaskEvent::Finished(task_id, final_status.clone()));
let _ = op_tx.send(vec![Operation::TaskFinished {
id: task_id,
status: final_status,
}]);
});
self.handles.insert(task_id, handle);
}
pub fn abort(&mut self, task_id: TaskId) {
if let Some(handle) = self.handles.remove(&task_id) {
handle.abort();
}
}
}
impl Default for TaskExecutor {
fn default() -> Self {
Self::new()
}
}
#[allow(clippy::too_many_arguments)]
async fn stream_output_parsed(
task_id: TaskId,
stream: OutputStream,
mut reader: impl tokio::io::AsyncRead + Unpin,
mut parser: TerminalParser,
extractor: &DiagnosticsExtractor,
mut engine: Option<MatcherEngine>,
tx: &broadcast::Sender<TaskEvent>,
op_tx: &tokio::sync::mpsc::UnboundedSender<Vec<Operation>>,
) {
let mut buf = vec![0u8; 4096];
loop {
match reader.read(&mut buf).await {
Ok(0) | Err(_) => break,
Ok(n) => {
let lines = parser.push(&buf[..n]);
broadcast_and_extract(task_id, &stream, &lines, extractor, engine.as_mut(), tx, op_tx);
}
}
}
if let Some(line) = parser.flush() {
broadcast_and_extract(task_id, &stream, &[line], extractor, engine.as_mut(), tx, op_tx);
}
if let Some(eng) = engine.as_mut() {
let issues = eng.flush();
send_issues(issues, op_tx);
}
}
fn broadcast_and_extract(
task_id: TaskId,
stream: &OutputStream,
lines: &[StyledLine],
extractor: &DiagnosticsExtractor,
engine: Option<&mut MatcherEngine>,
tx: &broadcast::Sender<TaskEvent>,
op_tx: &tokio::sync::mpsc::UnboundedSender<Vec<Operation>>,
) {
if lines.is_empty() {
return;
}
let _ = tx.send(TaskEvent::ParsedOutput(ParsedOutputEvent {
task_id,
stream: stream.clone(),
lines: lines.to_vec(),
}));
for line in lines {
let issues = extractor.extract_from_line(line);
if !issues.is_empty() {
let ops: Vec<Operation> =
issues.into_iter().map(|i| Operation::AddIssue { issue: i }).collect();
let _ = op_tx.send(ops);
}
}
if let Some(eng) = engine {
for line in lines {
let issues = eng.process_line(&line.text);
send_issues(issues, op_tx);
}
}
}
fn send_issues(
issues: Vec<crate::issue_registry::NewIssue>,
op_tx: &tokio::sync::mpsc::UnboundedSender<Vec<Operation>>,
) {
if !issues.is_empty() {
let ops: Vec<Operation> =
issues.into_iter().map(|i| Operation::AddIssue { issue: i }).collect();
let _ = op_tx.send(ops);
}
}
fn build_command(command: &str) -> Command {
#[cfg(unix)]
{
let mut cmd = Command::new("sh");
cmd.arg("-c").arg(command);
cmd
}
#[cfg(windows)]
{
let mut cmd = Command::new("cmd");
cmd.arg("/C").arg(command);
cmd
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::task_registry::{TaskKey, TaskQueueId, TaskTrigger, TaskRegistry};
use tokio::sync::mpsc::unbounded_channel;
use std::time::Duration;
const TEST_TIMEOUT: Duration = Duration::from_secs(15);
fn dummy_key() -> TaskKey {
TaskKey {
queue: TaskQueueId("test".into()),
target: "t".into(),
}
}
fn key_n(n: u64) -> TaskKey {
TaskKey {
queue: TaskQueueId("test".into()),
target: format!("t{n}"),
}
}
fn schedule(reg: &mut TaskRegistry, key: TaskKey, command: &str) -> (TaskId, CancellationToken, String) {
let cmd = command.to_string();
let id = reg.schedule_task(key, TaskTrigger::Manual, cmd.clone());
let token = reg.get(id).unwrap().cancellation_token.clone();
(id, token, cmd)
}
#[cfg(unix)]
mod cmds {
pub const ECHO_STDOUT: &str = "echo hello";
pub const ECHO_STDERR: &str = "echo err >&2";
pub const SLEEP_LONG: &str = "sleep 60";
pub const NO_SUCH: &str = "/no/such/binary/xyz_oo_test";
}
#[cfg(windows)]
mod cmds {
pub const ECHO_STDOUT: &str = "echo hello";
pub const ECHO_STDERR: &str = "echo err>&2";
pub const SLEEP_LONG: &str = "ping -n 120 127.0.0.1";
pub const NO_SUCH: &str = "no_such_binary_xyz_oo_test";
}
#[tokio::test]
async fn task_emits_stdout() {
tokio::time::timeout(TEST_TIMEOUT, async {
let mut reg = TaskRegistry::new();
let mut exec = TaskExecutor::new();
let mut rx = exec.subscribe();
let (op_tx, mut op_rx) = unbounded_channel::<Vec<Operation>>();
let (id, token, cmd) = schedule(&mut reg, dummy_key(), cmds::ECHO_STDOUT);
exec.spawn(id, cmd, token, "task:test:t".into(), None, &op_tx);
let mut got_output = false;
loop {
match rx.recv().await.unwrap() {
TaskEvent::ParsedOutput(ev)
if ev.task_id == id && ev.stream == OutputStream::Stdout =>
{
got_output = true;
}
TaskEvent::Finished(tid, status) if tid == id => {
assert_eq!(status, TaskStatus::Success);
break;
}
_ => {}
}
}
assert!(got_output, "expected at least one stdout output event");
let ops: Vec<Operation> = op_rx.try_recv().unwrap();
assert!(ops.iter().any(|o| matches!(o, Operation::TaskStarted(i) if *i == id)));
let ops2: Vec<Operation> = op_rx.recv().await.unwrap();
assert!(ops2.iter().any(|o| matches!(
o,
Operation::TaskFinished { id: i, status: TaskStatus::Success } if *i == id
)));
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn task_emits_stderr() {
tokio::time::timeout(TEST_TIMEOUT, async {
let mut reg = TaskRegistry::new();
let mut exec = TaskExecutor::new();
let mut rx = exec.subscribe();
let (op_tx, _op_rx) = unbounded_channel::<Vec<Operation>>();
let (id, token, cmd) = schedule(&mut reg, dummy_key(), cmds::ECHO_STDERR);
exec.spawn(id, cmd, token, "task:test:t".into(), None, &op_tx);
let mut got_stderr = false;
loop {
match rx.recv().await.unwrap() {
TaskEvent::ParsedOutput(ev)
if ev.task_id == id && ev.stream == OutputStream::Stderr =>
{
got_stderr = true;
}
TaskEvent::Finished(tid, _) if tid == id => break,
_ => {}
}
}
assert!(got_stderr, "expected at least one stderr output event");
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn cancellation_stops_task() {
tokio::time::timeout(TEST_TIMEOUT, async {
let mut reg = TaskRegistry::new();
let mut exec = TaskExecutor::new();
let mut rx = exec.subscribe();
let (op_tx, mut op_rx) = unbounded_channel::<Vec<Operation>>();
let (id, token, cmd) = schedule(&mut reg, dummy_key(), cmds::SLEEP_LONG);
exec.spawn(id, cmd, token.clone(), "task:test:t".into(), None, &op_tx);
loop {
if let Ok(TaskEvent::Started(tid)) = rx.recv().await
&& tid == id { break; }
}
token.cancel();
loop {
if let Ok(TaskEvent::Finished(tid, status)) = rx.recv().await
&& tid == id {
assert_eq!(status, TaskStatus::Cancelled);
break;
}
}
let mut found = false;
while let Some(ops) = op_rx.recv().await {
if ops.iter().any(|o| matches!(
o,
Operation::TaskFinished { id: i, status: TaskStatus::Cancelled }
if *i == id
)) {
found = true;
break;
}
}
assert!(found, "expected TaskFinished(Cancelled) in op_tx");
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn multiple_subscribers_receive_same_output() {
tokio::time::timeout(TEST_TIMEOUT, async {
let mut reg = TaskRegistry::new();
let mut exec = TaskExecutor::new();
let rx1 = exec.subscribe();
let rx2 = exec.subscribe();
let (op_tx, _op_rx) = unbounded_channel::<Vec<Operation>>();
let (id, token, cmd) = schedule(&mut reg, dummy_key(), cmds::ECHO_STDOUT);
exec.spawn(id, cmd, token, "task:test:t".into(), None, &op_tx);
async fn collect(mut rx: broadcast::Receiver<TaskEvent>, id: TaskId) -> usize {
let mut chunks = 0usize;
loop {
match rx.recv().await.unwrap() {
TaskEvent::ParsedOutput(_) => chunks += 1,
TaskEvent::Finished(tid, _) if tid == id => return chunks,
_ => {}
}
}
}
let (c1, c2) = tokio::join!(collect(rx1, id), collect(rx2, id));
assert_eq!(c1, c2, "both subscribers should see the same chunk count");
assert!(c1 > 0, "expected at least one output chunk");
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn spawn_failure_reports_error() {
tokio::time::timeout(TEST_TIMEOUT, async {
let mut reg = TaskRegistry::new();
let mut exec = TaskExecutor::new();
let mut rx = exec.subscribe();
let (op_tx, _op_rx) = unbounded_channel::<Vec<Operation>>();
let (id, token, _) = schedule(&mut reg, dummy_key(), cmds::NO_SUCH);
exec.spawn(id, cmds::NO_SUCH.to_string(), token, "task:test:t".into(), None, &op_tx);
let final_status;
loop {
match rx.recv().await.unwrap() {
TaskEvent::Finished(tid, status) if tid == id => {
final_status = status;
break;
}
_ => {}
}
}
assert!(
matches!(final_status, TaskStatus::Error),
"expected Error, got {final_status:?}"
);
})
.await
.expect("test timed out");
}
#[tokio::test]
async fn rapid_cancel_restart() {
tokio::time::timeout(TEST_TIMEOUT, async {
let mut reg = TaskRegistry::new();
let mut exec = TaskExecutor::new();
let mut rx = exec.subscribe();
let (op_tx, _op_rx) = unbounded_channel::<Vec<Operation>>();
let (id1, token1, cmd1) = schedule(&mut reg, key_n(1), cmds::SLEEP_LONG);
exec.spawn(id1, cmd1, token1.clone(), "task:test:t1".into(), None, &op_tx);
loop {
if let Ok(TaskEvent::Started(tid)) = rx.recv().await
&& tid == id1 { break; }
}
token1.cancel();
let (id2, token2, cmd2) = schedule(&mut reg, key_n(2), cmds::ECHO_STDOUT);
exec.spawn(id2, cmd2, token2, "task:test:t2".into(), None, &op_tx);
let mut saw_cancel = false;
let mut saw_success = false;
loop {
match rx.recv().await.unwrap() {
TaskEvent::Finished(tid, TaskStatus::Cancelled) if tid == id1 => {
saw_cancel = true;
}
TaskEvent::Finished(tid, TaskStatus::Success) if tid == id2 => {
saw_success = true;
}
_ => {}
}
if saw_cancel && saw_success { break; }
}
assert!(saw_cancel, "first task should be Cancelled");
assert!(saw_success, "second task should succeed");
})
.await
.expect("test timed out");
}
}