diff --git a/crates/libs/api/src/routes/e2b/lifecycle.rs b/crates/libs/api/src/routes/e2b/lifecycle.rs index f76c50971..22b7b26ec 100644 --- a/crates/libs/api/src/routes/e2b/lifecycle.rs +++ b/crates/libs/api/src/routes/e2b/lifecycle.rs @@ -14,6 +14,7 @@ use crate::public_sandbox_operations::SandboxLifecycleOperationPayload; const CONNECT_UNREACHABLE_TOUCH_RETRY_ATTEMPTS: usize = 2; const CONNECT_UNREACHABLE_TOUCH_RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(50); +const CONNECT_V2_DEFAULT_TIMEOUT_SECS: u32 = 300; const DELETE_OPERATION_WAIT: std::time::Duration = std::time::Duration::from_secs(10); const DELETE_OPERATION_POLL: std::time::Duration = std::time::Duration::from_millis(100); fn connect_activation_request() -> Option { @@ -652,6 +653,36 @@ fn e2b_operation_failure( E2bApiError::new(status, message) } +pub(super) async fn connect_sandbox_v2( + state: State, + host: MatchedEcwHost, + org: Extension, + federated: Option>, + sandbox_id: Path, + headers: HeaderMap, + body: Option>, +) -> Result, E2bApiError> { + // V2 supplies a five-minute minimum; legacy omission remains an activity-only touch. + let mut request = body + .map(|crate::e2b::E2bJson(request)| request) + .unwrap_or_default(); + let timeout = request.timeout.unwrap_or(CONNECT_V2_DEFAULT_TIMEOUT_SECS); + if timeout == 0 { + return Err(E2bApiError::bad_request("Timeout must be greater than 0")); + } + request.timeout = Some(timeout); + connect_sandbox( + state, + host, + org, + federated, + sandbox_id, + headers, + Some(crate::e2b::E2bJson(request)), + ) + .await +} + pub(super) async fn connect_sandbox( state: State, host: MatchedEcwHost, diff --git a/crates/libs/api/src/routes/e2b/router.rs b/crates/libs/api/src/routes/e2b/router.rs index 25f68e145..d30893439 100644 --- a/crates/libs/api/src/routes/e2b/router.rs +++ b/crates/libs/api/src/routes/e2b/router.rs @@ -20,9 +20,9 @@ use super::template_build::{ }; use super::{ AppState, ECW_SANDBOX_API_LOG_EVENT, EcwHostConfig, EcwRequestLogContext, connect_sandbox, - create_sandbox, delete_sandbox, fork_sandbox, get_sandbox, list_sandboxes, pause_sandbox_e2b, - record_ecw_sandbox_span_tag, refresh_sandbox, request_client_id, require_ecw_api_host, - resume_sandbox_e2b, timeout_sandbox, + connect_sandbox_v2, create_sandbox, delete_sandbox, fork_sandbox, get_sandbox, list_sandboxes, + pause_sandbox_e2b, record_ecw_sandbox_span_tag, refresh_sandbox, request_client_id, + require_ecw_api_host, resume_sandbox_e2b, timeout_sandbox, }; pub(crate) fn dedicated_host_router(state: AppState) -> Router { @@ -280,7 +280,7 @@ fn authenticated_e2b_api_routes() -> Router { ) .route( "/v2/sandboxes/{sandboxID}/connect", - axum::routing::post(connect_sandbox), + axum::routing::post(connect_sandbox_v2), ) .route( "/sandboxes/{sandboxID}/timeout", diff --git a/crates/libs/api/src/routes/e2b/tests.rs b/crates/libs/api/src/routes/e2b/tests.rs index 8776c4aa8..f9ea5873a 100644 --- a/crates/libs/api/src/routes/e2b/tests.rs +++ b/crates/libs/api/src/routes/e2b/tests.rs @@ -1543,6 +1543,12 @@ async fn sdk_v2_create_and_connect_routes_are_mounted() { StatusCode::BAD_REQUEST, "unsupported sandbox connect parameter: memory=false", ), + ( + "/v2/sandboxes/example/connect", + r#"{"timeout":0}"#, + StatusCode::BAD_REQUEST, + "Timeout must be greater than 0", + ), ] { let response = app .clone() @@ -3238,6 +3244,7 @@ async fn connect_route_extends_deadline_without_replacing_policy() -> TestResult struct Case { label: &'static str, + path_prefix: &'static str, body: Option<&'static str>, requested_timeout_secs: Option, } @@ -3256,28 +3263,69 @@ async fn connect_route_extends_deadline_without_replacing_policy() -> TestResult let cases = [ Case { - label: "omitted timeout", + label: "legacy empty body", + path_prefix: "", body: None, requested_timeout_secs: None, }, Case { - label: "shorter timeout", + label: "legacy empty object", + path_prefix: "", + body: Some("{}"), + requested_timeout_secs: None, + }, + Case { + label: "v2 empty body", + path_prefix: "/v2", + body: None, + requested_timeout_secs: Some(300), + }, + Case { + label: "v2 empty object", + path_prefix: "/v2", + body: Some("{}"), + requested_timeout_secs: Some(300), + }, + Case { + label: "v2 explicit default", + path_prefix: "/v2", + body: Some(r#"{"timeout":300}"#), + requested_timeout_secs: Some(300), + }, + Case { + label: "v2 explicit shorter timeout", + path_prefix: "/v2", + body: Some(r#"{"timeout":60}"#), + requested_timeout_secs: Some(60), + }, + Case { + label: "legacy explicit default", + path_prefix: "", body: Some(r#"{"timeout":300}"#), requested_timeout_secs: Some(300), }, Case { - label: "same timeout", + label: "legacy explicit longer timeout", + path_prefix: "", body: Some(r#"{"timeout":900}"#), requested_timeout_secs: Some(900), }, Case { - label: "longer timeout", + label: "v2 explicit longer timeout", + path_prefix: "/v2", body: Some(r#"{"timeout":1200}"#), requested_timeout_secs: Some(1_200), }, ]; - for lifecycle in ["started", "suspended"] { + for (lifecycle, policy_timeout) in [ + ("started", 120), + ("suspended", 120), + ("started", 300), + ("suspended", 300), + ("started", 900), + ("suspended", 900), + ] { for case in &cases { let sandbox_id = SandboxId::new(); insert_connect_route_sandbox( @@ -3288,26 +3336,26 @@ async fn connect_route_extends_deadline_without_replacing_policy() -> TestResult Some(worker_node_id), ) .await?; - let policy = e2b_auto_lifecycle_policy(900, true, false); - sqlx::query( + let policy = e2b_auto_lifecycle_policy(policy_timeout, true, false); + sqlx::query(sql!( "UPDATE sandboxes SET auto_lifecycle_json = $1 - WHERE id = $2", - ) + WHERE id = $2" + )) .bind(sqlx::types::Json(&policy)) .bind(sandbox_id) .execute(&db.pool) .await?; if lifecycle == "suspended" { - sqlx::query( + sqlx::query(sql!( "INSERT INTO sandbox_direct_route_provenance ( sandbox_id, last_route_generation, last_execution_generation, updated_at ) - VALUES ($1, 1, 1, NOW())", - ) + VALUES ($1, 1, 1, NOW())" + )) .bind(sandbox_id) .execute(&db.pool) .await?; @@ -3331,45 +3379,69 @@ async fn connect_route_extends_deadline_without_replacing_policy() -> TestResult } let accepted_at = jiff::Timestamp::now().as_second(); - let response = app - .clone() - .oneshot(connect_route_request( - sandbox_id, - ConnectRouteRequestShape { - label: case.label, - body: case.body, - }, - )?) - .await?; + // A pre-existing extension must also survive a shorter reconnect request. + let previous_floor = + (lifecycle == "started" && policy_timeout == 900).then_some(accepted_at + 1_800); + if previous_floor.is_some() { + worker + .state() + .apply_access_deadline(&sandbox_id.to_string(), previous_floor, None) + .await?; + } + let mut request = connect_route_request( + sandbox_id, + ConnectRouteRequestShape { + label: case.label, + body: case.body, + }, + )?; + *request.uri_mut() = + format!("{}/sandboxes/{sandbox_id}/connect", case.path_prefix).parse()?; + let response = app.clone().oneshot(request).await?; + let completed_at = jiff::Timestamp::now().as_second(); let status = response.status(); let body = to_bytes(response.into_body(), usize::MAX).await?; assert_eq!( status, StatusCode::OK, - "{lifecycle} / {}: {}", + "{lifecycle} / {policy_timeout}s / {}: {}", case.label, String::from_utf8_lossy(&body) ); let sqlx::types::Json(stored_policy): sqlx::types::Json = - sqlx::query_scalar( + sqlx::query_scalar(sql!( "SELECT auto_lifecycle_json FROM sandboxes - WHERE id = $1", - ) + WHERE id = $1" + )) .bind(sandbox_id) .fetch_one(&db.pool) .await?; let config = e2b_lifecycle_config_from_policy(&stored_policy) .expect("connect must leave an E2B-compatible policy"); - assert_eq!(config.timeout_secs, 900, "{lifecycle} / {}", case.label); + assert_eq!( + config.timeout_secs, policy_timeout, + "{lifecycle} / {policy_timeout}s / {}", + case.label + ); + let record = worker.state().get(&sandbox_id.to_string()).await?; if let Some(timeout) = case.requested_timeout_secs { - let record = worker.state().get(&sandbox_id.to_string()).await?; assert!( - record - .access_extended_until - .is_some_and(|deadline| deadline >= accepted_at + i64::from(timeout)), - "{lifecycle} / {}", + record.access_extended_until.is_some_and(|deadline| { + let earliest = + (accepted_at + i64::from(timeout)).max(previous_floor.unwrap_or(0)); + let latest = + (completed_at + i64::from(timeout)).max(previous_floor.unwrap_or(0)); + (earliest..=latest).contains(&deadline) + }), + "{lifecycle} / {policy_timeout}s / {}", + case.label + ); + } else { + assert_eq!( + record.access_extended_until, previous_floor, + "{lifecycle} / {policy_timeout}s / {} must retain legacy omission semantics", case.label ); }