Skip to content
Open
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
45 changes: 44 additions & 1 deletion packages/cli/src/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,39 @@ function getStdoutSize() {
}
}

function isRetryableError(err: unknown): boolean {
// Retry on SDK TimeoutError
if (err instanceof (e2b as any).TimeoutError) return true
Copy link
Member

Choose a reason for hiding this comment

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

(e2b as any) unnecessary - just import TimeoutError from e2b


// Some environments throw AbortError for aborted/timeout fetches
if (err && typeof err === 'object' && (err as any).name === 'AbortError')
Copy link
Member

Choose a reason for hiding this comment

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

you can infer the type as err as Error (in below occurrences also)

return true

// Network/system-level transient errors commonly exposed via code property
const code = (err as any)?.code ?? (err as any)?.cause?.code
const retryableCodes = new Set([
'ECONNRESET',
'ECONNREFUSED',
'ECONNABORTED',
'EPIPE',
'ETIMEDOUT',
'ENOTFOUND',
'EAI_AGAIN',
'EHOSTUNREACH',
'EADDRINUSE',
])
if (typeof code === 'string' && retryableCodes.has(code)) return true

// Undici/Fetch may surface as TypeError: fetch failed with nested cause
Copy link
Member

Choose a reason for hiding this comment

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

did you check this?

if ((err as any) instanceof TypeError) {
const msg = String((err as any).message || '').toLowerCase()
if (msg.includes('fetch failed') || msg.includes('network error'))
return true
}

return false
}

export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) {
// Clear local terminal emulator before starting terminal
// process.stdout.write('\x1b[2J\x1b[0f')
Expand All @@ -26,7 +59,17 @@ export async function spawnConnectedTerminal(sandbox: e2b.Sandbox) {

const inputQueue = new BatchedQueue<Buffer>(async (batch) => {
const combined = Buffer.concat(batch)
await sandbox.pty.sendInput(terminalSession.pid, combined)

const maxRetries = 3
for (let retry = 0; ; retry++) {
Copy link
Member

Choose a reason for hiding this comment

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

try to avoid infinite loops

try {
await sandbox.pty.sendInput(terminalSession.pid, combined)
break
Comment on lines +63 to +67

Choose a reason for hiding this comment

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

P2 Badge Avoid retrying PTY input without idempotency

Retrying sendInput can duplicate keystrokes/commands when a transient error is raised after the server already applied the input (e.g., timeout/connection reset after the PTY received data). Because the retry resends the same combined buffer without any sequence/dedup mechanism, users can see repeated characters or repeated command execution under exactly those network conditions. If sendInput is not explicitly idempotent, this introduces at-least-once semantics for terminal input.

Useful? React with 👍 / 👎.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Interesting point. @ValentaTomas wdyt about adding a "request number", idempotency key, or something similar so that the backend avoids duplicate processing?

Copy link
Member

Choose a reason for hiding this comment

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

Might make sense here! Alternatively, the client long running connection that we don't have implemented in the SDK might be also a solution.

} catch (err) {
// Do not retry on errors that come with valid HTTP/gRPC responses
if (!isRetryableError(err) || retry >= maxRetries) throw err
}
}
}, FLUSH_INPUT_INTERVAL_MS)

const resizeListener = process.stdout.on('resize', () =>
Expand Down