Repository navigation
fix(js-sdk): keep envd HTTP/2 sessions within Node's session memory budget #2083
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
devin-ai-integration
wants to merge
6
commits into
main
from
devin/1793722226-h2-session-memory-envd-writes
Closed
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ece2c43
fix(js-sdk): keep envd HTTP/2 sessions within Node's memory budget
devin-ai-integration[bot] d077e5a
test(js-sdk): assert envd REST stream cap without module mocking (bro…
devin-ai-integration[bot] 8a13a2d
refactor(js-sdk): isPoisonedHttp2SessionError -> isPoisonedHTTP2Sessi…
devin-ai-integration[bot] 5d407ea
fix(js-sdk): budget in-flight envd upload bytes instead of capping HT…
devin-ai-integration[bot] 861a151
fix(js-sdk): send over-budget envd uploads on a session of their own
devin-ai-integration[bot] b2a5dbb
test(js-sdk): mock createEnvdSoloFetch in envd/http2 module mocks (br…
devin-ai-integration[bot] File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| --- | ||
| 'e2b': patch | ||
| --- | ||
|
|
||
| Fix `TypeError: fetch failed` with `NGHTTP2_ENHANCE_YOUR_CALM` on concurrent `files.write()` calls. Node's HTTP/2 client queues each buffered request body in full against a 10 MB per-session memory budget and resets whichever stream's response arrives while it is exhausted; since undici 8 every envd REST request of a process shares one session. Buffered `files.write()` uploads are now admitted so that the bodies in flight together stay within 6 MB, an upload larger than that is sent over an HTTP/2 session of its own (so it cannot reset concurrent reads), and a dispatcher whose session Node reports as out of memory is retired and replaced instead of failing every later request. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,127 @@ | ||
| // Node's HTTP/2 client queues a buffered request body (Blob, FormData, bytes, | ||
| // string) in full against the session's memory budget (`maxSessionMemory`, | ||
| // 10 MB) and resets whichever stream's response headers arrive while that | ||
| // budget is exhausted (ENHANCE_YOUR_CALM). Since undici 8 every envd REST | ||
| // request of a process multiplexes over one session, so concurrent uploads | ||
| // share one budget — with every read and small write whose response may land | ||
| // while it is full. Buffered uploads are admitted here so the bodies in flight | ||
| // together stay well under it; a single body above this limit is sent on a | ||
| // session of its own instead (see `Filesystem.write`). A stream body is | ||
| // written chunk by chunk behind `drain`, so it is never queued whole. | ||
| export const ENVD_MAX_INFLIGHT_BODY_BYTES = 6 * 1024 * 1024 | ||
|
|
||
| const utf8Length = (text: string): number => | ||
| new TextEncoder().encode(text).byteLength | ||
|
|
||
| /** | ||
| * Bytes of a request body Node queues in full, or 0 for a body that is | ||
| * streamed or of unknown size. | ||
| */ | ||
| export function bodyByteLength(body: unknown): number { | ||
| if (body == null) { | ||
| return 0 | ||
| } | ||
| if (typeof body === 'string') { | ||
| return utf8Length(body) | ||
| } | ||
| if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { | ||
| return body.byteLength | ||
| } | ||
| if (body instanceof Blob) { | ||
| return body.size | ||
| } | ||
| if (body instanceof URLSearchParams) { | ||
| return utf8Length(body.toString()) | ||
| } | ||
| if (body instanceof FormData) { | ||
| let total = 0 | ||
| body.forEach((value, name) => { | ||
| total += | ||
| utf8Length(name) + | ||
| (typeof value === 'string' ? utf8Length(value) : value.size) | ||
| }) | ||
| return total | ||
| } | ||
| return 0 | ||
| } | ||
|
|
||
| export type BodyBudget = { | ||
| /** | ||
| * Run `fn` once `size` more bytes fit in flight; the bytes are released when | ||
| * the returned promise settles — fetch resolves on response headers, which | ||
| * envd sends after reading the whole body, so Node's queue is empty by then. | ||
| * A request aborted while waiting rejects with its signal's reason. | ||
| */ | ||
| run<T>( | ||
| size: number, | ||
| signal: AbortSignal | null | undefined, | ||
| fn: () => Promise<T> | ||
| ): Promise<T> | ||
| } | ||
|
|
||
| /** | ||
| * Admit requests in order while their bodies fit in `limit` bytes together; a | ||
| * body larger than the limit still goes alone. Sizeless requests pass through. | ||
| */ | ||
| export function createBodyBudget(limit: number): BodyBudget { | ||
| let inflight = 0 | ||
| const waiters: Array<{ size: number; admit: () => void }> = [] | ||
|
|
||
| const fits = (size: number) => inflight === 0 || inflight + size <= limit | ||
| const admitWaiting = () => { | ||
| while (waiters.length > 0 && fits(waiters[0].size)) { | ||
| waiters.shift()!.admit() | ||
| } | ||
| } | ||
|
|
||
| const acquire = async (size: number, signal?: AbortSignal | null) => { | ||
| if (signal?.aborted) { | ||
| throw signal.reason | ||
| } | ||
| if (waiters.length === 0 && fits(size)) { | ||
| inflight += size | ||
| return | ||
| } | ||
|
|
||
| await new Promise<void>((resolve, reject) => { | ||
| const waiter = { | ||
| size, | ||
| admit: () => { | ||
| signal?.removeEventListener('abort', onAbort) | ||
| inflight += size | ||
| resolve() | ||
| }, | ||
| } | ||
| const onAbort = () => { | ||
| const index = waiters.indexOf(waiter) | ||
| if (index !== -1) { | ||
| waiters.splice(index, 1) | ||
| } | ||
| reject(signal!.reason) | ||
| // The aborted waiter may have been holding smaller ones back. | ||
| admitWaiting() | ||
| } | ||
| signal?.addEventListener('abort', onAbort, { once: true }) | ||
| waiters.push(waiter) | ||
| }) | ||
| } | ||
|
|
||
| return { | ||
| async run(size, signal, fn) { | ||
| if (size <= 0) { | ||
| return fn() | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
Resolved
|
||
| await acquire(size, signal) | ||
| try { | ||
| return await fn() | ||
| } finally { | ||
| inflight -= size | ||
| admitWaiting() | ||
| } | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // One budget per process: envd REST dispatchers are shared across sandboxes, | ||
| // and sandboxes behind one authority share HTTP/2 sessions. | ||
| export const envdUploadBudget = createBodyBudget(ENVD_MAX_INFLIGHT_BODY_BYTES) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.