Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions crates/libs/api/src/routes/e2b/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<crate::models::SandboxActivationRequest> {
Expand Down Expand Up @@ -652,6 +653,36 @@ fn e2b_operation_failure(
E2bApiError::new(status, message)
}

pub(super) async fn connect_sandbox_v2(
state: State<AppState>,
host: MatchedEcwHost,
org: Extension<AuthOrg>,
federated: Option<Extension<crate::middleware::auth::AuthFederatedOwner>>,
sandbox_id: Path<String>,
headers: HeaderMap,
body: Option<crate::e2b::E2bJson<ConnectSandboxRequestE2b>>,
) -> Result<Json<SandboxResponseE2b>, 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<AppState>,
host: MatchedEcwHost,
Expand Down
8 changes: 4 additions & 4 deletions crates/libs/api/src/routes/e2b/router.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppState> {
Expand Down Expand Up @@ -280,7 +280,7 @@ fn authenticated_e2b_api_routes() -> Router<AppState> {
)
.route(
"/v2/sandboxes/{sandboxID}/connect",
axum::routing::post(connect_sandbox),
axum::routing::post(connect_sandbox_v2),
)
.route(
"/sandboxes/{sandboxID}/timeout",
Expand Down
136 changes: 104 additions & 32 deletions crates/libs/api/src/routes/e2b/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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<u32>,
}
Expand All @@ -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(
Expand All @@ -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?;
Expand All @@ -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<AutoLifecyclePolicy> =
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
);
}
Expand Down
Loading