Skip to content

Commit 2ac5de2

Browse files
authored
feat(js-sdk): support AbortSignal for request cancellation (#1328)
1 parent 70f0d83 commit 2ac5de2

18 files changed

Lines changed: 559 additions & 303 deletions

File tree

.changeset/abort-signal-support.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
'e2b': minor
3+
---
4+
5+
Add `signal: AbortSignal` option to JS SDK methods to support cancelling in-flight requests. The signal can be passed to `Sandbox.create`, `Sandbox.connect`, `sandbox.commands.run`, `sandbox.files.*`, volume methods, and other request options. When the signal is aborted, the underlying `fetch` is aborted and the returned promise rejects with an `AbortError`.
6+
7+
`SandboxPaginator.nextItems` and `SnapshotPaginator.nextItems` now accept a `SandboxApiOpts` argument (including `signal`) — when provided, the per-call options override the connection options the paginator was constructed with for that single request.
8+
9+
Same change in the Python SDK: `SandboxPaginator.next_items` / `SnapshotPaginator.next_items` (sync and async) now accept `**opts: ApiParams` (e.g. `api_key`, `domain`, `headers`, `request_timeout`); when provided, the per-call options override the ones the paginator was constructed with.

packages/cli/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@
5959
"json2md": "^2.0.1",
6060
"knip": "^5.43.6",
6161
"tsup": "^8.4.0",
62-
"typescript": "^5.2.2",
62+
"typescript": "^5.4.5",
6363
"vitest": "^3.2.4"
6464
},
6565
"files": [

packages/js-sdk/src/connectionConfig.ts

Lines changed: 101 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,105 @@ export interface ConnectionOpts {
6262
* Additional headers to send with the request.
6363
*/
6464
headers?: Record<string, string>
65+
66+
/**
67+
* An optional `AbortSignal` that can be used to cancel the in-flight request.
68+
* When the signal is aborted, the underlying `fetch` is aborted and the
69+
* returned promise rejects with an `AbortError`.
70+
*/
71+
signal?: AbortSignal
72+
}
73+
74+
/**
75+
* Build an `AbortSignal` that combines an optional request-timeout signal
76+
* (via `AbortSignal.timeout`) with an optional user-provided signal.
77+
*
78+
* Returns `undefined` when neither input would produce a signal.
79+
*
80+
* @internal
81+
*/
82+
export function buildRequestSignal(
83+
requestTimeoutMs: number | undefined,
84+
userSignal: AbortSignal | undefined
85+
): AbortSignal | undefined {
86+
const timeoutSignal = requestTimeoutMs
87+
? AbortSignal.timeout(requestTimeoutMs)
88+
: undefined
89+
90+
if (timeoutSignal && userSignal) {
91+
return AbortSignal.any([timeoutSignal, userSignal])
92+
}
93+
94+
return timeoutSignal ?? userSignal
95+
}
96+
97+
/**
98+
* Set up an internal `AbortController` for a streaming request.
99+
*
100+
* Until `clearStartTimeout` is called, the controller aborts when either
101+
* - the optional user signal aborts, or
102+
* - the optional request timeout elapses (used to bound the initial
103+
* handshake; long-lived streams should call `clearStartTimeout` once
104+
* the handshake succeeds).
105+
*
106+
* The user-signal listener stays attached for the full stream lifetime
107+
* so the caller can cancel a long-running stream by aborting the signal.
108+
*
109+
* `cleanup` is idempotent and detaches the listener, clears the handshake
110+
* timer (if still pending), and aborts the controller. Call it when the
111+
* stream finishes or when startup fails.
112+
*
113+
* @internal
114+
*/
115+
export function setupRequestController(
116+
requestTimeoutMs: number | undefined,
117+
userSignal: AbortSignal | undefined
118+
): {
119+
controller: AbortController
120+
clearStartTimeout: () => void
121+
cleanup: () => void
122+
} {
123+
const controller = new AbortController()
124+
125+
const onUserAbort = () => controller.abort(userSignal?.reason)
126+
if (userSignal) {
127+
if (userSignal.aborted) {
128+
controller.abort(userSignal.reason)
129+
} else {
130+
userSignal.addEventListener('abort', onUserAbort, { once: true })
131+
}
132+
}
133+
134+
let reqTimeout: ReturnType<typeof setTimeout> | undefined = requestTimeoutMs
135+
? setTimeout(
136+
() =>
137+
controller.abort(
138+
new DOMException(
139+
`Request handshake timed out after ${requestTimeoutMs}ms`,
140+
'TimeoutError'
141+
)
142+
),
143+
requestTimeoutMs
144+
)
145+
: undefined
146+
147+
const clearStartTimeout = () => {
148+
if (reqTimeout) {
149+
clearTimeout(reqTimeout)
150+
reqTimeout = undefined
151+
}
152+
}
153+
154+
let cleaned = false
155+
const cleanup = () => {
156+
if (cleaned) return
157+
cleaned = true
158+
userSignal?.removeEventListener('abort', onUserAbort)
159+
clearStartTimeout()
160+
controller.abort()
161+
}
162+
163+
return { controller, clearStartTimeout, cleanup }
65164
}
66165

67166
/**
@@ -125,10 +224,8 @@ export class ConnectionConfig {
125224
return getEnvVar('E2B_ACCESS_TOKEN')
126225
}
127226

128-
getSignal(requestTimeoutMs?: number) {
129-
const timeout = requestTimeoutMs ?? this.requestTimeoutMs
130-
131-
return timeout ? AbortSignal.timeout(timeout) : undefined
227+
getSignal(requestTimeoutMs?: number, signal?: AbortSignal) {
228+
return buildRequestSignal(requestTimeoutMs ?? this.requestTimeoutMs, signal)
132229
}
133230

134231
getSandboxUrl(

packages/js-sdk/src/sandbox/commands/commandHandle.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,8 @@ export class CommandHandle
235235
}
236236
} catch (e) {
237237
this.iterationError = handleRpcError(e)
238+
} finally {
239+
this.handleDisconnect()
238240
}
239241
}
240242
}

packages/js-sdk/src/sandbox/commands/index.ts

Lines changed: 36 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
ConnectionOpts,
1313
KEEPALIVE_PING_HEADER,
1414
KEEPALIVE_PING_INTERVAL_SEC,
15+
setupRequestController,
1516
Username,
1617
} from '../../connectionConfig'
1718
import { handleProcessStartEvent } from '../../envd/api'
@@ -29,7 +30,7 @@ export { Pty } from './pty'
2930
* Options for sending a command request.
3031
*/
3132
export interface CommandRequestOpts
32-
extends Partial<Pick<ConnectionOpts, 'requestTimeoutMs'>> {}
33+
extends Partial<Pick<ConnectionOpts, 'requestTimeoutMs' | 'signal'>> {}
3334

3435
/**
3536
* Options for starting a new command.
@@ -160,7 +161,10 @@ export class Commands {
160161
const res = await this.rpc.list(
161162
{},
162163
{
163-
signal: this.connectionConfig.getSignal(opts?.requestTimeoutMs),
164+
signal: this.connectionConfig.getSignal(
165+
opts?.requestTimeoutMs,
166+
opts?.signal
167+
),
164168
}
165169
)
166170

@@ -209,7 +213,10 @@ export class Commands {
209213
},
210214
},
211215
{
212-
signal: this.connectionConfig.getSignal(opts?.requestTimeoutMs),
216+
signal: this.connectionConfig.getSignal(
217+
opts?.requestTimeoutMs,
218+
opts?.signal
219+
),
213220
}
214221
)
215222
} catch (err) {
@@ -239,7 +246,10 @@ export class Commands {
239246
},
240247
},
241248
{
242-
signal: this.connectionConfig.getSignal(opts?.requestTimeoutMs),
249+
signal: this.connectionConfig.getSignal(
250+
opts?.requestTimeoutMs,
251+
opts?.signal
252+
),
243253
}
244254
)
245255
} catch (err) {
@@ -269,7 +279,10 @@ export class Commands {
269279
signal: Signal.SIGKILL,
270280
},
271281
{
272-
signal: this.connectionConfig.getSignal(opts?.requestTimeoutMs),
282+
signal: this.connectionConfig.getSignal(
283+
opts?.requestTimeoutMs,
284+
opts?.signal
285+
),
273286
}
274287
)
275288

@@ -301,13 +314,10 @@ export class Commands {
301314
const requestTimeoutMs =
302315
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs
303316

304-
const controller = new AbortController()
305-
306-
const reqTimeout = requestTimeoutMs
307-
? setTimeout(() => {
308-
controller.abort()
309-
}, requestTimeoutMs)
310-
: undefined
317+
const { controller, clearStartTimeout, cleanup } = setupRequestController(
318+
requestTimeoutMs,
319+
opts?.signal
320+
)
311321

312322
const events = this.rpc.connect(
313323
{
@@ -329,19 +339,19 @@ export class Commands {
329339

330340
try {
331341
const pid = await handleProcessStartEvent(events)
332-
333-
clearTimeout(reqTimeout)
342+
clearStartTimeout()
334343

335344
return new CommandHandle(
336345
pid,
337-
() => controller.abort(),
346+
cleanup,
338347
() => this.kill(pid),
339348
events,
340349
opts?.onStdout,
341350
opts?.onStderr,
342351
undefined
343352
)
344353
} catch (err) {
354+
cleanup()
345355
throw handleRpcError(err)
346356
}
347357
}
@@ -402,17 +412,6 @@ export class Commands {
402412
cmd: string,
403413
opts?: CommandStartOpts
404414
): Promise<CommandHandle> {
405-
const requestTimeoutMs =
406-
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs
407-
408-
const controller = new AbortController()
409-
410-
const reqTimeout = requestTimeoutMs
411-
? setTimeout(() => {
412-
controller.abort()
413-
}, requestTimeoutMs)
414-
: undefined
415-
416415
if (
417416
opts?.stdin === false &&
418417
compareVersions(this.envdVersion, ENVD_COMMANDS_STDIN) < 0
@@ -422,6 +421,14 @@ export class Commands {
422421
)
423422
}
424423

424+
const requestTimeoutMs =
425+
opts?.requestTimeoutMs ?? this.connectionConfig.requestTimeoutMs
426+
427+
const { controller, clearStartTimeout, cleanup } = setupRequestController(
428+
requestTimeoutMs,
429+
opts?.signal
430+
)
431+
425432
const events = this.rpc.start(
426433
{
427434
process: {
@@ -444,19 +451,19 @@ export class Commands {
444451

445452
try {
446453
const pid = await handleProcessStartEvent(events)
447-
448-
clearTimeout(reqTimeout)
454+
clearStartTimeout()
449455

450456
return new CommandHandle(
451457
pid,
452-
() => controller.abort(),
458+
cleanup,
453459
() => this.kill(pid),
454460
events,
455461
opts?.onStdout,
456462
opts?.onStderr,
457463
undefined
458464
)
459465
} catch (err) {
466+
cleanup()
460467
throw handleRpcError(err)
461468
}
462469
}

0 commit comments

Comments
 (0)