diff --git a/.changeset/lazy-trees-tell.md b/.changeset/lazy-trees-tell.md new file mode 100644 index 0000000000..2a9ad911c7 --- /dev/null +++ b/.changeset/lazy-trees-tell.md @@ -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. diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index 108a750739..5b08b3eae0 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -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 * @@ -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), } ) diff --git a/packages/js-sdk/tests/sandbox/commands/run.test.ts b/packages/js-sdk/tests/sandbox/commands/run.test.ts index e532d88bb8..fd5fa135f2 100644 --- a/packages/js-sdk/tests/sandbox/commands/run.test.ts +++ b/packages/js-sdk/tests/sandbox/commands/run.test.ts @@ -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 +) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 32b75fd26b..ae78b2f5e6 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -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. @@ -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 @@ -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: """ @@ -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 @@ -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 @@ -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, diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index 512b7d9923..9ce47067c9 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -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. @@ -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 @@ -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: """ @@ -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 @@ -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 @@ -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, diff --git a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py index f871ebdc61..2297eafb49 100644 --- a/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py +++ b/packages/python-sdk/tests/async/sandbox_async/commands/test_run.py @@ -1,3 +1,5 @@ +import time + import pytest from e2b import AsyncSandbox, TimeoutException @@ -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 diff --git a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py index e75e5ba32a..38106dd25b 100644 --- a/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py +++ b/packages/python-sdk/tests/sync/sandbox_sync/commands/test_run.py @@ -1,3 +1,5 @@ +import time + import pytest from e2b import Sandbox, TimeoutException @@ -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