use std::time::Duration;
use tokio::time::sleep;
use tracing::{debug, error, warn};
use super::output::{clear_to_run_async, get_status, wait_for_output};
use super::{main_shell, send_utf8_in_byte_chunks, ShellDeliveryCursor, DEFAULT_TIMEOUT};
use crate::errors::{Result, WinxError};
use crate::runtime::lock_session_store;
use crate::state::bash_state::BashState;
use crate::state::pty::PtyShell;
const COMMAND_CHUNK_SIZE: usize = 64;
fn spawn_background_reaper(owner_thread_id: String, background_id: String) {
tokio::spawn(async move {
loop {
sleep(Duration::from_millis(100)).await;
let shell = {
let manager = lock_session_store();
manager.get_shell(&owner_thread_id, &background_id)
};
let Some(shell) = shell else { return };
let finished = {
let mut guard = shell.lock().await;
match guard.as_mut() {
Some(shell) => shell.poll_output_nonblocking() || !shell.is_alive(),
None => true,
}
};
if finished {
drop(shell);
let removed = {
let mut manager = lock_session_store();
manager.prune_finished_shells();
manager.get_shell(&owner_thread_id, &background_id).is_none()
};
if removed {
return;
}
}
}
});
}
fn strip_tail_pipe(command: &str) -> String {
strip_tail_pipe_impl(command, keep_tail_pipe())
}
fn strip_tail_pipe_impl(command: &str, keep: bool) -> String {
static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
if keep {
return command.to_string();
}
#[allow(clippy::expect_used)]
let regex = RE.get_or_init(|| {
regex::Regex::new(r"\|\s*tail(?:\s+(?:-n\s*|-)?(\d+))?\s*$")
.expect("tail-pipe regex must compile")
});
match regex.find(command) {
Some(matched) => command[..matched.start()].trim_end().to_string(),
None => command.to_string(),
}
}
fn keep_tail_pipe() -> bool {
crate::config::env_flag("WINX_KEEP_TAIL_PIPE")
}
pub(super) async fn execute_command(
bash_state: &mut BashState,
command: &str,
is_background: bool,
allow_multi: bool,
timeout_secs: f64,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
let stripped_command = strip_tail_pipe(command);
let command = stripped_command.as_str();
debug!(bytes = command.len(), allow_multi, "Processing Command action");
if !bash_state.is_command_allowed(command) {
error!(bytes = command.len(), "Command not allowed in current mode");
return Err(WinxError::CommandNotAllowed(
"Error: BashCommand not allowed in current mode".to_string(),
));
}
let command = command.trim();
if !allow_multi {
let allow_shell_probe = matches!(bash_state.mode, crate::types::Modes::Wcgw);
crate::utils::bash_parser::assert_single_statement(command, allow_shell_probe)?;
}
if is_background {
return execute_in_background(bash_state, command, timeout_secs, delivery_cursor).await;
}
let foreground_gate = bash_state.foreground_command_gate.clone();
let _foreground_guard = foreground_gate.lock_owned().await;
let shell = main_shell(bash_state);
{
let guard = shell.lock().await;
if let Some(shell) = guard.as_ref().filter(|shell| shell.command_running) {
return Err(WinxError::CommandAlreadyRunning {
current_command: shell.last_command.clone(),
duration_seconds: shell
.command_elapsed()
.map_or(0.0, |elapsed| elapsed.as_secs_f64()),
});
}
}
if shell.lock().await.is_none() {
bash_state.init_pty_shell().await.map_err(|error| {
WinxError::CommandExecutionError(format!("Failed to init bash: {error}"))
})?;
}
let needs_reset = if shell.lock().await.is_some() {
if clear_to_run_async(&shell, DEFAULT_TIMEOUT).await {
false
} else {
warn!("clear_to_run: shell still busy after Ctrl-C, resetting it");
true
}
} else {
false
};
if needs_reset {
if let Err(error) = bash_state.init_pty_shell().await {
warn!("Failed to reset shell after clear_to_run: {error}");
}
}
let scratch_root = bash_state.workspace_root.clone();
{
let mut guard = shell.lock().await;
let shell = guard.as_mut().ok_or(WinxError::BashStateNotInitialized)?;
shell.output_buffer.clear();
shell.output_truncated = false;
shell.reset_scratch();
shell.set_scratch_root(&scratch_root);
shell.last_exit_code = None;
shell.last_returned_hash = None;
shell.mark_output_delivered("");
send_utf8_in_byte_chunks(shell, command, COMMAND_CHUNK_SIZE)?;
shell.send_special_key("Enter").map_err(|error| {
WinxError::CommandExecutionError(format!("Failed to send newline: {error}"))
})?;
shell.last_command = command.to_string();
shell.command_running = true;
shell.mark_command_started();
}
wait_for_output(bash_state, &shell, timeout_secs, false, None, false, delivery_cursor).await
}
async fn execute_in_background(
bash_state: &mut BashState,
command: &str,
timeout_secs: f64,
delivery_cursor: Option<&mut ShellDeliveryCursor>,
) -> Result<String> {
debug!(bytes = command.len(), "Executing command in background");
let restricted_mode =
matches!(bash_state.bash_command_mode.bash_mode, crate::types::BashMode::RestrictedMode);
let background_id = {
let cwd = bash_state.cwd.clone();
let shell = tokio::task::spawn_blocking(move || PtyShell::new(&cwd, restricted_mode))
.await
.map_err(|error| {
WinxError::CommandExecutionError(format!("bg shell init task failed: {error}"))
})?
.map_err(|error| {
WinxError::CommandExecutionError(format!(
"Failed to start background shell: {error}"
))
})?;
lock_session_store().register_shell(&bash_state.current_thread_id, shell)?
};
let shell = {
let manager = lock_session_store();
manager.get_shell(&bash_state.current_thread_id, &background_id).ok_or_else(|| {
WinxError::CommandExecutionError("Failed to get background shell".to_string())
})?
};
let scratch_root = bash_state.workspace_root.clone();
let send_result = {
let mut guard = shell.lock().await;
guard.as_mut().map(|shell| {
shell.set_scratch_root(&scratch_root);
shell.send_command(command)
})
};
let Some(send_result) = send_result else {
lock_session_store().remove_shell(&background_id);
return Err(WinxError::BashStateNotInitialized);
};
if let Err(error) = send_result {
lock_session_store().remove_shell(&background_id);
return Err(WinxError::CommandExecutionError(format!(
"Failed to send bg command: {error}"
)));
}
debug!("bg[{background_id}]: send_command returned, replying with bg_command_id");
spawn_background_reaper(bash_state.current_thread_id.clone(), background_id.clone());
let _ = (timeout_secs, delivery_cursor);
let _ = shell;
Ok(get_status(bash_state, true, Some(&background_id), true, None, None, Some(&bash_state.cwd)))
}
#[cfg(test)]
mod tests {
use super::strip_tail_pipe_impl;
#[test]
fn strips_trailing_tail_by_default() {
assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", false), "seq 1 5");
assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", false), "cat log");
assert_eq!(strip_tail_pipe_impl("cat log | tail", false), "cat log");
assert_eq!(strip_tail_pipe_impl("ls -la|tail -5", false), "ls -la");
}
#[test]
fn keeps_command_without_trailing_tail() {
assert_eq!(strip_tail_pipe_impl("tail -f log | grep err", false), "tail -f log | grep err");
assert_eq!(strip_tail_pipe_impl("echo hi", false), "echo hi");
assert_eq!(
strip_tail_pipe_impl("cat a | tail -5 | wc -l", false),
"cat a | tail -5 | wc -l"
);
}
#[test]
fn keep_mode_preserves_tail_pipe() {
assert_eq!(strip_tail_pipe_impl("seq 1 5 | tail -2", true), "seq 1 5 | tail -2");
assert_eq!(strip_tail_pipe_impl("cat log | tail -n 20", true), "cat log | tail -n 20");
}
}