Skip to content
Merged
Show file tree
Hide file tree
Changes from 18 commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
cbf465a
feat(sdk): stream file uploads and downloads instead of buffering in …
mishushakov Jun 12, 2026
9d72b2e
fix(js-sdk): return proper empty values from read() for empty files
mishushakov Jun 12, 2026
6cda03f
fix: address review comments on streaming reads
mishushakov Jun 12, 2026
58c5f86
Merge remote-tracking branch 'origin/main' into mishushakov/stream-wr…
mishushakov Jun 15, 2026
1ae9a34
fix(python-sdk): prevent connection leaks from abandoned stream reads
mishushakov Jun 15, 2026
22d98a3
fix(js-sdk): release stream-read connections on error and GC
mishushakov Jun 17, 2026
9b34ecf
refactor(python-sdk): drop fragile async GC net from AsyncFileStreamR…
mishushakov Jun 17, 2026
a869488
Merge remote-tracking branch 'origin/main' into mishushakov/stream-wr…
mishushakov Jun 17, 2026
a0d564f
fix(sdks): align streaming connection lifecycle across files and volumes
mishushakov Jun 17, 2026
e828699
feat(sdks): default file writes to octet-stream when data is streamable
mishushakov Jun 17, 2026
b0b1018
fix(python-sdk): give streamed file uploads the file-transfer timeout
mishushakov Jun 17, 2026
d2b7329
refactor(sdks): split volume streaming changes into a follow-up PR
mishushakov Jun 17, 2026
7a52f73
Merge remote-tracking branch 'origin/main' into mishushakov/stream-wr…
mishushakov Jun 18, 2026
21045a2
test(python-sdk): make stream connection-leak assertions race-free
mishushakov Jun 18, 2026
eb12c66
fix(js-sdk): cancel underlying body reader when abandoned stream is GC'd
mishushakov Jun 18, 2026
ed277d0
refactor(sdks): replace stream GC nets with an idle-read timeout
mishushakov Jun 18, 2026
40a3103
feat(sdks): add per-chunk idle timeout to streamed reads and writes
mishushakov Jun 18, 2026
2022d3a
fix(python-sdk): run streamed-upload reads and gzip off the event loop
mishushakov Jun 18, 2026
cc2cd57
refactor(sdks): make the JS read idle timeout bound only the wire
mishushakov Jun 18, 2026
d2939b0
docs(python-sdk): scope the FILE_TIMEOUT comment to volume transfers
mishushakov Jun 18, 2026
8c5b640
docs(python-sdk): note envd backstops the streamed-upload per-write t…
mishushakov Jun 19, 2026
b8db6b5
test(sdks): drop redundant streamed-read test coverage
mishushakov Jun 19, 2026
4351aae
refactor(python-sdk): drop the now-unused FILE_TIMEOUT constant
mishushakov Jun 19, 2026
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
11 changes: 11 additions & 0 deletions .changeset/cuddly-pots-stream.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"e2b": patch
"@e2b/python-sdk": patch
---

Stream uploads instead of buffering streaming input entirely in memory:

- `Sandbox.files.write()` / `write_files()`: `ReadableStream` data (JS, outside the browser) and file-like objects (Python) are streamed to the sandbox, including when `gzip` is enabled (compression now happens chunk by chunk). `useOctetStream`/`use_octet_stream` now defaults to auto-detect — octet-stream is used when any entry is streamable (so streamed uploads aren't silently buffered) and `multipart/form-data` otherwise; browsers always use `multipart/form-data`. A streamed upload is bounded by a per-chunk idle timeout (`streamIdleTimeoutMs` in JS, default the request timeout, `0` to disable) that aborts a stalled upload — a producer that stops yielding or a server that stops reading — so a stall no longer holds the connection indefinitely. On Python's `AsyncSandbox`, the blocking file reads and gzip compression of a streamed upload now run in a worker thread so a large upload doesn't stall the event loop.
- `Sandbox.files.read(format="stream")`: the request timeout now bounds only the initial handshake instead of killing the stream while it's being consumed. The body is bounded by a per-chunk idle timeout (`streamIdleTimeoutMs` in JS, `stream_idle_timeout` in Python, default the request timeout — 60s — `0`/`None` to disable) that bounds a stalled stream without limiting an actively-flowing one. Use `signal` (JS) to cancel an in-flight stream. A dropped connection during the stream handshake now surfaces the same typed, health-checked error as non-stream reads. The stream holds a pooled connection until it is consumed to the end, cancelled/closed, errors, or the idle timeout fires — consume it fully, use the context manager, or close it.
- Python `Sandbox.files.read(format="stream")`: the response body is now streamed from the sandbox instead of being downloaded into memory before iteration (sync and async).
- JS `Sandbox.files.read()` with `blob` or `stream` format now returns an empty `Blob`/`ReadableStream` for empty files instead of `""`.
154 changes: 154 additions & 0 deletions packages/js-sdk/src/connectionConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,160 @@ export function setupRequestController(
return { controller, clearStartTimeout, cleanup }
}

/**
* Create a resettable idle-timeout that aborts `controller` when no progress is
* made within `idleTimeoutMs`. `arm` (re)starts the timer; call it on each
* chunk. `clear` stops it. `0`/`undefined` disables it (both are no-ops).
*
* @internal
*/
function createIdleAbort(
controller: AbortController,
idleTimeoutMs: number | undefined,
label: string
): { arm: () => void; clear: () => void } {
let timer: ReturnType<typeof setTimeout> | undefined
const clear = () => {
if (timer) {
clearTimeout(timer)
timer = undefined
}
}
const arm = () => {
if (!idleTimeoutMs) return
clear()
timer = setTimeout(
() =>
controller.abort(
new DOMException(
`${label} idle for ${idleTimeoutMs}ms`,
'TimeoutError'
)
),
idleTimeoutMs
)
}
return { arm, clear }
}

/**
* Wrap a streaming response body so its pooled connection is released when the
* stream is fully read, cancelled, errors, or stays idle for too long.
*
* Clears the handshake timeout from {@link setupRequestController} (so
* consuming the body isn't killed by it) and replaces it with an idle-read
* timeout: if no chunk arrives within `idleTimeoutMs` it aborts `controller`,
* tearing down the fetch and releasing the connection. The timer resets on
* every chunk, so it bounds a stalled stream without limiting an
* actively-flowing one. Pass `0`/`undefined` to disable. Call once the
* handshake has succeeded.
*
* @internal
*/
export function wrapStreamWithConnectionCleanup(
body: ReadableStream<Uint8Array> | null,
{
clearStartTimeout,
cleanup,
controller,
idleTimeoutMs,
}: {
clearStartTimeout: () => void
cleanup: () => void
controller: AbortController
idleTimeoutMs?: number
}
): ReadableStream<Uint8Array> {
clearStartTimeout()

if (!body) {
cleanup()
return new Blob([]).stream()
}

const reader = body.getReader()
const idle = createIdleAbort(controller, idleTimeoutMs, 'Stream')

// Idempotent: safe to call from multiple stream callbacks.
const release = () => {
idle.clear()
cleanup()
}

return new ReadableStream<Uint8Array>({
start() {
idle.arm()
},
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
async pull(streamController) {
try {
const { done, value } = await reader.read()
if (done) {
release()
streamController.close()
} else {
idle.arm()
streamController.enqueue(value)
}
} catch (err) {
release()
streamController.error(err)
}
},
async cancel(reason) {
try {
await reader.cancel(reason)
} finally {
release()
}
},
})
}

/**
* Wrap an outgoing (upload) request body so the request is aborted if no chunk
* is sent within `idleTimeoutMs`. The timer resets on every chunk, bounding a
* stalled upload — a producer that stops yielding or a server that stops
* reading — without limiting an actively-flowing one. Pass `0`/`undefined` to
* disable, returning the body unwrapped.
*
* @internal
*/
export function wrapUploadStreamWithIdleTimeout(
body: ReadableStream<Uint8Array>,
controller: AbortController,
idleTimeoutMs?: number
): ReadableStream<Uint8Array> {
if (!idleTimeoutMs) return body

const reader = body.getReader()
const idle = createIdleAbort(controller, idleTimeoutMs, 'Upload')

return new ReadableStream<Uint8Array>({
start() {
idle.arm()
},
async pull(streamController) {
try {
const { done, value } = await reader.read()
if (done) {
idle.clear()
streamController.close()
} else {
idle.arm()
streamController.enqueue(value)
}
} catch (err) {
idle.clear()
streamController.error(err)
}
},
async cancel(reason) {
idle.clear()
await reader.cancel(reason)
},
})
}

function buildUserAgent(integration?: string) {
const userAgentParts = [`e2b-js-sdk/${version}`]

Expand Down
Loading
Loading