fix(cubemaster): support never-timeout semantics for refresh and update - #862
fix(cubemaster): support never-timeout semantics for refresh and update#862emailcannotbeblank wants to merge 7 commits into
Conversation
Note About E2B Refresh SemanticsIn E2B, refresh-sandbox can only extend the sandbox lifetime; it does not shorten it. The E2B refresh handler calls: a.orchestrator.KeepAliveFor(ctx, teamID, sandboxID, duration, false)The last argument is if !allowShorter && endTime.Before(sbx.EndTime) {
return sbx, nil
}By contrast, CubeSandbox refresh currently rewrites |
e30c481 to
97f2167
Compare
chenhengqi
left a comment
There was a problem hiding this comment.
I am fine with the changes. @fslongjin Please double check.
b07f7f0 to
6618a6c
Compare
| if req.Action == "resume" && req.Timeout != nil && *req.Timeout < 0 { | ||
| timeout := types.NeverTimeout | ||
| req.Timeout = &timeout | ||
| } |
There was a problem hiding this comment.
Silent normalization of invalid negative values + request struct mutation
This silently normalizes any value < 0 (e.g. -2, -100, -999) to NeverTimeout without any indication to the caller. On SetTimeout and Refresh, the same invalid values are explicitly rejected with a clear error. This asymmetry is a maintainability trap — developers working across both paths will find the behavior surprising.
Additionally, the mutation of req.Timeout happens before the cubelet RPC (line 80). If that RPC fails, the normalized value (-1) persists in the caller's request struct, so a retry with the same struct would silently change semantics. Consider either:
- Rejecting values
< -1here too (consistent with SetTimeout/Refresh), or - At minimum, logging the normalization, and using a local variable instead of mutating
req.Timeout.
There was a problem hiding this comment.
Fixed per option 1: Update now explicitly rejects values < -1 (consistent with SetTimeout/Refresh) and no longer mutates req.Timeout — a test verifies that -2 returns the error with the struct left untouched (sandbox_update_test.go:29-33).
719864d to
380b561
Compare
|
Hi maintainers, friendly ping. This PR has been rebased onto the latest master, with conflicts resolved and commits cleaned up. The remaining Claude Auto Review failure is caused by the |
380b561 to
87cb24f
Compare
fslongjin
left a comment
There was a problem hiding this comment.
Thanks for this PR! The core fix is solid — adding the missing timeout field to UpdateRequest and fixing the refresh validation gap are both real bugs that needed fixing.
I did a thorough review across several dimensions (security, correctness, architecture, test coverage, docs). The functional logic is correct, but I found a few things worth addressing before merging:
Blocking items
-
Missing input validation on
resume_sandbox—ResumedSandboxhas noValidatederive and the handler never callsbody.validate(), unlikeset_sandbox_timeoutandrefresh_sandboxwhich both validate. This is a defense-in-depth gap (see inline comments). -
Inconsistent timeout validation strategy —
SetTimeoutandRefreshrejecttimeout < -1with a clear error, butUpdate/resumesilently coerces any negative value to-1. A caller sendingtimeout=-2gets an explicit error fromSetTimeoutbut a 200 fromresume. This is a behavioral trap for SDK authors (see inline comment onsandbox_update.go:53). -
Missing upper-bound validation in CubeMaster's
Refresh()— CubeAPI enforcesmax=3600on refresh duration, but the Go side only checksDuration < -1with no upper bound (see inline comment onsandbox_timeout.go:98).
Non-blocking suggestions
- The default
duration=0(immediate timeout) when no duration is provided in refresh is a destructive default — worth documenting explicitly or reconsidering. - New function
publishUpdateTimeoutand fieldUpdateRequest.Timeoutare missing doc comments. - The
refreshTimeoutMetafallback for-1timeout computestime.Now().UnixMilli() + (-1)*1000which returns a ~1-second-past timestamp asEndAt. The lifecycle sweeper isn't affected (it reads from Redis directly), but API consumers receiving a past timestamp for a never-timeout sandbox might be confused. Consider adding a guard:if timeoutSeconds < 0 { return 0 // never-timeout: sentinel for "no expiry deadline" } return time.Now().UnixMilli() + int64(timeoutSeconds)*1000
All three blocking items are small-scope fixes (~15 lines total across Rust and Go). Once addressed, this PR looks good to merge.
| Path(sandbox_id): Path<String>, | ||
| Json(body): Json<RefreshRequest>, | ||
| ) -> AppResult<impl IntoResponse> { | ||
| body.validate() |
There was a problem hiding this comment.
👍 Good catch adding body.validate() here — the RefreshRequest struct had validation annotations that were never being invoked before this PR.
However, the same gap also exists in resume_sandbox (line 289). The ResumedSandbox struct at models/mod.rs:324 has neither a Validate derive nor validation annotations on its timeout field, and the handler never calls body.validate() either.
This means an attacker or buggy client can currently send arbitrary i32 values (e.g., i32::MIN, i32::MAX) as a resume timeout, bypassing the CubeAPI validation layer entirely. CubeMaster does normalize negative values to -1 as defense-in-depth, but this validation gap should be closed at the API boundary for consistency with the other two timeout endpoints.
Suggested fix:
In models/mod.rs, update ResumedSandbox:
#[derive(Debug, Deserialize, Validate, ToSchema)] // add Validate
#[allow(dead_code)]
pub struct ResumedSandbox {
#[serde(default, skip_serializing_if = "Option::is_none")]
#[validate(custom(function = "validate_timeout_value"))] // add this
pub timeout: Option<i32>,
#[serde(rename = "autoPause", default)]
pub auto_pause: bool,
}Then in the handler, add body.validate().map_err(|e| AppError::BadRequest(e.to_string()))?; after the destructuring, matching the pattern used here at line 470.
There was a problem hiding this comment.
Thanks, fixed as suggested above.
| #[derive(Debug, Deserialize, Validate, ToSchema)] | ||
| pub struct RefreshRequest { | ||
| #[validate(range(min = 0, max = 3600))] | ||
| #[validate(range(min = -1, max = 3600))] |
There was a problem hiding this comment.
👍 The range change from min = 0 to min = -1 is the right fix — refresh should accept the same -1 never-timeout sentinel that SetTimeoutRequest already does.
Side note: while we're fixing validation coverage on the Rust side for this PR, the same gap exists in the ResumedSandbox struct (line 324). It has no #[derive(Validate)] and its timeout field has no #[validate(...)] annotation, so resume requests bypass CubeAPI-level timeout validation. Would be great to fix it in the same PR for consistency — validate_timeout_value (line 495) already exists and can be reused.
There was a problem hiding this comment.
Thanks, fixed. ResumedSandbox now derives Validate, timeout reuses validate_timeout_value, and resume_sandbox calls body.validate() before forwarding the request.
| rsp.Ret = ret | ||
| return | ||
| } | ||
| if req.Action == "resume" && req.Timeout != nil && *req.Timeout < 0 { |
There was a problem hiding this comment.
issue: The validation strategy here is inconsistent with SetTimeout and Refresh.
SetTimeout (sandbox_timeout.go:49) and Refresh (sandbox_timeout.go:98) both reject values < -1 with ErrorCode_MasterParamsError:
if req.Timeout < -1 {
rsp.Ret.RetCode = int(errorcode.ErrorCode_MasterParamsError)
rsp.Ret.RetMsg = "timeout must be >= -1 (use -1 for never timeout)"
return
}But this resume path silently coerces any negative value to -1:
if req.Action == "resume" && req.Timeout != nil && *req.Timeout < 0 {
timeout := types.NeverTimeout
req.Timeout = &timeout
}A caller sending timeout=-2 to SetTimeout gets a clear error. The same caller sending timeout=-2 to resume gets a 200 with the value silently changed to never-timeout. This is a behavioral trap.
Suggested fix: align with SetTimeout/Refresh:
if req.Action == "resume" && req.Timeout != nil && *req.Timeout < -1 {
rsp.Ret.RetCode = int(errorcode.ErrorCode_MasterParamsError)
rsp.Ret.RetMsg = "timeout must be >= -1 (use -1 for never timeout)"
return
}Bonus: the current < 0 condition also captures -1 (the valid NeverTimeout sentinel) and does a no-op -1 → -1 assignment, which is correct but confusing to read. Changing to < -1 fixes that readability issue too.
There was a problem hiding this comment.
Fixed. Update/resume now rejects explicit timeout values < -1 with ErrorCode_MasterParamsError, matching SetTimeout and Refresh.
In the previous version, I noticed that sandbox creation normalized timeout values < -1 to -1, so the earlier change intentionally followed that normalization behavior for consistency with create. After your feedback, I aligned resume with the stricter validation strategy used by SetTimeout and Refresh instead.
| return | ||
| } | ||
| if req.Duration <= 0 { | ||
| if req.Duration < -1 { |
There was a problem hiding this comment.
issue: This check only validates the lower bound (Duration < -1), with no corresponding upper-bound check.
CubeAPI's RefreshRequest enforces #[validate(range(min = -1, max = 3600))] (models/mod.rs:506), capping the duration at 3600 seconds. But here in CubeMaster there's no equivalent — if someone calls CubeMaster directly (or CubeAPI has a bug), they could send duration=999999 and it would be accepted silently.
Suggested fix:
if req.Duration < -1 || req.Duration > 3600 {
rsp.Ret.RetCode = int(errorcode.ErrorCode_MasterParamsError)
rsp.Ret.RetMsg = "duration must be in [-1, 3600]"
return
}(Note: SetTimeout doesn't need an upper bound since arbitrarily large positive timeouts are valid there, but refresh has a specific window-extension semantic where a ceiling is appropriate.)
There was a problem hiding this comment.
Fixed. CubeMaster Refresh() now validates duration in [-1, 3600], matching CubeAPI's RefreshRequest constraint.
| body.validate() | ||
| .map_err(|e| AppError::BadRequest(e.to_string()))?; | ||
|
|
||
| let duration = body.duration.unwrap_or(0); |
There was a problem hiding this comment.
question (non-blocking): The default for an omitted duration is 0, which in this PR's semantics means "immediate timeout":
let duration = body.duration.unwrap_or(0);A caller who forgets to set duration will have their sandbox immediately scheduled for expiry rather than getting a refreshed lease. I understand this likely matches E2B's behavior, but could we either:
- Add an explicit doc comment on
RefreshRequestwarning that omittingdurationequalsduration=0(immediate timeout), or - Consider whether
Noneshould mean "just rebase the idle clock without changing the timeout" instead?
Not blocking — just raising it since it's a sharp edge for API consumers.
There was a problem hiding this comment.
Documented. I kept the existing behavior for compatibility, but added an explicit RefreshRequest.duration comment noting that omitted duration is treated as 0, which means immediate timeout, and -1 means never-timeout.
| return | ||
| } | ||
|
|
||
| func publishUpdateTimeout(ctx context.Context, req *types.UpdateRequest) { |
There was a problem hiding this comment.
nit: This function is one of the two core additions in this PR but has no doc comment. A brief explanation would help future maintainers:
// publishUpdateTimeout syncs the requested timeout to the lifecycle
// metadata channel after a successful sandbox resume. It is a
// best-effort operation: failures are logged but do not affect the
// resume response.
//
// Only acts on "resume" action with a non-nil Timeout. Pause
// requests and resumes without an explicit Timeout are silently
// skipped (preserving the existing timeout without rebasing the
// idle clock).
func publishUpdateTimeout(ctx context.Context, req *types.UpdateRequest) {This makes it immediately clear that: (1) it's resume-only, (2) failures are non-fatal, and (3) nil Timeout means "don't touch the timeout."
There was a problem hiding this comment.
Thanks, fixed as suggested above.
| SandboxID string `json:"sandbox_id" p:"sandbox_id" v:"required"` | ||
| InstanceType string `json:"instance_type" p:"instance_type" v:"required"` | ||
| Action string `json:"action" p:"action" v:"required"` | ||
| Timeout *int `json:"timeout,omitempty" p:"timeout"` |
There was a problem hiding this comment.
nit: The new Timeout field would benefit from a comment explaining its semantics, similar to how the SetTimeoutRequest type has detailed field documentation:
// Timeout is the new idle TTL in seconds for "resume" actions.
// Accepted values: -1 (never timeout), 0 (immediate timeout),
// or a positive number of seconds. nil preserves the existing
// timeout without rebasing the idle clock.
Timeout *int `json:"timeout,omitempty" p:"timeout"`The nil vs 0 vs -1 distinction is subtle enough that future readers will appreciate the hint.
There was a problem hiding this comment.
Thanks, fixed as suggested above.
fslongjin
left a comment
There was a problem hiding this comment.
Hello, can you add some e2e tests under tests/e2e/sdk_compat ?
|
@emailcannotbeblank Please also add e2e tests. See https://github.com/TencentCloud/CubeSandbox/tree/master/tests/e2e/sdk_compat |
534819b to
d51bc68
Compare
| // requests and resumes without an explicit Timeout are silently | ||
| // skipped (preserving the existing timeout without rebasing the | ||
| // idle clock). | ||
| func publishUpdateTimeout(ctx context.Context, req *types.UpdateRequest) { |
There was a problem hiding this comment.
Minor: publishUpdateTimeout discards the refreshTimeoutMeta return value, masking provider failures
refreshTimeoutMeta is called here for its side effect on the provider — the returned endAt is intentionally discarded. However, when the provider fails, the only evidence is a Warn-level log line. The HTTP response to the caller remains Success with no indication the lifecycle metadata channel may be stale.
Consider bumping the log to Error level when the provider fails during a resume, or adding a metrics counter so operators can monitor provider sync failures.
There was a problem hiding this comment.
Intentionally keeping Warn: metadata publishing is best-effort by design (the function comment notes failures never affect the resume response) — a failure only stalemates the metadata channel, not correctness, so Error-level would create unnecessary alert noise.
e740d2b to
78e3a5a
Compare
| if req.Duration <= 0 { | ||
| var duration int | ||
| if req.Duration == nil { | ||
| duration, _ = resolveTimeoutSeconds(nil, config.GetConfig().CubeletConf.DefaultTimeoutInsec) |
There was a problem hiding this comment.
The omitted-duration fallback resolves the cluster default via resolveTimeoutSeconds(nil, DefaultTimeoutInsec), then validates the resolved value against the refresh range. Two edge cases fall out:
- If
default_timeout_insecis configured > 3600 (the create path,ConstructCubeletReq, accepts any positive default with no upper bound), an omitted-duration refresh returns400 "duration must be -1 or in [1, 3600]". - If
default_timeout_insecis unset (0),resolveTimeoutSecondsreturnsNeverTimeout(-1), so an omitted-duration refresh silently flips the sandbox to never-timeout — a much stronger side effect than "extend with the platform default" — and the sandbox won't auto-pause until an explicitset_timeoutrestores a TTL.
Since this "omitted duration" path is new in this PR, consider clamping the resolved default into the accepted range (or bounding/documenting default_timeout_insec to [1, 3600]) so it can't 400 or silently disable timeouts depending on cluster config.
There was a problem hiding this comment.
Both edges are addressed in the final revision: refresh no longer has a 3600 cap (validation now only requires -1 or a positive value), so a >3600 cluster default no longer 400s; the omitted-duration → cluster-default behavior is intentional and covered by a dedicated warning and behavior-table entry in both the English and Chinese docs.
| const NEVER_TIMEOUT_SECONDS: i32 = -1; | ||
| const E2B_USER_AGENT_MARKERS: [&str; 3] = | ||
| ["e2b-python-sdk/", "e2b-js-sdk/", "e2b-code-interpreter/"]; | ||
|
|
There was a problem hiding this comment.
The User-Agent marker check is trivially spoofable (any client can set User-Agent: e2b-js-sdk/... and receive the far-future sentinel) and, conversely, breaks for genuine E2B traffic that passes through a proxy that rewrites or strips User-Agent. This is documented in docs/guide/lifecycle.md as a deliberate tradeoff, so it's acceptable — but note the behavior will also silently diverge if an E2B SDK ever changes its User-Agent marker (the match is a case-insensitive substring on three exact markers). Adding a debug log when the compat is applied would make the divergence observable.
There was a problem hiding this comment.
Background: for never-timeout (timeout=-1) sandboxes, Cube-native responses omit endAt, while E2B SDK models require endAt to be a valid datetime — this PR therefore adds a compat branch that returns the 9999-12-31 sentinel only on E2B-facing paths.
The design question we think more worth discussing here is: given that E2B SDKs must receive an endAt, should Cube change its behavior uniformly (returning endAt to Cube-native callers as well), or should CubeAPI differentiate by SDK and respond differently? This PR takes the latter route — distinguishing via User-Agent so Cube-native semantics stay untouched — and the tradeoffs (spoofability, proxy rewriting) are documented in lifecycle.md. So we're not making further changes here for now.
78e3a5a to
ba2ed56
Compare
| } | ||
| if req.Duration <= 0 { | ||
| var duration int | ||
| if req.Duration == nil { |
There was a problem hiding this comment.
Refresh with omitted duration silently makes a sandbox never-timeout on the default cluster.
When req.Duration == nil, this resolves the cluster default via resolveTimeoutSeconds(nil, DefaultTimeoutInsec). CubeMaster/conf.yaml ships default_timeout_insec: -1, and resolveTimeoutSeconds maps any default <= 0 to NeverTimeout. So a plain "keep this sandbox alive" refresh with no duration permanently disables the sandbox's idle timeout. It is consistent with create semantics and documented, but it's a sharp edge clients won't expect — worth an explicit warning in the docs/API or a guard.
There was a problem hiding this comment.
This behavior is intentional (omitted duration falls back to the cluster default, consistent with create semantics and the direction the maintainer requested), and the requested docs warning is in place: both the English and Chinese lifecycle docs carry a dedicated warning box plus a behavior-table entry stating that an omitted duration becomes never-timeout under the default config, and to pass an explicit positive duration when a finite TTL is needed.
## Summary Align refresh, resume/connect, and set_timeout timeout semantics across CubeAPI, CubeMaster, and SDK compatibility tests. ## Changes - Reject refresh duration 0 and invalid out-of-range values while preserving omitted duration as the CubeMaster cluster default. - Keep resume/connect timeout 0 as preserve-existing, accept -1 as never-timeout, and reject values below -1. - Add SDK compatibility E2E coverage for refresh, set_timeout, and resume timeout values across CubeSandbox and E2B backends. ## Verification - go test ./pkg/service/sandbox -run 'Test(SetTimeout|Refresh|Update).*Timeout|TestRefreshValidation|TestRefreshOmitted|TestRefreshTimeoutMeta' -count=1 - cargo test --locked models::tests - pytest --run-e2e --sdk-e2e-backends=cubesandbox,e2b -q tests/e2e/sdk_compat/cases/lifecycle/test_refresh.py tests/e2e/sdk_compat/cases/lifecycle/test_timeout.py tests/e2e/sdk_compat/cases/lifecycle/test_pause_resume.py::test_resume_accepts_never_timeout tests/e2e/sdk_compat/cases/lifecycle/test_pause_resume.py::test_resume_accepts_zero_and_positive_timeout tests/e2e/sdk_compat/cases/lifecycle/test_pause_resume.py::test_resume_rejects_invalid_negative_timeout Signed-off-by: zry <1292625211@qq.com> Assisted-by: Codex:GPT-5
ba2ed56 to
ca88e40
Compare
ca88e40 to
6427d67
Compare
| } | ||
| // refreshTimeoutMeta updates lifecycle metadata through the timeout provider. | ||
| // Resume does not return endAt, so the computed value is intentionally ignored. | ||
| refreshTimeoutMeta(ctx, req.SandboxID, *req.Timeout) |
There was a problem hiding this comment.
The nil/0 resume path is guarded by the atomic Lua RebaseTimeoutWindow, but this explicit-timeout path goes through refreshTimeoutMeta → RefreshTimeout, which is a non-atomic LoadMeta→HSET/XADD read-modify-write. The Lua script comment claims atomicity prevents resume from "writing back a stale timeout over a concurrent set_timeout or refresh request" — that guarantee only holds for the nil/0 branch. A resume carrying an explicit timeout (and any set_timeout/refresh) can still clobber a concurrent update. Not a regression, but the protection is asymmetric; routing the explicit-timeout case through a comparable atomic script (set timeout_seconds/created_at/end_at in one EVAL) would close the gap.
There was a problem hiding this comment.
Fixed in exactly this direction: storeTimeoutProvider.RefreshTimeout now routes through SetTimeoutWindow's single-EVAL script, atomically replacing the timeout, starting a new window, and publishing the event (lifecycle/init.go:61-68, lifecycle/store.go:189-216) — the explicit-timeout path shares the same atomic script as the nil/0 path, so the protection is no longer asymmetric.
6427d67 to
020826e
Compare
5110931 to
1ea6b20
Compare
| // preserves the stored timeout while moving its CreatedAt and EndAt forward | ||
| // from the resume time. Metadata updates are best effort and never change the | ||
| // resume response. | ||
| func publishUpdateTimeout(ctx context.Context, req *types.UpdateRequest) { |
There was a problem hiding this comment.
Explicit-timeout resume reports success even when the timeout was not persisted. For a legacy sandbox with no lifecycle metadata, SetTimeoutWindow returns (0, nil) — no error, no metadata created — so refreshTimeoutMeta falls back to a computed endAt and the resume succeeds while the requested timeout never takes effect. The RebaseTimeoutWindow error path (line 115) is likewise only logged. Since the resume response carries no indication, a client can believe resume(timeout=600) was applied when it wasn't. Consider having the store distinguish "metadata missing" from "success" so this path at least logs clearly.
There was a problem hiding this comment.
Fixed as suggested: the store's Lua script returns an explicit not-found marker for sandboxes without metadata, and SetTimeoutWindow/RebaseTimeoutWindow surface it as a "lifecycle metadata for sandbox %s was not found" error (lifecycle/store.go:26-28, 227-228), so this path now logs clearly instead of silently returning (0, nil). The resume response stays Success by the best-effort design (see the function comment).
Assisted-by: Codex:GPT-5
1ea6b20 to
d407231
Compare
1. Never-timeout support and lifecycle timeout alignment
1.1 Problem
UpdateRequestin CubeMaster had notimeoutfield, so the timeout sent by CubeAPI was not carried through on resume. Beyond that, refresh and resume did not fully support never-timeout: refresh rejected-1, and resume silently coerced any negative value to-1.1.2 Before (master)
< -1-10resolveTimeoutSeconds)now-1s, bogus)duration must be positive)duration must be positive)unwrap_or(0)→must be positive)UpdateRequesthas no field)1.3 After (this PR; bold = changed)
< -1-10now − 1s, a timestamp already in the past)set_timeout(0)— result unchanged)resolveTimeoutSeconds(nil, DefaultTimeoutInsec); was error)validate_timeout_value; was silently dropped)RebaseTimeoutWindow; was dropped)body.validate()added toConnectSandbox; was silently ignored)1.4 Why these changes
Per the maintainer: resume reject invalid values (
< -1) with an explicit error rather than silently normalizing them, and that it share the same value model as set_timeout and the other APIs (-1= never-timeout,0= immediate timeout, supported by set_timeout only, positive = TTL); an omitted refresh duration must not become an immediate timeout and should fall back to the cluster default.2. E2E tests
All cases run against real sandboxes via the shared adapter contract in
tests/e2e/sdk_compat/cases/lifecycle/(test_refresh.py,test_timeout.py,test_pause_resume.py). SDK-backed cases are collected for both backends (CubeSandbox SDK and E2B SDK); refresh cases drive the CubeAPI REST endpoint directly. Success cases additionally verify the sandbox stays usable (run_command).durationduration=-1duration=60duration=7200duration=0durationduration=-2durationtimeout=-1timeout=60timeout=0timeout=-2timeout=-1timeout=0timeout=60timeout=-2timeout)Run with:
pytest --run-e2e --sdk-e2e-backends=cubesandbox,e2b -q \ tests/e2e/sdk_compat/cases/lifecycle/{test_refresh,test_timeout,test_pause_resume}.py3. E2B SDK
endAtcompatibility3.1 Problem
CubeSandbox represents a never-timeout sandbox internally as
end_at = None, and the field is omitted from JSON responses (skip_serializing_if = "Option::is_none"). The E2B SDK'sSandboxDetail/ListedSandboxmodels however treatendAtas a required valid datetime — a missing or nullendAtfails deserialization, soget_info()/list()broke for never-timeout sandboxes.3.2 Design
Compatibility is applied at the CubeAPI handler boundary, on the read paths
GET /sandboxes/{id},GET /sandboxes, andGET /v2/sandboxes:User-Agentprefix:e2b-python-sdk/,e2b-js-sdk/,e2b-code-interpreter/.timeout_seconds == -1in the lifecycle metadata;end_atisNone.9999-12-31T23:59:59Z, built at compile time from Unix seconds253_402_300_799(no per-request parsing).The
timeout_seconds == -1condition is load-bearing: an omittedend_atwithouttimeout_seconds = -1means the metadata did not resolve a timeout, and such a sandbox must not be presented as never-timeout. This distinguishes "confirmed no deadline" from "unknown".Consistency: never-timeout is stored as
TimeoutSeconds=-1, EndAt=0; CubeAPI readsend_at<=0asNoneand omits it from responses; only E2B SDK requests that meet the three conditions get the sentinel. There is exactly one representation path for "no deadline" across the stack, so the values seen at each layer never disagree.Error handling: sandboxes whose metadata has no resolved timeout (e.g. legacy sandboxes,
timeout_secondsempty) fail the three-condition check, so theirendAtstays omitted and they are never misreported as never-timeout.3.3 Coverage
e2b-js-sdk/2.28.0 e2b-cli/2.0.0, and empty headers); native responses keependAtomitted.test_timeout.py): afterset_timeout(-1), all three E2B UA markers receive the sentinel on both detail and list endpoints, and a Cube-native UA seesendAtomitted — verified on both SDK backends.Assisted-by: codex:gpt5.6-sol