Skip to content
Draft
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
6 changes: 6 additions & 0 deletions .changeset/lazy-trees-tell.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@e2b/python-sdk': patch
'e2b': patch
---

Background commands (`commands.run(..., background=True)` / `{ background: true }`) now default to no timeout, so they are no longer killed after the default 60s command timeout. Pass an explicit `timeout`/`timeoutMs` to set one.
10 changes: 9 additions & 1 deletion packages/js-sdk/src/sandbox/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,9 @@ export class Commands {
* Start a new command in the background.
* You can use {@link CommandHandle.wait} to wait for the command to finish and get its result.
*
* Background commands default to no timeout, so they are not killed after the
* default command timeout. Pass `opts.timeoutMs` to set one explicitly.
*
* @param cmd command to execute.
* @param opts options for starting the command
*
Expand Down Expand Up @@ -445,7 +448,12 @@ export class Commands {
[KEEPALIVE_PING_HEADER]: KEEPALIVE_PING_INTERVAL_SEC.toString(),
},
signal: controller.signal,
timeoutMs: opts?.timeoutMs ?? this.defaultProcessConnectionTimeout,
// Background commands default to no timeout so they aren't killed after
// the default command timeout. An explicit `timeoutMs` still takes
// precedence.
timeoutMs: opts?.background
? opts?.timeoutMs
: (opts?.timeoutMs ?? this.defaultProcessConnectionTimeout),
}
)

Expand Down
27 changes: 27 additions & 0 deletions packages/js-sdk/tests/sandbox/commands/run.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,3 +42,30 @@ sandboxTest('run with too short timeout', async ({ sandbox }) => {
sandbox.commands.run('sleep 10', { timeoutMs: 1000 })
).rejects.toThrow()
})

sandboxTest(
'background run is capped by timeout',
async ({ sandbox }) => {
const start = Date.now()
const cmd = await sandbox.commands.run('sleep 20', {
background: true,
timeoutMs: 10_000,
})
await expect(cmd.wait()).rejects.toThrow()
// The command is capped by the timeout instead of running for the full 20s.
expect(Date.now() - start).toBeLessThan(20_000)
},
60_000
)

sandboxTest(
'background run without timeout completes',
async ({ sandbox }) => {
// Sleep longer than the previous 60s default so this actually exercises the
// no-timeout default for background commands rather than the old 60s cap.
const cmd = await sandbox.commands.run('sleep 70', { background: true })
const result = await cmd.wait()
assert.equal(result.exitCode, 0)
},
120_000
)
22 changes: 18 additions & 4 deletions packages/python-sdk/e2b/sandbox_async/commands/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,13 @@
from e2b.sandbox_async.utils import OutputHandler


class _Unset:
"""Sentinel for an omitted ``timeout`` argument (distinct from ``None``)."""


_UNSET = _Unset()


class Commands:
"""
Module for executing commands in the sandbox.
Expand Down Expand Up @@ -158,7 +165,7 @@ async def run(
:param on_stdout: Callback for command stdout output
:param on_stderr: Callback for command stderr output
:param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param timeout: Timeout for the command in **seconds**. Using `0` or `None` will not limit the command run time
:param request_timeout: Timeout for the request in **seconds**

:return: `CommandResult` result of the command execution
Expand All @@ -176,7 +183,7 @@ async def run(
on_stdout: Optional[OutputHandler[Stdout]] = None,
on_stderr: Optional[OutputHandler[Stderr]] = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
timeout: Optional[float] = 0,
request_timeout: Optional[float] = None,
) -> AsyncCommandHandle:
"""
Expand All @@ -190,7 +197,7 @@ async def run(
:param on_stdout: Callback for command stdout output
:param on_stderr: Callback for command stderr output
:param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param timeout: Timeout for the command in **seconds**. Background commands default to `0`; using `0` or `None` will not limit the command run time
:param request_timeout: Timeout for the request in **seconds**

:return: `AsyncCommandHandle` handle to interact with the running command
Expand All @@ -207,7 +214,7 @@ async def run(
on_stdout: Optional[OutputHandler[Stdout]] = None,
on_stderr: Optional[OutputHandler[Stderr]] = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
timeout: Union[float, None, _Unset] = _UNSET,
request_timeout: Optional[float] = None,
):
# Check version for stdin support
Expand All @@ -220,6 +227,13 @@ async def run(
# Default to `False`
stdin = stdin or False

# Resolve the default timeout: foreground commands default to 60s and
# background commands to no timeout. An explicit `None` means no timeout.
if isinstance(timeout, _Unset):
timeout = 0 if background else 60
elif timeout is None:
timeout = 0

proc = await self._start(
cmd,
envs,
Expand Down
22 changes: 18 additions & 4 deletions packages/python-sdk/e2b/sandbox_sync/commands/command.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,13 @@
from e2b.sandbox_sync.commands.command_handle import CommandHandle


class _Unset:
"""Sentinel for an omitted ``timeout`` argument (distinct from ``None``)."""


_UNSET = _Unset()


class Commands:
"""
Module for executing commands in the sandbox.
Expand Down Expand Up @@ -157,7 +164,7 @@ def run(
:param on_stdout: Callback for command stdout output
:param on_stderr: Callback for command stderr output
:param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param timeout: Timeout for the command in **seconds**. Using `0` or `None` will not limit the command run time
:param request_timeout: Timeout for the request in **seconds**

:return: `CommandResult` result of the command execution
Expand All @@ -175,7 +182,7 @@ def run(
on_stdout: None = None,
on_stderr: None = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
timeout: Optional[float] = 0,
request_timeout: Optional[float] = None,
) -> CommandHandle:
"""
Expand All @@ -187,7 +194,7 @@ def run(
:param user: User to run the command as
:param cwd: Working directory to run the command
:param stdin: If `True`, the command will have a stdin stream that you can send data to using `sandbox.commands.send_stdin()`
:param timeout: Timeout for the command connection in **seconds**. Using `0` will not limit the command connection time
:param timeout: Timeout for the command in **seconds**. Background commands default to `0`; using `0` or `None` will not limit the command run time
:param request_timeout: Timeout for the request in **seconds**

:return: `CommandHandle` handle to interact with the running command
Expand All @@ -204,7 +211,7 @@ def run(
on_stdout: Optional[Callable[[str], None]] = None,
on_stderr: Optional[Callable[[str], None]] = None,
stdin: Optional[bool] = None,
timeout: Optional[float] = 60,
timeout: Union[float, None, _Unset] = _UNSET,
request_timeout: Optional[float] = None,
):
# Check version for stdin support
Expand All @@ -217,6 +224,13 @@ def run(
# Default to `False`
stdin = stdin or False

# Resolve the default timeout: foreground commands default to 60s and
# background commands to no timeout. An explicit `None` means no timeout.
if isinstance(timeout, _Unset):
timeout = 0 if background else 60
elif timeout is None:
timeout = 0

proc = self._start(
cmd,
envs,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import time

import pytest

from e2b import AsyncSandbox, TimeoutException
Expand Down Expand Up @@ -51,3 +53,30 @@ async def test_run_with_timeout(async_sandbox: AsyncSandbox):
async def test_run_with_too_short_timeout(async_sandbox: AsyncSandbox):
with pytest.raises(TimeoutException):
await async_sandbox.commands.run("sleep 10", timeout=2)


@pytest.mark.timeout(60)
async def test_background_run_is_capped_by_timeout(async_sandbox: AsyncSandbox):
start = time.time()
cmd = await async_sandbox.commands.run("sleep 20", background=True, timeout=10)
with pytest.raises(TimeoutException):
await cmd.wait()
# The command is capped by the timeout instead of running for the full 20s.
assert time.time() - start < 20


@pytest.mark.timeout(120)
async def test_background_run_without_timeout_completes(async_sandbox: AsyncSandbox):
# Sleep longer than the previous 60s default so this actually exercises the
# no-timeout default for background commands rather than the old 60s cap.
cmd = await async_sandbox.commands.run("sleep 70", background=True)
result = await cmd.wait()
assert result.exit_code == 0


@pytest.mark.timeout(120)
async def test_run_with_none_timeout_completes(async_sandbox: AsyncSandbox):
# `timeout=None` means no timeout, so a command longer than the 60s default
# completes instead of being capped.
cmd = await async_sandbox.commands.run("sleep 70", timeout=None)
assert cmd.exit_code == 0
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import time

import pytest

from e2b import Sandbox, TimeoutException
Expand Down Expand Up @@ -56,3 +58,30 @@ def test_run_with_too_short_timeout_iterating(sandbox):
with pytest.raises(TimeoutException):
for _ in cmd:
pass


@pytest.mark.timeout(60)
def test_background_run_is_capped_by_timeout(sandbox):
start = time.time()
cmd = sandbox.commands.run("sleep 20", background=True, timeout=10)
with pytest.raises(TimeoutException):
cmd.wait()
# The command is capped by the timeout instead of running for the full 20s.
assert time.time() - start < 20


@pytest.mark.timeout(120)
def test_background_run_without_timeout_completes(sandbox):
# Sleep longer than the previous 60s default so this actually exercises the
# no-timeout default for background commands rather than the old 60s cap.
cmd = sandbox.commands.run("sleep 70", background=True)
result = cmd.wait()
assert result.exit_code == 0


@pytest.mark.timeout(120)
def test_run_with_none_timeout_completes(sandbox):
# `timeout=None` means no timeout, so a command longer than the 60s default
# completes instead of being capped.
cmd = sandbox.commands.run("sleep 70", timeout=None)
assert cmd.exit_code == 0
Loading