#![allow(clippy::unwrap_used, clippy::expect_used)]
use std::time::Duration;
use std::sync::Arc;
use kaish_kernel::{Kernel, KernelConfig};
async fn setup() -> Arc<Kernel> {
Kernel::new(KernelConfig::isolated()).expect("failed to create kernel").into_arc()
}
async fn wait_for_job(kernel: &Kernel, job_id: u64, timeout: Duration) -> String {
let start = std::time::Instant::now();
let status_cmd = format!("cat /v/jobs/{}/status", job_id);
loop {
let result = kernel.execute(&status_cmd).await.expect("status check failed");
let text = result.text_out();
let status = text.trim();
if status.starts_with("done:") || status.starts_with("failed:") || status.starts_with("killed:") {
return status.to_string();
}
if start.elapsed() > timeout {
panic!("Job {} did not complete within {:?}", job_id, timeout);
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
}
#[tokio::test]
async fn test_job_output_persistence_disabled_for_hostless_kernels() {
use kaish_kernel::vfs::{MemoryFs, VfsRouter};
use kaish_kernel::{KernelBackend, LocalBackend};
let isolated = Kernel::new(KernelConfig::isolated()).expect("isolated kernel");
assert!(
!isolated.jobs().persist_output_files(),
"NoLocal kernel must not write job output to host disk"
);
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
let backend: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(vfs)));
let embedded = Kernel::with_backend(backend, KernelConfig::isolated(), |_| {}, |_| {})
.expect("with_backend kernel");
assert!(
!embedded.jobs().persist_output_files(),
"with_backend kernel must not write job output to host disk"
);
}
#[cfg(feature = "localfs")]
#[tokio::test]
async fn test_job_output_persistence_enabled_for_host_owning_kernel() {
let host_owning = Kernel::new(KernelConfig::transient()).expect("transient kernel");
assert!(
host_owning.jobs().persist_output_files(),
"a host-owning kernel should still persist job output files"
);
}
#[tokio::test]
async fn test_with_backend_forces_in_memory_spill_not_host_disk() {
use kaish_kernel::vfs::{MemoryFs, VfsRouter};
use kaish_kernel::{KernelBackend, LocalBackend, OutputLimitConfig};
let mut limit = OutputLimitConfig::agent(); limit.set_limit(Some(16));
let config = KernelConfig::transient().with_output_limit(limit);
let mut vfs = VfsRouter::new();
vfs.mount("/", MemoryFs::new());
let backend: Arc<dyn KernelBackend> = Arc::new(LocalBackend::new(Arc::new(vfs)));
let kernel = Kernel::with_backend(backend, config, |_| {}, |_| {})
.expect("with_backend kernel");
let result = kernel.execute("seq 1 50").await.expect("execute failed");
assert!(result.did_spill, "output over the 16-byte limit should be truncated");
assert!(
result.text_out().contains("no spill file"),
"with_backend kernel must truncate in memory, not spill to host disk; got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_background_job_returns_job_id() {
let kernel = setup().await;
let result = kernel.execute("echo hello &").await.unwrap();
assert!(result.ok(), "background command should succeed, got: {}", result.err);
assert!(
result.text_out().contains("[1]") || result.text_out().contains("1"),
"expected job ID in output, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_background_job_creates_vfs_entry() {
let kernel = setup().await;
kernel.execute("echo hello &").await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let result = kernel.execute("ls /v/jobs").await.unwrap();
assert!(result.ok(), "ls /v/jobs failed: {}", result.err);
assert!(
result.text_out().contains("1"),
"expected job 1 in /v/jobs, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_background_job_captures_stdout() {
let kernel = setup().await;
kernel
.execute("echo 'hello from background' > /tmp/captures_stdout.txt &")
.await
.unwrap();
wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
let result = kernel.execute("cat /tmp/captures_stdout.txt").await.unwrap();
assert!(result.ok(), "cat redirected output failed: {}", result.err);
assert!(
result.text_out().contains("hello from background"),
"expected redirected output content, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_background_job_status_transitions() {
let kernel = setup().await;
kernel.execute("sleep 0.5 &").await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let result = kernel.execute("cat /v/jobs/1/status").await.unwrap();
assert!(result.ok(), "status check failed: {}", result.err);
assert_eq!(result.text_out().trim(), "running", "expected running status");
let status = wait_for_job(&kernel, 1, Duration::from_secs(5)).await;
assert_eq!(status, "done:0", "expected done:0 status");
}
#[tokio::test]
async fn test_background_job_command_file() {
let kernel = setup().await;
kernel.execute("echo test123 &").await.unwrap();
wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
let result = kernel.execute("cat /v/jobs/1/command").await.unwrap();
assert!(result.ok(), "cat command failed: {}", result.err);
assert!(
result.text_out().contains("echo") && result.text_out().contains("test123"),
"expected command in output, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_multiple_background_jobs() {
let kernel = setup().await;
kernel.execute("echo job1 &").await.unwrap();
kernel.execute("echo job2 &").await.unwrap();
kernel.execute("echo job3 &").await.unwrap();
wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
wait_for_job(&kernel, 2, Duration::from_secs(1)).await;
wait_for_job(&kernel, 3, Duration::from_secs(1)).await;
let result = kernel.execute("ls /v/jobs").await.unwrap();
assert!(result.ok());
assert!(result.text_out().contains("1"), "missing job 1");
assert!(result.text_out().contains("2"), "missing job 2");
assert!(result.text_out().contains("3"), "missing job 3");
}
#[tokio::test]
async fn test_each_job_has_correct_output() {
let kernel = setup().await;
kernel.execute("echo 'output-one' > /tmp/job1.txt &").await.unwrap();
kernel.execute("echo 'output-two' > /tmp/job2.txt &").await.unwrap();
wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
wait_for_job(&kernel, 2, Duration::from_secs(1)).await;
let r1 = kernel.execute("cat /tmp/job1.txt").await.unwrap();
let r2 = kernel.execute("cat /tmp/job2.txt").await.unwrap();
assert!(r1.text_out().contains("output-one"), "job 1 wrong output: {}", r1.text_out());
assert!(r2.text_out().contains("output-two"), "job 2 wrong output: {}", r2.text_out());
}
#[tokio::test]
async fn test_background_job_inherits_env() {
let kernel = setup().await;
kernel.execute("export MY_VAR=test_value").await.unwrap();
kernel.execute("echo $MY_VAR > /tmp/inherits_env.txt &").await.unwrap();
wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
let result = kernel.execute("cat /tmp/inherits_env.txt").await.unwrap();
assert!(
result.text_out().contains("test_value"),
"expected env var in redirected output, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_background_job_inherits_cwd() {
let kernel = setup().await;
let dir = format!("/tmp/test_cwd_{}", std::process::id());
kernel.execute(&format!("mkdir -p {dir}")).await.unwrap();
kernel.execute(&format!("cd {dir}")).await.unwrap();
kernel.execute("pwd > out.txt &").await.unwrap();
wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
let result = kernel.execute(&format!("cat {dir}/out.txt")).await.unwrap();
assert!(
result.text_out().contains(&dir),
"expected cwd in redirected output, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_failed_background_job_status() {
let kernel = setup().await;
kernel.execute("false &").await.unwrap();
let status = wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
assert!(
status.starts_with("failed:") || status == "done:1",
"expected failed status, got: {}",
status
);
}
#[tokio::test]
async fn test_spilled_background_job_reports_failed_not_done() {
let kernel = setup().await;
kernel.execute("set -o output-limit=64").await.unwrap();
kernel.execute("seq 1 5000 &").await.unwrap();
let status = wait_for_job(&kernel, 1, Duration::from_secs(5)).await;
assert_eq!(
status, "failed:3",
"a spilled background job must report failed:3 (loud), not done:0 (silent): {status}"
);
}
#[tokio::test]
async fn test_pipeline_in_background() {
let kernel = setup().await;
kernel
.execute("echo 'line1\nline2\nline3' | wc -l > /tmp/pipeline_out.txt &")
.await
.unwrap();
wait_for_job(&kernel, 1, Duration::from_secs(1)).await;
let result = kernel.execute("cat /tmp/pipeline_out.txt").await.unwrap();
assert!(result.ok(), "cat failed: {}", result.err);
assert!(
result.text_out().trim() == "3" || result.text_out().contains("3"),
"expected 3 lines, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_jobs_builtin_shows_background_job() {
let kernel = setup().await;
kernel.execute("echo background-test &").await.unwrap();
tokio::time::sleep(Duration::from_millis(10)).await;
let result = kernel.execute("jobs").await.unwrap();
assert!(result.ok(), "jobs command failed: {}", result.err);
assert!(
result.text_out().contains("1") && result.text_out().contains("/v/jobs/1/"),
"expected job info, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn jobs_json_carries_exit_code_for_failed_job() {
let kernel = setup().await;
kernel
.execute("bgfail() { return 42; }; bgfail & wait %1")
.await
.expect("execute");
let result = kernel.execute("jobs --json").await.expect("execute");
assert!(result.ok(), "jobs --json failed: {}", result.err);
let json: serde_json::Value =
serde_json::from_str(&result.text_out()).expect("jobs --json must be valid JSON");
let rows = json.as_array().expect("jobs --json is an array of rows");
assert_eq!(rows.len(), 1, "expected exactly one job row: {json}");
assert_eq!(rows[0]["status"], "failed");
assert_eq!(
rows[0]["exit_code"], 42,
"the exit code must survive onto jobs --json: {}",
rows[0]
);
assert_eq!(rows[0]["path"], "/v/jobs/1/");
}
#[tokio::test]
async fn wait_jobspec_percent_form_end_to_end() {
let kernel = setup().await;
let result = kernel
.execute("sleep 0.05 & wait %1")
.await
.expect("wait %1 should parse and run");
assert!(result.ok(), "wait %1 failed: {}", result.err);
assert!(
result.text_out().contains("[1]"),
"expected job 1 status, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn wait_propagates_background_job_failure() {
let kernel = setup().await;
let result = kernel
.execute("bgfail() { sleep 0.01; return 7; }; bgfail & wait %1")
.await
.expect("execute");
assert_eq!(
result.code, 1,
"wait on a failed job must exit 1: out={} err={}",
result.text_out(),
result.err
);
assert!(
result.text_out().contains("[1] Failed"),
"expected failed status line, got: {}",
result.text_out()
);
}
#[tokio::test]
async fn wait_after_kill_returns_the_cached_result() {
let kernel = setup().await;
let killed = kernel.execute("sleep 30 & kill %1").await.expect("execute");
assert_eq!(killed.code, 0, "kill %1 should succeed: {}", killed.err);
let waited = kernel.execute("wait %1").await.expect("execute");
assert!(
!waited.err.contains("not found"),
"a killed job must stay addressable, got: {}",
waited.err
);
assert_eq!(waited.code, 1, "the killed job did not finish its work");
assert!(
waited.text_out().contains("[1] Killed"),
"wait names the kill, not a generic failure: {}",
waited.text_out()
);
}
#[tokio::test]
async fn kill_terminates_builtin_background_job() {
let kernel = setup().await;
let r = kernel.execute("sleep 30 & kill %1").await.expect("execute");
assert_eq!(r.code, 0, "kill %1 of a builtin job should succeed: {}", r.err);
let out = r.text_out();
assert!(
out.contains("exited") && out.contains("killed:130"),
"kill must confirm the death and name the status, got: {out}"
);
let jobs = kernel.execute("jobs").await.expect("execute");
assert!(
jobs.text_out().contains("Killed"),
"killed job must stay listed with Killed status, got: {}",
jobs.text_out()
);
let again = kernel.execute("kill %1").await.expect("execute");
assert_eq!(again.code, 0, "second kill is a no-op, got: {}", again.err);
assert!(
again.text_out().contains("already finished (killed:130)"),
"second kill names the terminal status, got: {}",
again.text_out()
);
}
#[tokio::test]
async fn v_jobs_status_reports_killed() {
let kernel = setup().await;
let r = kernel.execute("sleep 30 & kill %1").await.expect("execute");
assert_eq!(r.code, 0, "kill should succeed: {}", r.err);
let status = kernel.execute("cat /v/jobs/1/status").await.expect("execute");
assert_eq!(status.text_out().trim(), "killed:130", "err: {}", status.err);
}
#[tokio::test]
async fn kill_no_wait_dispatches_without_confirming() {
let kernel = setup().await;
let r = kernel
.execute("sleep 30 & kill --no-wait %1")
.await
.expect("execute");
assert_eq!(r.code, 0, "kill --no-wait should succeed: {}", r.err);
assert!(
r.text_out().contains("not awaited"),
"--no-wait must say the death was not confirmed, got: {}",
r.text_out()
);
let status = wait_for_job(&kernel, 1, Duration::from_secs(5)).await;
assert_eq!(status, "killed:130");
}
#[cfg(all(unix, feature = "subprocess"))]
#[tokio::test]
async fn kill_survives_a_stale_process_group() {
let kernel = setup().await;
let r = kernel.execute("sleep 30 &").await.expect("execute");
assert_eq!(r.code, 0, "spawn: {}", r.err);
kernel.jobs().add_pgid(kaish_kernel::scheduler::JobId(1), 999_999_999).await;
let killed = kernel.execute("kill %1").await.expect("execute");
assert_eq!(
killed.code, 0,
"a stale group must not abort the kill: out={} err={}",
killed.text_out(),
killed.err
);
assert!(
killed.text_out().contains("exited (killed:130"),
"the token still terminates and confirms, got: {}",
killed.text_out()
);
}
#[tokio::test]
async fn v_jobs_status_reports_stopped() {
let kernel = setup().await;
let id = kernel
.jobs()
.register_stopped("sleep 30".to_string(), 4242, 4242)
.await;
let status = kernel
.execute(&format!("cat /v/jobs/{id}/status"))
.await
.expect("execute");
assert_eq!(status.text_out().trim(), "stopped", "err: {}", status.err);
}
#[tokio::test]
async fn kill_nonterminating_signal_on_builtin_job_errors() {
let kernel = setup().await;
let r = kernel
.execute("sleep 30 & kill --signal USR1 %1")
.await
.expect("execute");
assert!(!r.ok());
assert!(
r.err.contains("in-process task") && r.err.contains("USR1"),
"expected in-process-task error, got: {}",
r.err
);
let _ = kernel.execute("kill %1").await;
}