diff --git a/packages/cli/src/commands/sandbox/exec.ts b/packages/cli/src/commands/sandbox/exec.ts index a5c62df0..5d9231d4 100644 --- a/packages/cli/src/commands/sandbox/exec.ts +++ b/packages/cli/src/commands/sandbox/exec.ts @@ -2,7 +2,12 @@ * Execute a command in a running sandbox. */ -import { Sandbox, CommandExitError, SandboxNotFoundError } from '@abox-dev/sdk' +import { + Sandbox, + CommandExitError, + CommandNotFoundError, + SandboxNotFoundError, +} from '@abox-dev/sdk' import * as commander from 'commander' import { ensureAPIKey } from '../../api' @@ -182,7 +187,10 @@ async function sendStdin(sandbox: Sandbox, pid: number): Promise { try { await sandbox.commands.sendStdin(pid, chunk) } catch (err) { - if (err instanceof SandboxNotFoundError) { + if ( + err instanceof CommandNotFoundError || + err instanceof SandboxNotFoundError + ) { processExited = true console.error( 'agentbox: Remote command exited before stdin could be delivered.' @@ -203,7 +211,10 @@ async function sendStdin(sandbox: Sandbox, pid: number): Promise { try { await sandbox.commands.closeStdin(pid) } catch (err) { - if (err instanceof SandboxNotFoundError) { + if ( + err instanceof CommandNotFoundError || + err instanceof SandboxNotFoundError + ) { // Process already exited — EOF is moot. return } diff --git a/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts b/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts index 6bb41f54..a19bab16 100644 --- a/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_close_stdin.test.ts @@ -36,6 +36,7 @@ vi.mock('@abox-dev/sdk', () => { } class SandboxNotFoundError extends Error {} + class CommandNotFoundError extends Error {} return { Sandbox: { @@ -43,6 +44,7 @@ vi.mock('@abox-dev/sdk', () => { }, CommandExitError, SandboxNotFoundError, + CommandNotFoundError, } }) @@ -131,10 +133,10 @@ describe('sandbox exec closeStdin handling', () => { expect(exitSpy).toHaveBeenCalledWith(1) }) - test('keeps SandboxNotFoundError from closeStdin non-fatal', async () => { - const { SandboxNotFoundError } = await import('@abox-dev/sdk') + test('keeps CommandNotFoundError from closeStdin non-fatal', async () => { + const { CommandNotFoundError } = await import('@abox-dev/sdk') mocks.closeStdin.mockRejectedValue( - new SandboxNotFoundError('already exited') + new CommandNotFoundError('already exited') ) const exitSpy = vi @@ -153,10 +155,10 @@ describe('sandbox exec closeStdin handling', () => { expect(exitSpy).toHaveBeenCalledWith(0) }) - test('stops stdin streaming after SandboxNotFoundError from sendStdin', async () => { - const { SandboxNotFoundError } = await import('@abox-dev/sdk') + test('stops stdin streaming after CommandNotFoundError from sendStdin', async () => { + const { CommandNotFoundError } = await import('@abox-dev/sdk') mocks.sendStdin.mockRejectedValueOnce( - new SandboxNotFoundError('already exited') + new CommandNotFoundError('already exited') ) mocks.streamStdinChunks.mockImplementation( async ( diff --git a/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts b/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts index d02d8f1f..5f2418ed 100644 --- a/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_passthrough.test.ts @@ -26,6 +26,7 @@ vi.mock('@abox-dev/sdk', () => { } class SandboxNotFoundError extends Error {} + class CommandNotFoundError extends Error {} return { Sandbox: { @@ -33,6 +34,7 @@ vi.mock('@abox-dev/sdk', () => { }, CommandExitError, SandboxNotFoundError, + CommandNotFoundError, } }) diff --git a/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts b/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts index 9c46020d..544ee276 100644 --- a/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts +++ b/packages/cli/tests/commands/sandbox/exec_stdin_flag.test.ts @@ -32,6 +32,7 @@ vi.mock('@abox-dev/sdk', () => { } class SandboxNotFoundError extends Error {} + class CommandNotFoundError extends Error {} return { Sandbox: { @@ -39,6 +40,7 @@ vi.mock('@abox-dev/sdk', () => { }, CommandExitError, SandboxNotFoundError, + CommandNotFoundError, } }) diff --git a/packages/js-sdk/src/errors.ts b/packages/js-sdk/src/errors.ts index 79954452..9b8e1fc2 100644 --- a/packages/js-sdk/src/errors.ts +++ b/packages/js-sdk/src/errors.ts @@ -78,6 +78,16 @@ export class SandboxNotFoundError extends SandboxError { } } +/** + * Thrown when a command process has already exited or its PID does not exist. + */ +export class CommandNotFoundError extends SandboxError { + constructor(message: string) { + super(message) + this.name = 'CommandNotFoundError' + } +} + /** * Thrown when authentication fails. */ diff --git a/packages/js-sdk/src/index.ts b/packages/js-sdk/src/index.ts index a2f0664a..ecbfb2d1 100644 --- a/packages/js-sdk/src/index.ts +++ b/packages/js-sdk/src/index.ts @@ -10,6 +10,7 @@ export { NotEnoughSpaceError, SandboxError, SandboxNotFoundError, + CommandNotFoundError, TemplateError, TimeoutError, RateLimitError, diff --git a/packages/js-sdk/src/sandbox/commands/index.ts b/packages/js-sdk/src/sandbox/commands/index.ts index bbc92241..f85955dc 100644 --- a/packages/js-sdk/src/sandbox/commands/index.ts +++ b/packages/js-sdk/src/sandbox/commands/index.ts @@ -30,7 +30,7 @@ import { SandboxHealthCheck, } from '../../envd/rpc' import { ENVD_COMMANDS_STDIN, ENVD_ENVD_CLOSE } from '../../envd/versions' -import { SandboxError } from '../../errors' +import { CommandNotFoundError, SandboxError } from '../../errors' import { CommandHandle, CommandResult } from './commandHandle' export { Pty } from './pty' @@ -228,7 +228,9 @@ export class Commands { } ) } catch (err) { - throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth, { + [Code.NotFound]: (message) => new CommandNotFoundError(message), + }) } } @@ -265,7 +267,9 @@ export class Commands { } ) } catch (err) { - throw await handleRpcErrorWithHealthCheck(err, this.checkHealth) + throw await handleRpcErrorWithHealthCheck(err, this.checkHealth, { + [Code.NotFound]: (message) => new CommandNotFoundError(message), + }) } } diff --git a/packages/js-sdk/tests/envd/commandProcessNotFound.test.ts b/packages/js-sdk/tests/envd/commandProcessNotFound.test.ts new file mode 100644 index 00000000..1b6db782 --- /dev/null +++ b/packages/js-sdk/tests/envd/commandProcessNotFound.test.ts @@ -0,0 +1,32 @@ +import { Code, ConnectError } from '@connectrpc/connect' +import { describe, expect, it, vi } from 'vitest' + +import { CommandNotFoundError } from '../../src/errors' +import { Commands } from '../../src/sandbox/commands' + +function commandsWithMissingProcess(): Commands { + const missing = () => + Promise.reject(new ConnectError('process not found', Code.NotFound)) + const commands = Object.create(Commands.prototype) as Commands + Object.assign(commands, { + rpc: { sendInput: vi.fn(missing), closeStdin: vi.fn(missing) }, + envdVersion: '0.6.15', + connectionConfig: { getSignal: () => undefined }, + checkHealth: async () => true, + }) + return commands +} + +describe('command process disappearance', () => { + it('identifies a missing PID while sending stdin', async () => { + await expect( + commandsWithMissingProcess().sendStdin(1234, 'input') + ).rejects.toBeInstanceOf(CommandNotFoundError) + }) + + it('identifies a missing PID while closing stdin', async () => { + await expect( + commandsWithMissingProcess().closeStdin(1234) + ).rejects.toBeInstanceOf(CommandNotFoundError) + }) +})