diff --git a/CubeAPI/src/cubemaster/mod.rs b/CubeAPI/src/cubemaster/mod.rs index 37a0e505f..5627c1f34 100644 --- a/CubeAPI/src/cubemaster/mod.rs +++ b/CubeAPI/src/cubemaster/mod.rs @@ -1397,7 +1397,8 @@ pub struct SandboxUpdateRequest { /// "pause" | "resume" #[serde(rename = "action")] pub action: String, - /// TTL in seconds (for resume; 0 = keep original). Optional for pause. + /// Idle timeout in seconds for resume (-1 = never, 0 = immediate). + /// Omitted for pause or when the caller keeps the current timeout. #[serde(skip_serializing_if = "Option::is_none")] pub timeout: Option, } diff --git a/CubeAPI/src/handlers/sandboxes.rs b/CubeAPI/src/handlers/sandboxes.rs index 8e1828ec7..2b327d8b6 100644 --- a/CubeAPI/src/handlers/sandboxes.rs +++ b/CubeAPI/src/handlers/sandboxes.rs @@ -300,6 +300,7 @@ pub async fn pause_sandbox( request_body = ResumedSandbox, responses( (status = 201, description = "Sandbox resumed", body = Sandbox), + (status = 400, description = "Invalid timeout value", body = ApiError), (status = 404, description = "Sandbox not found", body = ApiError), (status = 409, description = "Sandbox is already running", body = ApiError), (status = 500, description = "Unexpected backend error", body = ApiError) @@ -310,6 +311,9 @@ pub async fn resume_sandbox( Path(sandbox_id): Path, Json(body): Json, ) -> AppResult { + body.validate() + .map_err(|e| AppError::BadRequest(e.to_string()))?; + state .logger .log( @@ -346,6 +350,7 @@ pub async fn resume_sandbox( request_body = ConnectSandbox, responses( (status = 200, description = "Sandbox connection info", body = Sandbox), + (status = 400, description = "Invalid timeout value", body = ApiError), (status = 404, description = "Sandbox not found", body = ApiError), (status = 500, description = "Unexpected backend error", body = ApiError) ) @@ -355,6 +360,9 @@ pub async fn connect_sandbox( Path(sandbox_id): Path, Json(body): Json, ) -> AppResult { + body.validate() + .map_err(|e| AppError::BadRequest(e.to_string()))?; + state .logger .log( diff --git a/CubeAPI/src/models/mod.rs b/CubeAPI/src/models/mod.rs index 1e9051e7c..4bbb60961 100644 --- a/CubeAPI/src/models/mod.rs +++ b/CubeAPI/src/models/mod.rs @@ -359,11 +359,12 @@ pub struct SandboxDetail { // ─── Sandbox — pause/resume/connect/snapshot ────────────────────────────── /// Request body for POST /sandboxes/{id}/resume (deprecated). -#[derive(Debug, Deserialize, ToSchema)] +#[derive(Debug, Deserialize, Validate, ToSchema)] #[allow(dead_code)] pub struct ResumedSandbox { /// Idle timeout in seconds; None when the client did not send one. #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(custom(function = "validate_timeout_value"))] pub timeout: Option, #[serde(rename = "autoPause", default)] pub auto_pause: bool, @@ -374,6 +375,7 @@ pub struct ResumedSandbox { pub struct ConnectSandbox { /// Idle timeout in seconds; None when the client did not send one. #[serde(default, skip_serializing_if = "Option::is_none")] + #[validate(custom(function = "validate_timeout_value"))] pub timeout: Option, } @@ -576,8 +578,8 @@ fn default_page_limit() -> i32 { #[cfg(test)] mod tests { use super::{ - CreateTemplateRequest, NewSandbox, SandboxNetworkConfig, SetTimeoutRequest, - TemplateAliasLookupResponse, + ConnectSandbox, CreateTemplateRequest, NewSandbox, ResumedSandbox, SandboxNetworkConfig, + SetTimeoutRequest, TemplateAliasLookupResponse, }; use validator::Validate; @@ -609,6 +611,29 @@ mod tests { } } + #[test] + fn resume_and_connect_use_timeout_value_semantics() { + for timeout in [None, Some(-1), Some(0), Some(60)] { + ConnectSandbox { timeout } + .validate() + .unwrap_or_else(|e| panic!("connect timeout={timeout:?} should be valid: {e}")); + ResumedSandbox { + timeout, + auto_pause: false, + } + .validate() + .unwrap_or_else(|e| panic!("resume timeout={timeout:?} should be valid: {e}")); + } + + assert!(ConnectSandbox { timeout: Some(-2) }.validate().is_err()); + assert!(ResumedSandbox { + timeout: Some(-2), + auto_pause: false, + } + .validate() + .is_err()); + } + #[test] fn sandbox_network_config_accepts_snake_case_policy_fields() { let cfg: SandboxNetworkConfig = serde_json::from_value(serde_json::json!({ diff --git a/CubeAPI/src/routes.rs b/CubeAPI/src/routes.rs index 4774bf632..dc481ebfa 100644 --- a/CubeAPI/src/routes.rs +++ b/CubeAPI/src/routes.rs @@ -350,6 +350,23 @@ mod tests { ); } + #[tokio::test] + async fn resume_and_connect_reject_invalid_timeout_before_cubemaster() { + let server = test_server().await; + + for path in ["/sandboxes/sb-1/resume", "/sandboxes/sb-1/connect"] { + let response = server + .post(path) + .json(&serde_json::json!({ "timeout": -2 })) + .await; + assert_eq!( + response.status_code(), + StatusCode::BAD_REQUEST, + "path={path}" + ); + } + } + #[tokio::test] async fn template_alias_route_is_mounted_before_template_id_route() { let server = test_server().await; diff --git a/CubeAPI/src/services/sandboxes.rs b/CubeAPI/src/services/sandboxes.rs index b0560f435..b4ad21475 100644 --- a/CubeAPI/src/services/sandboxes.rs +++ b/CubeAPI/src/services/sandboxes.rs @@ -381,6 +381,13 @@ impl SandboxService { )?; d = self.fetch_sandbox_detail(sandbox_id).await?; + } else if d.status == SandboxStatus::Running { + // E2B connect semantics reset an already-running sandbox's + // timeout too. Paused sandboxes apply the same value atomically + // with the successful resume in CubeMaster. + if let Some(timeout) = timeout { + self.set_timeout(sandbox_id, timeout).await?; + } } let envd_version = envd_version_from_annotations(&d.annotations); @@ -1131,12 +1138,152 @@ mod tests { extract::State, http::{header::RETRY_AFTER, StatusCode}, response::IntoResponse, - routing::{delete, post}, + routing::{delete, get, post}, Json, Router, }; use serde_json::Value; use tokio::sync::Mutex; + #[tokio::test] + async fn connect_running_sandbox_applies_explicit_timeout() { + #[derive(Clone, Default)] + struct Capture { + timeout_body: Arc>>, + } + + async fn info_handler() -> Json { + Json(serde_json::json!({ + "requestID": "req-info", + "ret": { "ret_code": 0, "ret_msg": "ok" }, + "data": [{ + "sandbox_id": "sb-running", + "host_id": "host-1", + "template_id": "tpl-1", + "status": 1, + "annotations": {} + }] + })) + } + + async fn timeout_handler( + State(capture): State, + Json(body): Json, + ) -> Json { + *capture.timeout_body.lock().await = Some(body); + Json(serde_json::json!({ + "requestID": "req-timeout", + "sandboxID": "sb-running", + "ret": { "ret_code": 0, "ret_msg": "ok" } + })) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let capture = Capture::default(); + let app = Router::new() + .route("/cube/sandbox/info", get(info_handler)) + .route("/cube/sandbox/timeout", post(timeout_handler)) + .with_state(capture.clone()); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("server should run"); + }); + + let service = SandboxService::new( + CubeMasterClient::new(format!("http://{address}"), reqwest::Client::new()), + "cubebox".to_string(), + "cube.app".to_string(), + ); + + service + .connect_sandbox("sb-running", Some(120)) + .await + .expect("connect should succeed"); + + let body = capture + .timeout_body + .lock() + .await + .clone() + .expect("running connect should reset the explicit timeout"); + assert_eq!(body["sandboxID"], "sb-running"); + assert_eq!(body["timeout"], 120); + } + + #[tokio::test] + async fn connect_paused_sandbox_applies_timeout_with_resume_only() { + #[derive(Clone, Default)] + struct Capture { + update_bodies: Arc>>, + timeout_calls: Arc>, + } + + async fn info_handler() -> Json { + Json(serde_json::json!({ + "requestID": "req-info", + "ret": { "ret_code": 0, "ret_msg": "ok" }, + "data": [{ + "sandbox_id": "sb-paused", + "host_id": "host-1", + "template_id": "tpl-1", + "status": 5, + "annotations": {} + }] + })) + } + + async fn update_handler( + State(capture): State, + Json(body): Json, + ) -> Json { + capture.update_bodies.lock().await.push(body); + Json(serde_json::json!({ + "ret": { "ret_code": 0, "ret_msg": "ok" } + })) + } + + async fn timeout_handler(State(capture): State) -> Json { + *capture.timeout_calls.lock().await += 1; + Json(serde_json::json!({ + "requestID": "req-timeout", + "sandboxID": "sb-paused", + "ret": { "ret_code": 0, "ret_msg": "ok" } + })) + } + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("listener should bind"); + let address = listener.local_addr().expect("listener address"); + let capture = Capture::default(); + let app = Router::new() + .route("/cube/sandbox/info", get(info_handler)) + .route("/cube/sandbox/update", post(update_handler)) + .route("/cube/sandbox/timeout", post(timeout_handler)) + .with_state(capture.clone()); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("server should run"); + }); + + let service = SandboxService::new( + CubeMasterClient::new(format!("http://{address}"), reqwest::Client::new()), + "cubebox".to_string(), + "cube.app".to_string(), + ); + + service + .connect_sandbox("sb-paused", Some(120)) + .await + .expect("connect should succeed"); + + let update_bodies = capture.update_bodies.lock().await; + assert_eq!(update_bodies.len(), 1); + assert_eq!(update_bodies[0]["action"], "resume"); + assert_eq!(update_bodies[0]["timeout"], 120); + assert_eq!(*capture.timeout_calls.lock().await, 0); + } + #[test] fn map_volume_mounts_returns_none_for_empty_input() { assert!(map_volume_mounts(&[]).is_none()); diff --git a/CubeMaster/pkg/lifecycle/store_test.go b/CubeMaster/pkg/lifecycle/store_test.go index 3e628f538..c3fb69851 100644 --- a/CubeMaster/pkg/lifecycle/store_test.go +++ b/CubeMaster/pkg/lifecycle/store_test.go @@ -11,6 +11,7 @@ import ( "reflect" "sync" "testing" + "time" ) // recordedCall captures one Do invocation for later assertion. @@ -22,6 +23,7 @@ type recordedCall struct { type fakeRedis struct { mu sync.Mutex calls []recordedCall + hget interface{} // errOn maps command name -> error to return on the Nth call (counter-based). failHSET bool failHDEL bool @@ -33,6 +35,11 @@ func (f *fakeRedis) Do(cmd string, args ...interface{}) (interface{}, error) { defer f.mu.Unlock() f.calls = append(f.calls, recordedCall{cmd: cmd, args: args}) switch cmd { + case "HGET": + if f.hget != nil { + return f.hget, nil + } + return nil, nil case "HSET": if f.failHSET { return nil, errors.New("HSET boom") @@ -49,6 +56,52 @@ func (f *fakeRedis) Do(cmd string, args ...interface{}) (interface{}, error) { return "OK", nil } +func TestStoreTimeoutProviderRefreshesOnlyMutableTimeoutFields(t *testing.T) { + timeout := 60 + createdAt := int64(1700000000000) + meta := SandboxLifecycleMeta{ + SandboxID: "sbx-refresh", + TemplateID: "tpl-1", + HostID: "host-1", + HostIP: "10.0.0.1", + InstanceType: "cubebox", + TimeoutSeconds: &timeout, + AutoPause: true, + AutoResume: true, + CreatedAt: createdAt, + } + raw, err := json.Marshal(meta) + if err != nil { + t.Fatalf("marshal seed meta: %v", err) + } + r := &fakeRedis{hget: raw} + provider := &storeTimeoutProvider{store: NewStore(r)} + + endAt, err := provider.RefreshTimeout(context.Background(), meta.SandboxID, 120) + if err != nil { + t.Fatalf("refresh timeout: %v", err) + } + if endAt <= time.Now().UnixMilli() || endAt > time.Now().Add(121*time.Second).UnixMilli() { + t.Fatalf("endAt=%d is not a fresh 120-second deadline", endAt) + } + + calls := r.snapshot() + if len(calls) != 3 || calls[0].cmd != "HGET" || calls[1].cmd != "HSET" || calls[2].cmd != "XADD" { + t.Fatalf("unexpected refresh calls: %+v", calls) + } + updated, ok := calls[1].args[2].([]byte) + if !ok { + t.Fatalf("updated meta payload type=%T", calls[1].args[2]) + } + var got SandboxLifecycleMeta + if err := json.Unmarshal(updated, &got); err != nil { + t.Fatalf("unmarshal updated meta: %v", err) + } + if got.TimeoutSeconds == nil || *got.TimeoutSeconds != 120 || !got.AutoPause || !got.AutoResume || got.TemplateID != meta.TemplateID { + t.Fatalf("refresh did not preserve policy/identity fields: %+v", got) + } +} + func (f *fakeRedis) snapshot() []recordedCall { f.mu.Lock() defer f.mu.Unlock() diff --git a/CubeMaster/pkg/service/sandbox/sandbox_update.go b/CubeMaster/pkg/service/sandbox/sandbox_update.go index f1c9498ce..310803b67 100644 --- a/CubeMaster/pkg/service/sandbox/sandbox_update.go +++ b/CubeMaster/pkg/service/sandbox/sandbox_update.go @@ -45,6 +45,11 @@ func Update(ctx context.Context, req *types.UpdateRequest) (rsp *types.Res) { rsp.Ret.RetMsg = "action should be pause or resume" return } + if req.Timeout != nil && *req.Timeout < types.NeverTimeout { + rsp.Ret.RetCode = int(errorcode.ErrorCode_MasterParamsError) + rsp.Ret.RetMsg = "timeout must be >= -1 (use -1 for never timeout)" + return + } if ret := normalizeSandboxIDInReq(ctx, &req.SandboxID); ret != nil { rsp.Ret = ret return @@ -79,6 +84,9 @@ func Update(ctx context.Context, req *types.UpdateRequest) (rsp *types.Res) { if config.GetConfig().Common.MockUpdateAction { rsp.Ret.RetCode = int(errorcode.ErrorCode_Success) rsp.Ret.RetMsg = "mock update action success" + if req.Action == "resume" && req.Timeout != nil { + refreshTimeoutMeta(ctx, req.SandboxID, *req.Timeout) + } return nil } @@ -88,6 +96,9 @@ func Update(ctx context.Context, req *types.UpdateRequest) (rsp *types.Res) { case "resume": *rsp = *resumeFromPauseSnapshot(ctx, req, hostIP) } + if req.Action == "resume" && req.Timeout != nil && rsp.Ret.RetCode == int(errorcode.ErrorCode_Success) { + refreshTimeoutMeta(ctx, req.SandboxID, *req.Timeout) + } return nil }) if err != nil { diff --git a/CubeMaster/pkg/service/sandbox/sandbox_update_test.go b/CubeMaster/pkg/service/sandbox/sandbox_update_test.go new file mode 100644 index 000000000..91a35da8b --- /dev/null +++ b/CubeMaster/pkg/service/sandbox/sandbox_update_test.go @@ -0,0 +1,143 @@ +package sandbox + +import ( + "context" + "encoding/json" + "os" + "testing" + + "github.com/alicebob/miniredis/v2" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/base/config" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/errorcode" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/localcache" + "github.com/tencentcloud/CubeSandbox/CubeMaster/pkg/service/sandbox/types" +) + +func TestMain(m *testing.M) { + server, err := miniredis.Run() + if err != nil { + panic(err) + } + if cfg := config.GetConfig(); cfg != nil { + cfg.RedisConf = &config.RedisConf{ + Nodes: server.Addr(), + MaxActive: 4, + MaxIdle: 1, + MaxRetry: 1, + DbNo: 0, + IdleTimeout: 30, + } + } + code := m.Run() + server.Close() + os.Exit(code) +} + +type recordingTimeoutProvider struct { + sandboxID string + timeoutSeconds int + calls int +} + +func (p *recordingTimeoutProvider) RefreshTimeout(_ context.Context, sandboxID string, timeoutSeconds int) (int64, error) { + p.sandboxID = sandboxID + p.timeoutSeconds = timeoutSeconds + p.calls++ + return 123, nil +} + +func (*recordingTimeoutProvider) LookupEndAt(context.Context, string) (int64, error) { + return 0, nil +} + +func TestUpdateSuccessfulResumeAppliesExplicitTimeout(t *testing.T) { + const sandboxID = "sb-resume-timeout" + cfg := ensureSandboxTestConfig(t) + originalMockUpdateAction := cfg.Common.MockUpdateAction + cfg.Common.MockUpdateAction = true + t.Cleanup(func() { cfg.Common.MockUpdateAction = originalMockUpdateAction }) + + localcache.SetSandboxCache(sandboxID, &localcache.SandboxCache{ + SandboxID: sandboxID, + HostIP: "127.0.0.1", + }) + t.Cleanup(func() { localcache.DeleteSandboxCache(sandboxID) }) + + provider := &recordingTimeoutProvider{} + SetTimeoutProvider(provider) + t.Cleanup(func() { SetTimeoutProvider(nil) }) + + var req types.UpdateRequest + if err := json.Unmarshal([]byte(`{ + "requestID":"req-resume-timeout", + "sandbox_id":"sb-resume-timeout", + "instance_type":"cubebox", + "action":"resume", + "timeout":120 + }`), &req); err != nil { + t.Fatalf("decode update request: %v", err) + } + + rsp := Update(context.Background(), &req) + if rsp.Ret.RetCode != int(errorcode.ErrorCode_Success) { + t.Fatalf("resume should succeed in mock mode, got ret=%+v", rsp.Ret) + } + if provider.calls != 1 || provider.sandboxID != sandboxID || provider.timeoutSeconds != 120 { + t.Fatalf("successful resume did not apply timeout: provider=%+v", provider) + } +} + +func TestUpdateDoesNotChangeTimeoutWhenOmittedOrPausing(t *testing.T) { + const sandboxID = "sb-update-timeout-unchanged" + cfg := ensureSandboxTestConfig(t) + originalMockUpdateAction := cfg.Common.MockUpdateAction + cfg.Common.MockUpdateAction = true + t.Cleanup(func() { cfg.Common.MockUpdateAction = originalMockUpdateAction }) + + localcache.SetSandboxCache(sandboxID, &localcache.SandboxCache{ + SandboxID: sandboxID, + HostIP: "127.0.0.1", + }) + t.Cleanup(func() { localcache.DeleteSandboxCache(sandboxID) }) + t.Cleanup(func() { SetTimeoutProvider(nil) }) + + for _, req := range []*types.UpdateRequest{ + { + RequestID: "req-resume-omitted", + SandboxID: sandboxID, + InstanceType: "cubebox", + Action: "resume", + }, + { + RequestID: "req-pause-timeout", + SandboxID: sandboxID, + InstanceType: "cubebox", + Action: "pause", + Timeout: types.TimeoutPtr(120), + }, + } { + provider := &recordingTimeoutProvider{} + SetTimeoutProvider(provider) + if rsp := Update(context.Background(), req); rsp.Ret.RetCode != int(errorcode.ErrorCode_Success) { + t.Fatalf("update should succeed, got ret=%+v", rsp.Ret) + } + if provider.calls != 0 { + t.Fatalf("action=%s timeout=%v unexpectedly changed timeout", req.Action, req.Timeout) + } + } +} + +func TestUpdateRejectsInvalidTimeout(t *testing.T) { + rsp := Update(context.Background(), &types.UpdateRequest{ + RequestID: "req-invalid-resume-timeout", + SandboxID: "sb-invalid-resume-timeout", + InstanceType: "cubebox", + Action: "resume", + Timeout: types.TimeoutPtr(-2), + }) + + if rsp.Ret.RetCode != int(errorcode.ErrorCode_MasterParamsError) || + rsp.Ret.RetMsg != "timeout must be >= -1 (use -1 for never timeout)" { + t.Fatalf("invalid timeout should fail before sandbox lookup, got ret=%+v", rsp.Ret) + } +} diff --git a/CubeMaster/pkg/service/sandbox/types/types.go b/CubeMaster/pkg/service/sandbox/types/types.go index 6b37d1d02..1e6213fa4 100644 --- a/CubeMaster/pkg/service/sandbox/types/types.go +++ b/CubeMaster/pkg/service/sandbox/types/types.go @@ -788,6 +788,7 @@ type UpdateRequest struct { SandboxID string `json:"sandbox_id"` InstanceType string `json:"instance_type"` Action string `json:"action"` + Timeout *int `json:"timeout,omitempty"` } // SetTimeoutRequest is the wire shape for POST /cube/sandbox/timeout. diff --git a/docs/guide/lifecycle.md b/docs/guide/lifecycle.md index 886a80a96..4d51dacf9 100644 --- a/docs/guide/lifecycle.md +++ b/docs/guide/lifecycle.md @@ -134,10 +134,12 @@ Existing `404 Not Found`, `408 Request Timeout`, and `running` sandbox delete be ```python sandbox.pause() # snapshot manually, free CPU/memory # ... time passes ... -sandbox.connect() # restore from snapshot +sandbox.connect(timeout=300) # restore and optionally reset idle timeout sandbox.run_code("print('back!')") # carry on as if never paused ``` +Like E2B, `connect(timeout=...)` resets the idle timeout whether the sandbox is already running or must first resume from a pause. Omitting `timeout` preserves Cube's current effective timeout instead of injecting an SDK default. + See [`examples/code-sandbox-quickstart/pause.py`](https://github.com/tencentcloud/CubeSandbox/blob/master/examples/code-sandbox-quickstart/pause.py) for a full demo. ### CubeProxy cache after Resume diff --git a/docs/zh/guide/lifecycle.md b/docs/zh/guide/lifecycle.md index 4cecaa080..1bf82b375 100644 --- a/docs/zh/guide/lifecycle.md +++ b/docs/zh/guide/lifecycle.md @@ -134,10 +134,12 @@ sandbox.kill() ```python sandbox.pause() # 主动保存快照,释放 CPU/内存 # ... 一段时间过去 ... -sandbox.connect() # 从快照恢复 +sandbox.connect(timeout=300) # 从快照恢复,并可选地重置空闲超时 sandbox.run_code("print('back!')") # 像没暂停过一样继续用 ``` +与 E2B 一致,`connect(timeout=...)` 会重置空闲超时,无论 sandbox 已经在运行,还是需要先从暂停状态恢复。省略 `timeout` 时,Cube 保留当前实际生效的 timeout,不由 SDK 注入默认值。 + 可参考示例:[`examples/code-sandbox-quickstart/pause.py`](https://github.com/tencentcloud/CubeSandbox/blob/master/examples/code-sandbox-quickstart/pause.py)。 ### Resume 后的 CubeProxy 缓存 diff --git a/sdk/python/README.md b/sdk/python/README.md index 28368957f..4f115ca1c 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -333,7 +333,7 @@ with Sandbox.create(config=cfg) as sb: | Method | Description | |---|---| | `Sandbox.create(template, *, timeout, env_vars, envs, metadata, distribution_scope, volume_mounts, config)` | `POST /sandboxes` — create a new sandbox (optionally restricted to specified compute nodes or mounting volumes); `envs` is the E2B-compatible alias for `env_vars` | -| `Sandbox.connect(sandbox_id, *, config)` | `POST /sandboxes/:id/connect` — connect (auto-resumes if paused) | +| `Sandbox.connect(sandbox_id, timeout=None, *, config)` | `POST /sandboxes/:id/connect` — connect (auto-resumes if paused) and optionally reset the idle timeout | | `Sandbox.list(config)` | `GET /sandboxes` — list running sandboxes (v1) | | `Sandbox.list_v2(config)` | `GET /v2/sandboxes` — list sandboxes (v2) | | `Sandbox.health(config)` | `GET /health` — service health check | diff --git a/sdk/python/README.zh.md b/sdk/python/README.zh.md index 49ef0bd7c..e5a1c3ff8 100644 --- a/sdk/python/README.zh.md +++ b/sdk/python/README.zh.md @@ -319,7 +319,7 @@ with Sandbox.create(config=cfg) as sb: | 方法 | 说明 | |---|---| | `Sandbox.create(template, *, timeout, env_vars, metadata, distribution_scope, volume_mounts, config)` | `POST /sandboxes` — 创建新沙箱(可限定计算节点或挂载卷) | -| `Sandbox.connect(sandbox_id, *, config)` | `POST /sandboxes/:id/connect` — 连接(暂停状态下自动恢复) | +| `Sandbox.connect(sandbox_id, timeout=None, *, config)` | `POST /sandboxes/:id/connect` — 连接(暂停状态下自动恢复),并可选地重置空闲超时 | | `Sandbox.list(config)` | `GET /sandboxes` — 列出运行中沙箱(v1) | | `Sandbox.list_v2(config)` | `GET /v2/sandboxes` — 列出沙箱(v2) | | `Sandbox.health(config)` | `GET /health` — 服务健康检查 | diff --git a/sdk/python/cubesandbox/sandbox.py b/sdk/python/cubesandbox/sandbox.py index 7bb6fc4f5..98d164165 100644 --- a/sdk/python/cubesandbox/sandbox.py +++ b/sdk/python/cubesandbox/sandbox.py @@ -327,13 +327,21 @@ def create( return cls(resp.json(), config=cfg) @classmethod - def connect(cls, sandbox_id: str, *, config: Config | None = None) -> "Sandbox": + def connect( + cls, + sandbox_id: str, + timeout: int | None = None, + *, + config: Config | None = None, + ) -> "Sandbox": """POST /sandboxes/:sandboxID/connect - Connect to an existing sandbox. Resumes the sandbox if it is currently paused. Args: sandbox_id: Sandbox identifier. + timeout: Sandbox idle timeout in seconds after connecting. ``None`` + keeps the sandbox's current timeout policy. config: SDK config. Uses default (env-based) config if omitted. Returns: @@ -345,9 +353,11 @@ def connect(cls, sandbox_id: str, *, config: Config | None = None) -> "Sandbox": """ cfg = config or Config() s = requests.Session() - # Connect omits timeout; see docs/guide/lifecycle.md. + body: dict[str, int] = {} + if timeout is not None: + body["timeout"] = timeout resp = s.post(f"{cfg.api_url}/sandboxes/{sandbox_id}/connect", - json={}, + json=body, headers={"Content-Type": "application/json", **_auth_headers(cfg)}) _check_response(resp) return cls(resp.json(), config=cfg) diff --git a/sdk/python/tests/test_sandbox.py b/sdk/python/tests/test_sandbox.py index 44447723b..092c62a1c 100644 --- a/sdk/python/tests/test_sandbox.py +++ b/sdk/python/tests/test_sandbox.py @@ -704,6 +704,12 @@ def test_connect_omits_timeout(self): body = m.call_args.kwargs["json"] assert "timeout" not in body + @pytest.mark.parametrize("timeout", [0, -1, 120]) + def test_connect_sends_explicit_timeout(self, timeout): + with patch("requests.Session.post", return_value=mock_response(SANDBOX_DATA)) as m: + Sandbox.connect(SANDBOX_ID, timeout, config=make_config()) + assert m.call_args.kwargs["json"]["timeout"] == timeout + # ── GET /sandboxes ──────────────────────────────────────────────────────────── diff --git a/tests/e2e/sdk_compat/adapters/__init__.py b/tests/e2e/sdk_compat/adapters/__init__.py index 4b15d562f..d2906e1af 100644 --- a/tests/e2e/sdk_compat/adapters/__init__.py +++ b/tests/e2e/sdk_compat/adapters/__init__.py @@ -80,17 +80,23 @@ def create_adapter_with_capacity_retry( ) -def connect_adapter(backend: str, sandbox_id: str, config: SdkE2EConfig) -> SandboxAdapter: +def connect_adapter( + backend: str, + sandbox_id: str, + config: SdkE2EConfig, + *, + timeout: int | None = None, +) -> SandboxAdapter: trace = get_current_trace() def _connect() -> SandboxAdapter: - return _adapter_for(backend).connect(sandbox_id, config) + return _adapter_for(backend).connect(sandbox_id, config, timeout=timeout) if trace is None: return _connect() adapter = trace.capture( "connect", - {"backend": backend, "sandbox_id": sandbox_id}, + {"backend": backend, "sandbox_id": sandbox_id, "timeout": timeout}, _connect, output=lambda result: {"sandbox_id": result.sandbox_id}, ) diff --git a/tests/e2e/sdk_compat/adapters/cubesandbox_adapter.py b/tests/e2e/sdk_compat/adapters/cubesandbox_adapter.py index a80da043e..eb27ababf 100644 --- a/tests/e2e/sdk_compat/adapters/cubesandbox_adapter.py +++ b/tests/e2e/sdk_compat/adapters/cubesandbox_adapter.py @@ -51,11 +51,17 @@ def connect( cls, sandbox_id: str, config: SdkE2EConfig, + *, + timeout: int | None = None, ) -> "CubeSandboxAdapter": from cubesandbox import Sandbox sdk_config = cls._sdk_config(config) - return cls(Sandbox.connect(sandbox_id, config=sdk_config), sdk_config=sdk_config, e2e_config=config) + return cls( + Sandbox.connect(sandbox_id, timeout=timeout, config=sdk_config), + sdk_config=sdk_config, + e2e_config=config, + ) @classmethod def list_sandboxes(cls, config: SdkE2EConfig) -> list[dict[str, Any]]: @@ -122,7 +128,11 @@ def pause(self, *, timeout: int = 60) -> None: self._sandbox.pause(timeout=timeout) def resume_or_connect(self, *, timeout: int = 60) -> "CubeSandboxAdapter": - return type(self).connect(self.sandbox_id, self._e2e_config or SdkE2EConfig.from_env()) + return type(self).connect( + self.sandbox_id, + self._e2e_config or SdkE2EConfig.from_env(), + timeout=timeout, + ) def get_host(self, port: int) -> str: return str(self._sandbox.get_host(port)) diff --git a/tests/e2e/sdk_compat/adapters/e2b_adapter.py b/tests/e2e/sdk_compat/adapters/e2b_adapter.py index c9825a1c1..12ca983d7 100644 --- a/tests/e2e/sdk_compat/adapters/e2b_adapter.py +++ b/tests/e2e/sdk_compat/adapters/e2b_adapter.py @@ -99,6 +99,7 @@ def _sandbox_info_to_raw(info: Any) -> dict[str, Any]: "startedAt", "end_at", "endAt", + "timeout", "state", "status", "cpu_count", @@ -215,7 +216,7 @@ def connect(cls, sandbox_id: str, config: SdkE2EConfig, *, timeout: int | None = connect_method = getattr(Sandbox, "connect", None) if callable(connect_method): kwargs = dict(_e2b_api_params(config)) - if _accepts_keyword(connect_method, "timeout"): + if timeout is not None and _accepts_keyword(connect_method, "timeout"): kwargs["timeout"] = timeout sandbox = connect_method(sandbox_id, **kwargs) else: diff --git a/tests/e2e/sdk_compat/cases/lifecycle/test_connect.py b/tests/e2e/sdk_compat/cases/lifecycle/test_connect.py index d8c8b9dab..effec5f2b 100644 --- a/tests/e2e/sdk_compat/cases/lifecycle/test_connect.py +++ b/tests/e2e/sdk_compat/cases/lifecycle/test_connect.py @@ -3,11 +3,14 @@ from __future__ import annotations +from datetime import datetime, timezone + import pytest from adapters import connect_adapter from framework.assertions import assert_command_ok -from framework.capabilities import LIFECYCLE +from framework.capabilities import LIFECYCLE, PAUSE_RESUME +from framework.lifecycle import wait_until_paused, wait_until_running pytestmark = [ pytest.mark.e2e, @@ -17,6 +20,43 @@ pytest.mark.requires_capability(LIFECYCLE), ] +# E2B keeps the existing timeout when a running sandbox receives a shorter +# value; use a longer explicit value so this shared case is valid for both SDKs. +_EXPLICIT_TIMEOUT = 180 + + +def _assert_timeout_visible( + adapter, + requested_timeout: int, +) -> None: + raw = adapter.info().raw + returned_timeout = raw.get("timeout") + end_at = raw.get("endAt") or raw.get("end_at") + + if returned_timeout is not None: + assert int(returned_timeout) == requested_timeout + + assert end_at or returned_timeout is not None, ( + "sandbox info must expose either timeout or endAt to verify the explicit " + f"connect timeout; raw={raw!r}" + ) + + if not end_at: + return + + try: + deadline = datetime.fromisoformat(str(end_at).replace("Z", "+00:00")) + except ValueError as exc: + raise AssertionError(f"invalid endAt returned by sandbox info: {end_at!r}") from exc + if deadline.tzinfo is None: + deadline = deadline.replace(tzinfo=timezone.utc) + + remaining_seconds = (deadline - datetime.now(timezone.utc)).total_seconds() + assert requested_timeout - 15 <= remaining_seconds <= requested_timeout + 15, ( + f"endAt is not consistent with explicit connect timeout={requested_timeout}s: " + f"remaining={remaining_seconds:.1f}s, endAt={end_at!r}" + ) + def test_connect_existing_sandbox_preserves_id(sdk_sandbox, sdk_backend, sdk_e2e_config): sandbox_id = sdk_sandbox.sandbox_id @@ -43,3 +83,47 @@ def test_connect_existing_sandbox_allows_commands(sdk_sandbox, sdk_backend, sdk_ assert result.stdout == "connected" finally: connected.close() + + +@pytest.mark.sandbox_create_options(timeout=120) +def test_connect_existing_running_sandbox_applies_explicit_timeout( + sdk_sandbox, + sdk_backend, + sdk_e2e_config, +): + connected = connect_adapter( + sdk_backend, + sdk_sandbox.sandbox_id, + sdk_e2e_config, + timeout=_EXPLICIT_TIMEOUT, + ) + try: + _assert_timeout_visible( + connected, + _EXPLICIT_TIMEOUT, + ) + finally: + connected.close() + + +@pytest.mark.requires_capability(PAUSE_RESUME) +@pytest.mark.sandbox_create_options( + timeout=120, + lifecycle={"on_timeout": "pause", "auto_resume": False}, +) +def test_connect_paused_sandbox_applies_explicit_timeout( + sdk_sandbox, + sdk_e2e_config, +): + sdk_sandbox.pause(timeout=sdk_e2e_config.default_timeout) + assert wait_until_paused(sdk_sandbox, timeout=sdk_e2e_config.default_timeout) == "paused" + + connected = sdk_sandbox.resume_or_connect(timeout=_EXPLICIT_TIMEOUT) + try: + assert wait_until_running(connected, timeout=sdk_e2e_config.default_timeout) == "running" + _assert_timeout_visible( + connected, + _EXPLICIT_TIMEOUT, + ) + finally: + connected.close() diff --git a/tests/e2e/sdk_compat/docs/test-coverage.md b/tests/e2e/sdk_compat/docs/test-coverage.md index 64d7a6da8..b20769c27 100644 --- a/tests/e2e/sdk_compat/docs/test-coverage.md +++ b/tests/e2e/sdk_compat/docs/test-coverage.md @@ -30,7 +30,7 @@ pytest --run-e2e -m "lifecycle and slow" | File | Main Behavior | Capability / Prerequisite | Risk And Execution Guidance | | --- | --- | --- | --- | | `cases/lifecycle/test_create.py` | `info` after creation and Linux command smoke | `lifecycle` | P0 / PR gate candidate | -| `cases/lifecycle/test_connect.py` | connect to an existing sandbox, ID and file/command usability | `lifecycle` | P1 | +| `cases/lifecycle/test_connect.py` | connect to an existing sandbox, ID and file/command usability, and explicit timeout application for running/paused sandboxes | `lifecycle`; paused case also requires `pause_resume` | P1 | | `cases/lifecycle/test_create_options.py` | metadata, env vars, timeout, command after create options | `lifecycle` | P1 | | `cases/lifecycle/test_pause_resume.py` | SDK pause, connect resume, file/env/kernel preservation | `pause_resume`, partially Code Interpreter | P1 | | `cases/lifecycle/test_pause_resume_network.py` | pause/resume keeps egress deny/allowlist and restricted public-access token | `pause_resume` + network capabilities; CubeProxy for ingress token case | P1 + `requires_internet` | diff --git a/tests/e2e/sdk_compat/docs/zh/test-coverage.md b/tests/e2e/sdk_compat/docs/zh/test-coverage.md index 2dc146e9f..f099f5717 100644 --- a/tests/e2e/sdk_compat/docs/zh/test-coverage.md +++ b/tests/e2e/sdk_compat/docs/zh/test-coverage.md @@ -29,7 +29,7 @@ pytest --run-e2e -m "lifecycle and slow" | 文件 | 主要行为 | 能力/前提 | 风险与执行建议 | | --- | --- | --- | --- | | `cases/lifecycle/test_create.py` | 创建后的 `info`、Linux command smoke | `lifecycle` | P0/PR gate 候选 | -| `cases/lifecycle/test_connect.py` | connect 既有实例、ID 与文件/命令可用性 | `lifecycle` | P1 | +| `cases/lifecycle/test_connect.py` | connect 既有实例、ID 与文件/命令可用性,以及 running/paused sandbox 的显式 timeout 应用 | `lifecycle`;paused 用例还需 `pause_resume` | P1 | | `cases/lifecycle/test_create_options.py` | metadata、env vars、timeout 和创建参数后的 command | `lifecycle` | P1 | | `cases/lifecycle/test_pause_resume.py` | SDK pause、connect resume、文件/env/kernel 状态保留 | `pause_resume`,部分需 Code Interpreter | P1 | | `cases/lifecycle/test_pause_resume_network.py` | pause/resume 后仍保持出站 deny/allowlist 与限制公网访问 token | `pause_resume` + 网络能力;ingress token 用例需 CubeProxy | P1 + `requires_internet` |