Skip to content

fix(h2-transport): guard enqueue after cancel; deduplicate body reads; avoid arrayBuffer copy - #808

Merged
jrvb-rl merged 3 commits into
mainfrom
loadtest/large-response-and-h2-cancel-fix
Jun 19, 2026
Merged

fix(h2-transport): guard enqueue after cancel; deduplicate body reads; avoid arrayBuffer copy#808
jrvb-rl merged 3 commits into
mainfrom
loadtest/large-response-and-h2-cancel-fix

Conversation

@jrvb-rl

@jrvb-rl jrvb-rl commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

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 await over a streaming/SSE response, the ReadableStream is cancelled. Buffered data events already queued in the Node.js event loop then call controller.enqueue() on a closed controller, throwing TypeError. This is an unhandled exception that terminates the process.

Fix: wrap controller.enqueue() and controller.close() in try/catch; destroy the h2 stream on enqueue failure.

Bug 2 – body-consumed race (response.ts)
_bodyConsumed was a boolean set synchronously. Two concurrent callers both see false, 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() called buf.buffer.slice(...), creating an unnecessary second copy. Buffer.concat() always produces a buffer with byteOffset === 0 that owns its full backing store.

Fix: return buf.buffer directly 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:

65535 bytes / 0.065 s ≈ 1 MB/s

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:

  • Pass settings: { initialWindowSize: 16 MB } on connect → tells the server each new stream starts with a 16 MB window.
  • Call 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)

Config Large DL HOL ratio Concurrency wall
v1.22.1 HTTP/1.1 31 MB/s 1.2× ✓ 168ms
v1.23.1 HTTP/1.1 30 MB/s 1.2–3.2× 167ms
v1.23.1 HTTP/2 (undici) 4.7 MB/s ⚠ 12–13× 654ms ⚠
v1.24.0 HTTP/2 (h2-native) 1.1 MB/s 1.0× ✓ 182ms
local HTTP/1.1 30 MB/s 1.9× 190ms
local HTTP/2 (this PR) 89 MB/s 1.9× 159ms

Notable findings:

  • undici HTTP/2 has severe HOL blocking (12–13×) — serializes requests within pool rather than truly multiplexing, worse than HTTP/1.1 for mixed workloads
  • h2-native without window fix: 1.1 MB/s (28× regression vs HTTP/1.1)
  • h2-native with window fix (this PR): 89 MB/s + no HOL blocking

New load tests

Extended loadtest/large-response-test.ts: large exec stdout/stderr (1MB, 10MB), many-line exec (500 lines, last_n override), 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.ts

Test plan

  • SMOKE_HTTP2=1 npx tsx loadtest/large-response-test.ts passes (all 11 tests)
  • SMOKE_HTTP2=1 npx tsx loadtest/cancel-test.ts shows "no uncaught errors"
  • HTTP/2 large download throughput ≥ HTTP/1.1

🤖 Generated with Claude Code


CodeAnt-AI Description

Fix HTTP/2 streaming crashes and slow large downloads, with load tests to catch regressions

What Changed

  • Early cancel of a streamed SSE response no longer throws an uncaught error or breaks the connection pool.
  • Concurrent reads of the same response body now reuse the same result instead of failing with “Body already consumed.”
  • Large downloads no longer make an extra copy when turning response data into an ArrayBuffer, reducing memory work on big files.
  • HTTP/2 connections now allow much larger response windows, so large file downloads are not limited by small flow-control updates.
  • Added load tests for large responses, concurrent downloads, SSE streaming, and early cancel behavior.

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:

@codeant-ai ask: Your question here

This lets you have a chat with CodeAnt AI about your pull request, making it easier to understand and improve your code.

Example

@codeant-ai ask: Can you suggest a safer alternative to storing this secret?

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:

@codeant-ai: Your feedback here

This helps CodeAnt AI learn and adapt to your team's coding style and standards.

Example

@codeant-ai: Do not flag unused imports.

Retrigger review

Ask CodeAnt AI to review the PR again, by typing:

@codeant-ai: review

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.

Reflex and others added 2 commits June 19, 2026 00:34
…; 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 jrvb-rl changed the title WIP fix(h2-transport): guard enqueue after cancel; deduplicate body reads; avoid arrayBuffer copy fix(h2-transport): guard enqueue after cancel; deduplicate body reads; avoid arrayBuffer copy Jun 19, 2026
@jrvb-rl
jrvb-rl marked this pull request as ready for review June 19, 2026 05:36
@jrvb-rl
jrvb-rl requested review from tode-rl and wall-rl June 19, 2026 05:36
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@codeant-ai

codeant-ai Bot commented Jun 19, 2026

Copy link
Copy Markdown
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 ·
Reddit ·
LinkedIn

@codeant-ai codeant-ai Bot added the size:XL This PR changes 500-999 lines, ignoring generated files label Jun 19, 2026
@jrvb-rl
jrvb-rl merged commit 316d558 into main Jun 19, 2026
8 of 9 checks passed
@jrvb-rl
jrvb-rl deleted the loadtest/large-response-and-h2-cancel-fix branch June 19, 2026 05:54
@stainless-app stainless-app Bot mentioned this pull request Jun 19, 2026
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Fix in Cursor Fix in VSCode Claude

(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
👍 | 👎

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL This PR changes 500-999 lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants