Skip to content

http: publish to http.server.* diagnostics_channel channels - #29588

Closed
robobun wants to merge 11 commits into
mainfrom
farm/8f8809b8/http-server-diagnostics-channel
Closed

http: publish to http.server.* diagnostics_channel channels#29588
robobun wants to merge 11 commits into
mainfrom
farm/8f8809b8/http-server-diagnostics-channel

Conversation

@robobun

@robobun robobun commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

Fixes #29586.

node:http server never published to the three standard Node.js HTTP
server diagnostics_channel channels that observability libraries
(OpenTelemetry, Prometheus/APM tooling) rely on. Subscribers to any of
http.server.request.start, http.server.response.created, and
http.server.response.finish silently got nothing, so those libraries
collected no HTTP server metrics/traces on Bun.

Repro

const dc = require("node:diagnostics_channel");
const http = require("node:http");
const events = [];
["http.server.request.start",
 "http.server.response.created",
 "http.server.response.finish"].forEach(name =>
  dc.subscribe(name, m => events.push({ channel: name, keys: Object.keys(m) })));
http.createServer((req, res) => res.end("ok")).listen(0, async function () {
  await (await fetch(`http://127.0.0.1:${this.address().port}/`)).text();
  setTimeout(() => { console.log("count:", events.length); this.close(); }, 100);
});

Before: count: 0. After: count: 3 (matches Node).

Fix (src/js/node/_http_server.ts)

  • Resolve the three channels at module load, mirroring Node's
    lib/_http_server.js.
  • Publish http.server.response.created ({ request, response }) from
    the ServerResponse constructor, so direct new http.ServerResponse(req)
    (light-my-request, fastify.inject(), mocks) also fires it.
  • Publish http.server.request.start
    ({ request, response, socket, server }) once per non-upgrade request
    before the dispatch branching, so it fires on the normal,
    checkContinue, checkExpectation, 417, and dropRequest/503 paths.
  • Attach the http.server.response.finish listener unconditionally
    (matching Node's resOnFinish), with the hasSubscribers guard
    inside the handler, so a subscriber added between request arrival and
    response finish is still observed.
  • Synchronous publishes keep a call-site hasSubscribers guard so no
    payload object is allocated when nobody is listening.

Note on dc.subscribe without a held channel reference

dc.subscribe("http.server.request.start", cb) without keeping a
reference to the channel object can have the channel GC'd and the
subscription dropped. That is a separate WeakReference GC bug in
diagnostics_channel.ts, tracked/fixed in #26537, so it is out of scope
here.

How did you verify your code works?

New tests in test/js/node/diagnostics_channel/diagnostics_channel.test.ts
cover ordering (response.createdrequest.start → handler →
response.finish), payload shapes and object identity, the header
visibility contract (response.created fires before the handler mutates
the response, response.finish sees the final value), request.start
on the checkContinue / 417 paths, no request.start on the upgrade
path, late subscription to response.finish, direct
new http.ServerResponse() construction, and the no-subscriber path.

Verified against Node's observable contract from
test/parallel/test-diagnostics-channel-http.js and
test-diagnostic-channel-http-response-created.js. All 14 tests pass;
they fail (6/8) when _http_server.ts is reverted to main, so the gate
holds. Rebuilt against current main (post Rust-rewrite, bun test v1.4.0).


[review] gate passed · iteration 8 · 3 files touched

fails on main (without fix)
ASAN without fix: 8 failed, 3 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/diagnostics_channel/diagnostics_channel.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (c11cc9880)

test/js/node/diagnostics_channel/diagnostics_channel.test.ts:
(pass) Channel > can have subscribers [15.28ms]
(pass) Channel > can have symbol as name [14.70ms]
(pass) Channel > does not throw when unsubscribed [10.58ms]
(pass) Channel > can publish and subscribe [14.03ms]
(pass) Channel > can publish and subscribe using object [11.57ms]
(todo) Channel > can handle subscriber errors
(todo) Channel > can use bind store
(pass) Channel > references are not leaked [305.02ms]
(todo) TracingChannel > TODO
372 |       await using server = http.createServer((_req, res) => res.end("ok"));
373 |       const port = await listen(server);
374 |       await (await fetch(`http://127.0.0.1:${port}/`)).text();

... (truncated)

release without fix: 1 failed, 3 skipped
bun test v1.4.0-canary.1 (584c61559)

test/js/node/diagnostics_channel/diagnostics_channel.test.ts:
(pass) Channel > can have subscribers [3.10ms]
(pass) Channel > can have symbol as name [1.84ms]
(pass) Channel > does not throw when unsubscribed [0.22ms]
(pass) Channel > can publish and subscribe [0.20ms]
(pass) Channel > can publish and subscribe using object [0.15ms]
(todo) Channel > can handle subscriber errors
(todo) Channel > can use bind store
(pass) Channel > references are not leaked [4.23ms]
(todo) TracingChannel > TODO
(pass) http server channels (#29586) > publishes http.server.request.start, response.created, response.finish [24.81ms]
(pass) http server channels (#29586) > response.created fires before the request handler runs [3.48ms]
(pass) http server channels (#29586) > response.finish delivers to subscribers added after the request arrived [2.83ms]
(pass) http server channels (#29586) > response.created fires for direct new http.ServerResponse() [0.70ms]
(pass) http server channels (#29586) > request.start fires on checkContinue path [15.05ms]
(pass) http server channels (#29586) > request.start fires on 417 Expectation Failed path [3.82ms]
(pass) 
... (truncated)
passes on PR (with fix)
ASAN with fix: 3 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/diagnostics_channel/diagnostics_channel.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (c11cc9880)

test/js/node/diagnostics_channel/diagnostics_channel.test.ts:
(pass) Channel > can have subscribers [15.24ms]
(pass) Channel > can have symbol as name [16.18ms]
(pass) Channel > does not throw when unsubscribed [11.07ms]
(pass) Channel > can publish and subscribe [15.20ms]
(pass) Channel > can publish and subscribe using object [12.12ms]
(todo) Channel > can handle subscriber errors
(todo) Channel > can use bind store
(pass) Channel > references are not leaked [344.35ms]
(todo) TracingChannel > TODO
(pass) http server channels (#29586) > publishes http.server.request.start, response.created, response.finish [456.79ms]
(pass) http server channels (#29586) > response.created fires before the requ
... (truncated)

release with fix: 3 skipped
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 728ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/20] gen JS modules (bundle-modules)
Preprocess modules (6931ms)
Bundle modules (51ms)
Postprocesss modules (176ms)
Bundle Functions (757ms)
Generate Code (90ms)

[8.02s] Bundled "src/js" for production
  1912 kb
  162 internal modules
  12 native modules
  90 internal functions across 19 files
[1/7] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: component rust-std is up to date

info: checking for self-update (current version: 1.29.0)
  nightly-2026-05-06-x86_64-unknown-linux-gnu unchanged - rustc 1.97.0-nightly (e95e73209 202
... (truncated)
diff hotspot
src/js/node/_http_server.ts                        |  30 +-
 src/js/node/http2.ts                               |  37 ++-
 .../diagnostics_channel.test.ts                    | 369 +++++++++++++++++++++
 3 files changed, 427 insertions(+), 9 deletions(-)

gate history · 3 passed · 0 rejected · iteration 8

evidence per changed file
file                                                      reads  edits  tests
src/js/node/_http_server.ts                                  22     17     44
src/js/node/http2.ts                                          7     10     41
…js/node/diagnostics_channel/diagnostics_channel.test.ts     12     14     39

root cause · written by the author bot

Bun's node:http server implementation never published to the standard Node.js server-side diagnostics channels (http.server.request.start, http.server.response.created, and http.server.response.finish), so observability libraries subscribing to them were silently never invoked. The fix instruments the server request and response lifecycle to publish the expected {request, response, socket, server} payloads at each point, lazily creating the channels and publishing only when subscribers exist. On both the plain http path and the http2 allowHTTP1 fallback, the response.finish publish fires fr…

@robobun

robobun commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 12:33 AM PT - Jul 15th, 2026

@robobun, your commit c11cc98 has 2 failures in Build #73188 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29588

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

bun-29588 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. Bun doesn't work correctly with Open Telemetry express/fastify/http instrumentation #26536 - PR adds the missing http.server.* diagnostics_channel publications that OTel's HttpInstrumentation subscribes to, and fixes the WeakReference bug that causes subscriptions to be silently GC'd
  2. tracingChannel().hasSubscribers is undefined #27805 - The WeakReference fix directly addresses subscriptions being lost to GC, which is the underlying cause of tracingChannel().hasSubscribers appearing undefined

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

Fixes #26536
Fixes #27805

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix(diagnostics_channel): retain channels with active subscribers #26537 - Fixes the same WeakReference GC bug in diagnostics_channel.ts where channels with active subscribers are not retained (same root cause, same fix approach)

🤖 Generated with Claude Code

Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts Outdated
Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts Outdated
@coderabbitai

coderabbitai Bot commented Apr 22, 2026

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Lazily initializes three diagnostics_channel channels for HTTP server (http.server.request.start, http.server.response.created, http.server.response.finish); conditionally publishes payloads {request, response, socket, server} at response creation, at request start (before handler paths) and on response finish (only when subscribers exist); adds unconditional finish listener and direct-construction publishing.

Changes

Cohort / File(s) Summary
HTTP Server diagnostics implementation
src/js/node/_http_server.ts
Added lazyLoadHttpServerChannels() and emitResponseFinishChannel(); attach unconditional finish listener on each ServerResponse that conditionally publishes http.server.response.finish; publish http.server.request.start conditionally before request handling/parsing branches (including 100-continue/default); publish http.server.response.created from ServerResponse constructor when subscribers exist.
HTTP Server diagnostics tests
test/js/node/diagnostics_channel/diagnostics_channel.test.ts
Added test suite http server channels (#29586) covering response.created, request.start, response.finish: verifies event ordering, payload key sets and identity relationships, header visibility at created vs finish, late subscription delivery for finish, direct-construction ServerResponse publish, non-publication on upgrade, and normal behavior when no subscribers; includes explicit unsubscribe/cleanup.
🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The pull request has no description; the required template sections 'What does this PR do?' and 'How did you verify your code works?' are completely missing. Add a pull request description following the template. Include what changes were made and how they were verified.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main objective: publishing to HTTP server diagnostics_channel channels, matching the core changes in _http_server.ts.
Linked Issues check ✅ Passed The PR fully addresses the requirements in #29586 by implementing publishes for all three required channels (http.server.request.start, http.server.response.created, http.server.response.finish) with correct payload shapes and ordering.
Out of Scope Changes check ✅ Passed All changes are directly scoped to implementing the diagnostics_channel publishes required by #29586; 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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/node/diagnostics_channel/diagnostics_channel.test.ts`:
- Around line 348-405: The test currently inspects the internal _subscribers for
cleanup which couples it to internals; instead, when calling
channels.map/ch.subscribe store each listener callback in a parallel
subscriptions array (capture the function passed to ch.subscribe) and in the
finally block call ch.unsubscribe with that stored function for each channel
(use the same channel variables: channels, ch.subscribe, ch.unsubscribe) so
teardown uses the public API rather than _subscribers.
🪄 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: 22f8b3cb-0f24-4411-b3ba-6af92223da15

📥 Commits

Reviewing files that changed from the base of the PR and between 3453c22 and f8858d935a584f781ae9d5b5f0669f848a6af046.

📒 Files selected for processing (3)
  • src/js/node/_http_server.ts
  • src/js/node/diagnostics_channel.ts
  • test/js/node/diagnostics_channel/diagnostics_channel.test.ts

Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts
@robobun
robobun force-pushed the farm/8f8809b8/http-server-diagnostics-channel branch from 2a426cc to ff8f883 Compare April 22, 2026 08:41
Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts
Comment thread src/js/node/_http_server.ts Outdated

@coderabbitai coderabbitai Bot left a comment

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.

♻️ Duplicate comments (1)
test/js/node/diagnostics_channel/diagnostics_channel.test.ts (1)

356-360: 🧹 Nitpick | 🔵 Trivial

Unsubscribe the exact listener instead of reading _subscribers.

Teardown is reaching into an internal field here. That makes the test brittle, and [_subscribers][0] can remove the wrong callback if the channel already has another subscriber. Store each subscribed function and pass that same reference to unsubscribe().

💡 Safer teardown
-    const channels = channelNames.map(n => channel(n));
-    for (const ch of channels) {
-      ch.subscribe(msg => {
+    const subscriptions: { ch: ReturnType<typeof channel>; fn: (msg: unknown) => void }[] = [];
+    for (const name of channelNames) {
+      const ch = channel(name);
+      const fn = (msg: unknown) => {
         events.push({ channel: ch.name as string, payload: msg as Record<string, unknown> });
-      });
+      };
+      ch.subscribe(fn);
+      subscriptions.push({ ch, fn });
     }
@@
-      for (const ch of channels) ch.unsubscribe(ch._subscribers?.[0] ?? (() => {}));
+      for (const { ch, fn } of subscriptions) ch.unsubscribe(fn);

Also applies to: 398-399

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@test/js/node/diagnostics_channel/diagnostics_channel.test.ts` around lines
356 - 360, The test currently reaches into the internal _subscribers to remove
callbacks which is brittle; instead capture and store the exact listener
function returned/used when calling ch.subscribe for each channel (e.g., the
callback passed to ch.subscribe in the channels loop) and later call
ch.unsubscribe(listener) with that same function reference to tear down; update
both loops (the one at lines around the channels subscribe and the similar block
at lines ~398–399) to retain the listener variable per channel and pass it to
ch.unsubscribe rather than manipulating _subscribers directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@test/js/node/diagnostics_channel/diagnostics_channel.test.ts`:
- Around line 356-360: The test currently reaches into the internal _subscribers
to remove callbacks which is brittle; instead capture and store the exact
listener function returned/used when calling ch.subscribe for each channel
(e.g., the callback passed to ch.subscribe in the channels loop) and later call
ch.unsubscribe(listener) with that same function reference to tear down; update
both loops (the one at lines around the channels subscribe and the similar block
at lines ~398–399) to retain the listener variable per channel and pass it to
ch.unsubscribe rather than manipulating _subscribers directly.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4a92971b-64c6-430f-b067-e381577f6f24

📥 Commits

Reviewing files that changed from the base of the PR and between f8858d935a584f781ae9d5b5f0669f848a6af046 and 2a426cccd28196f97d772fdcd92b410670552a94.

📒 Files selected for processing (1)
  • test/js/node/diagnostics_channel/diagnostics_channel.test.ts

@robobun
robobun force-pushed the farm/8f8809b8/http-server-diagnostics-channel branch from 96674aa to d08aca7 Compare April 22, 2026 08:46

@claude claude Bot left a comment

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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/_http_server.ts:669-676 — Node.js publishes http.server.request.start once unconditionally in parserOnIncoming (after creating res, before any branching), so it fires for the checkContinue-listener, checkExpectation-listener, 417 auto-response, and dropRequest/503 paths too. Here it's only published in the two branches that reach server.emit('request', ...), so APM/OTel subscribers will silently miss those other paths in Bun. Hoist to a single publish guarded by \!is_upgrade just before the if (reachedRequestsLimit) chain and drop the two duplicated blocks.

    Extended reasoning...

    What the bug is

    In Node.js lib/_http_server.js, onRequestStartChannel.publish({request, response, socket, server}) is called once, unconditionally, in parserOnIncoming at line ~1234 — immediately after const res = new server[kServerResponse](req, ...) and before the requireHostHeader 400 check, the maxRequestsPerSocket/dropRequest 503 path, and the entire Expect-header branching. (Upgrade requests return early at the very top of parserOnIncoming, before res is created, so they correctly never see request.start.) Consequently Node publishes http.server.request.start for every non-upgrade request that gets a ServerResponse, regardless of which branch ultimately handles it.

    This PR instead duplicates the publish inside only the two branches that lead to server.emit('request', ...): the post-writeContinue() path and the default no-Expect path. It is not published for:

    • server.emit('checkContinue', http_req, http_res) (Expect: 100-continue with a checkContinue listener)
    • server.emit('checkExpectation', http_req, http_res) (other Expect value with a checkExpectation listener)
    • the 417 Expectation Failed auto-response (other Expect value, no listener)
    • server.emit('dropRequest', ...) + 503 (maxRequestsPerSocket exceeded)

    Step-by-step proof

    1. Subscribe: dc.subscribe('http.server.request.start', cb).
    2. Create a server with server.on('checkContinue', (req, res) => res.end()).
    3. Send a request with Expect: 100-continue.
    4. In onNodeHTTPRequest: http_res is constructed → response.created publishes → branching reaches http_req.headers.expect === '100-continue'server.listenerCount('checkContinue') > 0 is true → server.emit('checkContinue', ...) is called. No request.start publish on this path.
    5. Node: parserOnIncoming constructs res, publishes request.start, then enters the same branch and emits checkContinue. cb fires.
    6. Result: cb is invoked in Node, not in Bun. The same divergence applies to the checkExpectation, 417, and dropRequest/503 branches.

    Why nothing prevents it

    The two publish blocks were placed adjacent to the two server.emit('request', ...) calls, presumably on the assumption that request.start semantically pairs with the 'request' event. But Node's contract is broader: it pairs with "a ServerResponse was created for an inbound request", independent of which event the server ultimately emits. Nothing else in onNodeHTTPRequest publishes to this channel.

    Impact

    Observable Node-compat divergence in exactly the feature this PR adds. APM/OTel libraries that count or trace inbound requests via http.server.request.start will undercount in Bun vs Node for servers that use checkContinue/checkExpectation listeners, receive non-100-continue Expect headers, or hit maxRequestsPerSocket. These are uncommon paths (hence nit), but the fix is also a code-dup cleanup, so it's worth doing.

    How to fix

    Match Node's single placement. Since Bun already has const is_upgrade = http_req.headers.upgrade; computed before the dispatch chain, publish once right before if (reachedRequestsLimit):

    if (\!is_upgrade && onRequestStartChannel.hasSubscribers) {
      onRequestStartChannel.publish({
        request: http_req,
        response: http_res,
        socket,
        server,
      });
    }
    if (reachedRequestsLimit) {
      ...

    and delete the two duplicated publish blocks inside the writeContinue and default branches. (The \!is_upgrade guard mirrors Node's early-return-before-publish for upgrades.)

Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts Outdated
@robobun
robobun force-pushed the farm/8f8809b8/http-server-diagnostics-channel branch from 4210834 to 1e31cb3 Compare April 22, 2026 09:03

@claude claude Bot left a comment

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.

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/node/_http_server.ts:693-700 — Node.js publishes http.server.request.start once, unconditionally, right after constructing the ServerResponse and before any expect-header / maxRequestsPerSocket branching (lib/_http_server.js parserOnIncoming ~1234), so it fires for the 'checkContinue'-listener, 'checkExpectation'-listener, 417, and dropRequest/503 paths too — but here it's only published inside the two branches that reach server.emit('request', ...), so those four paths never fire it (and on the writeContinue path the ordering is reversed: Node publishes before writeContinue(), this publishes after). Fix: hoist a single guarded publish to just before the if (reachedRequestsLimit) branching, gated on !is_upgrade, and drop the two nested copies.

    Extended reasoning...

    What the bug is

    In Node.js's lib/_http_server.js, parserOnIncoming does (verified on main, ~lines 1221-1241):

    const res = new server[kServerResponse](req, ...);
    ...
    if (onRequestStartChannel.hasSubscribers) {
      onRequestStartChannel.publish({ request: req, response: res, socket, server });
    }
    // ...only AFTER this: requireHostHeader/400, maxRequestsPerSocket/dropRequest/503,
    //                     expect → checkContinue / writeContinue / checkExpectation / 417

    i.e. http.server.request.start is published once for every non-upgrade request, regardless of which branch is subsequently taken. (Upgrades return early before the response is constructed, so they never publish.)

    This PR instead publishes request.start only inside the two branches that lead directly to server.emit('request', ...): after http_res.writeContinue() (lines ~676-684) and in the no-expect-header else (lines ~693-700). Four code paths therefore never publish request.start in Bun but do in Node:

    1. reachedRequestsLimitserver.emit('dropRequest', ...) → 503
    2. Expect: 100-continue with a 'checkContinue' listener registered
    3. Expect: <other> with a 'checkExpectation' listener registered
    4. Expect: <other> with no listener → 417

    There's also a minor ordering divergence on the writeContinue path: Node publishes request.start before res.writeContinue() (publish at ~1234, writeContinue at ~1308), this PR publishes after. Observable to subscribers that inspect _sent100/socket state.

    Step-by-step proof (path 2 — the most realistic one)

    1. App registers server.on('checkContinue', (req, res) => { res.writeContinue(); handler(req, res); }) — the documented Node pattern for gating large uploads.
    2. OTel HttpInstrumentation subscribes to http.server.request.start to open the server span.
    3. Client sends PUT /upload with Expect: 100-continue.
    4. onNodeHTTPRequest runs: reachedRequestsLimit false, is_upgrade falsy, http_req.headers.expect === '100-continue', server.listenerCount('checkContinue') > 0 → takes the server.emit('checkContinue', http_req, http_res) branch.
    5. No onRequestStartChannel.publish call lives on that branch. Control never reaches either of the two publish sites.
    6. Node for the same request: parserOnIncoming constructs res, publishes request.start at ~1234, then reaches the expect branching at ~1305 and emits 'checkContinue'. The OTel span opens.
    7. Result: on Node the upload is traced; on Bun (with this PR) it is silently untraced.

    The same walk applies to a 'checkExpectation' listener, the 417 auto-reply, and the maxRequestsPerSocket 503/dropRequest path — none of those branches contain a publish.

    Why existing code doesn't prevent it

    The two publish sites are nested inside specific branches; nothing publishes on the other branches. response.created and the 'finish' listener are hoisted above the branching, so those two channels do fire on all four paths — only request.start is misplaced. This is distinct from inline comment #3122715185, which is about response.created placement (different channel, different fix).

    Impact

    Edge-case Node-compat gap in the feature being added. The primary path (no Expect header, no request limit) is correct, and the PR is a strict improvement over the prior state (no events at all) — hence nit. But APM/OTel libraries that subscribe to request.start will miss spans for apps that use 'checkContinue' (Express/Fastify large-upload pattern) or 'checkExpectation', which works on Node.

    How to fix

    Replace the two nested publishes with a single one placed just before the if (reachedRequestsLimit) chain (after is_upgrade is computed and setCloseCallback(http_res, onClose)), gated on !is_upgrade to match Node's early-return for upgrades:

    setCloseCallback(http_res, onClose);
    
    if (!is_upgrade && onRequestStartChannel.hasSubscribers) {
      onRequestStartChannel.publish({
        request: http_req,
        response: http_res,
        socket,
        server,
      });
    }
    
    if (reachedRequestsLimit) {
      ...

    Then delete the two nested copies. This also fixes the writeContinue ordering as a side effect (publish now precedes http_res.writeContinue()).

@robobun

robobun commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator Author

@claude thanks — cross-checked against Node and that was already fixed in 1e31cb3 (before your comment landed). http.server.request.start is now hoisted into a single publish at the same placement Node uses in parserOnIncoming: right after setCloseCallback(http_res, onClose), gated on !is_upgrade, before the reachedRequestsLimit / checkContinue / checkExpectation / 417 branching. Regression tests added for the checkContinue, 417, and upgrade (no publish) paths; they fail without the hoist and pass with it.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/js/node/diagnostics_channel/diagnostics_channel.test.ts`:
- Line 503: The test currently uses a dynamic require for node:net when
constructing sockets (e.g., new (require("node:net").Socket)() in the creation
of http.IncomingMessage); move the node:net import to module scope by importing
Socket alongside the existing http import and replace new
(require("node:net").Socket)() with new Socket(); do the same replacement for
the other occurrence around line 610 so all Socket uses reference the
module-scoped Socket import.
🪄 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: a4f4378a-9cf2-456d-955e-093e346e5dcf

📥 Commits

Reviewing files that changed from the base of the PR and between 4210834a4a2dbc3b851e7b830938fcfe02258898 and 1e31cb321b5ff393be393f4e21b1bdbce9047769.

📒 Files selected for processing (2)
  • src/js/node/_http_server.ts
  • test/js/node/diagnostics_channel/diagnostics_channel.test.ts

Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts Outdated
Comment thread src/js/node/_http_server.ts
Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts Outdated
Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts
@robobun
robobun force-pushed the farm/8f8809b8/http-server-diagnostics-channel branch from f590077 to afe3376 Compare April 22, 2026 09:19
@robobun

robobun commented Apr 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI failures on build 47215 are all four darwin test-bun steps in Expired state — the Mac agent fleet did not pick up the jobs before timeout. Same-shaped all-four-Expired pattern hit recently-merged PRs #29582 (47152), #29569 (47106), #29566 (47097) within the last ~20 merges. Every other platform (Linux x64 / aarch64 / musl / asan, Alpine x3, Windows x3) passes, and all 14 diagnostics_channel tests pass locally on 44640cd1. No code changes needed — the jobs just need a buildkite retry on a free darwin agent.

@Jarred-Sumner
Jarred-Sumner force-pushed the farm/8f8809b8/http-server-diagnostics-channel branch from 44640cd to 71ca34f Compare May 4, 2026 10:36

@claude claude Bot left a comment

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.

All prior feedback is addressed and I found no issues in this revision, but since this adds an unconditional per-request .bind() + 'finish' listener inside onNodeHTTPRequest (the node:http hot path), it's worth a maintainer signing off on the perf trade-off vs. gating the attach on hasSubscribers.

Extended reasoning...

Overview

Adds Node-compatible diagnostics_channel publishing for http.server.request.start, http.server.response.created, and http.server.response.finish in src/js/node/_http_server.ts, plus ~280 lines of tests in test/js/node/diagnostics_channel/diagnostics_channel.test.ts. Channels are now created at module-load time (matching Node's lib/_http_server.js), response.created is published from the ServerResponse constructor, request.start is published once before the 503/checkContinue/417/normal branching gated on !is_upgrade, and a 'finish' listener is attached unconditionally per request with the hasSubscribers check inside the handler.

Security risks

None identified. The change only publishes references to already-existing per-request objects (req, res, socket, server) to in-process diagnostics_channel subscribers; there's no new parsing, no auth/crypto, and no user-controlled data flowing into new sinks.

Level of scrutiny

Medium-high. onNodeHTTPRequest is the hot path for every node:http request and this file already contains micro-optimizations (e.g. canUseInternalAssignSocket to avoid .once('close', ...)). The unconditional emitResponseFinishChannel.bind({req, res, socket, server}) + http_res.on('finish', ...) runs for every request even when nobody subscribes — Node pays the same cost via resOnFinish.bind(...), so it's faithful, but Bun has historically diverged from Node for perf in exactly this function. That's a judgment call a maintainer should make rather than a bot.

Other factors

  • The PR went through five revision rounds; every inline comment (mine and coderabbit's) is now resolved and reflected in the current diff: module-load-time channels (resolves the lazy-load/custom-ServerResponse coupling), constructor-placed response.created (fixes direct new ServerResponse(req)), finish-time hasSubscribers check (late-subscriber compat), module-scope import http/import net, and the upgrade-test flake/dead-code cleanup.
  • Test coverage is thorough (ordering, payload shapes/identity, checkContinue, 417, upgrade-no-publish, late subscribe, direct construction, no-subscriber hot path).
  • CI failures on build 47215 are darwin agent expirations (infra), not test failures; all Linux/Windows/Alpine lanes pass.
  • Not a simple/mechanical change, so deferring rather than approving.

@robobun

robobun commented May 4, 2026

Copy link
Copy Markdown
Collaborator Author

Build #51105 failure is a Buildkite agent-fleet outage between ~10:36Z and ~12:14Z on May 4: only 6 of 287 jobs got an agent, zero jobs actually failed tests (jobs_failed_count: 0), the remaining 28 steps expired in queue. Builds #51101, #51103, #51104, #51107, and #51108 (all triggered in the same window) failed with the identical 30–32 steps_failed / 2–6 steps_passed pattern, every one finishing at exactly 12:14Z when the fleet timed them out. Not a code issue — retrigger needed, or wait for the next push to pick up available agents.

robobun and others added 4 commits June 18, 2026 08:41
Node's node:http server publishes to three diagnostics_channel channels
at request/response lifecycle points, which observability libraries
(OpenTelemetry, Prometheus, APM tooling) rely on for instrumentation.
Bun had not implemented these, so subscribers silently collected
nothing.

Adds publishes for:
- http.server.response.created — { request, response }
- http.server.request.start    — { request, response, socket, server }
- http.server.response.finish  — { request, response, socket, server }

Channel placement mirrors Node's lib/_http_server.js so all observable
edges match Node:

- response.created is published from inside the ServerResponse
  constructor, so it fires for direct `new http.ServerResponse(req)`
  (the pattern used by light-my-request / fastify.inject() / mocks) in
  addition to the live-server path. Node does the same.
- request.start is published once per non-upgrade request before the
  dispatch chain, so it fires on the normal, checkContinue,
  checkExpectation, 417, and dropRequest/503 branches — not only the
  two that reach server.emit('request', ...).
- response.finish is wired via an always-attached res.on('finish')
  listener (matching Node's resOnFinish) with the hasSubscribers guard
  inside the listener, so subscribers that register between request
  arrival and response finish are still observed.

Synchronous publishes keep a call-site hasSubscribers guard so no
payload object is allocated when nobody is listening.

Ordering matches Node: response.created fires before the 'request'
event so user handlers can't have mutated the response yet, and
response.finish fires after the response is sent.

Fixes #29586
Pull the listen/port and setImmediate-drain boilerplate into two helpers in the describe block; drop the channelNames/subs ceremony in the multi-channel test in favor of three flat subscribe/unsubscribe pairs; collapse a multi-line request.start publish + trim its comment to a single line.
@robobun
robobun force-pushed the farm/8f8809b8/http-server-diagnostics-channel branch from 71ca34f to 695396b Compare June 18, 2026 08:45
@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased on main to clear the merge conflict. Two conflicts, both in src/js/node/_http_server.ts and both mechanical:

  1. Main added the optimizeEmptyRequests pre-dump block right where this PR publishes http.server.request.start (both sit between setCloseCallback(...) and the reachedRequestsLimit branching). Kept both — the pre-dump runs first, then the request.start publish, still before any dispatch branching as Node does.
  2. The tighten follow-up commit reworded the request.start comment and collapsed the publish to one line; took that wording on top of main's block.

No semantic change to the diagnostics_channel behaviour. The dc.channel(...) declarations, the constructor response.created publish, and the unconditional finish listener all re-applied cleanly. Rebuilt against post-Rust-rewrite main (bun test v1.4.0): all 14 http-server-channel tests pass, and they still fail (6/8) when _http_server.ts is reverted to main, so the gate holds.

Comment thread src/js/node/_http_server.ts Outdated
Comment thread src/js/node/_http_server.ts
…ttp2 allowHTTP1 fallback

Register the http.server.response.finish listener before
endSocketOnFinishIfNeeded so the publish observes the socket before its
writable side is ended on the Connection: close path, matching Node's
resOnFinish (which publishes before socket.destroySoon()).

The response.created publish lives in the ServerResponse constructor, so
it already fired on the http2 createSecureServer({ allowHTTP1: true })
HTTP/1 fallback path (connectionListenerHTTP1), but that path never
published request.start or response.finish, leaving orphaned events.
Publish all three there, like Node routes allowHTTP1 through the full
http1 connectionListener.
@robobun
robobun force-pushed the farm/8f8809b8/http-server-diagnostics-channel branch from 8cdc285 to d2594e3 Compare June 18, 2026 09:24
Comment thread src/js/node/http2.ts Outdated
Drop the redundant require("node:diagnostics_channel") inside
connectionListenerHTTP1 (it shadowed the module-level dc) and resolve
the two http.server channels once at module load alongside the existing
http2.* channel constants, instead of re-resolving them on every HTTP/1
connection.

@claude claude Bot left a comment

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.

No bugs found and all prior review feedback has been addressed — deferring only because this adds an unconditional per-request 'finish' listener + .bind() in onNodeHTTPRequest and a publish in the ServerResponse constructor, both on the HTTP server hot path, so a human should sign off on the perf trade-off.

Extended reasoning...

Overview

Adds Node-compatible diagnostics_channel publishing for the three http.server.* channels (request.start, response.created, response.finish). Touches src/js/node/_http_server.ts (~40 lines in onNodeHTTPRequest and the ServerResponse constructor), src/js/node/http2.ts (~20 lines in the allowHTTP1 fallback), and adds ~350 lines of tests. The PR has been through eight review iterations; every inline comment I and CodeRabbit raised has been addressed, and the current bug-hunting run found nothing.

Security risks

None. diagnostics_channel is read-only observability; the published payloads ({request, response, socket, server}) are the same objects already handed to the 'request' event listener. No auth, crypto, or permissions code is touched.

Level of scrutiny

Moderate-to-high. The implementation is straightforward and closely mirrors Node's lib/_http_server.js, but it modifies onNodeHTTPRequest — the per-request hot path for every node:http server request in Bun — and the ServerResponse constructor. The unconditional http_res.on('finish', emitResponseFinishChannel.bind({...})) adds one bound-function + context-object allocation per request even with zero subscribers. That matches both Node's resOnFinish.bind(...) and the existing endSocketOnFinishIfNeeded.bind(...) immediately below it, so it's consistent with established patterns — but Bun's HTTP serve performance is heavily benchmarked, so a maintainer should confirm the trade-off is acceptable rather than have it auto-approved.

Other factors

Test coverage is thorough (10 new tests covering ordering, payload shapes/identity, checkContinue/417/upgrade paths, late subscription, direct construction, the http2 allowHTTP1 fallback, and the no-subscriber hot path). No CODEOWNERS apply to these files. The PR description is accurate to the current diff (the earlier out-of-scope WeakReference change was correctly dropped and is now noted as tracked separately in #26537).

@robobun

robobun commented Jun 18, 2026

Copy link
Copy Markdown
Collaborator Author

Build #63300: 72 lanes green, only darwin-14-x64-test-bun red, and its failures are unrelated to this diff:

  • test/js/third_party/grpc-js/test-server.test.ts — SIGTRAP (gRPC)
  • test/js/node/test/parallel/test-tls-client-destroy-soon.js — raw-TLS destroySoon byte-count mismatch on aarch64 (2097152 vs 2048000)
  • test/bake/dev-and-prod.test.ts — Buildkite-labeled flaky, passed on retry

None touch node:http/_http_server.ts/http2.ts; that darwin-14-x64-test-bun lane also flakes on unrelated merged PRs (e.g. #30219). The diagnostics_channel changes are green on every lane that runs them. Diff is ready; the remaining red is flaky CI on an unrelated lane.

robobun added 2 commits July 15, 2026 04:16
…ener

Avoids the second per-request 'finish' listener and its .bind({...})
allocation by publishing http.server.response.finish from the same
listener that already closes the socket when needed, like Node's
resOnFinish. hasSubscribers is still checked at call time so late
subscribers are observed.
@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Merged current main and folded the http.server.response.finish publish into the existing per-request 'finish' listener (renamed endSocketOnFinishIfNeeded to resOnFinish to match Node), which addresses the hot-path concern raised in the last two reviews: there is no additional listener and no extra .bind({...}) object allocation per request, just two more bound arguments on the listener that was already there. hasSubscribers is still checked at call time so the late-subscriber test continues to pass.

All 10 http server channels tests pass on the debug build and 8/10 fail on stock 1.4.0, so the gate still holds.

Comment thread src/js/node/http2.ts Outdated
Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts Outdated
…fallback

Move the publish from a res 'finish' listener into handle.onfinished,
before its socket.end() call, so subscribers observe the socket before
its writable side is ended on non-keep-alive requests - the same
ordering resOnFinish uses in node:_http_server. Also drops the per
request closure. The allowHTTP1 test now sends Connection: close and
asserts socket.writableEnded === false at publish time.

Refresh a stale test comment that referenced the removed
endSocketOnFinishIfNeeded two-listener mechanism.
Comment thread src/js/node/http2.ts
Publishing from handle.onfinished ran synchronously inside handle.end(),
before ServerResponse.prototype.end set finished = true, so subscribers
observed response.finished === false where Node (and node:_http_server's
resOnFinish) show true. Publish from a res 'finish' listener instead and
defer the socket teardown into the same listener after the publish -
both the non-keep-alive socket.end() and the close-delimited one, which
handle.end() now records on handle.closeDelimited instead of ending the
socket itself. Subscribers now observe finished === true and
writableEnded === false, the same publish-time state as the plain http
path.

Both ordering tests now assert { finished, writableEnded } at publish
time; the allowHTTP1 one fails against the previous placement.

@claude claude Bot left a comment

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.

c11cc98 addresses my last note — the allowHTTP1 response.finish publish is back on res.once('finish', ...) and socket.end() now runs after it, so both response.finished === true and socket.writableEnded === false hold at publish time (the new test asserts exactly that). No further findings from this pass.

Deferring to a human because the http2.ts hunk now also moves the allowHTTP1 non-keep-alive / close-delimited socket.end() from synchronous-inside-handle.end() to the nextTick'd 'finish' listener — closer to Node's resOnFinish, but a socket-lifecycle timing change to pre-existing code that's worth a maintainer glance alongside the hot-path onNodeHTTPRequest / ServerResponse constructor edits.

Checked: resOnFinish fold preserves the pre-existing kMustCloseConnectionsocket?.end() behavior with two extra bound args and no new listener; request.start publish sits after the is_upgrade computation and before all dispatch branches; handle.closeDelimited is initialized in the handle literal so the deferred socket.end() still fires for close-delimited bodies.

Extended reasoning...

Overview

Adds Node-compatible diagnostics_channel publishes for the three http.server.* channels. _http_server.ts: three module-level dc.channel(...) constants, response.created published from the ServerResponse constructor, request.start published in onNodeHTTPRequest before dispatch branching (gated on !is_upgrade), and endSocketOnFinishIfNeeded renamed/extended to resOnFinish which publishes response.finish before conditionally ending the socket. http2.ts: two module-level channel constants and, in connectionListenerHTTP1 (the allowHTTP1 fallback), a request.start publish plus a res.once('finish', ...) closure that publishes response.finish and then ends the socket on non-keep-alive / close-delimited responses — replacing the previous synchronous socket.end() inside handle.end() / handle.onfinished. ~370 lines of new tests covering ordering, payload shapes/identity, checkContinue/417/upgrade paths, late subscription, direct construction, publish-time socket/response state, and the allowHTTP1 fallback.

Security risks

None identified. Publishes are guarded by .hasSubscribers and pass existing request/response/socket/server references to in-process subscribers; no new parsing, no external data flow, no auth/crypto surface.

Level of scrutiny

Medium-high. onNodeHTTPRequest and the ServerResponse constructor are per-request hot paths for every node:http server; the hasSubscribers guards keep the no-subscriber cost to a property read and the resOnFinish fold avoids adding a listener. The http2.ts change goes beyond pure diagnostics wiring: it defers when socket.end() runs on the allowHTTP1 non-keep-alive / close-delimited path (from sync-inside-res.end() to the nextTick'd 'finish' event). That's the correct Node-matching order and the 'finish' emit is unconditional in ServerResponse.prototype.end, but it is a behavior change to a production socket-lifecycle path that a maintainer should confirm.

Other factors

This PR has been through eight review iterations; every prior inline comment (including all of mine) is resolved, and c11cc98 addresses the last one I left. The bug-hunting system found nothing this run. Test coverage is thorough and gate-verified (fails on main, passes on the branch). I'm deferring rather than approving solely because the change set touches the per-request hot path and reworks socket-close timing in http2.ts — that's the kind of thing the approval guidelines say warrants a human sign-off even when the review is clean.

@robobun

robobun commented Jul 15, 2026

Copy link
Copy Markdown
Collaborator Author

Build #73188 red is unrelated to this diff:

  • test/js/node/test/parallel/test-worker-message-port-transfer-terminate.js — JSC assertion (!scope.exception() || !result in JSObject::getOwnPropertyDescriptor) on x64-asan; fails identically on main and is already being fixed in a separate session.
  • test/js/node/test/parallel/test-net-connect-memleak.js — GC-collection flake on x64-baseline only; passes 3/3 locally on this branch.
  • test/regression/issue/30205.test.ts — Buildkite-labeled flaky, passed on retry.

None exercise node:http / _http_server.ts / http2.ts. All diagnostics_channel tests pass on every lane that ran them. The diff is ready; latest review pass found no further issues.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing in favor of #32628. It publishes http.server.request.start, http.server.response.created and http.server.response.finish on both the native dispatch path (_http_server.ts) and the HTTP/1 fallback path (internal/http1_server_fallback.ts, where the http2 allowHTTP1 code this PR patched now lives), and vendors Node's test-diagnostics-channel-http-server-start.js. This branch also conflicts with main again. Main (f426a8e) still publishes nothing on these channels, so #29586 stays open; #32628 lists it as fixed.

@robobun robobun closed this Aug 13, 2026
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.

node:http server does not publish to HTTP server diagnostics_channel

1 participant