fix(desktop): report a post-startup sidecar crash as recovery

When a sidecar that had already published its endpoint crashed, the
loader's status poll reaped it before the supervisor respawned it and
reported "failed". Its 30 second startup timer also still ran from the
first launch, so users saw "Cline could not finish starting - Desktop
backend did not publish its endpoint within 30 seconds" and a Retry button
for about a second during an automatic recovery.

Track whether the current child published. When it exits after publishing,
record the exit as a diagnostic, restart the startup timer, and stop
tracking the dead child so the endpoint request or the periodic supervisor
respawns it while the status reports "starting". Exits before publishing
still report a startup failure.
This commit is contained in:
Cursor Agent
2026-10-15 23:26:44 +00:00
parent 0bb3df774a
commit 660bbeaf25
@@ -29,6 +29,9 @@ const MAX_DIAGNOSTIC_LINE: usize = 1024;
#[derive(Default)]
struct BackendDiagnostics {
started_at: Option<Instant>,
/// The tracked child published its endpoint, so its exit is a crash that
/// the supervisor recovers from rather than a startup failure.
published: bool,
lines: VecDeque<String>,
exit_status: Option<String>,
error: Option<String>,
@@ -430,6 +433,7 @@ fn ensure_desktop_backend_started_locked(
}
diagnostics.error = None;
diagnostics.started_at = Some(Instant::now());
diagnostics.published = false;
}
let mut child = spawn_backend().map_err(|error| {
let error = sanitize_startup_diagnostic(&error);
@@ -472,6 +476,7 @@ fn ensure_desktop_backend_started_locked(
}
if let Ok(mut diagnostics) = state_for_stdout.diagnostics.lock() {
diagnostics.error = None;
diagnostics.published = true;
}
}
}
@@ -598,12 +603,32 @@ fn desktop_backend_status(backend_state: &DesktopBackendState) -> DesktopBackend
// Reap an exited child before inspecting endpoint readiness. The stdout
// reader and periodic supervisor can otherwise lag behind the process.
if let Ok(mut process) = backend_state.process.lock() {
if let Some(child) = process.as_mut() {
if let Ok(Some(status)) = child.try_wait() {
if let Ok(mut endpoint) = backend_state.ws_endpoint.lock() {
*endpoint = None;
}
if let Ok(mut diagnostics) = backend_state.diagnostics.lock() {
let exited = match process.as_mut().map(Child::try_wait) {
Some(Ok(Some(status))) => Some(status),
_ => None,
};
if let Some(status) = exited {
if let Ok(mut endpoint) = backend_state.ws_endpoint.lock() {
*endpoint = None;
}
if let Ok(mut diagnostics) = backend_state.diagnostics.lock() {
if diagnostics.published {
// The endpoint request or the periodic supervisor respawns a
// sidecar that crashed after startup. Report recovery, time the
// replacement from now rather than the first launch, and stop
// tracking the dead child so later polls cannot re-reap it as
// a startup failure.
diagnostics.published = false;
diagnostics.error = None;
diagnostics.started_at = Some(Instant::now());
if diagnostics.lines.len() == MAX_STARTUP_DIAGNOSTICS {
diagnostics.lines.pop_front();
}
diagnostics
.lines
.push_back(format!("Sidecar exited after startup: {status}"));
*process = None;
} else {
diagnostics.exit_status = Some(status.to_string());
}
}
@@ -899,6 +924,61 @@ mod tests {
state.stop();
}
#[cfg(unix)]
#[test]
fn crash_after_publishing_reports_recovery_instead_of_startup_failure() {
let state = Arc::new(DesktopBackendState::default());
ensure_desktop_backend_started_with(&state, || {
spawn_ready_sidecar("ws://127.0.0.1:9/transport")
})
.unwrap();
let deadline = Instant::now() + Duration::from_secs(2);
while desktop_backend_status(&state).state != "ready" {
assert!(Instant::now() < deadline, "endpoint should be published");
thread::sleep(Duration::from_millis(5));
}
// A long-running app: the first launch is older than the startup timeout.
state.diagnostics.lock().unwrap().started_at = Some(Instant::now() - ENDPOINT_WAIT_TIMEOUT);
state
.process
.lock()
.unwrap()
.as_mut()
.unwrap()
.kill()
.unwrap();
wait_until_tracked_child_exits(&state);
for _ in 0..3 {
let status = desktop_backend_status(&state);
assert_eq!(status.state, "starting");
assert!(status.error.is_none());
assert!(status.exit_status.is_none());
}
assert!(desktop_backend_status(&state)
.diagnostics
.iter()
.any(|line| line.starts_with("Sidecar exited after startup")));
let respawned = AtomicUsize::new(0);
ensure_desktop_backend_started_with(&state, || {
respawned.fetch_add(1, Ordering::SeqCst);
spawn_pending_sidecar()
})
.unwrap();
assert_eq!(respawned.load(Ordering::SeqCst), 1);
assert_eq!(desktop_backend_status(&state).state, "starting");
state
.process
.lock()
.unwrap()
.as_mut()
.unwrap()
.kill()
.unwrap();
state.stop();
}
#[cfg(unix)]
#[test]
fn repeated_retry_replaces_failed_child_once_and_respects_shutdown() {