#![cfg(unix)]
use std::time::{Duration, Instant};
use serde_json::{json, Value};
use super::helpers::{user_config, AftProcess};
const SESSION: &str = "bash-arch-session";
fn configure(aft: &mut AftProcess, project: &std::path::Path, storage: &std::path::Path) {
let response = aft.send(
&json!({
"id": "cfg-bash-arch",
"session_id": SESSION,
"command": "configure",
"harness": "opencode",
"project_root": project,
"storage_dir": storage,
"config": user_config(serde_json::json!({
"experimental": { "bash": { "background": true } }
})),
"max_background_bash_tasks": 32,
})
.to_string(),
);
assert_eq!(response["success"], true, "configure failed: {response:?}");
}
fn spawn_bash(aft: &mut AftProcess, params: Value) -> Value {
aft.send(
&json!({
"id": "bash-arch-spawn",
"session_id": SESSION,
"command": "bash",
"params": params,
})
.to_string(),
)
}
fn status(aft: &mut AftProcess, task_id: &str) -> Value {
aft.send(
&json!({
"id": format!("status-{task_id}"),
"session_id": SESSION,
"command": "bash_status",
"params": { "task_id": task_id },
})
.to_string(),
)
}
fn drain(aft: &mut AftProcess) -> Value {
aft.send(
&json!({
"id": "drain-bash-arch",
"session_id": SESSION,
"command": "bash_drain_completions",
})
.to_string(),
)
}
fn wait_terminal(aft: &mut AftProcess, task_id: &str) -> Value {
let started = Instant::now();
loop {
let response = status(aft, task_id);
assert_eq!(response["success"], true, "status failed: {response:?}");
if matches!(
response["status"].as_str(),
Some("completed" | "failed" | "killed" | "timed_out")
) {
return response;
}
assert!(started.elapsed() < Duration::from_secs(10));
std::thread::sleep(Duration::from_millis(50));
}
}
fn wait_terminal_with_drain_completion(aft: &mut AftProcess, task_id: &str) -> Value {
let started = Instant::now();
loop {
let _terminal = wait_terminal(aft, task_id);
let drained = drain(aft);
assert_eq!(drained["success"], true, "drain failed: {drained:?}");
let completions = drained["bg_completions"].as_array().unwrap();
if completions
.iter()
.any(|completion| completion["task_id"].as_str() == Some(task_id))
{
return drained;
}
assert!(
started.elapsed() < Duration::from_secs(10),
"timed out waiting for drain completion for {task_id}"
);
std::thread::sleep(Duration::from_millis(50));
}
}
#[test]
fn foreground_bash_returns_immediately_and_does_not_block_dispatch_loop() {
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let mut aft = AftProcess::spawn();
configure(&mut aft, project.path(), storage.path());
let read_path = project.path().join("probe.txt");
std::fs::write(&read_path, "probe").unwrap();
let started = Instant::now();
let bash = spawn_bash(&mut aft, json!({ "command": "sleep 5", "timeout": 10_000 }));
assert_eq!(bash["success"], true, "bash failed: {bash:?}");
assert_eq!(bash["status"], "running");
assert!(
started.elapsed() < Duration::from_secs(10),
"foreground bash appears deadlocked before promotion: {bash:?}"
);
let read_started = Instant::now();
let read = aft.send(
&json!({
"id": "read-after-bash",
"session_id": SESSION,
"command": "read",
"file": read_path,
})
.to_string(),
);
assert_eq!(read["success"], true, "read failed: {read:?}");
assert!(
read_started.elapsed() < Duration::from_secs(10),
"read was blocked behind foreground bash: {read:?}"
);
assert!(aft.shutdown().success());
}
#[test]
fn foreground_bash_with_daemonized_child_does_not_wait_for_inherited_fds() {
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let mut aft = AftProcess::spawn();
configure(&mut aft, project.path(), storage.path());
let started = Instant::now();
let bash = spawn_bash(
&mut aft,
json!({ "command": "nohup sh -c 'sleep 30 && echo done' > /dev/null 2>&1 &" }),
);
assert_eq!(bash["success"], true, "bash failed: {bash:?}");
assert_eq!(bash["status"], "running");
assert!(started.elapsed() < Duration::from_secs(10));
let terminal = wait_terminal(&mut aft, bash["task_id"].as_str().unwrap());
assert_eq!(terminal["status"], "completed");
assert!(aft.shutdown().success());
}
#[test]
fn no_notify_foreground_poll_completion_does_not_enqueue_completion() {
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let mut aft = AftProcess::spawn();
configure(&mut aft, project.path(), storage.path());
let bash = spawn_bash(
&mut aft,
json!({ "command": "printf done", "notify_on_completion": false }),
);
assert_eq!(bash["success"], true, "bash failed: {bash:?}");
let _ = wait_terminal(&mut aft, bash["task_id"].as_str().unwrap());
let drained = drain(&mut aft);
assert_eq!(drained["bg_completions"].as_array().unwrap().len(), 0);
assert!(aft.shutdown().success());
}
#[test]
fn bash_promote_reenables_completion_delivery() {
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let mut aft = AftProcess::spawn();
configure(&mut aft, project.path(), storage.path());
let bash = spawn_bash(
&mut aft,
json!({ "command": "sleep 0.3; printf promoted", "notify_on_completion": false }),
);
assert_eq!(bash["success"], true, "bash failed: {bash:?}");
let task_id = bash["task_id"].as_str().unwrap();
let promoted = aft.send(
&json!({
"id": "promote-bash-arch",
"session_id": SESSION,
"command": "bash_promote",
"params": { "task_id": task_id },
})
.to_string(),
);
assert_eq!(promoted["success"], true, "promote failed: {promoted:?}");
let drained = wait_terminal_with_drain_completion(&mut aft, task_id);
let completions = drained["bg_completions"].as_array().unwrap();
assert_eq!(completions.len(), 1, "drained: {drained:?}");
assert_eq!(completions[0]["task_id"], task_id);
assert!(aft.shutdown().success());
}
#[test]
fn long_running_reminder_frame_fires_after_configured_interval() {
let project = tempfile::tempdir().unwrap();
let storage = tempfile::tempdir().unwrap();
let mut aft = AftProcess::spawn();
let configure = aft.send(
&json!({
"id": "cfg-bash-reminder",
"session_id": SESSION,
"command": "configure",
"harness": "opencode",
"project_root": project.path(),
"storage_dir": storage.path(),
"config": user_config(serde_json::json!({
"experimental": {
"bash": {
"background": true,
"long_running_reminder_enabled": true,
"long_running_reminder_interval_ms": 100
}
}
})),
})
.to_string(),
);
assert_eq!(
configure["success"], true,
"configure failed: {configure:?}"
);
let bash = spawn_bash(&mut aft, json!({ "command": "sleep 1", "timeout": 2_000 }));
assert_eq!(bash["success"], true, "bash failed: {bash:?}");
let task_id = bash["task_id"].as_str().unwrap();
let deadline = Instant::now() + Duration::from_secs(3);
loop {
let Some(frame) = aft.try_read_next_timeout(Duration::from_millis(500)) else {
assert!(
Instant::now() < deadline,
"timed out waiting for reminder frame"
);
continue;
};
if frame["type"] == "bash_long_running" {
assert_eq!(frame["task_id"], task_id);
assert_eq!(frame["session_id"], SESSION);
assert!(frame["elapsed_ms"].as_u64().unwrap() >= 100);
break;
}
}
assert!(aft.shutdown().success());
}