Skip to content
Open
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
25 changes: 9 additions & 16 deletions packages/cli/src/commands/sandbox/exec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,22 +197,15 @@ async function sendStdin(sandbox: Sandbox, pid: number): Promise<void> {
return
}

// Fail fast instead of leaving a command blocked on stdin forever.
await killProcessBestEffort(sandbox, pid)
// Fail fast, and avoid leaking a process blocked on stdin.
try {
await sandbox.commands.kill(pid)
} catch (killErr) {
console.error(
'e2b: Failed to kill remote process after stdin EOF signaling failed.'
)
console.error(killErr)
}
throw err
}
}

async function killProcessBestEffort(
sandbox: Sandbox,
pid: number
): Promise<void> {
try {
await sandbox.commands.kill(pid)
} catch (killErr) {
console.error(
'e2b: Failed to kill remote process after stdin EOF signaling failed.'
)
console.error(killErr)
}
}
222 changes: 47 additions & 175 deletions packages/cli/tests/commands/sandbox/backend_integration.test.ts
Original file line number Diff line number Diff line change
@@ -1,46 +1,36 @@
import { spawn, spawnSync } from 'node:child_process'
import path from 'node:path'
import { afterAll, beforeAll, describe, expect, test } from 'vitest'
import { Sandbox } from 'e2b'
import { getUserConfig } from 'src/user'

type UserConfigWithDomain = NonNullable<ReturnType<typeof getUserConfig>> & {
domain?: string
E2B_DOMAIN?: string
}

const userConfig = safeGetUserConfig() as UserConfigWithDomain | null
const domain =
process.env.E2B_DOMAIN ||
userConfig?.E2B_DOMAIN ||
userConfig?.domain ||
'e2b.app'
const apiKey = process.env.E2B_API_KEY || userConfig?.teamApiKey
import {
bufferToText,
isDebug,
parseEnvInt,
runCli,
runCliWithPipedStdin,
} from '../../setup'

const integrationTest = test.skipIf(isDebug)
Comment thread
matthewlouisbrockman marked this conversation as resolved.
Outdated
const templateId =
process.env.E2B_CLI_BACKEND_TEMPLATE_ID ||
process.env.E2B_TEMPLATE_ID ||
'base'
const isDebug = process.env.E2B_DEBUG !== undefined
const hasCreds = Boolean(apiKey)
const shouldSkip = !hasCreds || isDebug
const testIf = test.skipIf(shouldSkip)
const cliPath = path.join(process.cwd(), 'dist', 'index.js')
const sandboxTimeoutMs = parseEnvInt(
'E2B_CLI_BACKEND_SANDBOX_TIMEOUT_MS',
20_000
)
const perTestTimeoutMs = parseEnvInt('E2B_CLI_BACKEND_TEST_TIMEOUT_MS', 30_000)
const spawnTimeoutMs = perTestTimeoutMs
const runCliInSandbox = (args: string[]) =>
runCli(args, { timeoutMs: spawnTimeoutMs })
const runCliWithPipeInSandbox = (args: string[], input: Buffer) =>
runCliWithPipedStdin(args, input, { timeoutMs: spawnTimeoutMs })

describe('sandbox cli backend integration', () => {
let sandbox: Sandbox

beforeAll(async () => {
if (shouldSkip) return
if (isDebug) return

sandbox = await Sandbox.create(templateId, {
apiKey,
domain,
timeoutMs: sandboxTimeoutMs,
})
}, 30_000)
Expand All @@ -57,35 +47,33 @@ describe('sandbox cli backend integration', () => {
}
}, 15_000)

testIf('list shows the sandbox', { timeout: perTestTimeoutMs }, async () => {
const listResult = runCli(['sandbox', 'list', '--format', 'json'])
expect(listResult.status).toBe(0)
expect(sandboxExistsInList(listResult.stdout, sandbox.sandboxId)).toBe(true)
})
integrationTest(
'list shows the sandbox',
{ timeout: perTestTimeoutMs },
async () => {
const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json'])
expect(listResult.status).toBe(0)
expect(sandboxExistsInList(listResult.stdout, sandbox.sandboxId)).toBe(true)
}
)

testIf(
integrationTest(
'exec runs a command without piped stdin',
{ timeout: perTestTimeoutMs },
async () => {
const execResult = runCli([
'sandbox',
'exec',
sandbox.sandboxId,
'--',
'sh',
'-lc',
'echo backend-non-pipe',
const execResult = runCliInSandbox([
'sandbox', 'exec', sandbox.sandboxId, '--', 'sh', '-lc', 'echo backend-non-pipe',
])
expect(execResult.status).toBe(0)
expect(bufferToText(execResult.stdout)).toContain('backend-non-pipe')
}
)

testIf(
integrationTest(
'exec runs a command with piped stdin',
{ timeout: perTestTimeoutMs },
async () => {
const pipedExecResult = await runCliWithPipedStdin(
const pipedExecResult = await runCliWithPipeInSandbox(
['sandbox', 'exec', sandbox.sandboxId, '--', 'sh', '-lc', 'wc -c'],
Buffer.from('hello\n', 'utf8')
)
Expand All @@ -97,132 +85,41 @@ describe('sandbox cli backend integration', () => {
}
)

testIf(
'logs returns successfully',
{ timeout: perTestTimeoutMs },
async () => {
const logsResult = runCli([
'sandbox',
'logs',
sandbox.sandboxId,
'--format',
'json',
])
expect(logsResult.status).toBe(0)
}
)

testIf(
'metrics returns successfully',
{ timeout: perTestTimeoutMs },
async () => {
const metricsResult = runCli([
'sandbox',
'metrics',
sandbox.sandboxId,
'--format',
'json',
])
expect(metricsResult.status).toBe(0)
}
)
for (const command of ['logs', 'metrics'] as const) {
integrationTest(
`${command} returns successfully`,
{ timeout: perTestTimeoutMs },
async () => {
const result = runCliInSandbox([
'sandbox',
command,
sandbox.sandboxId,
'--format',
'json',
])
expect(result.status).toBe(0)
}
)
}

testIf(
integrationTest(
'kill removes the sandbox',
{ timeout: perTestTimeoutMs },
async () => {
const killResult = runCli(['sandbox', 'kill', sandbox.sandboxId])
const killResult = runCliInSandbox(['sandbox', 'kill', sandbox.sandboxId])
expect(killResult.status).toBe(0)

await assertSandboxNotListed(sandbox.sandboxId)
}
)
})

function runCli(
args: string[],
opts?: { input?: string | Buffer }
): ReturnType<typeof spawnSync> {
const env: NodeJS.ProcessEnv = {
...process.env,
E2B_DOMAIN: domain,
E2B_API_KEY: apiKey,
}
delete env.E2B_DEBUG

return spawnSync('node', [cliPath, ...args], {
env,
input: opts?.input,
encoding: 'utf8',
timeout: spawnTimeoutMs,
})
}

type PipeRunResult = {
status: number | null
stdout: Buffer
stderr: Buffer
error?: Error
}

function runCliWithPipedStdin(
args: string[],
input: Buffer
): Promise<PipeRunResult> {
const env: NodeJS.ProcessEnv = {
...process.env,
E2B_DOMAIN: domain,
E2B_API_KEY: apiKey,
}
delete env.E2B_DEBUG

return new Promise((resolve) => {
const child = spawn('node', [cliPath, ...args], {
env,
stdio: ['pipe', 'pipe', 'pipe'],
})

const stdoutChunks: Buffer[] = []
const stderrChunks: Buffer[] = []
let childError: Error | undefined
let timedOut = false

const timer = setTimeout(() => {
timedOut = true
child.kill()
}, spawnTimeoutMs)

child.stdout.on('data', (chunk) => stdoutChunks.push(Buffer.from(chunk)))
child.stderr.on('data', (chunk) => stderrChunks.push(Buffer.from(chunk)))
child.on('error', (err) => {
childError = err
})
child.on('close', (code) => {
clearTimeout(timer)
const timeoutError = timedOut
? Object.assign(new Error('CLI command timed out'), {
code: 'ETIMEDOUT',
} as NodeJS.ErrnoException)
: undefined
resolve({
status: code,
stdout: Buffer.concat(stdoutChunks),
stderr: Buffer.concat(stderrChunks),
error: childError ?? timeoutError,
})
})

child.stdin.write(input)
child.stdin.end()
})
}

async function assertSandboxNotListed(sandboxId: string): Promise<void> {
const retries = 10
const delayMs = 500

for (let i = 0; i < retries; i++) {
const listResult = runCli(['sandbox', 'list', '--format', 'json'])
const listResult = runCliInSandbox(['sandbox', 'list', '--format', 'json'])
if (listResult.status === 0) {
const exists = sandboxExistsInList(listResult.stdout, sandboxId)
if (!exists) {
Expand All @@ -248,28 +145,3 @@ function sandboxExistsInList(
const parsed = JSON.parse(text) as Array<{ sandboxId?: string }>
return parsed.some((item) => item.sandboxId === sandboxId)
}

function bufferToText(value: Buffer | string | null | undefined): string {
if (!value) {
return ''
}
return typeof value === 'string' ? value : value.toString('utf8')
}

function parseEnvInt(name: string, fallback: number): number {
const raw = process.env[name]
if (!raw) {
return fallback
}
const parsed = Number.parseInt(raw, 10)
return Number.isFinite(parsed) ? parsed : fallback
}

function safeGetUserConfig(): ReturnType<typeof getUserConfig> | null {
try {
return getUserConfig()
} catch (err) {
console.warn(`Failed to read ~/.e2b/config.json: ${String(err)}`)
return null
}
}
Loading