kaish-kernel 0.7.0

Core kernel for kaish: lexer, parser, interpreter, and runtime
Documentation
//! timeout — Run a command with a time limit (kills the child on elapsed).
//!
//! Derives a child cancellation token from `ctx.cancel`, spawns a delay task
//! that cancels it after `duration`, runs the inner command under the child
//! token, and overrides the exit code to 124 (coreutils convention) when the
//! timer fired. The kernel's `try_execute_external` honors the cancelled
//! token by killing the child process group with SIGTERM/grace/SIGKILL.

use async_trait::async_trait;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};

use crate::ast::{Arg, Command, Expr, Value};
use crate::duration::parse_duration;
use crate::interpreter::ExecResult;
use crate::tools::{ExecContext, ParamSchema, Tool, ToolArgs, ToolSchema};

/// Timeout tool: run a command with a deadline.
pub struct Timeout;

#[async_trait]
impl Tool for Timeout {
    fn name(&self) -> &str {
        "timeout"
    }

    fn schema(&self) -> ToolSchema {
        ToolSchema::new(
            "timeout",
            "Run a command with a time limit; kills the child on elapsed",
        )
        .param(ParamSchema::required(
            "duration",
            "string",
            "Time limit: 30 (seconds), 30s, 500ms, 5m, 1h",
        ))
        .param(ParamSchema::required(
            "command",
            "string",
            "Command to run",
        ))
        .example("With seconds", "timeout 5 sleep 10")
        .example("With duration suffix", "timeout 500ms curl example.com")
        .example("Minutes", "timeout 2m cargo build")
    }

    async fn execute(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
        if args.positional.len() < 2 {
            return ExecResult::failure(
                1,
                "timeout: usage: timeout DURATION COMMAND [ARGS...]",
            );
        }

        let duration_str = match &args.positional[0] {
            Value::String(s) => s.clone(),
            Value::Int(i) => i.to_string(),
            Value::Float(f) => f.to_string(),
            other => {
                return ExecResult::failure(
                    1,
                    format!("timeout: invalid duration: {:?}", other),
                )
            }
        };

        let duration = match parse_duration(&duration_str) {
            Some(d) => d,
            None => {
                return ExecResult::failure(
                    1,
                    format!(
                        "timeout: invalid duration '{}' (try: 30, 5s, 500ms, 2m, 1h)",
                        duration_str
                    ),
                )
            }
        };

        let cmd_name = match &args.positional[1] {
            Value::String(s) => s.clone(),
            other => {
                return ExecResult::failure(
                    1,
                    format!("timeout: invalid command: {:?}", other),
                )
            }
        };

        let inner_args: Vec<Arg> = args.positional[2..]
            .iter()
            .map(|v| Arg::Positional(Expr::Literal(v.clone())))
            .collect();

        let inner_cmd = Command {
            name: cmd_name,
            args: inner_args,
            redirects: vec![],
        };

        let Some(dispatcher) = ctx.dispatcher.clone() else {
            return ExecResult::failure(
                1,
                "timeout: no dispatcher available (Kernel must be created via into_arc())",
            );
        };

        // Derive a child cancel token from the current ctx token. The timer
        // task cancels it on elapsed; the cascade fires SIGTERM/SIGKILL on
        // any external children via wait_or_kill. Swap the child token onto
        // ctx for the duration of the inner dispatch so cancellation
        // propagates naturally.
        let parent_token = ctx.cancel.clone();
        let child_token = parent_token.child_token();

        let elapsed = Arc::new(AtomicBool::new(false));
        let elapsed_writer = elapsed.clone();
        let timer_token = child_token.clone();
        let timer = tokio::spawn(async move {
            tokio::time::sleep(duration).await;
            elapsed_writer.store(true, Ordering::SeqCst);
            timer_token.cancel();
        });

        let saved = std::mem::replace(&mut ctx.cancel, child_token);
        let dispatch_result = dispatcher.dispatch(&inner_cmd, ctx).await;
        ctx.cancel = saved;
        timer.abort();

        match dispatch_result {
            Ok(mut result) => {
                if elapsed.load(Ordering::SeqCst) {
                    result.code = 124;
                    if result.err.is_empty() {
                        result.err =
                            format!("timeout: timed out after {}", duration_str);
                    }
                }
                result
            }
            Err(e) => ExecResult::failure(1, format!("timeout: {}", e)),
        }
    }
}

#[cfg(test)]
mod tests {
    use crate::kernel::{Kernel, KernelConfig};

    /// Create a Kernel wrapped in Arc for tests that need full dispatch.
    async fn make_kernel() -> std::sync::Arc<Kernel> {
        Kernel::new(KernelConfig::isolated().with_skip_validation(true))
            .unwrap()
            .into_arc()
    }

    #[tokio::test]
    async fn test_timeout_missing_args() {
        let kernel = make_kernel().await;
        let result = kernel.execute("timeout").await.unwrap();
        assert!(!result.ok());
        assert!(result.err.contains("usage"));
    }

    #[tokio::test]
    async fn test_timeout_invalid_duration() {
        let kernel = make_kernel().await;
        let result = kernel.execute("timeout abc echo hi").await.unwrap();
        assert!(!result.ok());
        assert!(result.err.contains("invalid duration"));
    }

    #[tokio::test]
    async fn test_timeout_numeric_duration_succeeds() {
        let kernel = make_kernel().await;
        let result = kernel.execute("timeout 5 echo works").await.unwrap();
        assert!(
            result.ok(),
            "expected ok, got code={} err={:?}",
            result.code,
            result.err
        );
        assert!(result.text_out().contains("works"));
    }

    #[tokio::test]
    #[ignore = "lexer rejects numeric-prefix identifiers like `5s`; tracked in docs/issues.md"]
    async fn test_timeout_suffix_duration_succeeds() {
        let kernel = make_kernel().await;
        let result = kernel.execute("timeout 5s echo hello").await.unwrap();
        assert!(result.ok());
        assert!(result.text_out().contains("hello"));
    }

    #[tokio::test]
    #[ignore = "lexer rejects numeric-prefix identifiers like `100ms`; tracked in docs/issues.md"]
    async fn test_timeout_builtin_times_out() {
        let kernel = make_kernel().await;
        let result = kernel.execute("timeout 100ms sleep 10").await.unwrap();
        assert_eq!(result.code, 124);
        assert!(result.err.contains("timed out"));
    }

    #[tokio::test]
    #[ignore = "lexer rejects numeric-prefix identifiers like `5s`; tracked in docs/issues.md"]
    async fn test_timeout_command_not_found() {
        let kernel = make_kernel().await;
        let result = kernel
            .execute("timeout 5s not_a_command_xyz_123")
            .await
            .unwrap();
        assert!(!result.ok());
        assert_eq!(result.code, 127);
    }
}