Skip to content

http2: route ERR_HTTP2_PUSH_DISABLED through pushStream callback#30944

Open
robobun wants to merge 2 commits into
mainfrom
farm/c413079a/http2-pushstream-async-error
Open

http2: route ERR_HTTP2_PUSH_DISABLED through pushStream callback#30944
robobun wants to merge 2 commits into
mainfrom
farm/c413079a/http2-pushstream-async-error

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented May 17, 2026

Fixes #30942.

Repro

// server.cjs
const http2 = require("http2");
const server = http2.createServer();
server.on("stream", (stream) => {
  stream.pushStream({ ":path": "/x.js" }, (err, pushStream) => {
    if (err) { console.log("push error:", err.message); return; }
    pushStream.respond({ ":status": 200 });
    pushStream.end("console.log(1)");
  });
  stream.respond({ ":status": 200 });
  stream.end("ok");
});
server.listen(3000);
// client.cjs — default http2.connect has push enabled
const c = require("http2").connect("http://localhost:3000");
const req = c.request({ ":path": "/" });
req.on("data", () => {});
req.on("end", () => { console.log("done"); c.close(); });
req.end();

Before: Bun crashes out of the 'stream' handler with a synchronous
ERR_HTTP2_PUSH_DISABLED throw. Node routes the error through the
(err, pushStream) callback (when push is disabled) or actually sends
the PUSH_PROMISE frame (when push is allowed — which is the default
client setting), so the same code prints done on Node.

Cause

ServerHttp2Stream.pushStream in src/js/node/http2.ts was a stub:

pushStream() {
  throw $ERR_HTTP2_PUSH_DISABLED();
}

It ignored the callback argument entirely and threw synchronously. Any
user code following the Node-style error-first callback pattern died
inside the event handler before it could read err.

Fix

Match Node's callback contract while push-promise sending stays
unimplemented in Bun:

  • Accept (headers, options, callback) and the 2-arg (headers, callback)
    form.
  • validateFunction(callback, "callback") so bad callback types still
    throw ERR_INVALID_ARG_TYPE synchronously (what Node does).
  • Deliver ERR_HTTP2_PUSH_DISABLED via process.nextTick(callback, err)
    instead of throwing, so user code can observe the error and continue.

Http2ServerResponse.createPushResponse already calls pushStream with
an error-first callback and propagates err — so this change also
unbreaks res.createPushResponse(headers, cb).

Once full PUSH_PROMISE support lands (see #28713), the stub disappears
and real success paths run through the same callback. This change is a
stopgap so the reporter's code stops crashing in the meantime.

Verification

Five new tests in test/js/node/http2/node-http2.test.js cover:

  • pushStream does not throw synchronously; body round-trips as "ok"
  • error is delivered asynchronously, not synchronously, from the callback
  • (headers, callback) shape (options omitted) is accepted
  • non-function callback synchronously throws ERR_INVALID_ARG_TYPE
  • response.createPushResponse delivers the error via its callback

Gate:

git stash push -- src/
bun bd test test/js/node/http2/node-http2.test.js -t "createPushResponse|pushStream"
  → 5 fail (sync throw out of 'stream' handler)
git stash pop
bun bd test test/js/node/http2/node-http2.test.js -t "createPushResponse|pushStream"
  → 5 pass

Full node-http2.test.js suite: 249 pass, 6 skip, 1 pre-existing timeout
(http2 server with minimal maxSessionMemory handles multiple requests
fails on main too in this container, unrelated to this PR).

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented May 17, 2026

Updated 11:04 PM PT - May 17th, 2026

@robobun, your commit d2e55f987aa7180455f53ab988b38180560ac570 passed in Build #55576! 🎉


🧪   To try this PR locally:

bunx bun-pr 30944

That installs a local version of the PR into your bun-30944 executable, so you can run:

bun-30944 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

Review Change Stack

Walkthrough

ServerHttp2Stream.pushStream is refactored to accept a callback and deliver the disabled-push error asynchronously via process.nextTick instead of throwing synchronously. The method supports argument shuffling when options is omitted and validates that the callback is a function. Five regression tests cover non-throwing behavior, async callback timing, argument shapes, validation errors, and the related createPushResponse API.

Changes

HTTP/2 Server Push Async Error Handling

Layer / File(s) Summary
pushStream async callback implementation
src/js/node/http2.ts
ServerHttp2Stream.pushStream accepts (headers, options, callback) with argument shuffling, validates the callback, and delivers ERR_HTTP2_PUSH_DISABLED asynchronously via process.nextTick instead of immediate throws.
pushStream callback behavior regression tests
test/regression/issue/30942.test.ts
Five test cases verify that pushStream does not throw synchronously, the callback is invoked asynchronously (not same tick), (headers, callback) signature works without options, non-function callbacks synchronously throw ERR_INVALID_ARG_TYPE, and createPushResponse follows the same async callback pattern.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically summarizes the main change: routing ERR_HTTP2_PUSH_DISABLED through the pushStream callback instead of throwing synchronously.
Description check ✅ Passed The description is comprehensive, following the template with detailed explanation of what the PR does, how it was verified through tests and gate process, and includes reproduction cases.
Linked Issues check ✅ Passed The PR fully addresses issue #30942 by implementing Node-style error-first callback behavior for pushStream, accepting both (headers, options, callback) and (headers, callback) signatures, validating callback types synchronously, and delivering errors asynchronously via process.nextTick.
Out of Scope Changes check ✅ Passed All changes are directly related to fixing #30942: the pushStream method signature update and regression tests for this specific issue. No unrelated modifications are present.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. HTTP/2 server push streams disabled in Bun #28712 - Reports the same synchronous ERR_HTTP2_PUSH_DISABLED throw from pushStream() that this PR fixes by routing the error through the callback

If this is helpful, copy the block below into the PR description to auto-close this issue on merge.

Fixes #28712

🤖 Generated with Claude Code

@github-actions
Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. Implement HTTP/2 server push streams (PUSH_PROMISE) #28713 - Both modify ServerHttp2Stream.pushStream() in src/js/node/http2.ts to fix the synchronous ERR_HTTP2_PUSH_DISABLED throw; Implement HTTP/2 server push streams (PUSH_PROMISE) #28713 replaces the stub with a full PUSH_PROMISE implementation while this PR routes the error through the callback as a stopgap

🤖 Generated with Claude Code

Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

LGTM — small, well-tested stopgap that routes the unimplemented push-stream error through the Node-style callback instead of throwing synchronously.

Extended reasoning...

Overview

This PR changes a single method, ServerHttp2Stream.pushStream in src/js/node/http2.ts, from an argless stub that throws ERR_HTTP2_PUSH_DISABLED synchronously into one that accepts Node's (headers, options?, callback) signature, validates the callback with the already-imported validateFunction, and delivers the error via process.nextTick(callback, err). Five new regression tests are added to test/js/node/http2/node-http2.test.js covering sync-vs-async delivery, the 2-arg overload, callback type validation, and the downstream createPushResponse path (which already passes an error-first callback at http2.ts:1076 and now correctly receives the error instead of being bypassed by a throw).

Security risks

None. No user-controlled input reaches new sinks; the change only reshuffles arguments and reschedules an existing error object onto the next tick. No auth, crypto, filesystem, or network-protocol parsing is touched.

Level of scrutiny

Low. The source change is ~14 lines in a stub method that was previously a one-line throw. validateFunction is already imported (line 348) and used identically elsewhere in the file, and $ERR_HTTP2_PUSH_DISABLED() was already the error being raised. The argument-shuffle pattern (typeof options === "function") matches Node's own implementation and other overloads in this file. The behavior is strictly less crash-prone than before and is explicitly framed as a stopgap until real PUSH_PROMISE support lands.

Other factors

  • Verified the error message in ErrorCode.cpp ("HTTP/2 client has disabled push streams") matches the test assertion.
  • createPushResponse calls pushStream(headers, {}, cb) (3-arg form), so both overloads are exercised by real callers.
  • Note: Node technically throws ERR_HTTP2_PUSH_DISABLED synchronously when the peer has disabled push, but since Bun lacks PUSH_PROMISE support entirely (so it would fail even when the peer allows push), routing through the callback is the more compatible choice for user code written against Node — the PR description acknowledges this trade-off.
  • No CODEOWNERS entry covers this path; no prior reviews on the PR; bug-hunting system found no issues.

@robobun robobun force-pushed the farm/c413079a/http2-pushstream-async-error branch from b49d3b4 to 17376ec Compare May 17, 2026 19:52
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/regression/issue/30942.test.ts`:
- Around line 1-8: Replace the multi-line file header in
test/regression/issue/30942.test.ts with the two-line regression test pattern:
keep the issue URL comment and a single brief follow-up line describing the test
intent, removing the extra historical/context lines (the current explanatory
block after the URL). Ensure the header precedes the test and uses the same
comment style as other regression tests so the file follows the repository's
standard test header format.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8f975bde-fd3d-4280-953e-ac12e85d35c1

📥 Commits

Reviewing files that changed from the base of the PR and between b49d3b4 and 17376ec.

📒 Files selected for processing (2)
  • src/js/node/http2.ts
  • test/regression/issue/30942.test.ts

Comment on lines +1 to +8
// https://github.com/oven-sh/bun/issues/30942
//
// `ServerHttp2Stream.pushStream` used to throw `ERR_HTTP2_PUSH_DISABLED`
// synchronously from inside the 'stream' event handler, which killed user
// code following Node's `(err, pushStream) => {}` callback pattern. Node
// routes the same error through the callback instead (async). The stub
// now matches that contract so callers can observe the error and recover
// until real PUSH_PROMISE support lands (see #28713).
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.

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Condense the file header to the two-line regression test pattern.

The multi-line explanation (lines 3-8) restates bug history and context rather than documenting test design rationale. As per coding guidelines, reduce to one brief follow-up line.

📝 Suggested revision
 // https://github.com/oven-sh/bun/issues/30942
-//
-// `ServerHttp2Stream.pushStream` used to throw `ERR_HTTP2_PUSH_DISABLED`
-// synchronously from inside the 'stream' event handler, which killed user
-// code following Node's `(err, pushStream) => {}` callback pattern. Node
-// routes the same error through the callback instead (async). The stub
-// now matches that contract so callers can observe the error and recover
-// until real PUSH_PROMISE support lands (see `#28713`).
+// pushStream should route ERR_HTTP2_PUSH_DISABLED via callback, not throw synchronously

As per coding guidelines for regression tests.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// https://github.com/oven-sh/bun/issues/30942
//
// `ServerHttp2Stream.pushStream` used to throw `ERR_HTTP2_PUSH_DISABLED`
// synchronously from inside the 'stream' event handler, which killed user
// code following Node's `(err, pushStream) => {}` callback pattern. Node
// routes the same error through the callback instead (async). The stub
// now matches that contract so callers can observe the error and recover
// until real PUSH_PROMISE support lands (see #28713).
// https://github.com/oven-sh/bun/issues/30942
// pushStream should route ERR_HTTP2_PUSH_DISABLED via callback, not throw synchronously
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/regression/issue/30942.test.ts` around lines 1 - 8, Replace the
multi-line file header in test/regression/issue/30942.test.ts with the two-line
regression test pattern: keep the issue URL comment and a single brief follow-up
line describing the test intent, removing the extra historical/context lines
(the current explanatory block after the URL). Ensure the header precedes the
test and uses the same comment style as other regression tests so the file
follows the repository's standard test header format.

Comment on lines +1 to +11
// https://github.com/oven-sh/bun/issues/30942
//
// `ServerHttp2Stream.pushStream` used to throw `ERR_HTTP2_PUSH_DISABLED`
// synchronously from inside the 'stream' event handler, which killed user
// code following Node's `(err, pushStream) => {}` callback pattern. Node
// routes the same error through the callback instead (async). The stub
// now matches that contract so callers can observe the error and recover
// until real PUSH_PROMISE support lands (see #28713).

import { expect, test } from "bun:test";
import http2 from "node:http2";
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.

🟡 Per repo conventions (root CLAUDE.md:66, test/CLAUDE.md:153), test/regression/issue/ is reserved for true regressions (worked in a previous release, then broke) — pushStream() was always a throwing stub, so these five tests belong in the existing test/js/node/http2/node-http2.test.js instead. The PR description and gate commands also claim the tests live in test/js/node/http2/node-http2.test.js, which contradicts the actual diff and suggests the verification shown may not have exercised this file.

Extended reasoning...

What's wrong

The new test file is created at test/regression/issue/30942.test.ts, but the repo's contribution guidelines explicitly reserve that directory for regressions only:

  • Root CLAUDE.md:66: "test/regression/issue/${issueNumber}.test.ts is reserved for bugs that have a GitHub issue number and are true regressions (worked in a previous release, then broke). An issue number alone is not enough — if the behavior was never correct, it's not a regression and the test belongs in the existing file for that module."
  • test/CLAUDE.md:152-153: "Add tests to the existing test file for the code you're changing — do not create a new file." (followed by the same regression-only rule)

ServerHttp2Stream.pushStream() was always a synchronous-throw stub in Bun — the PR description itself says it "was a stub". It never worked correctly in any prior release, so #30942 is a missing-feature/compat bug, not a regression.

Description ↔ diff mismatch

The PR description's Verification section claims:

Five new tests in test/js/node/http2/node-http2.test.js

and shows gate commands like:

bun bd test test/js/node/http2/node-http2.test.js -t "createPushResponse|pushStream"

But the diff doesn't touch test/js/node/http2/node-http2.test.js at all — it creates test/regression/issue/30942.test.ts instead. So either the description is stale, or the gate commands shown were run against a file that doesn't contain these tests (in which case the "5 fail → 5 pass" output didn't actually exercise this code).

Step-by-step

  1. Before this PR, pushStream() was throw $ERR_HTTP2_PUSH_DISABLED() — never worked, in any Bun release.
  2. Therefore http2 pushStream throws synchronously instead of returning error in callback #30942 is not a regression; it's behavior that was never correct.
  3. Per CLAUDE.md:66 / test/CLAUDE.md:153, non-regression tests must go in the existing module test file, not test/regression/issue/.
  4. The existing module test file is test/js/node/http2/node-http2.test.js, which already exists and is where the PR description claims the tests were added.
  5. → The file is in the wrong location and the description/diff disagree.

Why existing checks don't catch this

There's no lint or CI check enforcing the test/regression/ placement rule — it's a documented convention only. CodeRabbit's "Files selected for processing" list even shows test/js/node/http2/node-http2.test.js (matching the description), not the file actually added, so the tooling appears to have been confused by the same mismatch.

Impact

Organizational only — the tests themselves are fine and will run in CI from either location. But it violates an explicit, twice-documented repo policy, and the description/diff contradiction means a reviewer can't trust the stated gate output without re-running it.

Fix

Move the five tests into test/js/node/http2/node-http2.test.js (as the PR description already claims), delete test/regression/issue/30942.test.ts, and re-run the gate against the correct file.

Comment on lines +68 to +81
let callbackInvoked = false;
try {
stream.pushStream({ ":path": "/x.js" }, err => {
callbackInvoked = true;
try {
expect((err as any)?.code).toBe("ERR_HTTP2_PUSH_DISABLED");
} catch (e) {
reject(e);
}
});
if (callbackInvoked) {
reject(new Error("pushStream callback fired synchronously"));
return;
}
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.

🟡 Tests 2 and 3 will still pass if pushStream is changed to never invoke the callback at all — stream.respond()/end() run synchronously after pushStream() returns and the test resolves on the client's req.on('end'), so a no-op implementation goes undetected. Test 2's title says the callback "fires asynchronously (next tick)" but only checks the negative (not-synchronous) half; consider asserting callbackInvoked === true inside req.on('end') before resolve() (and similarly gating test 3's resolve() on the callback having fired). Tests 1 and 5 already cover the positive case suite-wide, so this is just a robustness nit.

Extended reasoning...

What's wrong

In test 2 ("pushStream callback fires asynchronously (next tick), not synchronously", lines 63–105) the server handler does:

let callbackInvoked = false;
stream.pushStream({ ":path": "/x.js" }, err => {
  callbackInvoked = true;
  expect((err as any)?.code).toBe("ERR_HTTP2_PUSH_DISABLED");
});
if (callbackInvoked) reject(new Error("pushStream callback fired synchronously"));
stream.respond({ ":status": 200 });
stream.end("ok");

and the client side does:

req.on("end", () => { client.close(); server.close(); resolve(); });

So the test resolves as soon as the response round-trips. The pushStream callback is never required to fire for resolve() to be reached. Test 3 ("accepts the (headers, callback) shape", lines 107–143) has the same shape: respond()/end() are outside the callback and resolve() is on req.on('end').

Step-by-step proof

Suppose pushStream were changed to:

pushStream(headers, options, callback) {
  if (typeof options === "function") { callback = options; options = undefined; }
  validateFunction(callback, "callback");
  // forget to schedule callback — no-op
}

Walk test 2:

  1. pushStream(...) returns without throwing → outer try does not reject.
  2. callbackInvoked is still false → the "fired synchronously" check passes.
  3. stream.respond() / stream.end("ok") run synchronously.
  4. Client receives response, req.on('end') fires → resolve().
  5. The callback never runs, so the expect((err as any)?.code).toBe(...) assertion is never evaluated.
  6. Test passes despite the implementation never invoking the callback.

Walk test 3 the same way: the callback body (the only place the ERR_HTTP2_PUSH_DISABLED assertion lives) never executes; respond()/end() still run; req.on('end')resolve(); test passes.

So test 2's title ("callback fires asynchronously") is only half-verified — it proves the callback does not fire synchronously, but never proves it does fire on the next tick. Test 3 never confirms the 2-arg overload actually reaches the callback.

Why existing code doesn't prevent it

Tests 1 and 5 are immune because stream.respond()/stream.end() (or res.end()) live inside the pushStream/createPushResponse callback there — if the callback never fired, the request would hang and the test would time out. So the suite as a whole would catch a no-op regression. The gap is only at the individual-test level: tests 2 and 3 are weaker than their titles imply, and someone reading test 2 in isolation would assume it covers the positive case.

Impact

Test-quality nit, not a runtime bug. The implementation in this PR is correct (it does call process.nextTick(callback, err)), and suite-level coverage exists via tests 1 & 5. But if a future refactor accidentally dropped the process.nextTick call, tests 2 and 3 would not flag it on their own.

Fix

Trivial — in test 2, assert the positive half before resolving:

req.on("end", () => {
  client.close();
  server.close();
  if (!callbackInvoked) return reject(new Error("pushStream callback never fired"));
  resolve();
});

(hoist callbackInvoked so it's visible in the client closure, or set a second flag). For test 3, either move resolve() into the callback after the err assertion, or track a callbackInvoked flag and check it in req.on('end') the same way.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

http2 pushStream throws synchronously instead of returning error in callback

1 participant