Repository navigation
Conversation
OutgoingMessage called Stream with its options, so EventEmitter read captureRejections from the options of a ServerResponse. Node.js calls Stream.call(this) there. The ahead-of-time text did the same for every OutgoingMessage but a ServerResponse made with new. Both texts now call Stream with undefined.
ChildProcess had no constructor, so the default one handed its arguments to EventEmitter, which read captureRejections from them. Node.js calls EventEmitter with none. A counted program records what the change in node:http costs ahead of time: after 501 messages, each OutgoingMessage made in a loop asks the run time for room for three fields.
|
Updated 2:05 AM PT - Oct 30th, 2026
❌ @robobun, your commit b9a3394 has 1 failures in
🧪 To try this PR locally: bunx bun-pr 47434That installs a local version of the PR into your bun-47434 --bun |
|
Status: closed without a merge No user reported this difference. A maintainer asked to close #48311 for that reason (review), and the same holds here. How I reproduced it. Release build of main 2596ac5, against node v26.3.0:
What a later cut needs, if a user reports this or a maintainer wants node's behaviour. The line numbers are those of main 2596ac5.
I built that cut on 2596ac5 and did not push it. With it the two new tests pass, and they fail with the
The source diff of that cutdiff --git a/src/js/ahead-of-time/node/_http_outgoing.ts b/src/js/ahead-of-time/node/_http_outgoing.ts
index 75c54845f9c..67cf071dee6 100644
--- a/src/js/ahead-of-time/node/_http_outgoing.ts
+++ b/src/js/ahead-of-time/node/_http_outgoing.ts
@@ -1,5 +1,5 @@
// What the compiler reads in place of src/js/node/_http_outgoing.ts, which `bun run` evaluates. The two do the same.
-// Written against that file with the SHA-256 7eb68379dcc85e37da343c90d1da52f21b0bd91937cea041c06b56a6db7551fc.
+// Written against that file with the SHA-256 fd216fcabdbf577569fbb632c6d624d1d274693f1f402f11ed8cfdce28f6be12.
// When that file changes, bring the change here and write the new hash: test/internal/second-texts-of-modules.test.ts.
// Hardcoded module "node:_http_outgoing"
@@ -152,7 +152,7 @@ function isContentDispositionField(s: string) {
declare global {
namespace $Node {
- interface OutgoingMessageOptions extends EventEmitterOptions {
+ interface OutgoingMessageOptions {
highWaterMark?: number;
rejectNonStandardBodyWrites?: boolean;
}
@@ -276,7 +276,7 @@ function OutgoingMessage(this: $Node.OutgoingMessage, options?: $Node.OutgoingMe
// A call on a response that exists lays its fields anew. What its server keeps of the dispatch is not among them, and stays.
const kept = this.$exchange;
if (kept !== undefined) http.responseRoad.spillStateReachedThrough!(this);
- Stream.$call(this, options);
+ Stream.$call(this, undefined);
// Queue that holds all currently pending data, until the response will be
// assigned to the socket (until it will its turn in the HTTP pipeline).
diff --git a/src/js/ahead-of-time/node/_http_server.ts b/src/js/ahead-of-time/node/_http_server.ts
index aa946e2a188..eb995640350 100644
--- a/src/js/ahead-of-time/node/_http_server.ts
+++ b/src/js/ahead-of-time/node/_http_server.ts
@@ -3383,11 +3383,7 @@ function ServerResponse(
this._eventsCount = 0;
this[kShapeMode] = false;
this._maxListeners ??= undefined;
- if (options?.captureRejections) {
- validateBoolean(options.captureRejections, "options.captureRejections");
- this[kCapture] = !!options.captureRejections;
- storeEmitWithRejectionCapture(this);
- } else if ((this[kCapture] = EventEmitterPrototype[kCapture])) {
+ if ((this[kCapture] = EventEmitterPrototype[kCapture])) {
storeEmitWithRejectionCapture(this);
}
this.outputData = [];
diff --git a/src/js/node/_http_outgoing.ts b/src/js/node/_http_outgoing.ts
index ef8cc79cc01..abf4b718d7c 100644
--- a/src/js/node/_http_outgoing.ts
+++ b/src/js/node/_http_outgoing.ts
@@ -137,7 +137,8 @@ function isContentDispositionField(s) {
}
function OutgoingMessage(options) {
- Stream.$call(this, options);
+ // As in node, Stream gets no options. `undefined` and not a missing argument: that call needs no arity fix-up.
+ Stream.$call(this, undefined);
// Queue that holds all currently pending data, until the response will be
// assigned to the socket (until it will its turn in the HTTP pipeline).
diff --git a/src/js/node/child_process.ts b/src/js/node/child_process.ts
index 3b0faef5b83..846e79795c9 100644
--- a/src/js/node/child_process.ts
+++ b/src/js/node/child_process.ts
@@ -1161,6 +1161,11 @@ class ChildProcess extends EventEmitter {
channel;
killed = false;
+ // Not the default constructor, which hands its arguments to EventEmitter. Node's takes none.
+ constructor() {
+ super();
+ }
+
[Symbol.dispose]() {
if (!this.killed) {
this.kill(); |
Its count holds on x64 only. On Alpine aarch64 the same program stays under the bound, so its Todo line fails there.
|
Closing this without a merge. No user reported this difference. A maintainer asked to close #48311 for that reason (review), and the same holds here. The disagreement between The status comment says what is left on main. It has the source diff of a cut on 2596ac5, which I did not push. |
Problem
new http.ServerResponse(req, { captureRejections: true })captures rejections inbun run. Node v26.3.0 ignores the option. A non-boolean value throws in Bun only.OutgoingMessagecallsStream.$call(this, options)(src/js/node/_http_outgoing.ts:139). Node callsStream.call(this).ChildProcesshas no constructor, so its arguments reachEventEmittertoo.--aotandbun rundisagree.Fix
OutgoingMessagecallsStreamwithundefined, insrc/js/node/and insrc/js/ahead-of-time/.ChildProcessgetsconstructor() { super(); }.EventEmitternothing in both places.EventEmitter.captureRejectionsstill works.test/js/node/http/node-http-stores-of-the-response-constructor.test.tsandtest/js/node/child_process/child_process-node.test.jsfail on main. CI runs the new program under--aot.// Todo:program, and tracking issues.Background
captureRejectionsis anEventEmitterconstructor option. With it,emit()handles a listener's rejected promise.src/js/ahead-of-time/holds the text thatbun build --compile --aotcompiles in place ofsrc/js/node/. Both must behave the same.ServerResponsemust pass its options on, and so mustStream, as in node.Downsides
EventEmitter.call(res, { captureRejections: true })restores capture.--aoton x64,new http.OutgoingMessage()in a loop costs 1,056 instructions, against 670. Notes has the cause.bun run,new ServerResponse(), a sentClientRequest, 40--aotserver routes.Notes
Order. This is a draft because it conflicts with #46528 and #46522 in one line: the SHA-256 of the first text in the head of
src/js/ahead-of-time/node/_http_outgoing.ts. The other hunks merge without a conflict. Both branches still haveStream.$call(this, options)(#46528 at 001958b::145and:295). On #46528 the rule for such a PR is: it stays a draft and is cut again on top.Where it comes from. The review comment found that the two texts differ for
new ServerResponse(req, { captureRejections: true }). It proposed that the second text read the option. Node does not read it, so this PR goes the other way. No issue mentionscaptureRejections, and node documents no options for these constructors.Stream.$call(this, options)came with #17093, whereWritable.$call(this, options)became a call ofStream.A program that relied on the option:
Error: boom, exit code 1bun rundestroyed: true, exit code 0bun runerror: boom, exit code 1With
EventEmitter.call(res, { captureRejections: true })after the constructor, node, main and this PR all printdestroyed: true, underbun runand under--aot.The new program,
test/aot/parallel/library-modules/http-messages-hand-no-options-to-the-emitter.mjs. It makes a message in five ways. For each it prints three answers: withcaptureRejections: true, withcaptureRejections: "yes", and with no options whileEventEmitter.captureRejectionsis true.new OutgoingMessage, a class that extends it, a class that extendsServerResponse,ServerResponse.call(object)new ServerResponsefalse,false,truefalse,false,truebun runtrue,ERR_INVALID_ARG_TYPE,truetrue,ERR_INVALID_ARG_TYPE,true--aottrue,ERR_INVALID_ARG_TYPE,truefalse,false,truebun runand--aotfalse,false,truefalse,false,truetest/aot/run-parallel.tson the program with a release build: main fails ("it does not exit and print as plain Bun does"), this PR passes.ChildProcess. Node has
function ChildProcess() { FunctionPrototypeCall(EventEmitter, this);(child_process.js:255). Fornew ChildProcess(options)and for a class that extends it, node printsfalse,false,trueand main printstrue,ERR_INVALID_ARG_TYPE,true.child_process.tshas no second text. The test is inchild_process-node.test.js. Two tests ofchild_process.test.tsfail on a debug build in my container, with and without this change. One needs more than 1,024 descriptors, and one takes more than 5 s.Why
undefinedand not a missing argument.Streamdeclares one parameter. In the interpreter a call with no argument costs 83 instructions more, an arity fix-up. The second text already wroteStream.$call(this, undefined).Measurements. Release builds of f7607a3 and of this PR, on Linux x64. Instructions in user mode per object, from
perf_event_open, in a loop that makes the object and drops it. Each program imports one module. The programs of the last two rows of the second table also importnode:events,node:http,node:netandnode:stream. The interpreter and--aotvalues are the median of three readings. The JIT values are the range of six readings, and of three forChildProcess.bun runnew http.OutgoingMessage()new http.ServerResponse(req)new http.ServerResponse(req, { highWaterMark: 7 })new (class extends http.ServerResponse {})(req)new ChildProcess()A server passes options for every response, so the third row is the one a request takes.
bun build --compile --aotnew http.OutgoingMessage()new http.OutgoingMessage({ highWaterMark: 7 })new (class extends http.OutgoingMessage {})()new http.ServerResponse(req)new http.ServerResponse(req, { highWaterMark: 7 })new (class extends http.ServerResponse {})(req){ highWaterMark: 7 }http.ServerResponse.call(this, req)in a constructor functionnew ChildProcess()new EventEmitter(),new Readable(),new Writable(),new Duplex()new net.Socket()Servers under
--aot, instructions a request:bun test/aot/perf/compare-builds.ts --servers node,express,fastify,koa: 32 rows, 0 slower, 0 faster.nodeserver oftest/aot/servers/appwithcreateServer({ ServerResponse: class extends ServerResponse {} }, ..): all 8 routes cost less with this PR, by 0.3% to 2.0%. Thetextroute costs 31,205 to 31,239 on main and 30,595 to 30,616 with this PR.http.get()against a local server callsoperationAOTEnsureExtensionGranules7 times in all. That holds for 200 and for 1,200 requests, before and after.OutgoingMessage(construct body) 8,922 to 5,788 bytes,EventEmitter10,935 to 10,904,initializeProxyReceiver5,120 to 5,092,OutgoingMessage3,552 to 3,548.bunbinary has 138,270,840 bytes before and after.sizereports 38 bytes more text.The three rows that rise. They rise by a decision of the slot planner.
testaot print program.aot --command="plan-slots layouts fill" references=narrowshows it for the layout ofOutgoingMessage:size=160size=144_events@40,filled=40%out@8,filled=32%[kShapeMode]@136,filled=55% likelyout@34,filled=44%_onPendingData@120out@60The engine puts a field into the cell when its estimated fill is at least half of the creations. That is
isLikelyFilledinAOTSlotPlanSteps.h(oven-sh/WebKit 8c6c963e). With a constantundefinedin place ofoptions, the estimate for[kShapeMode]falls below one half. Three fields then lie outside the cell. From the 502nd object on, or after a collection, eachnew http.OutgoingMessage()callsoperationAOTEnsureExtensionGranulesonce.The numbers move with the rest of the program. The program below makes 2,519 calls for 3,000 objects. Without its last block it is the loop of the table. With the block, which never runs, it makes 3 calls, as main does:
Two more release builds gave the numbers of this one. One kept the call in both arms of the
if. The other also left the options interface as it was. I did not bend the second text to win a layout back.The cost belongs to the layout on x64. A plain loop of 1,000
new http.OutgoingMessage()makes 519 calls with this PR and 1 on main, on x64. On Alpine aarch64 the same loop makes 40 calls or fewer with this PR. I saw that in CI and have no arm64 machine to look closer. An earlier commit of this PR held that count in a program with a// Todo:line. I took the program out again, because one line cannot be right for both architectures.src/aot/CLAUDE.mdasks for a fix or a revert of a row over +2%. Three rows are over it on x64, and I ask for the exception: this change is what makes the two texts agree with each other and with node.What is left out. I gave
captureRejections: trueand"yes"to 68 public constructors and factories ofnode:*modules that make an emitter. I compared each with node v26.3.0. Besides the two classes of this PR, these differ, on main and with this PR:tls.TLSSocket,tls.connectandhttps.request.TLSSockethands its whole options object tonet.Socket(src/js/node/tls.ts:790). Node hands ten named keys (wrap.js:590). The fix there is a fixed list of keys, which changes more than this option.tls.connect({ captureRejections: true })captures in Bun today.tty.ReadStream(fd, options)hands on nothing (src/js/node/tty.ts:65). Node hands the options on.undicimodule:Agenthas no constructor (src/js/thirdparty/undici.ts:269), sonew Agent(options)hands its options on.new http.ServerResponse(req, { highWaterMark: 7 }).writableHighWaterMarkis 65536, and 7 in node. The option is stored as in node. The getter atsrc/js/node/_http_server.ts:3961answers for the socket that it makes.optionsinnew ServerResponse(req, options)in another order, which a Proxy shows.Within
node:http,Agent,Server,IncomingMessageandClientRequestagree with node.Also in the diff.
interface OutgoingMessageOptionsno longer extendsEventEmitterOptions: nothing hands these options to an emitter.tsc -p src/jsprints the same 315 lines before and after. The SHA-256 in the head of the second text is that of the new first text.Suites run.
bun bd teston the two test files: with thesrc/of main 2 tests fail, with this PR none.test/js/node/http,child_process,eventsandcluster, the two stream tests that run programs, andEventEmitterAsyncResource.test.ts. This PR: 1,649 pass. One test fails before and after (spawn still works with more than 10240 fds open,EMFILEin my container). Main differs in the two new tests only.test/js/node/test/parallel(test-http-*,test-https-*,test-child-process-*,test-cluster-*,test-event-*,*capture-rejection*,*catch-rejections*): 716 exit with 0 before and after. The other two needbun test.test/aot/run-parallel.tson the 50 programs oftest/aot/parallel/node-httpand 16 oflibrary-modules: 66 pass.test/internal/source-lints(194 pass),test/internal/second-texts-of-modules.test.ts,test/aot/every-test-runs-in-ci.test.ts,bun fmt --check,bun lint.no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/node/child_process/child_process-node.test.js