-
Notifications
You must be signed in to change notification settings - Fork 1k
fix(sdk): let callers omit lifecycle.onTimeout #1711
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| --- | ||
| 'e2b': patch | ||
| '@e2b/python-sdk': patch | ||
| --- | ||
|
|
||
| Make `lifecycle.onTimeout` / `lifecycle["on_timeout"]` optional, so the "no timeout action configured" state the SDKs already put on the wire can actually be expressed by a typed caller, and treat a nullish `keepMemory` / `keep_memory` as unconfigured rather than as an explicit choice. | ||
|
|
||
| ```ts | ||
| import { Sandbox } from 'e2b' | ||
|
|
||
| // Opt out of auto-resume without expressing a preference about the timeout | ||
| // action — previously a type error, since onTimeout was required. | ||
| await Sandbox.create({ lifecycle: { autoResume: false } }) | ||
|
|
||
| // A keepMemory that spreads in as undefined is no longer sent as `true`, and no | ||
| // longer trips the pause-only guard on a kill action. | ||
| const keepMemory: boolean | undefined = undefined | ||
| await Sandbox.create({ lifecycle: { onTimeout: { action: 'pause', keepMemory } } }) | ||
| ``` | ||
|
|
||
| ```python | ||
| from e2b import Sandbox | ||
|
|
||
| # Opt out of auto-resume without expressing a preference about the timeout | ||
| # action — previously a type error, since on_timeout was required. | ||
| Sandbox.create(lifecycle={"auto_resume": False}) | ||
|
|
||
| # A keep_memory that is None is no longer sent as True, and no longer trips the | ||
| # pause-only guard on a kill action. | ||
| Sandbox.create(lifecycle={"on_timeout": {"action": "pause", "keep_memory": None}}) | ||
| ``` | ||
|
|
||
| `autoResume: True` still requires an explicit `onTimeout` of `'pause'`; the error now names that knob instead of implying the SDK knows which action the API would have picked. | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -456,14 +456,15 @@ export type SandboxLifecycle = { | |
| * Omitted from the create request when unset, leaving the API's default | ||
| * (currently `kill`) in effect. | ||
| */ | ||
| onTimeout: SandboxOnTimeout | ||
| onTimeout?: SandboxOnTimeout | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TASTE treats docstrings as part of the API, and the newly accepted spelling is documented only in the changeset. For a typed JS caller, While you're in this neighbourhood: this type has no type-level JSDoc at all, and |
||
|
|
||
| /** | ||
| * Auto-resume enabled flag. | ||
| * | ||
| * Leave unset to let the API pick the behavior. Set `false` to opt out | ||
| * explicitly and keep auto-resume off even if the API's default changes. | ||
| * Can be `true` only when `onTimeout` is `pause`. Not supported when | ||
| * Can be `true` only alongside an explicit `onTimeout` of `pause`, because | ||
| * auto-resume only has meaning for a sandbox that pauses. Not supported when | ||
| * `keepMemory` is `false` (a filesystem-only snapshot must be resumed | ||
| * explicitly via `connect()`). | ||
| */ | ||
|
|
@@ -1575,18 +1576,21 @@ export class SandboxApi { | |
| // `{ action, keepMemory }`. The discriminated union type forbids `keepMemory` | ||
| // on `action: 'kill'`; re-check at runtime for untyped callers. | ||
| const requestedOnTimeout = opts?.lifecycle?.onTimeout | ||
| // A missing (or explicitly nullish, for untyped callers) onTimeout is not a | ||
| // choice of `kill` — it leaves the timeout action to the API. Locally it | ||
| // still resolves to kill semantics for the validation below. | ||
| // A missing (or explicitly nullish) onTimeout is not a choice of `kill` — it | ||
| // leaves the timeout action to the API. The guards below therefore never | ||
| // assume what the API would pick; they only constrain what the caller said. | ||
| const onTimeoutConfigured = requestedOnTimeout != null | ||
| const onTimeout = requestedOnTimeout ?? 'kill' | ||
| const action = typeof onTimeout === 'string' ? onTimeout : onTimeout.action | ||
| const hasKeepMemory = | ||
| // A nullish keepMemory is not a choice of snapshot kind, the same way a | ||
| // nullish onTimeout is not a choice of action: the field is left out of the | ||
| // request and the API's default applies. | ||
| const requestedKeepMemory = | ||
| typeof onTimeout !== 'string' && 'keepMemory' in onTimeout | ||
| const keepMemory = | ||
| typeof onTimeout !== 'string' && 'keepMemory' in onTimeout | ||
| ? (onTimeout.keepMemory ?? true) | ||
| : true | ||
| ? onTimeout.keepMemory | ||
| : undefined | ||
| const hasKeepMemory = requestedKeepMemory != null | ||
| const keepMemory = requestedKeepMemory ?? true | ||
| // A missing autoResume (or an explicit null from an untyped caller) is left | ||
| // out of the request entirely, so the API keeps ownership of the default | ||
| // instead of receiving the SDK's local one as an explicit opt-out. | ||
|
|
@@ -1599,8 +1603,13 @@ export class SandboxApi { | |
| } | ||
|
|
||
| if (autoResume && action !== 'pause') { | ||
| // Without a configured action there is no `kill` to name — the SDK no | ||
| // longer decides what an unset onTimeout means — so point at the knob. | ||
| const hint = onTimeoutConfigured | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Same gap as the python twin, and istanbul shows it precisely: this Mutating this to a plain One test closes it. I verified this passes at head and fails against that mutant: test('the unset-onTimeout hint is omitted once an action was configured', async () => {
await expect(
Sandbox.create('base', {
apiKey: TEST_API_KEY,
lifecycle: { onTimeout: 'kill', autoResume: true },
})
).rejects.toThrowError(
/^autoResume can only be true when onTimeout action is 'pause'\.$/
)
})The anchored |
||
| ? '' | ||
| : " Set lifecycle.onTimeout to 'pause': leaving it unset defers the action to the API." | ||
| throw new InvalidArgumentError( | ||
| "autoResume can only be true when onTimeout action is 'pause'." | ||
| `autoResume can only be true when onTimeout action is 'pause'.${hint}` | ||
| ) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -93,17 +93,26 @@ test('Sandbox.create sends the pause snapshot kind alongside autoPause', async ( | |
| }) | ||
|
|
||
| test('Sandbox.create omits autoPause for a lifecycle without onTimeout', async () => { | ||
| // Untyped callers can build the lifecycle conditionally and leave | ||
| // onTimeout out, or pass it as null; neither selects an action. | ||
| // onTimeout is optional, so opting out of auto-resume without expressing a | ||
| // preference about the timeout action is a typed call, not a cast. | ||
| await Sandbox.create('base', { | ||
| apiKey: TEST_API_KEY, | ||
| lifecycle: { autoResume: false } as never, | ||
| lifecycle: { autoResume: false }, | ||
| }) | ||
|
|
||
| expect(lastCreateBody).toBeDefined() | ||
| expect(lastCreateBody).not.toHaveProperty('autoPause') | ||
| expect(lastCreateBody?.autoResume).toEqual({ enabled: false }) | ||
|
|
||
| // An empty lifecycle expresses nothing at all. | ||
| await Sandbox.create('base', { apiKey: TEST_API_KEY, lifecycle: {} }) | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nothing typechecks this file, so dropping the That matters because the type relaxation is this PR's headline change, and the runtime assertions can't see it: with the source reverted to base, this test — including the Cheapest options, in order of intrusiveness: a |
||
|
|
||
| expect(lastCreateBody).toBeDefined() | ||
| expect(lastCreateBody).not.toHaveProperty('autoPause') | ||
| expect(lastCreateBody).not.toHaveProperty('autoResume') | ||
|
|
||
| // Untyped callers can also pass onTimeout as null; that selects no action | ||
| // either. | ||
| await Sandbox.create('base', { | ||
| apiKey: TEST_API_KEY, | ||
| lifecycle: { onTimeout: null } as never, | ||
|
|
@@ -115,16 +124,25 @@ test('Sandbox.create omits autoPause for a lifecycle without onTimeout', async ( | |
| }) | ||
|
|
||
| test('Sandbox.create rejects autoResume without a timeout action', async () => { | ||
| // An unconfigured onTimeout still resolves to kill semantics locally, so | ||
| // autoResume has no pause to attach to. | ||
| // Auto-resume only has meaning for a sandbox that pauses, so it needs an | ||
| // explicit 'pause' rather than whichever action the API would have picked. | ||
| await expect( | ||
| Sandbox.create('base', { | ||
| apiKey: TEST_API_KEY, | ||
| lifecycle: { autoResume: true } as never, | ||
| lifecycle: { autoResume: true }, | ||
| }) | ||
| ).rejects.toThrowError(InvalidArgumentError) | ||
|
|
||
| expect(lastCreateBody).toBeUndefined() | ||
|
|
||
| // The message points at the knob to turn instead of naming a default the SDK | ||
| // no longer decides. | ||
| await expect( | ||
| Sandbox.create('base', { | ||
| apiKey: TEST_API_KEY, | ||
| lifecycle: { autoResume: true }, | ||
| }) | ||
| ).rejects.toThrowError(/Set lifecycle.onTimeout to 'pause'/) | ||
| }) | ||
|
|
||
| test('an explicit autoResume: false is sent', async () => { | ||
|
|
@@ -154,3 +172,42 @@ test('an explicit null autoResume from an untyped caller is omitted', async () = | |
|
|
||
| expect(lastCreateBody).not.toHaveProperty('autoResume') | ||
| }) | ||
|
|
||
| test('a nullish keepMemory is not a choice of snapshot kind', async () => { | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding to the TASTE review's finding on this file (which reproduces — I confirmed The naive fix, adding these files to {
"extends": "./tsconfig.json",
"compilerOptions": { "declaration": false },
"include": [
"src",
"tests/sandbox/lifecycleRequest.test.ts",
"tests/sandbox/lifecyclePayload.test.ts"
]
}Measured both ways: 0 errors at head, and 4 It would also revive |
||
| // Spreading an optional value in yields `keepMemory: undefined`, which is no | ||
| // more a choice than leaving the key out. | ||
| const unset: boolean | undefined = undefined | ||
|
|
||
| await Sandbox.create('base', { | ||
| apiKey: TEST_API_KEY, | ||
| lifecycle: { onTimeout: { action: 'pause', keepMemory: unset } }, | ||
| }) | ||
|
|
||
| expect(lastCreateBody?.autoPause).toBe(true) | ||
| expect(lastCreateBody).not.toHaveProperty('autoPauseMemory') | ||
|
|
||
| // It therefore doesn't trip the pause-only guard on a kill action either. | ||
| await Sandbox.create('base', { | ||
| apiKey: TEST_API_KEY, | ||
| lifecycle: { onTimeout: { action: 'kill', keepMemory: unset } as never }, | ||
| }) | ||
|
|
||
| expect(lastCreateBody?.autoPause).toBe(false) | ||
| expect(lastCreateBody).not.toHaveProperty('autoPauseMemory') | ||
| }) | ||
|
|
||
| test('a nullish keepMemory still allows autoResume', async () => { | ||
| const unset: boolean | undefined = undefined | ||
|
|
||
| await Sandbox.create('base', { | ||
| apiKey: TEST_API_KEY, | ||
| lifecycle: { | ||
| onTimeout: { action: 'pause', keepMemory: unset }, | ||
| autoResume: true, | ||
| }, | ||
| }) | ||
|
|
||
| expect(lastCreateBody?.autoPause).toBe(true) | ||
| expect(lastCreateBody).not.toHaveProperty('autoPauseMemory') | ||
| expect(lastCreateBody?.autoResume).toEqual({ enabled: true }) | ||
| }) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -534,7 +534,7 @@ | |
| `"kill"`); an omitted `auto_resume` leaves the choice to the API. | ||
| """ | ||
|
|
||
| on_timeout: SandboxOnTimeout | ||
| on_timeout: NotRequired[SandboxOnTimeout] | ||
| """ | ||
| What should happen to the sandbox when timeout is reached. `"kill"` terminates | ||
| the sandbox; `"pause"` pauses it for later resume. Accepts either the bare | ||
|
|
@@ -548,8 +548,9 @@ | |
| """ | ||
| Whether activity should cause the sandbox to resume when paused. Leave unset | ||
| to let the API pick the behavior. Set `False` to opt out explicitly and keep | ||
| auto-resume off even if the API's default changes. Can be `True` only when | ||
| `on_timeout` is `pause`. Not supported when `keep_memory` is `False` | ||
| auto-resume off even if the API's default changes. Can be `True` only | ||
| alongside an explicit `on_timeout` of `pause`, because auto-resume only has | ||
| meaning for a sandbox that pauses. Not supported when `keep_memory` is `False` | ||
| (a filesystem-only snapshot must be resumed explicitly via `connect()`). | ||
| """ | ||
|
|
||
|
|
@@ -811,20 +812,23 @@ | |
| """ | ||
| # on_timeout accepts a bare action or {"action", "keep_memory"}; normalize. | ||
| # Only the object form carries keep_memory; anything else (a bare action | ||
| # string, or an unexpected value from an untyped caller) passes through as | ||
| # the action, so a non-"pause" value resolves to kill instead of crashing. | ||
| on_timeout_raw = lifecycle.get("on_timeout") if lifecycle else None | ||
| # A missing on_timeout — or an explicit None from an untyped caller — is not | ||
| # a choice of kill. It only resolves to kill semantics locally, for the | ||
| # validation below and for keep_memory. | ||
| # A missing on_timeout — or an explicit None — is not a choice of kill. The | ||
| # guards below therefore never assume what the API would pick; they only | ||
| # constrain what the caller said. | ||
| on_timeout_configured = on_timeout_raw is not None | ||
| if isinstance(on_timeout_raw, dict): | ||
| on_timeout = on_timeout_raw.get("action", "kill") | ||
| keep_memory_provided = "keep_memory" in on_timeout_raw | ||
| # A None keep_memory is not a choice of snapshot kind, the same way a | ||
| # None on_timeout is not a choice of action: the field is left out of | ||
| # the request and the API's default applies. | ||
| keep_memory = on_timeout_raw.get("keep_memory") | ||
| keep_memory_provided = keep_memory is not None | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L3-L3 Useful? React with 👍 / 👎. |
||
| else: | ||
| # Only fall back when unconfigured, not on other falsy-but-present | ||
| # values an untyped caller might pass. | ||
|
Check warning on line 831 in packages/python-sdk/e2b/sandbox/sandbox_api.py
|
||
|
Comment on lines
815
to
831
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Extended reasoning...This PR's runtime code in I verified this empirically with pyright against the real package. Both of the exact patterns this PR's own description and changeset advertise fail to typecheck:
Both produce This is why the PR's own new Python tests ( Impact is limited to static type checking, not runtime: dicts still pass through and behave correctly regardless of the annotation, so nothing crashes and no data is lost. That's why this is a nit rather than a normal-severity finding — but it does directly undercut the stated purpose of the PR, which is to make these states expressible to typed callers without a cast. Step-by-step proof:
Fix: change line 492 to |
||
| on_timeout = on_timeout_raw if on_timeout_configured else "kill" | ||
| keep_memory = None | ||
| keep_memory_provided = False | ||
|
|
@@ -845,9 +849,15 @@ | |
| auto_resume = lifecycle.get("auto_resume") if lifecycle else None | ||
|
|
||
| if auto_resume and on_timeout != "pause": | ||
| raise InvalidArgumentException( | ||
| "auto_resume can only be True when on_timeout action is 'pause'." | ||
| ) | ||
| message = "auto_resume can only be True when on_timeout action is 'pause'." | ||
| if not on_timeout_configured: | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the one new gap: only the
Confirmed by mutation rather than by reading the percentage. Replacing this line with Worth closing because the hint makes a claim about the caller's input ("you left it unset"), so getting it wrong would tell someone who wrote A drop-in test is in my comment on |
||
| # Without a configured action there is no "kill" to name — the SDK no | ||
| # longer decides what an unset on_timeout means — so point at the knob. | ||
| message += ( | ||
| " Set lifecycle['on_timeout'] to 'pause': leaving it unset defers" | ||
| " the action to the API." | ||
| ) | ||
| raise InvalidArgumentException(message) | ||
|
|
||
| if not keep_memory and auto_resume: | ||
| raise InvalidArgumentException( | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This sample does not typecheck, and the realistic version of it doesn't either. Written verbatim into a file and checked with the repo's own checker from
packages/python-sdk,tyreports two diagnostics for this line (invalid-argument-typeon thecreatecall, plus one naming theon_timeoutkey), becauseNotRequired[bool]means "may be absent", not "may beNone". The same is true of the call shape the PR is actually motivated by — akeep_memory: Optional[bool]variable that happens to beNone— which adds two more. The JS twin twelve lines above compiles clean, so the fix this PR ships is reachable by a typed JS caller and not by a typed Python one. All three lifecycle keys share the trait:{"on_timeout": None},{"auto_resume": None}and{"keep_memory": None}are each runtime-tolerated and statically rejected (6 diagnostics across those three calls).Codex's P2 suggests widening the field to accept
None. I'd push back on that, on two grounds. TASTE's spelling of absence is omission, not a null sentinel; and the surface is currently uniform about it — of the ~330NotRequired[...]fields in the option TypedDicts (sandbox_api.py17,mcp.py302), zero acceptNone. The only nineNotRequired[Optional[...]]fields in the package are inCopyItem/Instruction(e2b/template/types.py:112-130), which are the camelCase serialized wire shapes rather than the option surface. Widening one key would also just move the asymmetry, since this PR made all three runtime-lenient — you'd needon_timeoutandauto_resumetoo.The smaller fix is the sample. Either spelling below typechecks clean, and I verified both produce a request body byte-identical to what the current sample produces at runtime (
autoPause: true,autoPauseMemoryabsent):Worth keeping the sentence about
Nonebeing treated as unconfigured — the runtime leniency is still the right defensive behavior for untyped callers — but the published sample shouldn't be a snippet CI would reject.