Compare commits

...

1 Commits

Author SHA1 Message Date
Adam
b3991603e2 fix(desktop): server spawn resilience 2026-02-10 15:58:17 -06:00
3 changed files with 112 additions and 22 deletions

View File

@@ -1,8 +1,9 @@
use tauri::{AppHandle, Manager, path::BaseDirectory};
use tauri_plugin_shell::{
ShellExt,
process::{Command, CommandChild, CommandEvent},
process::{Command, CommandChild, CommandEvent, TerminatedPayload},
};
use tokio::sync::oneshot;
use crate::{LogState, constants::MAX_LOG_ENTRIES};
@@ -188,22 +189,58 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command {
};
}
pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> CommandChild {
pub fn serve(
app: &AppHandle,
hostname: &str,
port: u32,
password: &str,
) -> (CommandChild, oneshot::Receiver<TerminatedPayload>) {
let log_state = app.state::<LogState>();
let log_state_clone = log_state.inner().clone();
let (exit_tx, exit_rx) = oneshot::channel::<TerminatedPayload>();
println!("spawning sidecar on port {port}");
let (mut rx, child) = create_command(
app,
format!("serve --hostname {hostname} --port {port}").as_str(),
)
if let Ok(mut logs) = log_state_clone.0.lock() {
let args = format!(
"--print-logs --log-level WARN serve --hostname {hostname} --port {port}"
);
#[cfg(target_os = "windows")]
{
logs.push_back(format!("[SPAWN] sidecar=opencode-cli args=\"{args}\"\n"));
}
#[cfg(not(target_os = "windows"))]
{
let sidecar = get_sidecar_path(app);
let shell = get_user_shell();
let cmd = if shell.ends_with("/nu") {
format!("^\"{}\" {}", sidecar.display(), args)
} else {
format!("\"{}\" {}", sidecar.display(), args)
};
logs.push_back(format!(
"[SPAWN] shell=\"{shell}\" argv=\"-il -c {cmd}\"\n"
));
}
while logs.len() > MAX_LOG_ENTRIES {
logs.pop_front();
}
}
let args = format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}");
let (mut rx, child) = create_command(app, &args)
.env("OPENCODE_SERVER_USERNAME", "opencode")
.env("OPENCODE_SERVER_PASSWORD", password)
.spawn()
.expect("Failed to spawn opencode");
tokio::spawn(async move {
let mut exit_tx = Some(exit_tx);
while let Some(event) = rx.recv().await {
match event {
CommandEvent::Stdout(line_bytes) => {
@@ -232,10 +269,35 @@ pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> Comm
}
}
}
CommandEvent::Error(err) => {
eprintln!("{err}");
if let Ok(mut logs) = log_state_clone.0.lock() {
logs.push_back(format!("[ERROR] {err}\n"));
while logs.len() > MAX_LOG_ENTRIES {
logs.pop_front();
}
}
}
CommandEvent::Terminated(payload) => {
if let Ok(mut logs) = log_state_clone.0.lock() {
logs.push_back(format!(
"[EXIT] code={:?} signal={:?}\n",
payload.code, payload.signal
));
while logs.len() > MAX_LOG_ENTRIES {
logs.pop_front();
}
}
if let Some(tx) = exit_tx.take() {
let _ = tx.send(payload);
}
}
_ => {}
}
}
});
child
(child, exit_rx)
}

View File

@@ -364,15 +364,26 @@ async fn initialize(app: AppHandle) {
let app = app.clone();
Some(
async move {
let Ok(Ok(_)) = timeout(Duration::from_secs(30), health_check.0).await
else {
let _ = child.kill();
return Err(format!(
"Failed to spawn OpenCode Server. Logs:\n{}",
get_logs(app.clone()).await.unwrap()
));
let res = timeout(Duration::from_secs(30), health_check.0).await;
let err = match res {
Ok(Ok(Ok(()))) => None,
Ok(Ok(Err(e))) => Some(e),
Ok(Err(e)) => Some(format!("Health check task failed: {e}")),
Err(_) => Some("Health check timed out".to_string()),
};
if let Some(err) = err {
let _ = child.kill();
let logs = get_logs(app.clone())
.await
.unwrap_or_else(|e| format!("[DESKTOP] Failed to read sidecar logs: {e}\n"));
return Err(format!(
"Failed to spawn OpenCode Server ({err}). Logs:\n{logs}"
));
}
println!("CLI health check OK");
#[cfg(windows)]

View File

@@ -70,26 +70,43 @@ pub fn spawn_local_server(
port: u32,
password: String,
) -> (CommandChild, HealthCheck) {
let child = cli::serve(&app, &hostname, port, &password);
let (child, exit) = cli::serve(&app, &hostname, port, &password);
let health_check = HealthCheck(tokio::spawn(async move {
let url = format!("http://{hostname}:{port}");
let timestamp = Instant::now();
loop {
tokio::time::sleep(Duration::from_millis(100)).await;
if check_health(&url, Some(&password)).await {
println!("Server ready after {:?}", timestamp.elapsed());
break;
let ready = async {
loop {
tokio::time::sleep(Duration::from_millis(100)).await;
if check_health(&url, Some(&password)).await {
println!("Server ready after {:?}", timestamp.elapsed());
return Ok(());
}
}
};
let terminated = async {
match exit.await {
Ok(payload) => Err(format!(
"Sidecar terminated before becoming healthy (code={:?} signal={:?})",
payload.code, payload.signal
)),
Err(_) => Err("Sidecar terminated before becoming healthy".to_string()),
}
};
tokio::select! {
res = ready => res,
res = terminated => res,
}
}));
(child, health_check)
}
pub struct HealthCheck(pub JoinHandle<()>);
pub struct HealthCheck(pub JoinHandle<Result<(), String>>);
pub async fn check_health(url: &str, password: Option<&str>) -> bool {
let Ok(url) = reqwest::Url::parse(url) else {