Skip to content

fix(js-sdk): keep envd HTTP/2 sessions within Node's session memory budget - #2083

Closed
devin-ai-integration[bot] wants to merge 6 commits into
mainfrom
devin/1793722226-h2-session-memory-envd-writes
Closed

devin-ai-integration[bot] wants to merge 6 commits into
mainfrom
devin/1793722226-h2-session-memory-envd-writes

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Fixes SDK-425: concurrent sandbox.files.write() calls fail with TypeError: fetch failed → NGHTTP2_ENHANCE_YOUR_CALM since e2b ≥ 2.35 (undici 8).

Cause. The RST is sent by Node's own http2 client, not by E2B infra. Node queues each buffered request body in full against the session's 10 MB maxSessionMemory; if a response HEADERS frame arrives on any stream while that budget is exhausted, Node resets that stream with ENHANCE_YOUR_CALM. undici 7 kept one request per session (so our 10-connection pool = 10 budgets); undici 8 multiplexes, so every envd REST request in the process shares one session and one budget. Streams destroyed before their body is flushed (timeouts, aborts, killed sandboxes) additionally leak their queued bytes on Node ≤ 24 (fixed on main by nodejs/node#67113), so a session degrades over time until every small write on it fails — matching the customer's "climbing since Monday".

Fix (JS SDK only — Python uses pyqwest/hyper, not Node http2):

  1. src/envd/uploadBudget.ts: files.write() runs each buffered /files upload through envdUploadBudget.run(size, signal, …), a process-wide semaphore over request-body bytes with ENVD_MAX_INFLIGHT_BODY_BYTES = 6 MiB. Bodies are admitted FIFO while they fit together; streamed bodies and every non-upload call (read, makeDir, RPC, health…) cost 0 and are never held. Waiters honour the request's AbortSignal.
  2. Filesystem.uploadAdmission: a buffered body larger than the budget is not paced but sent through createEnvdSoloFetch → buildSingleUseDispatchedFetch: a fresh single-connection dispatcher (its own HTTP/2 session, so its own 10 MB) that is closed after the response — what undici 7 gave every request in 2.27.1. This keeps a big upload from resetting the reads and small writes multiplexed next to it. HTTP/1.1 skips both 1 and 2.
  3. src/undici.ts: when a request fails with ERR_HTTP2_STREAM_ERROR + NGHTTP2_ENHANCE_YOUR_CALM/NGHTTP2_INTERNAL_ERROR (isPoisonedHTTP2SessionError), the fetcher retires that dispatcher and builds a fresh one for subsequent requests. The failed request is not replayed (envd may already have applied the write). Only the dispatcher that served the failing request can replace the current one.

An earlier revision used maxConcurrentStreams: 1 instead; dropped because undici 8's Pool parks all requests behind its first ("protocol probe") client until it drains, so one long-lived streamed files.read starved every other REST call to that sandbox.

No API change.

Measured (built package, real sandboxes, Node 24.22):

scenario 2.62.1 this PR
10 × 2 MB parallel writes, 5 rounds 16/50 failed 0/50
20 × 1 MB, 4 × 16 MB, 200 × 4 KB — 0 failed
16 MB (and 32 MB) write + 10 concurrent files.read() 30/30 reads failed 0/33
poisoned session (maxSessionMemory=1, aborted big writes) 50/50 fail, forever 0 failed (big writes no longer share the session)
10 unconsumed 300 MB streamed reads, then a write 169 ms 169 ms (was timeout with the stream cap)

Upstream: backport request on nodejs/node#67113 (nodejs/node#67113 (comment)) and undici issue nodejs/undici#6149 (poisoned session kept in the pool).

Link to Devin session: https://app.devin.ai/sessions/98f09d080013403b954d3f7b07c51b3b
Open in Devin Desktop: https://app.devin.ai/desktop/session/98f09d080013403b954d3f7b07c51b3b?variant=devin
Requested by: @mishushakov

Node charges queued request bodies against a 10 MB per-session budget and
resets the stream whose response arrives while it is exhausted
(NGHTTP2_ENHANCE_YOUR_CALM). Since undici 8 all envd REST requests of a
process shared one session. envd REST dispatchers now carry one request per
HTTP/2 session across the connection pool, and a dispatcher whose session
Node reports as out of memory is retired and replaced.

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@devin-ai-integration

Copy link
Copy Markdown
Contributor Author

I'll fix CI failures and address comments from users with write access. I'll skip comments containing "(aside)".

  • Disable automatic comment, CI, and merge conflict monitoring

@cla-bot cla-bot Bot added the cla-signed label Nov 3, 2026
@changeset-bot

changeset-bot Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: b2a5dbb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
e2b Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@linear-code

linear-code Bot commented Nov 3, 2026

Copy link
Copy Markdown

SDK-425

devin-ai-integration[bot]

This comment was marked as resolved.

@github-actions

github-actions Bot commented Nov 3, 2026 •

Copy link
Copy Markdown
Contributor

Package Artifacts

Built from 9a305a2. Download artifacts from this workflow run.

JS SDK (e2b@2.62.2-devin-1793722226-h2-session-memory-envd-writes.0, with @e2b/dockerfile-utils@0.1.2-devin-1793722226-h2-session-memory-envd-writes.0):

npm install ./e2b-dockerfile-utils-0.1.2-devin-1793722226-h2-session-memory-envd-writes.0.tgz ./e2b-2.62.2-devin-1793722226-h2-session-memory-envd-writes.0.tgz

CLI (@e2b/cli@2.23.1-devin-1793722226-h2-session-memory-envd-writes.0):

npm install ./e2b-cli-2.23.1-devin-1793722226-h2-session-memory-envd-writes.0.tgz

Code Interpreter JS SDK (@e2b/code-interpreter@2.9.2-devin-1793722226-h2-session-memory-envd-writes.0):

npm install ./e2b-code-interpreter-2.9.2-devin-1793722226-h2-session-memory-envd-writes.0.tgz

Desktop JS SDK (@e2b/desktop@2.5.1-devin-1793722226-h2-session-memory-envd-writes.0):

npm install ./e2b-desktop-2.5.1-devin-1793722226-h2-session-memory-envd-writes.0.tgz

Python SDK (e2b==2.62.0+devin.1793722226.h2.session.memory.envd.writes, with e2b-dockerfile-utils==0.1.1+devin.1793722226.h2.session.memory.envd.writes):

pip install ./e2b_dockerfile_utils-0.1.1+devin.1793722226.h2.session.memory.envd.writes-py3-none-any.whl ./e2b-2.62.0+devin.1793722226.h2.session.memory.envd.writes-py3-none-any.whl

Code Interpreter Python SDK (e2b-code-interpreter==2.11.7+devin.1793722226.h2.session.memory.envd.writes):

pip install ./e2b_code_interpreter-2.11.7+devin.1793722226.h2.session.memory.envd.writes-py3-none-any.whl

Desktop Python SDK (e2b-desktop==2.7.2+devin.1793722226.h2.session.memory.envd.writes):

pip install ./e2b_desktop-2.7.2+devin.1793722226.h2.session.memory.envd.writes-py3-none-any.whl

…wser runner)

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@mishushakov
mishushakov marked this pull request as ready for review November 3, 2026 16:17
@mishushakov
mishushakov self-requested a review as a code owner November 3, 2026 16:17
@mishushakov

Copy link
Copy Markdown
Member

check above comments

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Nov 3, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-11-03T16:19:57.264752Z d077e5a Draft marked ready
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

…onError

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
devin-ai-integration[bot]

This comment was marked as resolved.

@mishushakov

Copy link
Copy Markdown
Member

@claude review

claude[bot]

This comment was marked as resolved.

…TP/2 streams

A 1-stream-per-session cap starves the origin: undici 8's Pool holds every
request behind its first (protocol-probe) client until it drains, so one
long-lived REST request blocked all others to that sandbox. Keep sessions
multiplexed and instead admit buffered upload bodies so that together they
stay within 6 MB of Node's 10 MB session memory budget.

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
devin-ai-integration[bot]

This comment was marked as resolved.

devin-ai-integration Bot and others added 2 commits November 3, 2026 17:32
A buffered upload larger than the 6 MiB budget was admitted alone but still
shared the pooled HTTP/2 session, so any read whose response landed while it
filled Node's session memory was reset (16 MB write + 10 reads: 30/30 reads
failed, also on base). Such uploads now go through a single-use dispatcher
that is closed after the response, like undici 7 gave them in 2.27.1.

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
…owser runner)

Co-Authored-By: mish@e2b.dev <mish@e2b.dev>
@mishushakov

Copy link
Copy Markdown
Member

proceeding with #2086 instead until the Node issue is fixed

@mishushakov mishushakov closed this Nov 3, 2026
mishushakov added a commit that referenced this pull request Nov 3, 2026
…TP/2 dispatchers (#2086)

## Summary

Fixes [SDK-425](https://linear.app/e2b/issue/SDK-425): concurrent
`sandbox.files.write()` calls fail with `TypeError: fetch failed` →
`NGHTTP2_ENHANCE_YOUR_CALM` since `e2b` ≥ 2.35 (undici 8). Simpler
alternative to #2083 (byte budget + one-off sessions), same
measurements.

**Cause.** The RST is sent by Node's own http2 client, not by E2B infra.
Node queues each buffered request body in full against the session's 10
MB `maxSessionMemory`; if a response HEADERS frame arrives on *any*
stream while that budget is exhausted, Node resets that stream with
`ENHANCE_YOUR_CALM`. undici 7 kept one request per session (so our
10-connection pool = 10 budgets); undici 8 multiplexes, so every envd
REST request in the process shares one session and one budget — parallel
or large uploads reset each other and the reads next to them. Streams
destroyed before their body is flushed (timeouts, aborts, killed
sandboxes) additionally leak their queued bytes on Node ≤ 24 (fixed on
`main` by nodejs/node#67113), so a session degrades until every small
write on it fails — the customer's "climbing since Monday".

**Fix (JS SDK only — Python uses pyqwest/hyper, not Node http2):**

1. `Filesystem` POSTs `/files` (octet-stream, streamed, gzip and
multipart uploads) through `createEnvdUploadFetch` — an HTTP/1.1
dispatcher pool of its own (`E2B_ENVD_UPLOAD_CONNECTIONS`, default 50,
like `E2B_ENVD_RPC_CONNECTIONS`); everything else (`read`, `makeDir`,
RPC, health…) stays on HTTP/2, and `httpVersion` / `E2B_HTTP_VERSION`
keep governing those. The `ConnectionOpts.httpVersion` JSDoc documents
the exception.
   ```ts
   // sandbox/filesystem/index.ts
const uploadFetch = createEnvdUploadFetch({ proxy:
connectionConfig.proxy })
   …
   this.envdApi.api.POST('/files', { …, fetch: this.uploadFetch })
   ```
One request per connection, no shared memory budget — what undici 7
effectively gave 2.27.1 (which capped uploads at 10 per process; this
pool allows 50, beyond that uploads queue in undici with their request
timeout running).
2. `buildDispatchedFetch` (`src/undici.ts`): a request that fails with
`ERR_HTTP2_STREAM_ERROR` +
`NGHTTP2_ENHANCE_YOUR_CALM`/`NGHTTP2_INTERNAL_ERROR`
(`isPoisonedHTTP2SessionError`) retires that dispatcher and builds a
fresh one for subsequent requests. The failed request is **not**
replayed (envd may already have applied the write). Only the dispatcher
that served the failing request can replace the current one. Safety net
for whatever else might fill a session.

No API change.

**Measured** (built package, real sandboxes, Node 24.22; ALPN confirmed:
writes `http/1.1`, reads `h2`):

| scenario | 2.62.1 | this PR |
|---|---|---|
| 10 × 2 MB parallel writes, 5 rounds | 16/50 failed | 0/50 |
| 30 × 1 MB, 20 × 1 MB, 4 × 16 MB, customer pattern (makeDir + 10
parallel small writes ×200) | — | 0 failed |
| 16 MB write + 10 concurrent `files.read()` | 30/30 reads failed | 0/33
|
| poisoned session (`maxSessionMemory=1`, aborted big writes) | 50/50
fail, forever | 0 failed |
| streamed (async iterable), gzip, multipart, string, aborted upload | —
| all OK |

**Upstream:** backport request on nodejs/node#67113
(nodejs/node#67113 (comment)) and
undici issue nodejs/undici#6149.

Link to Devin session:
https://app.devin.ai/sessions/98f09d080013403b954d3f7b07c51b3b
Open in Devin Desktop:
https://app.devin.ai/desktop/session/98f09d080013403b954d3f7b07c51b3b?variant=devin
Requested by: @mishushakov

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
Co-authored-by: mish@e2b.dev <mish@e2b.dev>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant