fix(h2-transport): guard enqueue after cancel; deduplicate body reads; avoid arrayBuffer copy - #808
Merged
Merged
Conversation
…; avoid arrayBuffer copy Three bugs fixed in the h2-transport added in 1.24.0: 1. **Uncaught exception on SSE early cancel** (`session.ts`): When a consumer breaks out of a `for await` loop over an SSE stream, the ReadableStream `cancel()` callback calls `stream.destroy()` — but Node.js h2 DATA frames already queued in the event loop can still fire `data` events. `controller.enqueue()` on a cancelled ReadableStream throws a TypeError that was unhandled, crashing the process. Fixed by wrapping `controller.enqueue()` and `controller.close()` in try/catch and calling `stream.destroy()` on error. 2. **Race condition in concurrent body reads** (`response.ts`): `_consumeBody()` set a boolean flag synchronously then did async reads, so two concurrent callers (e.g. SDK error-retry + user code) would have the second one throw "Body already consumed" before the first had finished. Fixed by replacing the flag with a shared Promise so concurrent callers all await the same read. 3. **Unnecessary ArrayBuffer copy in `arrayBuffer()`** (`response.ts`): `Buffer.concat()` always allocates a fresh buffer with `byteOffset=0` that owns its full backing `ArrayBuffer`, so the subsequent `.buffer.slice()` was copying the data a second time. Fixed by returning `buf.buffer` directly in that case. Also adds three loadtest scripts to exercise and guard these paths going forward: - `loadtest/large-response-test.ts` — 11 tests covering exec stdout/stderr (1 MB, 10 MB), writeFileContents + readFileContents (512 KB, 2 MB), downloadFile (5 MB, 50 MB), 5× concurrent downloads, SSE streaming (10k lines), and SSE early cancel. - `loadtest/h2-stress-test.ts` — configurable stress test (default: 10 × 20 MB concurrent downloads with md5 verification). - `loadtest/cancel-test.ts` — isolated repro for the SSE early-cancel bug. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…load stalls The HTTP/2 protocol default flow-control window is 64 KB per stream and 64 KB at the session level. With a ~60 ms round-trip time to api.runloop.pro, this caps effective throughput at roughly 1 MB/s regardless of actual link capacity. Fix both levels: - Send SETTINGS_INITIAL_WINDOW_SIZE = 16 MB so each new stream starts with a 16 MB send window (stream level). - Call session.setLocalWindowSize(16 MB) after connect so the server can push up to 16 MB of response body before waiting for a WINDOW_UPDATE (session level). Measured impact: 10 MB file download throughput went from ~1 MB/s to ~89 MB/s (~3× faster than the HTTP/1.1 path). Also add two TypeScript casts in arrayBuffer() to satisfy the compiler: Node.js Buffers are always backed by a regular ArrayBuffer (not SharedArrayBuffer), so the casts are sound. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
jrvb-rl
marked this pull request as ready for review
June 19, 2026 05:36
wall-rl
approved these changes
Jun 19, 2026
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Contributor
Thanks for using CodeAnt! 🎉We're free for open-source projects. if you're enjoying it, help us grow by sharing. Share on X · |
Merged
Comment on lines
+126
to
+131
| if (allOk) { | ||
| console.log(`\n=== PASS: all data intact (transport: ${transport}) ===`); | ||
| process.exit(0); | ||
| } else { | ||
| console.error(`\n=== FAIL: data corruption detected (transport: ${transport}) ===`); | ||
| process.exit(1); |
Contributor
There was a problem hiding this comment.
Suggestion: Calling process.exit() inside the try block skips the asynchronous finally cleanup, so client.devboxes.shutdown(id) will not run and the test can leak devboxes. Set process.exitCode (or return a status) and let control flow reach finally so shutdown always executes. [missing cleanup]
Severity Level: Critical 🚨
- ❌ Devboxes left running; shutdown() in finally never executes.
- ⚠️ Load tests consume unnecessary cloud resources and quotas.
- ⚠️ Subsequent runs may fail from exceeding devbox limits.Steps of Reproduction ✅
1. Run `loadtest/h2-stress-test.ts` (entrypoint at `loadtest/h2-stress-test.ts:35`
function `main` and call at `loadtest/h2-stress-test.ts:143`) with `RUNLOOP_API_KEY` set
so the early `process.exit(1)` at `loadtest/h2-stress-test.ts:18-20` is not triggered.
2. Observe that `main()` creates a devbox via `client.devboxes.createAndAwaitRunning(...)`
at `loadtest/h2-stress-test.ts:43-53`, stores its `id` at `loadtest/h2-stress-test.ts:54`,
and enters the `try` block starting at `loadtest/h2-stress-test.ts:57`.
3. Allow the script to complete the concurrent downloads and verification loop at
`loadtest/h2-stress-test.ts:83-118`, which sets `allOk` and then executes the branch at
`loadtest/h2-stress-test.ts:126-132`—on success calling `process.exit(0)` at line 128, or
on failure calling `process.exit(1)` at line 131, both from inside the `try` block.
4. Because `process.exit()` terminates the Node.js process immediately when called (it
invokes the underlying `_exit` syscall) rather than unwinding the JavaScript stack, the
`finally` block at `loadtest/h2-stress-test.ts:133-140` that logs "Shutting down
devbox..." and awaits `client.devboxes.shutdown(id)` never executes, so the remote devbox
is not shut down for any run that reaches the `if (allOk) { ... } else { ... }` branch.(Use Cmd/Ctrl + Click for best experience)
Prompt for AI Agent 🤖
This is a comment left during a code review.
**Path:** loadtest/h2-stress-test.ts
**Line:** 126:131
**Comment:**
*Missing Cleanup: Calling `process.exit()` inside the `try` block skips the asynchronous `finally` cleanup, so `client.devboxes.shutdown(id)` will not run and the test can leak devboxes. Set `process.exitCode` (or return a status) and let control flow reach `finally` so shutdown always executes.
Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
3 tasks
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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.
User description
Summary
Four bugs fixed in the h2-transport (v1.24.0), found via end-to-end loadtests and a cross-version performance benchmark. The fourth bug—a severe throughput regression—was discovered via the benchmark.
Bugs fixed
Bug 1 – SSE early-cancel crash (
session.ts)When a consumer breaks early from
for awaitover a streaming/SSE response, theReadableStreamis cancelled. Buffereddataevents already queued in the Node.js event loop then callcontroller.enqueue()on a closed controller, throwingTypeError. This is an unhandled exception that terminates the process.Fix: wrap
controller.enqueue()andcontroller.close()in try/catch; destroy the h2 stream on enqueue failure.Bug 2 – body-consumed race (
response.ts)_bodyConsumedwas a boolean set synchronously. Two concurrent callers both seefalse, and whichever runs second throws"Body already consumed".Fix: replace with a shared
_bodyPromise: Promise<Buffer> | null. Both callers get the same in-flight read.Bug 3 – unnecessary ArrayBuffer copy (
response.ts)arrayBuffer()calledbuf.buffer.slice(...), creating an unnecessary second copy.Buffer.concat()always produces a buffer withbyteOffset === 0that owns its full backing store.Fix: return
buf.bufferdirectly when the fast-path conditions hold.Bug 4 – flow-control window limits throughput to ~1 MB/s (
session.ts)The HTTP/2 default flow-control window is 64 KB per stream AND 64 KB at the session level. With a ~60 ms RTT to api.runloop.pro:
Benchmarks confirmed v1.24.0 HTTP/2 delivers only ~1 MB/s for 10 MB downloads vs 31 MB/s over HTTP/1.1 — a 28× regression.
Fix:
settings: { initialWindowSize: 16 MB }on connect → tells the server each new stream starts with a 16 MB window.session.setLocalWindowSize(16 MB)after connect → enlarges the session-level window.Measured after fix: 89 MB/s for 10 MB downloads (3× faster than HTTP/1.1).
Performance benchmark results (6 configs × 3 devboxes × 4 scenarios)
Notable findings:
New load tests
Extended
loadtest/large-response-test.ts: large exec stdout/stderr (1MB, 10MB), many-line exec (500 lines,last_noverride), file round-trip 512KB, readFileContents 2MB, binary download 5MB+50MB (md5 verified), 5× concurrent downloads, SSE 10k lines, SSE early cancel.New:
loadtest/h2-stress-test.ts,loadtest/cancel-test.tsTest plan
SMOKE_HTTP2=1 npx tsx loadtest/large-response-test.tspasses (all 11 tests)SMOKE_HTTP2=1 npx tsx loadtest/cancel-test.tsshows "no uncaught errors"🤖 Generated with Claude Code
CodeAnt-AI Description
Fix HTTP/2 streaming crashes and slow large downloads, with load tests to catch regressions
What Changed
Impact
✅ Fewer crashes when stopping streamed output early✅ Fewer failed retries on shared response reads✅ Lower memory use during large downloads✅ Faster large file downloads over HTTP/2💡 Usage Guide
Checking Your Pull Request
Every time you make a pull request, our system automatically looks through it. We check for security issues, mistakes in how you're setting up your infrastructure, and common code problems. We do this to make sure your changes are solid and won't cause any trouble later.
Talking to CodeAnt AI
Got a question or need a hand with something in your pull request? You can easily get in touch with CodeAnt AI right here. Just type the following in a comment on your pull request, and replace "Your question here" with whatever you want to ask:
This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.
Example
Preserve Org Learnings with CodeAnt
You can record team preferences so CodeAnt AI applies them in future reviews. Reply directly to the specific CodeAnt AI suggestion (in the same thread) and replace "Your feedback here" with your input:
This helps CodeAnt AI learn and adapt to your team's coding style and standards.
Example
Retrigger review
Ask CodeAnt AI to review the PR again, by typing:
Check Your Repository Health
To analyze the health of your code repository, visit our dashboard at https://app.codeant.ai. This tool helps you identify potential issues and areas for improvement in your codebase, ensuring your repository maintains high standards of code health.