refactor: remote cleanup

This commit is contained in:
zhom
2026-08-01 14:51:23 +04:00
parent bb914a8458
commit 9c84793e28
2 changed files with 122 additions and 0 deletions
+46
View File
@@ -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<String>,
@@ -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<String>,
) -> Result<Json<StopRemoteResponse>, (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) {
+76
View File
@@ -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<EndRemoteSessionOutcome, RemoteSessionError> {
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::<EndRemoteSessionOutcome>()
.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");