Skip to content
Merged
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
7 changes: 7 additions & 0 deletions .changeset/share-process-kill.md
Original file line number Diff line number Diff line change
@@ -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
14 changes: 14 additions & 0 deletions packages/cli/src/commands/template/generators/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import * as commander from 'commander'

export enum Language {
TypeScript = 'typescript',
PythonSync = 'python-sync',
Expand All @@ -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
Expand Down
12 changes: 2 additions & 10 deletions packages/cli/src/commands/template/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
GeneratedFiles,
Language,
languageDisplay,
parseLanguage,
} from './generators'
import { generateReadmeContent } from './generators/handlebars'

Expand Down Expand Up @@ -153,16 +154,7 @@ export const initCommand = new commander.Command('init')
.option(
'-l, --language <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(
Expand Down
12 changes: 2 additions & 10 deletions packages/cli/src/commands/template/migrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
generateAndWriteTemplateFiles,
Language,
languageDisplay,
parseLanguage,
} from './generators'

/**
Expand Down Expand Up @@ -129,16 +130,7 @@ export const migrateCommand = new commander.Command('migrate')
.option(
'-l, --language <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(
Expand Down
4 changes: 2 additions & 2 deletions packages/js-sdk/src/envd/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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<string> {
Expand Down
43 changes: 38 additions & 5 deletions packages/js-sdk/src/envd/rpc.ts
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<typeof ProcessService>,
pid: number,
signal: AbortSignal | undefined,
checkHealth: SandboxHealthCheck
): Promise<boolean> {
Comment on lines +251 to +256

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Shared kill paths lack regression coverage

When killProcess replaces both kill implementations, no tests are created or updated for its error paths. Existing kill tests cover success and missing processes, leaving transport failures untested.

Learn more

The repository requires creating or updating tests for affected codepaths. This PR adds shared kill helpers in killProcess, kill_process, and akill_process without changing any tests. Existing kill integration tests cover ordinary success and missing PIDs, but do not cover failures handled by the helpers.

Example: If sendSignal rejects with a transport failure, the helper must preserve the health-check mapping; no new or updated test asserts that behavior.

Recommended fix: Add focused tests for the shared helpers' success, not-found, and health-check failure branches in JS and both Python modes. Update CLI tests for the extracted parseLanguage parser, then run the affected tests.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

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':
Expand Down
45 changes: 8 additions & 37 deletions packages/js-sdk/src/sandbox/commands/index.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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'
Expand Down Expand Up @@ -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<boolean> {
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
)
}

/**
Expand Down
49 changes: 9 additions & 40 deletions packages/js-sdk/src/sandbox/commands/pty.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -22,6 +13,7 @@ import { CommandHandle } from './commandHandle'
import {
authenticationHeader,
handleRpcErrorWithHealthCheck,
killProcess,
SandboxHealthCheck,
} from '../../envd/rpc'
import {
Expand Down Expand Up @@ -316,34 +308,11 @@ export class Pty {
pid: number,
opts?: Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>
): Promise<boolean> {
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
)
}
}
46 changes: 46 additions & 0 deletions packages/python-sdk/e2b/envd/rpc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Loading
Loading