use super::*;
#[derive(Debug)]
pub(crate) struct BackgroundStartup {
pub(crate) ready_message: String,
pub(crate) log_excerpt: String,
pub(crate) detected_url: Option<String>,
}
pub(crate) async fn run_background_command(
command: &str,
workdir: &Path,
startup_timeout_secs: u64,
ready_pattern: Option<&str>,
open_url: Option<&str>,
ctx: ExecContext,
) -> ToolOutcome {
let start = Instant::now();
{
let log_path = background_log_path();
let pid =
match launch_background_process(command, workdir, &log_path, ctx.scratchpad.as_deref())
.await
{
Ok(pid) => pid,
Err(error) => {
return ToolOutcome::error(error, start.elapsed().as_secs_f64());
},
};
let startup = match wait_for_background_startup(
pid,
&log_path,
startup_timeout_secs,
ready_pattern,
&ctx,
)
.await
{
Ok(startup) => startup,
Err(BackgroundWaitError::Cancelled) => {
mermaid_model::utils::terminate_tree(pid, mermaid_model::utils::Grace::Graceful)
.await;
return ToolOutcome::cancelled();
},
Err(BackgroundWaitError::ExitedEarly(log_excerpt)) => {
return ToolOutcome::error(
format!(
"Background command exited during startup. Log: {}\n\n{}",
log_path.display(),
log_excerpt
),
start.elapsed().as_secs_f64(),
);
},
};
let opened = if let Some(url) = open_url {
Some((url.to_string(), open_browser_url(url).await))
} else {
None
};
let mut output = format!(
"Background command started.\nPID: {}\nLog: {}\n{}\n",
pid,
log_path.display(),
startup.ready_message
);
if let Some(url) = startup.detected_url.as_ref() {
output.push_str(&format!("Detected URL: {url}\n"));
}
if let Some((url, result)) = opened {
match result {
Ok(()) => output.push_str(&format!("Opened URL: {url}\n")),
Err(error) => output.push_str(&format!("Open URL failed: {url} ({error})\n")),
}
}
if !startup.log_excerpt.trim().is_empty() {
output.push_str("\n--- startup output ---\n");
output.push_str(&startup.log_excerpt);
}
let duration_secs = start.elapsed().as_secs_f64();
let log_path_str = log_path.display().to_string();
let detected_urls = startup.detected_url.iter().cloned().collect::<Vec<_>>();
let process = ManagedProcess {
id: format!("bg-{pid}"),
pid,
command: command.to_string(),
cwd: Some(workdir.display().to_string()),
log_path: log_path_str.clone(),
detected_url: startup.detected_url.clone(),
status: mermaid_runtime::ProcessStatus::Running,
};
let byte_count = output.len();
let mut metadata = command_metadata(CommandMetadataInput {
command: command.to_string(),
working_dir: Some(workdir.display().to_string()),
exit_code: None,
timed_out: false,
background: true,
stdout_lines: startup.log_excerpt.lines().count(),
stderr_lines: 0,
detected_urls,
pid: Some(pid),
log_path: Some(log_path_str),
byte_count: Some(byte_count),
});
metadata.process = Some(process);
ToolOutcome::success(output, "background process started", duration_secs)
.with_metadata(metadata)
}
}
#[cfg(not(target_os = "windows"))]
pub(crate) async fn launch_background_process(
command: &str,
workdir: &Path,
log_path: &Path,
scratchpad: Option<&Path>,
) -> Result<u32, String> {
create_log_file_blocking(log_path).map_err(|e| {
format!(
"failed to create background log {}: {e}",
log_path.display()
)
})?;
let mut launcher = Command::new("sh");
launcher
.arg("-c")
.arg(
r#"log=$MERMAID_BG_LOG
cmd=$MERMAID_BG_COMMAND
: > "$log" || exit 125
if command -v setsid >/dev/null 2>&1; then
setsid sh -c "$cmd" > "$log" 2>&1 < /dev/null &
else
nohup sh -c "$cmd" > "$log" 2>&1 < /dev/null &
fi
printf '%s\n' "$!""#,
)
.env("MERMAID_BG_LOG", log_path)
.env("MERMAID_BG_COMMAND", command)
.current_dir(workdir)
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
scrub_secret_env(&mut launcher);
harden_noninteractive_env(&mut launcher);
export_scratchpad_env(&mut launcher, scratchpad);
let output = launcher
.output()
.await
.map_err(|e| format!("failed to launch background command: {}", e))?;
if !output.status.success() {
return Err(format!(
"background launcher failed: {}",
String::from_utf8_lossy(&output.stderr)
));
}
let stdout = String::from_utf8_lossy(&output.stdout);
stdout.trim().parse::<u32>().map_err(|e| {
format!(
"background launcher did not return a pid: {} ({})",
stdout, e
)
})
}
#[cfg(target_os = "windows")]
pub(crate) async fn launch_background_process(
command: &str,
workdir: &Path,
log_path: &Path,
scratchpad: Option<&Path>,
) -> Result<u32, String> {
use mermaid_model::utils::{CREATE_NEW_PROCESS_GROUP, CREATE_NO_WINDOW};
let log = std::fs::File::create(log_path).map_err(|e| {
format!(
"failed to create background log {}: {e}",
log_path.display()
)
})?;
let log_err = log
.try_clone()
.map_err(|e| format!("failed to clone background log handle: {e}"))?;
let mut launcher = Command::new(powershell_program());
launcher
.args(["-NoProfile", "-NonInteractive", "-Command"])
.arg(command)
.current_dir(workdir)
.stdin(Stdio::null())
.stdout(Stdio::from(log))
.stderr(Stdio::from(log_err))
.creation_flags(CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP);
scrub_secret_env(&mut launcher);
harden_noninteractive_env(&mut launcher);
export_scratchpad_env(&mut launcher, scratchpad);
let child = launcher
.spawn()
.map_err(|e| format!("failed to launch background command: {e}"))?;
child
.id()
.ok_or_else(|| "background command produced no pid".to_string())
}
#[derive(Debug)]
pub(crate) enum BackgroundWaitError {
Cancelled,
ExitedEarly(String),
}
pub(crate) async fn wait_for_background_startup(
pid: u32,
log_path: &Path,
startup_timeout_secs: u64,
ready_pattern: Option<&str>,
ctx: &ExecContext,
) -> Result<BackgroundStartup, BackgroundWaitError> {
let start = Instant::now();
let startup_timeout = Duration::from_secs(startup_timeout_secs);
loop {
if ctx.token.is_cancelled() {
return Err(BackgroundWaitError::Cancelled);
}
let last_log = read_log_lossy(log_path).await;
let detected_url = first_url(&last_log);
if !process_running(pid).await {
return Err(BackgroundWaitError::ExitedEarly(tail_lines(&last_log, 40)));
}
if let Some(pattern) = ready_pattern {
if last_log.contains(pattern) {
return Ok(BackgroundStartup {
ready_message: format!("Ready: matched pattern {pattern:?}"),
log_excerpt: tail_lines(&last_log, 40),
detected_url,
});
}
} else if start.elapsed() >= Duration::from_secs(1) || !last_log.is_empty() {
return Ok(BackgroundStartup {
ready_message:
"Ready: no ready_pattern provided; process is running after startup check"
.to_string(),
log_excerpt: tail_lines(&last_log, 40),
detected_url,
});
}
if start.elapsed() >= startup_timeout {
let ready_message = if let Some(pattern) = ready_pattern {
format!(
"Ready: pattern {pattern:?} was not seen within {startup_timeout_secs}s; process is still running"
)
} else {
format!(
"Ready: startup check reached {startup_timeout_secs}s; process is still running"
)
};
return Ok(BackgroundStartup {
ready_message,
log_excerpt: tail_lines(&last_log, 40),
detected_url,
});
}
tokio::select! {
_ = ctx.token.cancelled() => return Err(BackgroundWaitError::Cancelled),
_ = tokio::time::sleep(Duration::from_millis(200)) => {},
}
}
}
pub(crate) async fn read_log_lossy(path: &Path) -> String {
tokio::fs::read_to_string(path).await.unwrap_or_default()
}
#[cfg(not(target_os = "windows"))]
pub(crate) async fn process_running(pid: u32) -> bool {
Command::new("kill")
.arg("-0")
.arg(pid.to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.await
.map(|status| status.success())
.unwrap_or(false)
}
#[cfg(target_os = "windows")]
pub(crate) async fn process_running(pid: u32) -> bool {
Command::new("tasklist")
.args(["/FI", &format!("PID eq {pid}"), "/NH"])
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.map(|out| String::from_utf8_lossy(&out.stdout).contains(&pid.to_string()))
.unwrap_or(false)
}
pub(crate) fn background_log_path() -> PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or_default();
let name = format!("mermaid-bg-{}-{}.log", std::process::id(), nanos);
match mermaid_model::utils::private_temp_dir() {
Ok(dir) => dir.join(name),
Err(_) => std::env::temp_dir().join(name),
}
}
#[cfg(unix)]
pub(crate) fn create_log_file_blocking(path: &Path) -> std::io::Result<std::fs::File> {
use std::os::unix::fs::OpenOptionsExt;
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(path)
}
pub(crate) fn create_tee_log_blocking(path: &Path) -> Option<tokio::fs::File> {
#[cfg(unix)]
let std_file = create_log_file_blocking(path).ok();
#[cfg(not(unix))]
let std_file = std::fs::File::create(path).ok();
std_file.map(tokio::fs::File::from_std)
}