From 9c84793e281895d4240eb1a46d1106775fd23c6d Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sat, 1 Aug 2026 14:51:23 +0400 Subject: [PATCH] refactor: remote cleanup --- src-tauri/src/api_server.rs | 46 ++++++++++++++++++++ src-tauri/src/remote_session.rs | 76 +++++++++++++++++++++++++++++++++ 2 files changed, 122 insertions(+) diff --git a/src-tauri/src/api_server.rs b/src-tauri/src/api_server.rs index 4a7ef7f..31017be 100644 --- a/src-tauri/src/api_server.rs +++ b/src-tauri/src/api_server.rs @@ -294,6 +294,14 @@ pub struct RunRemoteResponse { pub status: String, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct StopRemoteResponse { + pub session_id: String, + pub status: String, + /// What the session actually cost, in seconds. + pub billed_seconds: u64, +} + #[derive(Debug, Deserialize, ToSchema)] struct RunProfileRequest { url: Option, @@ -593,6 +601,7 @@ impl ApiServer { .routes(routes!(get_profile, update_profile, delete_profile)) .routes(routes!(run_profile)) .routes(routes!(run_profile_remote)) + .routes(routes!(stop_remote_session)) .routes(routes!(set_profile_cloud_sync)) .routes(routes!(open_url_in_profile)) .routes(routes!(kill_profile)) @@ -2358,6 +2367,43 @@ pub fn remote_launch_precondition( Ok(()) } +// API Handler - Stop a REMOTE session started by run-remote +#[utoipa::path( + delete, + path = "/v1/remote-sessions/{id}", + params( + ("id" = String, Path, description = "Remote session ID from run-remote") + ), + responses( + (status = 200, description = "Remote session stopped", body = StopRemoteResponse), + (status = 401, description = "Unauthorized"), + (status = 404, description = "No such remote session"), + (status = 429, description = "Automation request rate limit exceeded"), + (status = 503, description = "The fleet could not be reached; the session is still running"), + (status = 500, description = "Internal server error") + ), + security( + ("bearer_auth" = []) + ), + tag = "profiles" +)] +async fn stop_remote_session( + Path(id): Path, +) -> Result, (StatusCode, String)> { + // Without this route, `run-remote` hands back a session id nothing can act + // on: the only thing that ends a session is the fleet's own two-hour cap, so + // every launch bills 7200s no matter how briefly it ran. + let outcome = crate::remote_session::end_remote_session(&id) + .await + .map_err(remote_session_error_response)?; + + Ok(Json(StopRemoteResponse { + session_id: outcome.session_id, + status: outcome.status, + billed_seconds: outcome.billed_seconds, + })) +} + fn remote_session_error_response( err: crate::remote_session::RemoteSessionError, ) -> (StatusCode, String) { diff --git a/src-tauri/src/remote_session.rs b/src-tauri/src/remote_session.rs index e0b661b..4d9ea91 100644 --- a/src-tauri/src/remote_session.rs +++ b/src-tauri/src/remote_session.rs @@ -140,6 +140,57 @@ pub async fn start_remote_session( .map_err(|e| classify_error_string(&e)) } +/// What the backend returns when a session is stopped. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct EndRemoteSessionOutcome { + pub session_id: String, + pub status: String, + /// What the session actually cost, in seconds. + pub billed_seconds: u64, +} + +/// Ask donutbrowser-infra to stop a remote session. +/// +/// Without this the only thing that ends a session is the fleet's own two-hour +/// cap, so every launch bills the full 7200s however briefly it was used — a +/// handful of runs exhausts an allowance meant for a hundred. The backend +/// refuses to retire a row it could not stop on the fleet, so a successful +/// return here means the browser is really down and the profile lock released. +pub async fn end_remote_session( + session_id: &str, +) -> Result { + let endpoint = format!( + "{}/api/remote-sessions/{}", + crate::cloud_auth::CLOUD_API_URL, + urlencoding::encode(session_id) + ); + + crate::cloud_auth::CLOUD_AUTH + .api_call_with_retry(|token| { + let endpoint = endpoint.clone(); + async move { + let response = reqwest::Client::new() + .delete(&endpoint) + .bearer_auth(token) + .send() + .await + .map_err(|e| format!("reach backend: {e}"))?; + + let status = response.status().as_u16(); + if !(200..300).contains(&status) { + let text = response.text().await.unwrap_or_default(); + return Err(format!("({status}) {text}")); + } + response + .json::() + .await + .map_err(|e| format!("decode response: {e}")) + } + }) + .await + .map_err(|e| classify_error_string(&e)) +} + /// Recover a typed error from `api_call_with_retry`'s string. /// /// That helper flattens everything to `String` to do its 401 sniffing, so the @@ -223,6 +274,31 @@ mod tests { )); } + #[test] + fn the_stop_response_parses_what_the_backend_actually_sends() { + // Pinned against EndRemoteSessionOutcome in donutbrowser-infra + // (apps/backend/src/remote-sessions/remote-sessions.service.ts). A field + // name that does not match makes every stop fail at the decode step, and + // the session then runs to the 2h cap and bills 7200s — the exact defect + // this endpoint exists to fix, reintroduced silently. + let outcome: EndRemoteSessionOutcome = + serde_json::from_str(r#"{"session_id":"sess-1","status":"closed","billed_seconds":42}"#) + .expect("the backend's stop payload must deserialize"); + assert_eq!(outcome.session_id, "sess-1"); + assert_eq!(outcome.status, "closed"); + assert_eq!(outcome.billed_seconds, 42); + } + + #[test] + fn a_stop_of_an_unknown_session_is_not_reported_as_capacity() { + // The backend 404s a session that is not the caller's. Mapping that to + // NoCapacity would tell the user the fleet is busy and invite a retry. + assert!(matches!( + classify_error_string("(404) No such remote session"), + RemoteSessionError::Other(_) + )); + } + #[test] fn idempotency_key_is_stable_for_one_attempt_and_distinct_across_attempts() { let a = idempotency_key("p1", "attempt-1");