-
Notifications
You must be signed in to change notification settings - Fork 4.7k
http2: route ERR_HTTP2_PUSH_DISABLED through pushStream callback #30944
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
base: main
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,216 @@ | ||
| // 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"; | ||
|
Check warning on line 11 in test/regression/issue/30942.test.ts
|
||
|
Comment on lines
+1
to
+11
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Per repo conventions (root Extended reasoning...What's wrongThe new test file is created at
Description ↔ diff mismatchThe PR description's Verification section claims:
and shows gate commands like: But the diff doesn't touch Step-by-step
Why existing checks don't catch thisThere's no lint or CI check enforcing the ImpactOrganizational 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. FixMove the five tests into |
||
|
|
||
| test("pushStream delivers ERR_HTTP2_PUSH_DISABLED through the callback, not a sync throw", async () => { | ||
| const server = http2.createServer(); | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| server.on("stream", stream => { | ||
| try { | ||
| stream.pushStream({ ":path": "/x.js" }, (err, pushStream) => { | ||
| try { | ||
| expect(err).toBeInstanceOf(Error); | ||
| expect((err as any).code).toBe("ERR_HTTP2_PUSH_DISABLED"); | ||
| expect(err!.message).toBe("HTTP/2 client has disabled push streams"); | ||
| expect(pushStream).toBeUndefined(); | ||
| stream.respond({ ":status": 200 }); | ||
| stream.end("ok"); | ||
| } catch (e) { | ||
| reject(e); | ||
| } | ||
| }); | ||
| } catch (e) { | ||
| reject(new Error(`pushStream threw synchronously: ${(e as any)?.code ?? (e as Error).message}`)); | ||
| } | ||
| }); | ||
|
|
||
| server.listen(0, () => { | ||
| const port = (server.address() as any).port; | ||
| const client = http2.connect(`http://localhost:${port}`); | ||
| const req = client.request({ ":path": "/" }); | ||
| let body = ""; | ||
| req.setEncoding("utf8"); | ||
| req.on("data", chunk => { | ||
| body += chunk; | ||
| }); | ||
| req.on("end", () => { | ||
| try { | ||
| expect(body).toBe("ok"); | ||
| resolve(); | ||
| } catch (e) { | ||
| reject(e); | ||
| } finally { | ||
| client.close(); | ||
| server.close(); | ||
| } | ||
| }); | ||
| req.on("error", reject); | ||
| req.end(); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); | ||
|
|
||
| test("pushStream callback fires asynchronously (next tick), not synchronously", async () => { | ||
| const server = http2.createServer(); | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| server.on("stream", stream => { | ||
| 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; | ||
| } | ||
|
Comment on lines
+68
to
+81
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Tests 2 and 3 will still pass if Extended reasoning...What's wrongIn 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 Step-by-step proofSuppose pushStream(headers, options, callback) {
if (typeof options === "function") { callback = options; options = undefined; }
validateFunction(callback, "callback");
// forget to schedule callback — no-op
}Walk test 2:
Walk test 3 the same way: the callback body (the only place the 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 itTests 1 and 5 are immune because ImpactTest-quality nit, not a runtime bug. The implementation in this PR is correct (it does call FixTrivial — 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 |
||
| } catch (e) { | ||
| reject(new Error(`pushStream threw synchronously: ${(e as any)?.code ?? (e as Error).message}`)); | ||
| return; | ||
| } | ||
| stream.respond({ ":status": 200 }); | ||
| stream.end("ok"); | ||
| }); | ||
|
|
||
| server.listen(0, () => { | ||
| const port = (server.address() as any).port; | ||
| const client = http2.connect(`http://localhost:${port}`); | ||
| const req = client.request({ ":path": "/" }); | ||
| req.resume(); | ||
| req.on("end", () => { | ||
| client.close(); | ||
| server.close(); | ||
| resolve(); | ||
| }); | ||
| req.on("error", reject); | ||
| req.end(); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); | ||
|
|
||
| test("pushStream accepts the (headers, callback) shape with options omitted", async () => { | ||
| const server = http2.createServer(); | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| server.on("stream", stream => { | ||
| try { | ||
| stream.pushStream({ ":path": "/x.js" }, (err, pushStream) => { | ||
| try { | ||
| expect((err as any)?.code).toBe("ERR_HTTP2_PUSH_DISABLED"); | ||
| expect(pushStream).toBeUndefined(); | ||
| } catch (e) { | ||
| reject(e); | ||
| } | ||
| }); | ||
| stream.respond({ ":status": 200 }); | ||
| stream.end("ok"); | ||
| } catch (e) { | ||
| reject(e); | ||
| } | ||
| }); | ||
|
|
||
| server.listen(0, () => { | ||
| const port = (server.address() as any).port; | ||
| const client = http2.connect(`http://localhost:${port}`); | ||
| const req = client.request({ ":path": "/" }); | ||
| req.resume(); | ||
| req.on("end", () => { | ||
| client.close(); | ||
| server.close(); | ||
| resolve(); | ||
| }); | ||
| req.on("error", reject); | ||
| req.end(); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); | ||
|
|
||
| test("pushStream rejects a non-function callback synchronously (ERR_INVALID_ARG_TYPE)", async () => { | ||
| const server = http2.createServer(); | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| server.on("stream", stream => { | ||
| try { | ||
| expect(() => (stream as any).pushStream({ ":path": "/x.js" }, "not-a-function")).toThrow( | ||
| expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), | ||
| ); | ||
| expect(() => (stream as any).pushStream({ ":path": "/x.js" })).toThrow( | ||
| expect.objectContaining({ code: "ERR_INVALID_ARG_TYPE" }), | ||
| ); | ||
| stream.respond({ ":status": 200 }); | ||
| stream.end("ok"); | ||
| resolve(); | ||
| } catch (e) { | ||
| reject(e); | ||
| } | ||
| }); | ||
|
|
||
| server.listen(0, () => { | ||
| const port = (server.address() as any).port; | ||
| const client = http2.connect(`http://localhost:${port}`); | ||
| const req = client.request({ ":path": "/" }); | ||
| req.resume(); | ||
| req.on("end", () => { | ||
| client.close(); | ||
| server.close(); | ||
| }); | ||
| req.on("error", () => {}); | ||
| req.end(); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); | ||
|
|
||
| test("Http2ServerResponse.createPushResponse delivers the error via its callback", async () => { | ||
| const server = http2.createServer((req, res) => { | ||
| res.createPushResponse({ ":path": "/x.js" }, (err, pushRes) => { | ||
| expect((err as any)?.code).toBe("ERR_HTTP2_PUSH_DISABLED"); | ||
| expect(pushRes).toBeUndefined(); | ||
| res.end("ok"); | ||
| }); | ||
| }); | ||
| const { promise, resolve, reject } = Promise.withResolvers<void>(); | ||
|
|
||
| server.listen(0, () => { | ||
| const port = (server.address() as any).port; | ||
| const client = http2.connect(`http://localhost:${port}`); | ||
| const req = client.request({ ":path": "/" }); | ||
| let body = ""; | ||
| req.setEncoding("utf8"); | ||
| req.on("data", chunk => { | ||
| body += chunk; | ||
| }); | ||
| req.on("end", () => { | ||
| try { | ||
| expect(body).toBe("ok"); | ||
| resolve(); | ||
| } catch (e) { | ||
| reject(e); | ||
| } finally { | ||
| client.close(); | ||
| server.close(); | ||
| } | ||
| }); | ||
| req.on("error", reject); | ||
| req.end(); | ||
| }); | ||
|
|
||
| await promise; | ||
| }); | ||
There was a problem hiding this comment.
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
As per coding guidelines for regression tests.
📝 Committable suggestion
🤖 Prompt for AI Agents