mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-01 16:58:43 +02:00
refactor: remote cleanup
This commit is contained in:
@@ -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) {
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user