From 968edecb027121b693cb166d33e2ae0a3c75aafe Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Mon, 19 Oct 2026 07:05:50 +0000 Subject: [PATCH] chore(sdk): share process kill between commands and PTY, share CLI --language parser, drop unused internal exports Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .changeset/share-process-kill.md | 7 +++ .../src/commands/template/generators/types.ts | 14 ++++++ packages/cli/src/commands/template/init.ts | 12 +---- packages/cli/src/commands/template/migrate.ts | 12 +---- packages/js-sdk/src/envd/api.ts | 4 +- packages/js-sdk/src/envd/rpc.ts | 43 ++++++++++++++-- packages/js-sdk/src/sandbox/commands/index.ts | 45 +++-------------- packages/js-sdk/src/sandbox/commands/pty.ts | 49 ++++--------------- packages/python-sdk/e2b/envd/rpc.py | 46 +++++++++++++++++ .../e2b/sandbox_async/commands/command.py | 26 +++------- .../e2b/sandbox_async/commands/pty.py | 26 +++------- .../e2b/sandbox_sync/commands/command.py | 26 +++------- .../e2b/sandbox_sync/commands/pty.py | 26 +++------- 13 files changed, 156 insertions(+), 180 deletions(-) create mode 100644 .changeset/share-process-kill.md diff --git a/.changeset/share-process-kill.md b/.changeset/share-process-kill.md new file mode 100644 index 0000000000..e993ff1c12 --- /dev/null +++ b/.changeset/share-process-kill.md @@ -0,0 +1,7 @@ +--- +'e2b': patch +'@e2b/python-sdk': patch +'@e2b/cli': patch +--- + +Share the process kill implementation between commands and PTY, share the CLI `--language` option parser between `template init` and `template migrate`, and drop unused internal exports diff --git a/packages/cli/src/commands/template/generators/types.ts b/packages/cli/src/commands/template/generators/types.ts index cd1bff4790..4ed00df5fd 100644 --- a/packages/cli/src/commands/template/generators/types.ts +++ b/packages/cli/src/commands/template/generators/types.ts @@ -1,3 +1,5 @@ +import * as commander from 'commander' + export enum Language { TypeScript = 'typescript', PythonSync = 'python-sync', @@ -10,6 +12,18 @@ export const languageDisplay = { [Language.PythonAsync]: 'Python (async)', } +/** + * Parses the `--language` option value, rejecting unknown languages. + */ +export function parseLanguage(value: string): Language { + if (!Object.values(Language).includes(value as Language)) { + throw new commander.InvalidArgumentError( + `Invalid language. Must be one of: ${Object.values(Language).join(', ')}` + ) + } + return value as Language +} + export interface TemplateJSON { fromImage?: string fromTemplate?: string diff --git a/packages/cli/src/commands/template/init.ts b/packages/cli/src/commands/template/init.ts index e1b83e7e61..7d97194942 100644 --- a/packages/cli/src/commands/template/init.ts +++ b/packages/cli/src/commands/template/init.ts @@ -13,6 +13,7 @@ import { GeneratedFiles, Language, languageDisplay, + parseLanguage, } from './generators' import { generateReadmeContent } from './generators/handlebars' @@ -153,16 +154,7 @@ export const initCommand = new commander.Command('init') .option( '-l, --language ', `target language: ${Object.values(Language).join(', ')}`, - (value) => { - if (!Object.values(Language).includes(value as Language)) { - throw new commander.InvalidArgumentError( - `Invalid language. Must be one of: ${Object.values(Language).join( - ', ' - )}` - ) - } - return value as Language - } + parseLanguage ) .alias('it') .action( diff --git a/packages/cli/src/commands/template/migrate.ts b/packages/cli/src/commands/template/migrate.ts index 6bfbb6d069..e2e0e924cf 100644 --- a/packages/cli/src/commands/template/migrate.ts +++ b/packages/cli/src/commands/template/migrate.ts @@ -14,6 +14,7 @@ import { generateAndWriteTemplateFiles, Language, languageDisplay, + parseLanguage, } from './generators' /** @@ -129,16 +130,7 @@ export const migrateCommand = new commander.Command('migrate') .option( '-l, --language ', `specify target language: ${Object.values(Language).join(', ')}`, - (value) => { - if (!Object.values(Language).includes(value as Language)) { - throw new commander.InvalidArgumentError( - `Invalid language. Must be one of: ${Object.values(Language).join( - ', ' - )}` - ) - } - return value as Language - } + parseLanguage ) .addOption(pathOption) .action( diff --git a/packages/js-sdk/src/envd/api.ts b/packages/js-sdk/src/envd/api.ts index f92223c940..e6f9054795 100644 --- a/packages/js-sdk/src/envd/api.ts +++ b/packages/js-sdk/src/envd/api.ts @@ -69,7 +69,7 @@ export async function checkSandboxHealth( * the sandbox: the connection could not be established or was dropped mid-request * (the latter surfaces with runtime-specific wording, e.g. undici's `terminated`). */ -export function isFetchTransportFailure(err: unknown): err is Error { +function isFetchTransportFailure(err: unknown): err is Error { return ( err instanceof Error && (isConnectionTerminatedMessage(err.message) || isNetworkError(err)) @@ -104,7 +104,7 @@ export async function handleEnvdApiFetchError( * Extracts the error message of a non-2xx envd API response: the `message` of its * JSON body, else its text body, else its status text. */ -export async function getEnvdApiErrorMessage(res: { +async function getEnvdApiErrorMessage(res: { error?: ApiError response: Response }): Promise { diff --git a/packages/js-sdk/src/envd/rpc.ts b/packages/js-sdk/src/envd/rpc.ts index 893af57caa..350095dff5 100644 --- a/packages/js-sdk/src/envd/rpc.ts +++ b/packages/js-sdk/src/envd/rpc.ts @@ -1,4 +1,4 @@ -import { Code, ConnectError } from '@connectrpc/connect' +import { Client, Code, ConnectError } from '@connectrpc/connect' import { codeFromString } from '@connectrpc/connect/protocol-connect' import { runtime } from '../utils' @@ -19,6 +19,7 @@ import { } from '../errors' import { isNetworkError } from '../retry' import { ENVD_DEFAULT_USER } from './versions' +import { Process as ProcessService, Signal } from './process/process_pb' /** * Sandbox health probe: resolves to `true` if the sandbox is running, `false` if it @@ -117,10 +118,7 @@ export function isAmbiguousUnavailable(err: unknown): boolean { * kept, with the failed request as its cause; otherwise the probe's failure is * reported alongside the request's, which becomes the cause. */ -export function formatSandboxUnreachableError( - err: Error, - probeErr: unknown -): Error { +function formatSandboxUnreachableError(err: Error, probeErr: unknown): Error { if (probeErr instanceof SandboxUnreachableError) { probeErr.cause = err return probeErr @@ -245,6 +243,41 @@ export async function handleRpcErrorWithHealthCheck( return handleRpcError(err, errorMap) } +/** + * Kills the process with the given PID with `SIGKILL`. + * + * @returns `true` if the process was killed, `false` if it was not found. + */ +export async function killProcess( + rpc: Client, + pid: number, + signal: AbortSignal | undefined, + checkHealth: SandboxHealthCheck +): Promise { + try { + await rpc.sendSignal( + { + process: { + selector: { + case: 'pid', + value: pid, + }, + }, + signal: Signal.SIGKILL, + }, + { signal } + ) + + return true + } catch (err) { + if (err instanceof ConnectError && err.code === Code.NotFound) { + return false + } + + throw await handleRpcErrorWithHealthCheck(err, checkHealth) + } +} + function encode64(value: string): string { switch (runtime) { case 'deno': diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index a9905d084d..446147ca8e 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -1,10 +1,4 @@ -import { - Client, - Code, - ConnectError, - Transport, - createClient, -} from '@connectrpc/connect' +import { Client, Transport, createClient } from '@connectrpc/connect' import { compareVersions } from 'compare-versions' import { @@ -23,12 +17,12 @@ import { import { ConnectResponse, Process as ProcessService, - Signal, StartResponse, } from '../../envd/process/process_pb' import { authenticationHeader, handleRpcErrorWithHealthCheck, + killProcess, SandboxHealthCheck, } from '../../envd/rpc' import { ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE } from '../../envd/versions' @@ -282,35 +276,12 @@ export class Commands { * @returns `true` if the command was killed, `false` if the command was not found. */ async kill(pid: number, opts?: CommandRequestOpts): Promise { - try { - await this.rpc.sendSignal( - { - process: { - selector: { - case: 'pid', - value: pid, - }, - }, - signal: Signal.SIGKILL, - }, - { - signal: this.connectionConfig.getSignal( - opts?.requestTimeoutMs, - opts?.signal - ), - } - ) - - return true - } catch (err) { - if (err instanceof ConnectError) { - if (err.code === Code.NotFound) { - return false - } - } - - throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) - } + return killProcess( + this.rpc, + pid, + this.connectionConfig.getSignal(opts?.requestTimeoutMs, opts?.signal), + this.checkHealth + ) } /** diff --git a/packages/js-sdk/src/sandbox/commands/pty.ts b/packages/js-sdk/src/sandbox/commands/pty.ts index 29928a92f3..7fea01131d 100644 --- a/packages/js-sdk/src/sandbox/commands/pty.ts +++ b/packages/js-sdk/src/sandbox/commands/pty.ts @@ -1,15 +1,6 @@ -import { - Code, - ConnectError, - createClient, - Client, - Transport, -} from '@connectrpc/connect' +import { createClient, Client, Transport } from '@connectrpc/connect' -import { - Signal, - Process as ProcessService, -} from '../../envd/process/process_pb' +import { Process as ProcessService } from '../../envd/process/process_pb' import { ConnectionConfig, ConnectionOpts, @@ -22,6 +13,7 @@ import { CommandHandle } from './commandHandle' import { authenticationHeader, handleRpcErrorWithHealthCheck, + killProcess, SandboxHealthCheck, } from '../../envd/rpc' import { @@ -316,34 +308,11 @@ export class Pty { pid: number, opts?: Pick ): Promise { - try { - await this.rpc.sendSignal( - { - process: { - selector: { - case: 'pid', - value: pid, - }, - }, - signal: Signal.SIGKILL, - }, - { - signal: this.connectionConfig.getSignal( - opts?.requestTimeoutMs, - opts?.signal - ), - } - ) - - return true - } catch (err) { - if (err instanceof ConnectError) { - if (err.code === Code.NotFound) { - return false - } - } - - throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) - } + return killProcess( + this.rpc, + pid, + this.connectionConfig.getSignal(opts?.requestTimeoutMs, opts?.signal), + this.checkHealth + ) } } diff --git a/packages/python-sdk/e2b/envd/rpc.py b/packages/python-sdk/e2b/envd/rpc.py index bb9d7dfa5c..2643aa416b 100644 --- a/packages/python-sdk/e2b/envd/rpc.py +++ b/packages/python-sdk/e2b/envd/rpc.py @@ -3,8 +3,12 @@ from typing import Awaitable, Callable, Optional from connectrpc.code import Code from connectrpc.errors import ConnectError +from protobuf import Oneof from pyqwest import ReadError, StreamError, WriteError +from e2b.envd.process import process_connect, process_pb +from e2b.envd.utils import timeout_to_ms + from e2b.exceptions import ( SandboxException, InvalidArgumentException, @@ -199,3 +203,45 @@ async def ahandle_rpc_exception_with_health( except Exception as probe_error: return format_sandbox_unreachable_exception(e, probe_error) return handle_rpc_exception(e, error_map, sandbox_running) + + +def _kill_request(pid: int) -> process_pb.SendSignalRequest: + return process_pb.SendSignalRequest( + process=process_pb.ProcessSelector(selector=Oneof("pid", pid)), + signal=process_pb.Signal.SIGKILL, + ) + + +def kill_process( + rpc: process_connect.ProcessClientSync, + pid: int, + timeout: Optional[float], + check_health: Callable[[], Optional[bool]], +) -> bool: + """Kill the process with the given PID with ``SIGKILL``. + + :return: ``True`` if the process was killed, ``False`` if it was not found + """ + try: + rpc.send_signal(_kill_request(pid), timeout_ms=timeout_to_ms(timeout)) + return True + except Exception as e: + if isinstance(e, ConnectError) and e.code == Code.NOT_FOUND: + return False + raise handle_rpc_exception_with_health(e, check_health) + + +async def akill_process( + rpc: process_connect.ProcessClient, + pid: int, + timeout: Optional[float], + check_health: Callable[[], Awaitable[Optional[bool]]], +) -> bool: + """Async version of :func:`kill_process`.""" + try: + await rpc.send_signal(_kill_request(pid), timeout_ms=timeout_to_ms(timeout)) + return True + except Exception as e: + if isinstance(e, ConnectError) and e.code == Code.NOT_FOUND: + return False + raise await ahandle_rpc_exception_with_health(e, check_health) diff --git a/packages/python-sdk/e2b/sandbox_async/commands/command.py b/packages/python-sdk/e2b/sandbox_async/commands/command.py index 514add11af..e7a52b55af 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/command.py @@ -3,8 +3,6 @@ import httpx from e2b.api.client_async import get_envd_api -from connectrpc.code import Code -from connectrpc.errors import ConnectError from packaging.version import Version from e2b.connection_config import ( ConnectionConfig, @@ -16,7 +14,7 @@ from e2b.envd.process import process_connect, process_pb from e2b.envd.api import acheck_sandbox_health -from e2b.envd.rpc import ahandle_rpc_exception_with_health +from e2b.envd.rpc import akill_process, ahandle_rpc_exception_with_health from e2b.envd.utils import ( authentication_header, extract_start_pid, @@ -110,22 +108,12 @@ async def kill( :return: `True` if the command was killed, `False` if the command was not found """ - try: - await self._rpc.send_signal( - process_pb.SendSignalRequest( - process=process_pb.ProcessSelector(selector=Oneof("pid", pid)), - signal=process_pb.Signal.SIGKILL, - ), - timeout_ms=timeout_to_ms( - self._connection_config.get_request_timeout(request_timeout) - ), - ) - return True - except Exception as e: - if isinstance(e, ConnectError): - if e.code == Code.NOT_FOUND: - return False - raise await ahandle_rpc_exception_with_health(e, self._check_health) + return await akill_process( + self._rpc, + pid, + self._connection_config.get_request_timeout(request_timeout), + self._check_health, + ) async def send_stdin( self, diff --git a/packages/python-sdk/e2b/sandbox_async/commands/pty.py b/packages/python-sdk/e2b/sandbox_async/commands/pty.py index 1047a81140..d1dd3b3b37 100644 --- a/packages/python-sdk/e2b/sandbox_async/commands/pty.py +++ b/packages/python-sdk/e2b/sandbox_async/commands/pty.py @@ -4,8 +4,6 @@ from e2b.api.client_async import get_envd_api -from connectrpc.code import Code -from connectrpc.errors import ConnectError from packaging.version import Version from protobuf import Oneof @@ -17,7 +15,7 @@ KEEPALIVE_PING_INTERVAL_SEC, ) from e2b.envd.api import acheck_sandbox_health -from e2b.envd.rpc import ahandle_rpc_exception_with_health +from e2b.envd.rpc import akill_process, ahandle_rpc_exception_with_health from e2b.envd.utils import ( authentication_header, extract_start_pid, @@ -75,22 +73,12 @@ async def kill( :return: `true` if the PTY was killed, `false` if the PTY was not found """ - try: - await self._rpc.send_signal( - process_pb.SendSignalRequest( - process=process_pb.ProcessSelector(selector=Oneof("pid", pid)), - signal=process_pb.Signal.SIGKILL, - ), - timeout_ms=timeout_to_ms( - self._connection_config.get_request_timeout(request_timeout) - ), - ) - return True - except Exception as e: - if isinstance(e, ConnectError): - if e.code == Code.NOT_FOUND: - return False - raise await ahandle_rpc_exception_with_health(e, self._check_health) + return await akill_process( + self._rpc, + pid, + self._connection_config.get_request_timeout(request_timeout), + self._check_health, + ) async def send_stdin( self, diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/command.py b/packages/python-sdk/e2b/sandbox_sync/commands/command.py index a46a84dc98..82e871144a 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/command.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/command.py @@ -13,8 +13,6 @@ import httpx from e2b.api.client_sync import get_envd_api -from connectrpc.code import Code -from connectrpc.errors import ConnectError from packaging.version import Version from e2b.connection_config import ( ConnectionConfig, @@ -26,7 +24,7 @@ from e2b.envd.process import process_connect, process_pb from e2b.envd.api import check_sandbox_health -from e2b.envd.rpc import handle_rpc_exception_with_health +from e2b.envd.rpc import handle_rpc_exception_with_health, kill_process from e2b.envd.utils import ( authentication_header, extract_start_pid, @@ -119,22 +117,12 @@ def kill( :return: `True` if the command was killed, `False` if the command was not found """ - try: - self._rpc.send_signal( - process_pb.SendSignalRequest( - process=process_pb.ProcessSelector(selector=Oneof("pid", pid)), - signal=process_pb.Signal.SIGKILL, - ), - timeout_ms=timeout_to_ms( - self._connection_config.get_request_timeout(request_timeout) - ), - ) - return True - except Exception as e: - if isinstance(e, ConnectError): - if e.code == Code.NOT_FOUND: - return False - raise handle_rpc_exception_with_health(e, self._check_health) + return kill_process( + self._rpc, + pid, + self._connection_config.get_request_timeout(request_timeout), + self._check_health, + ) def send_stdin( self, diff --git a/packages/python-sdk/e2b/sandbox_sync/commands/pty.py b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py index 5ea770d85c..d0122d349c 100644 --- a/packages/python-sdk/e2b/sandbox_sync/commands/pty.py +++ b/packages/python-sdk/e2b/sandbox_sync/commands/pty.py @@ -4,8 +4,6 @@ from e2b.api.client_sync import get_envd_api -from connectrpc.code import Code -from connectrpc.errors import ConnectError from packaging.version import Version from protobuf import Oneof @@ -17,7 +15,7 @@ KEEPALIVE_PING_INTERVAL_SEC, ) from e2b.envd.api import check_sandbox_health -from e2b.envd.rpc import handle_rpc_exception_with_health +from e2b.envd.rpc import handle_rpc_exception_with_health, kill_process from e2b.envd.utils import ( authentication_header, extract_start_pid, @@ -71,22 +69,12 @@ def kill( :return: `true` if the PTY was killed, `false` if the PTY was not found """ - try: - self._rpc.send_signal( - process_pb.SendSignalRequest( - process=process_pb.ProcessSelector(selector=Oneof("pid", pid)), - signal=process_pb.Signal.SIGKILL, - ), - timeout_ms=timeout_to_ms( - self._connection_config.get_request_timeout(request_timeout) - ), - ) - return True - except Exception as e: - if isinstance(e, ConnectError): - if e.code == Code.NOT_FOUND: - return False - raise handle_rpc_exception_with_health(e, self._check_health) + return kill_process( + self._rpc, + pid, + self._connection_config.get_request_timeout(request_timeout), + self._check_health, + ) def send_stdin( self,