use std::collections::VecDeque;
use std::sync::Arc;
use std::time::{Duration, Instant};
use dashmap::DashMap;
use tokio::sync::{Mutex, mpsc};
use tracing::debug;
pub const MAX_PROCESSES: usize = 64;
pub const FINISHED_TTL: Duration = Duration::from_secs(1800);
const WATCH_MAX_PER_WINDOW: u32 = 8;
const WATCH_WINDOW_SECONDS: u64 = 10;
const WATCH_OVERLOAD_KILL_SECONDS: u64 = 45;
const WATCH_MAX_OUTPUT_LINES: usize = 20;
const WATCH_MAX_OUTPUT_CHARS: usize = 2000;
#[derive(Debug, Clone)]
pub struct WatchState {
pub patterns: Vec<String>,
pub hits: u64,
pub suppressed: u64,
pub disabled: bool,
window_hits: u32,
window_start: Instant,
overload_since: Option<Instant>,
}
impl WatchState {
pub fn new(patterns: Vec<String>) -> Self {
Self {
patterns,
hits: 0,
suppressed: 0,
disabled: false,
window_hits: 0,
window_start: Instant::now(),
overload_since: None,
}
}
}
#[derive(Debug, Clone)]
pub struct WatchEvent {
pub process_id: String,
pub command: String,
pub pattern: String,
pub matched_output: String,
pub suppressed_count: u64,
pub event_type: WatchEventType,
pub exit_code: Option<i32>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum WatchEventType {
Match,
Disabled,
TailPreview,
Exited,
}
pub fn format_watch_activity_notice(event: &WatchEvent) -> String {
match event.event_type {
WatchEventType::Match => format!(
"🔔 process {} matched `{}`: {}",
event.process_id,
event.pattern,
event.matched_output.lines().next().unwrap_or("").trim()
),
WatchEventType::Disabled => format!(
"🔔 process {} watch disabled (rate limit)",
event.process_id
),
WatchEventType::TailPreview => {
format!("📟 {} …\n{}", event.process_id, event.matched_output)
}
WatchEventType::Exited => format!(
"✅ process {} {}",
event.process_id,
crate::tool_progress_tail::format_process_exit_status(event.exit_code)
),
}
}
pub fn check_watch_patterns(
line: &str,
process_id: &str,
command: &str,
state: &mut WatchState,
sink: &mpsc::UnboundedSender<WatchEvent>,
) {
if state.disabled {
return;
}
let now = Instant::now();
for pattern in &state.patterns {
if !line.contains(pattern.as_str()) {
continue;
}
state.hits += 1;
if now.duration_since(state.window_start).as_secs() >= WATCH_WINDOW_SECONDS {
state.window_hits = 0;
state.window_start = now;
}
state.window_hits += 1;
if state.window_hits > WATCH_MAX_PER_WINDOW {
state.suppressed += 1;
if state.overload_since.is_none() {
state.overload_since = Some(now);
} else if let Some(since) = state.overload_since
&& now.duration_since(since).as_secs() >= WATCH_OVERLOAD_KILL_SECONDS
{
state.disabled = true;
debug!(
process_id,
"Watch patterns permanently disabled (sustained overload)"
);
let _ = sink.send(WatchEvent {
process_id: process_id.to_string(),
command: command.to_string(),
pattern: pattern.clone(),
matched_output: trim_watch_output(line),
suppressed_count: state.suppressed,
event_type: WatchEventType::Disabled,
exit_code: None,
});
state.suppressed = 0;
}
return; }
state.overload_since = None;
let suppressed_count = state.suppressed;
state.suppressed = 0;
let _ = sink.send(WatchEvent {
process_id: process_id.to_string(),
command: command.to_string(),
pattern: pattern.clone(),
matched_output: trim_watch_output(line),
suppressed_count,
event_type: WatchEventType::Match,
exit_code: None,
});
break; }
}
fn trim_watch_output(output: &str) -> String {
let lines: Vec<&str> = output.lines().take(WATCH_MAX_OUTPUT_LINES).collect();
let joined = lines.join("\n");
if joined.len() <= WATCH_MAX_OUTPUT_CHARS {
joined
} else {
let mut end = WATCH_MAX_OUTPUT_CHARS;
while !joined.is_char_boundary(end) && end > 0 {
end -= 1;
}
format!("{}…", &joined[..end])
}
}
#[derive(Debug, Clone)]
pub struct ProcessRecord {
pub process_id: String,
pub command: String,
pub status: ProcessStatus,
pub started_at: std::time::SystemTime,
pub cwd: String,
pub output_lines: VecDeque<String>,
pub partial_line: String,
pub carriage_return_pending: bool,
pub exit_code: Option<i32>,
pub pid: Option<u32>,
pub session_key: String,
pub stdin_tx: Option<tokio::sync::mpsc::UnboundedSender<String>>,
pub watch_state: Option<WatchState>,
pub last_tail_notify: Option<Instant>,
watch_sink: Option<mpsc::UnboundedSender<WatchEvent>>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ProcessStatus {
Running,
Exited,
Killed,
}
impl ProcessStatus {
pub fn as_str(&self) -> &'static str {
match self {
Self::Running => "running",
Self::Exited => "exited",
Self::Killed => "killed",
}
}
}
const RING_CAPACITY: usize = 500;
const PARTIAL_LINE_FLUSH_BYTES: usize = 4096;
fn push_output_line(rec: &mut ProcessRecord, line: String) {
if rec.output_lines.len() >= RING_CAPACITY {
rec.output_lines.pop_front();
}
rec.output_lines.push_back(line);
}
fn redact_terminal_control(text: &str) -> String {
strip_ansi_escapes::strip_str(text)
}
fn snapshot_output_lines(rec: &ProcessRecord) -> Vec<String> {
let mut lines: Vec<String> = rec
.output_lines
.iter()
.map(|line| redact_terminal_control(line))
.collect();
if !rec.partial_line.is_empty() {
lines.push(redact_terminal_control(&rec.partial_line));
}
lines
}
fn tail_chars_from_end(text: &str, max_chars: usize) -> String {
let trimmed = text.trim();
if trimmed.chars().count() <= max_chars {
return trimmed.to_string();
}
let start = trimmed
.char_indices()
.nth(trimmed.chars().count().saturating_sub(max_chars))
.map(|(i, _)| i)
.unwrap_or(0);
format!("…{}", &trimmed[start..])
}
pub struct ProcessTable {
records: DashMap<String, Arc<Mutex<ProcessRecord>>>,
controls: DashMap<String, ProcessControl>,
next_id: std::sync::atomic::AtomicU32,
}
enum ProcessControl {
Remote(RemoteProcessControl),
}
struct RemoteProcessControl {
kill_tx: mpsc::UnboundedSender<()>,
}
impl ProcessTable {
pub fn new() -> Self {
Self {
records: DashMap::new(),
controls: DashMap::new(),
next_id: std::sync::atomic::AtomicU32::new(1),
}
}
fn allocate_id(&self) -> String {
let n = self
.next_id
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
format!("proc-{n}")
}
pub async fn prune_if_full(&self) {
if self.records.len() < MAX_PROCESSES {
return;
}
let mut entries: Vec<(String, std::time::SystemTime, bool)> = Vec::new();
for entry in self.records.iter() {
let rec = entry.value().lock().await;
let is_running = rec.status == ProcessStatus::Running;
entries.push((entry.key().clone(), rec.started_at, is_running));
}
entries.sort_by(|a, b| {
let a_fin = !a.2; let b_fin = !b.2;
b_fin.cmp(&a_fin).then_with(|| a.1.cmp(&b.1))
});
let to_remove = entries.len().saturating_sub(MAX_PROCESSES - 1);
for (id, _, _) in entries.into_iter().take(to_remove) {
self.records.remove(&id);
self.controls.remove(&id);
}
}
pub fn register(
&self,
command: impl Into<String>,
cwd: impl Into<String>,
session_key: impl Into<String>,
) -> String {
let id = self.allocate_id();
let record = ProcessRecord {
process_id: id.clone(),
command: command.into(),
status: ProcessStatus::Running,
started_at: std::time::SystemTime::now(),
cwd: cwd.into(),
output_lines: VecDeque::new(),
partial_line: String::new(),
carriage_return_pending: false,
exit_code: None,
pid: None,
session_key: session_key.into(),
stdin_tx: None,
watch_state: None,
last_tail_notify: None,
watch_sink: None,
};
self.records
.insert(id.clone(), Arc::new(Mutex::new(record)));
id
}
pub async fn maybe_emit_tail_preview(
&self,
process_id: &str,
sink: &mpsc::UnboundedSender<WatchEvent>,
) {
let Some(entry) = self.get_record(process_id) else {
return;
};
let mut rec = entry.lock().await;
let now = Instant::now();
if !crate::tool_progress_tail::should_emit_progress(rec.last_tail_notify, now) {
return;
}
let lines = snapshot_output_lines(&rec);
let tail_lines: Vec<String> = lines
.iter()
.map(|line| crate::tool_progress_tail::sanitize_output_line(line))
.filter(|line| !line.is_empty())
.collect();
let matched_output = crate::tool_progress_tail::format_tail_from_lines(&tail_lines);
if matched_output.is_empty() {
return;
}
rec.last_tail_notify = Some(now);
let _ = sink.send(WatchEvent {
process_id: process_id.to_string(),
command: rec.command.clone(),
pattern: String::new(),
matched_output,
suppressed_count: 0,
event_type: WatchEventType::TailPreview,
exit_code: None,
});
}
pub async fn set_watch_sink(&self, process_id: &str, sink: mpsc::UnboundedSender<WatchEvent>) {
if let Some(entry) = self.get_record(process_id) {
let mut rec = entry.lock().await;
rec.watch_sink = Some(sink.clone());
drop(rec);
self.maybe_emit_tail_preview(process_id, &sink).await;
}
}
async fn maybe_notify_tail(&self, process_id: &str) {
let sink = if let Some(entry) = self.get_record(process_id) {
entry.lock().await.watch_sink.clone()
} else {
None
};
if let Some(sink) = sink {
self.maybe_emit_tail_preview(process_id, &sink).await;
}
}
fn emit_lifecycle_event(rec: &ProcessRecord, process_id: &str, exit_code: Option<i32>) {
let Some(ref sink) = rec.watch_sink else {
return;
};
let _ = sink.send(WatchEvent {
process_id: process_id.to_string(),
command: rec.command.clone(),
pattern: String::new(),
matched_output: String::new(),
suppressed_count: 0,
event_type: WatchEventType::Exited,
exit_code,
});
}
pub async fn set_stdin_tx(
&self,
process_id: &str,
tx: tokio::sync::mpsc::UnboundedSender<String>,
) {
if let Some(entry) = self.records.get(process_id) {
let mut rec = entry.value().lock().await;
rec.stdin_tx = Some(tx);
}
}
pub async fn set_watch_patterns(&self, process_id: &str, patterns: Vec<String>) {
if patterns.is_empty() {
return;
}
if let Some(entry) = self.records.get(process_id) {
let mut rec = entry.value().lock().await;
rec.watch_state = Some(WatchState::new(patterns));
}
}
pub fn get_record(&self, process_id: &str) -> Option<Arc<Mutex<ProcessRecord>>> {
self.records.get(process_id).map(|e| Arc::clone(e.value()))
}
pub fn set_remote_kill(&self, process_id: &str, tx: mpsc::UnboundedSender<()>) {
self.controls.insert(
process_id.to_string(),
ProcessControl::Remote(RemoteProcessControl { kill_tx: tx }),
);
}
pub async fn get_stdin_tx(
&self,
process_id: &str,
) -> Option<tokio::sync::mpsc::UnboundedSender<String>> {
if let Some(entry) = self.records.get(process_id) {
let rec = entry.value().lock().await;
rec.stdin_tx.clone()
} else {
None
}
}
pub async fn has_active_for_session(&self, session_key: &str) -> bool {
for entry in self.records.iter() {
let rec = entry.value().lock().await;
if rec.session_key == session_key && rec.status == ProcessStatus::Running {
return true;
}
}
false
}
pub async fn set_pid(&self, process_id: &str, pid: u32) {
if let Some(entry) = self.records.get(process_id) {
let mut rec = entry.value().lock().await;
rec.pid = Some(pid);
}
}
fn maybe_check_watch_line(rec: &mut ProcessRecord, process_id: &str, line: &str) {
let trimmed = line.trim_end_matches('\n').trim_end_matches('\r');
if trimmed.is_empty() {
return;
}
let command = rec.command.clone();
if let (Some(watch), Some(sink)) = (&mut rec.watch_state, rec.watch_sink.as_ref()) {
check_watch_patterns(trimmed, process_id, &command, watch, sink);
}
}
pub async fn append_output(&self, process_id: &str, lines: Vec<String>) {
if let Some(entry) = self.records.get(process_id) {
let mut rec: tokio::sync::MutexGuard<'_, ProcessRecord> = entry.value().lock().await;
for line in lines {
let merged = if rec.partial_line.is_empty() {
line
} else {
rec.partial_line.push_str(&line);
std::mem::take(&mut rec.partial_line)
};
push_output_line(&mut rec, merged.clone());
Self::maybe_check_watch_line(&mut rec, process_id, &merged);
}
}
self.maybe_notify_tail(process_id).await;
}
pub async fn append_output_chunk(&self, process_id: &str, chunk: &str) {
if let Some(entry) = self.records.get(process_id) {
let mut rec = entry.value().lock().await;
let mut completed_lines: Vec<String> = Vec::new();
let mut chars = chunk.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if matches!(chars.peek(), Some('\n')) {
completed_lines.push(std::mem::take(&mut rec.partial_line));
rec.carriage_return_pending = false;
let _ = chars.next();
} else {
rec.carriage_return_pending = true;
}
}
'\n' => {
completed_lines.push(std::mem::take(&mut rec.partial_line));
rec.carriage_return_pending = false;
}
_ => {
if rec.carriage_return_pending {
rec.partial_line.clear();
rec.carriage_return_pending = false;
}
rec.partial_line.push(ch);
if rec.partial_line.len() >= PARTIAL_LINE_FLUSH_BYTES {
completed_lines.push(std::mem::take(&mut rec.partial_line));
}
}
}
}
for line in completed_lines {
push_output_line(&mut rec, line.clone());
Self::maybe_check_watch_line(&mut rec, process_id, &line);
}
}
self.maybe_notify_tail(process_id).await;
}
pub async fn replace_output(&self, process_id: &str, lines: Vec<String>) {
if let Some(entry) = self.records.get(process_id) {
let mut rec = entry.value().lock().await;
rec.output_lines.clear();
rec.partial_line.clear();
rec.carriage_return_pending = false;
for line in &lines {
push_output_line(&mut rec, line.clone());
}
for line in lines.iter().rev().take(3).rev() {
Self::maybe_check_watch_line(&mut rec, process_id, line);
}
}
self.maybe_notify_tail(process_id).await;
}
pub async fn mark_exited(&self, process_id: &str, exit_code: i32) {
if let Some(entry) = self.records.get(process_id) {
let mut rec: tokio::sync::MutexGuard<'_, ProcessRecord> = entry.value().lock().await;
rec.status = ProcessStatus::Exited;
rec.exit_code = Some(exit_code);
Self::emit_lifecycle_event(&rec, process_id, Some(exit_code));
}
self.controls.remove(process_id);
}
pub async fn mark_exited_if_running(&self, process_id: &str, exit_code: i32) {
if let Some(entry) = self.records.get(process_id) {
let mut rec = entry.value().lock().await;
if rec.status == ProcessStatus::Running {
rec.status = ProcessStatus::Exited;
rec.exit_code = Some(exit_code);
Self::emit_lifecycle_event(&rec, process_id, Some(exit_code));
drop(rec);
self.controls.remove(process_id);
}
}
}
pub async fn mark_killed(&self, process_id: &str) {
if let Some(entry) = self.records.get(process_id) {
let mut rec: tokio::sync::MutexGuard<'_, ProcessRecord> = entry.value().lock().await;
rec.status = ProcessStatus::Killed;
rec.exit_code = Some(-1);
Self::emit_lifecycle_event(&rec, process_id, Some(-1));
}
self.controls.remove(process_id);
}
pub async fn mark_killed_if_running(&self, process_id: &str) {
if let Some(entry) = self.records.get(process_id) {
let mut rec = entry.value().lock().await;
if rec.status == ProcessStatus::Running {
rec.status = ProcessStatus::Killed;
rec.exit_code = Some(-1);
Self::emit_lifecycle_event(&rec, process_id, Some(-1));
drop(rec);
self.controls.remove(process_id);
}
}
}
pub async fn list_all(&self) -> Vec<ProcessRecord> {
let mut records: Vec<ProcessRecord> = Vec::new();
for entry in self.records.iter() {
let rec: tokio::sync::MutexGuard<'_, ProcessRecord> = entry.value().lock().await;
records.push(rec.clone());
}
records.sort_by_key(|record| std::cmp::Reverse(record.started_at));
records
}
pub async fn get_output(&self, process_id: &str) -> Option<Vec<String>> {
if let Some(entry) = self.records.get(process_id) {
let rec: tokio::sync::MutexGuard<'_, ProcessRecord> = entry.value().lock().await;
Some(snapshot_output_lines(&rec))
} else {
None
}
}
pub async fn get_output_tail(
&self,
process_id: &str,
tail: usize,
) -> Option<(String, ProcessStatus, Option<i32>)> {
if let Some(entry) = self.records.get(process_id) {
let rec = entry.value().lock().await;
let lines = snapshot_output_lines(&rec);
let skip = lines.len().saturating_sub(tail);
let text = lines
.iter()
.skip(skip)
.cloned()
.collect::<Vec<_>>()
.join("\n");
Some((text, rec.status.clone(), rec.exit_code))
} else {
None
}
}
pub async fn get_output_page(
&self,
process_id: &str,
offset: usize,
limit: usize,
) -> Option<(String, usize, ProcessStatus, Option<i32>)> {
if let Some(entry) = self.records.get(process_id) {
let rec = entry.value().lock().await;
let lines = snapshot_output_lines(&rec);
let total = lines.len();
let selected: Vec<&str> = if offset == 0 {
let skip = total.saturating_sub(limit);
lines.iter().skip(skip).map(|s| s.as_str()).collect()
} else {
lines
.iter()
.skip(offset)
.take(limit)
.map(|s| s.as_str())
.collect()
};
let text = selected.join("\n");
Some((text, total, rec.status.clone(), rec.exit_code))
} else {
None
}
}
pub async fn get_output_tail_chars(
&self,
process_id: &str,
max_chars: usize,
) -> Option<(String, usize, ProcessStatus, Option<i32>)> {
if let Some(entry) = self.records.get(process_id) {
let rec = entry.value().lock().await;
let lines = snapshot_output_lines(&rec);
let total = lines.len();
let text = tail_chars_from_end(&lines.join("\n"), max_chars);
Some((text, total, rec.status.clone(), rec.exit_code))
} else {
None
}
}
pub async fn kill(&self, process_id: &str) -> bool {
if let Some(_entry) = self.records.get(process_id) {
#[cfg(unix)]
{
let pid = {
let rec = _entry.value().lock().await;
rec.pid
};
if let Some(pid) = pid {
unsafe {
libc::kill(-(pid as libc::pid_t), libc::SIGKILL);
libc::kill(pid as libc::pid_t, libc::SIGKILL);
}
}
}
if let Some(control) = self.controls.get(process_id) {
match control.value() {
ProcessControl::Remote(remote) => {
let _ = remote.kill_tx.send(());
}
}
}
self.mark_killed(process_id).await;
true
} else {
false
}
}
pub async fn kill_all(&self, session_key: Option<&str>) -> usize {
let ids_to_kill: Vec<String> = {
let mut ids = Vec::new();
for entry in self.records.iter() {
let rec = entry.value().lock().await;
let matches = match session_key {
Some(key) => rec.session_key == key,
None => true,
};
if matches && rec.status == ProcessStatus::Running {
ids.push(entry.key().clone());
}
}
ids
};
let mut killed = 0;
for id in ids_to_kill {
if self.kill(&id).await {
killed += 1;
}
}
killed
}
pub async fn gc(&self, age: Duration) {
let now = Instant::now();
let cutoff = std::time::SystemTime::now()
.checked_sub(age)
.unwrap_or(std::time::SystemTime::UNIX_EPOCH);
let mut to_remove = Vec::new();
for entry in self.records.iter() {
let rec: tokio::sync::MutexGuard<'_, ProcessRecord> = entry.value().lock().await;
if rec.status != ProcessStatus::Running && rec.started_at < cutoff {
to_remove.push(entry.key().clone());
}
drop(rec); }
let _ = now;
for id in to_remove {
self.records.remove(&id);
self.controls.remove(&id);
}
}
pub fn spawn_gc_task(self: &Arc<Self>, cancel: tokio_util::sync::CancellationToken) {
let table = Arc::clone(self);
tokio::spawn(async move {
let mut interval = tokio::time::interval(Duration::from_secs(300)); loop {
tokio::select! {
_ = interval.tick() => {
table.gc(FINISHED_TTL).await;
}
_ = cancel.cancelled() => break,
}
}
});
}
pub fn len(&self) -> usize {
self.records.len()
}
pub fn is_empty(&self) -> bool {
self.records.is_empty()
}
}
impl Default for ProcessTable {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn register_and_list() {
let table = ProcessTable::new();
let id = table.register("cargo build", "/tmp", "");
let records = table.list_all().await;
assert_eq!(records.len(), 1);
assert_eq!(records[0].process_id, id);
assert_eq!(records[0].status, ProcessStatus::Running);
}
#[tokio::test]
async fn append_output_ring_buffer() {
let table = ProcessTable::new();
let id = table.register("echo loop", "/tmp", "");
let mut lines = Vec::new();
for i in 0..=RING_CAPACITY + 10 {
lines.push(format!("line {i}"));
}
table.append_output(&id, lines).await;
let output = table.get_output(&id).await.expect("should exist");
assert!(output.len() <= RING_CAPACITY, "ring buffer exceeded");
}
#[tokio::test]
async fn append_output_chunk_preserves_partial_prompt() {
let table = ProcessTable::new();
let id = table.register("python", "/tmp", "");
table.append_output_chunk(&id, ">>> ").await;
let output = table.get_output(&id).await.expect("should exist");
assert_eq!(output, vec![">>> "]);
}
#[tokio::test]
async fn append_output_chunk_treats_carriage_return_as_line_rewrite() {
let table = ProcessTable::new();
let id = table.register("build", "/tmp", "");
table.append_output_chunk(&id, "Progress 10%\r").await;
table.append_output_chunk(&id, "Progress 25%\r").await;
let output = table.get_output(&id).await.expect("should exist");
assert_eq!(output, vec!["Progress 25%"]);
}
#[tokio::test]
async fn get_output_strips_ansi_escape_sequences() {
let table = ProcessTable::new();
let id = table.register("color", "/tmp", "");
table
.append_output_chunk(&id, "\u{1b}[31mred\u{1b}[0m\n")
.await;
let output = table.get_output(&id).await.expect("should exist");
assert_eq!(output, vec!["red"]);
}
#[tokio::test]
async fn kill_marks_record() {
let table = ProcessTable::new();
let id = table.register("long_running", "/tmp", "");
let killed = table.kill(&id).await;
assert!(killed, "kill should return true for existing id");
let records = table.list_all().await;
assert_eq!(records[0].status, ProcessStatus::Killed);
}
#[tokio::test]
async fn kill_unknown_returns_false() {
let table = ProcessTable::new();
let killed = table.kill("proc-9999").await;
assert!(!killed);
}
#[tokio::test]
async fn gc_removes_old_entries() {
let table = ProcessTable::new();
let id = table.register("old", "/tmp", "");
table.mark_exited(&id, 0).await;
table.gc(Duration::from_secs(0)).await;
assert!(table.is_empty(), "GC should have removed old entries");
}
#[tokio::test]
async fn monotonic_ids() {
let table = ProcessTable::new();
let id1 = table.register("cmd1", "/tmp", "");
let id2 = table.register("cmd2", "/tmp", "");
assert_ne!(id1, id2);
assert!(id2 > id1, "ids should be monotonically increasing");
}
#[tokio::test]
async fn has_active_for_session_returns_true_for_running() {
let table = ProcessTable::new();
table.register("server", "/tmp", "telegram:42");
assert!(table.has_active_for_session("telegram:42").await);
assert!(!table.has_active_for_session("telegram:99").await);
}
#[tokio::test]
async fn has_active_for_session_false_after_exit() {
let table = ProcessTable::new();
let id = table.register("build", "/tmp", "cli:1");
table.mark_exited(&id, 0).await;
assert!(!table.has_active_for_session("cli:1").await);
}
#[tokio::test]
async fn kill_all_no_filter_kills_all_running() {
let table = ProcessTable::new();
let id1 = table.register("proc1", "/tmp", "session:A");
let id2 = table.register("proc2", "/tmp", "session:B");
let id3 = table.register("proc3", "/tmp", "session:A");
table.mark_exited(&id3, 0).await;
let killed = table.kill_all(None).await;
assert_eq!(killed, 2, "should kill both running processes");
let recs = table.list_all().await;
for rec in &recs {
if rec.process_id == id1 || rec.process_id == id2 {
assert_eq!(rec.status, ProcessStatus::Killed);
}
}
}
#[tokio::test]
async fn kill_all_with_session_key_kills_only_matching() {
let table = ProcessTable::new();
let id1 = table.register("serverA", "/tmp", "session:A");
let id2 = table.register("serverB", "/tmp", "session:B");
let killed = table.kill_all(Some("session:A")).await;
assert_eq!(killed, 1, "only session:A process killed");
let recs = table.list_all().await;
for rec in &recs {
if rec.process_id == id1 {
assert_eq!(rec.status, ProcessStatus::Killed);
} else if rec.process_id == id2 {
assert_eq!(rec.status, ProcessStatus::Running);
}
}
}
#[tokio::test]
async fn kill_all_returns_zero_when_none_running() {
let table = ProcessTable::new();
let id = table.register("done", "/tmp", "s:1");
table.mark_exited(&id, 0).await;
let killed = table.kill_all(None).await;
assert_eq!(killed, 0);
}
#[tokio::test]
async fn get_output_page_default_returns_tail() {
let table = ProcessTable::new();
let id = table.register("cmd", "/tmp", "");
let lines: Vec<String> = (0..20).map(|i| format!("line {i}")).collect();
table.append_output(&id, lines).await;
let (text, total, _status, _ec) = table
.get_output_page(&id, 0, 5)
.await
.expect("should exist");
assert_eq!(total, 20);
let result_lines: Vec<&str> = text.lines().collect();
assert_eq!(result_lines.len(), 5);
assert_eq!(result_lines[0], "line 15");
assert_eq!(result_lines[4], "line 19");
}
#[tokio::test]
async fn get_output_page_with_offset_skips_lines() {
let table = ProcessTable::new();
let id = table.register("cmd", "/tmp", "");
let lines: Vec<String> = (0..10).map(|i| format!("line {i}")).collect();
table.append_output(&id, lines).await;
let (text, total, _status, _ec) = table
.get_output_page(&id, 3, 4)
.await
.expect("should exist");
assert_eq!(total, 10);
let result_lines: Vec<&str> = text.lines().collect();
assert_eq!(result_lines.len(), 4);
assert_eq!(result_lines[0], "line 3");
assert_eq!(result_lines[3], "line 6");
}
#[tokio::test]
async fn get_output_page_not_found_returns_none() {
let table = ProcessTable::new();
assert!(table.get_output_page("proc-999", 0, 10).await.is_none());
}
#[test]
fn watch_single_match() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["error".to_string()]);
check_watch_patterns(
"compilation error: failed",
"proc-1",
"cargo build",
&mut state,
&tx,
);
let event = rx.try_recv().expect("should receive event");
assert_eq!(event.process_id, "proc-1");
assert_eq!(event.pattern, "error");
assert!(event.matched_output.contains("compilation error"));
assert_eq!(event.event_type, WatchEventType::Match);
assert_eq!(event.suppressed_count, 0);
}
#[test]
fn watch_no_match() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["error".to_string()]);
check_watch_patterns("all good here", "proc-1", "cargo build", &mut state, &tx);
assert!(rx.try_recv().is_err(), "should not fire on non-match");
}
#[test]
fn watch_multiple_patterns_first_wins() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["err".to_string(), "error".to_string()]);
check_watch_patterns("error: something", "proc-1", "cargo build", &mut state, &tx);
let event = rx.try_recv().expect("should receive event");
assert_eq!(event.pattern, "err"); assert!(rx.try_recv().is_err(), "only one notification per line");
}
#[test]
fn watch_rate_limit_suppresses() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["error".to_string()]);
for i in 0..(WATCH_MAX_PER_WINDOW + 2) {
check_watch_patterns(
&format!("error line {i}"),
"proc-1",
"cargo build",
&mut state,
&tx,
);
}
let mut received = 0;
while rx.try_recv().is_ok() {
received += 1;
}
assert_eq!(received, WATCH_MAX_PER_WINDOW as usize);
assert!(state.suppressed > 0, "should have suppressed matches");
}
#[test]
fn watch_suppressed_count_bundled() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["error".to_string()]);
for i in 0..(WATCH_MAX_PER_WINDOW + 3) {
check_watch_patterns(
&format!("error line {i}"),
"proc-1",
"cargo build",
&mut state,
&tx,
);
}
while rx.try_recv().is_ok() {}
state.window_start = Instant::now() - Duration::from_secs(WATCH_WINDOW_SECONDS + 1);
state.window_hits = 0;
check_watch_patterns("error again", "proc-1", "cargo build", &mut state, &tx);
let event = rx.try_recv().expect("should receive after window reset");
assert!(event.suppressed_count > 0, "should report suppressed count");
}
#[test]
fn watch_disabled_after_sustained_overload() {
let (tx, _rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["error".to_string()]);
state.window_hits = WATCH_MAX_PER_WINDOW + 1;
state.overload_since =
Some(Instant::now() - Duration::from_secs(WATCH_OVERLOAD_KILL_SECONDS + 1));
check_watch_patterns("error overload", "proc-1", "cargo build", &mut state, &tx);
assert!(state.disabled, "should be permanently disabled");
}
#[test]
fn watch_disabled_no_further_events() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["error".to_string()]);
state.disabled = true;
check_watch_patterns("error line", "proc-1", "cargo build", &mut state, &tx);
assert!(rx.try_recv().is_err(), "disabled state should fire nothing");
}
#[test]
fn format_watch_activity_notice_match() {
let notice = super::format_watch_activity_notice(&WatchEvent {
process_id: "p1".into(),
command: "cargo build".into(),
pattern: "error".into(),
matched_output: "error: failed\nmore".into(),
suppressed_count: 0,
event_type: WatchEventType::Match,
exit_code: None,
});
assert!(notice.contains("p1"));
assert!(notice.contains("error"));
}
#[test]
fn watch_output_trimmed() {
let long_line = "e".repeat(3000);
let trimmed = trim_watch_output(&format!("error: {long_line}"));
assert!(
trimmed.len() <= WATCH_MAX_OUTPUT_CHARS + 4, "should be trimmed: got {} chars",
trimmed.len()
);
}
#[test]
fn watch_window_resets() {
let (tx, mut rx) = mpsc::unbounded_channel();
let mut state = WatchState::new(vec!["error".to_string()]);
for i in 0..WATCH_MAX_PER_WINDOW {
check_watch_patterns(
&format!("error line {i}"),
"proc-1",
"cargo build",
&mut state,
&tx,
);
}
while rx.try_recv().is_ok() {}
state.window_start = Instant::now() - Duration::from_secs(WATCH_WINDOW_SECONDS + 1);
check_watch_patterns("error new window", "proc-1", "cargo build", &mut state, &tx);
assert!(rx.try_recv().is_ok(), "should fire in new window");
}
#[tokio::test]
async fn watch_patterns_set_on_process() {
let table = ProcessTable::new();
let id = table.register("cargo test", "/tmp", "");
table
.set_watch_patterns(&id, vec!["FAIL".to_string()])
.await;
let rec_arc = table.get_record(&id).expect("should exist");
let rec = rec_arc.lock().await;
let ws = rec.watch_state.as_ref().expect("should have watch state");
assert_eq!(ws.patterns, vec!["FAIL"]);
assert!(!ws.disabled);
}
}