diff options
| author | Adam <[email protected]> | 2026-02-11 03:44:26 -0600 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-02-11 09:44:26 +0000 |
| commit | 7e1247c4208002eda989b52c9462a2294224e296 (patch) | |
| tree | 732b5cc14ef561ef762c98752179fcaef74ea92e | |
| parent | 783888131efb8f9eef4516a33b15dc3d63d6e401 (diff) | |
| download | opencode-7e1247c4208002eda989b52c9462a2294224e296.tar.gz opencode-7e1247c4208002eda989b52c9462a2294224e296.zip | |
fix(desktop): server spawn resilience (#13028)
Co-authored-by: Brendan Allan <[email protected]>
| -rw-r--r-- | packages/desktop/src-tauri/src/cli.rs | 68 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/lib.rs | 21 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/server.rs | 33 |
3 files changed, 105 insertions, 17 deletions
diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index 6882d369e..70ac973f0 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -1,9 +1,10 @@ use tauri::{AppHandle, Manager, path::BaseDirectory}; use tauri_plugin_shell::{ ShellExt, - process::{Command, CommandChild, CommandEvent}, + process::{Command, CommandChild, CommandEvent, TerminatedPayload}, }; use tauri_plugin_store::StoreExt; +use tokio::sync::oneshot; use crate::{ LogState, @@ -273,12 +274,45 @@ pub fn create_command(app: &tauri::AppHandle, args: &str, extra_env: &[(&str, St } } -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}"); + 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 envs = [ ("OPENCODE_SERVER_USERNAME", "opencode".to_string()), ("OPENCODE_SERVER_PASSWORD", password.to_string()), @@ -286,13 +320,14 @@ pub fn serve(app: &AppHandle, hostname: &str, port: u32, password: &str) -> Comm let (mut rx, child) = create_command( app, - format!("serve --hostname {hostname} --port {port}").as_str(), + format!("--print-logs --log-level WARN serve --hostname {hostname} --port {port}").as_str(), &envs, ) .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) => { @@ -321,10 +356,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) } diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index f50464a7b..0d8090f3d 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -582,14 +582,25 @@ 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 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. Logs:\n{}", - get_logs(app.clone()).await.unwrap() + "Failed to spawn OpenCode Server ({err}). Logs:\n{logs}" )); - }; + } println!("CLI health check OK"); diff --git a/packages/desktop/src-tauri/src/server.rs b/packages/desktop/src-tauri/src/server.rs index 8113fc7e3..9d09512ce 100644 --- a/packages/desktop/src-tauri/src/server.rs +++ b/packages/desktop/src-tauri/src/server.rs @@ -113,26 +113,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 { |
