Skip to content

node:domain: return the callback's value from bind() and intercept() - #35444

Open
robobun wants to merge 4 commits into
mainfrom
farm/906b9a61/domain-bind-return-value
Open

node:domain: return the callback's value from bind() and intercept()#35444
robobun wants to merge 4 commits into
mainfrom
farm/906b9a61/domain-bind-return-value

Conversation

@robobun

@robobun robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator

What does this PR do?

d.bind(fn) and d.intercept(fn) returned wrappers that called fn but dropped its return value (and invoked it with this === null instead of the caller's receiver).

gulp's async-done wraps every task via d.bind(task) and inspects the return value to decide whether it got back a promise, stream, or observable. Because bind() swallowed the return value, the task's promise was never awaited and gulp immediately reported:

[20:30:03] Starting 'clean'...
[20:30:03] The following tasks did not complete: default, clean
[20:30:03] Did you forget to signal async completion?

Fix

Both wrappers now match Node for the pieces async-done depends on:

  • enter/exit the domain around the call
  • forward the caller's this and arguments
  • return the callback's result
  • bind() sets .domain on the returned function
  • intercept() only diverts to the domain's 'error' listener when the first argument is an Error instance (previously any truthy value), and tags the error with domainBound / domainThrown / domain the way Node does

The enter/apply/exit body is now a single runInDomain() helper that bind, intercept, and run all delegate to. The helper still catches a synchronous throw and routes it to the domain's 'error' listener; Node lets the throw propagate and catches it later via the process._fatalException domain hook, which Bun does not have yet. That is pre-existing shim behaviour and outside the scope of this change.

How did you verify your code works?

test/js/node/domain/domain.test.ts covers the return value, receiver forwarding, active-domain-during-call, .domain property, the Error-vs-truthy intercept branch, and an async-done-shaped integration case. Every assertion was cross-checked against Node v26.3.0.

With a gulp 4 project (gulp.series(del, gulp.src().pipe(gulp.dest()))):

# before
$ bun --bun x gulp
[..] The following tasks did not complete: default, clean
[..] Did you forget to signal async completion?

# after
$ bun-debug --bun x gulp
[..] Finished 'clean' after 503 ms
[..] Finished 'copy' after 1.51 s
[..] Finished 'default' after 2.08 s

Fixes #5923
Fixes #24287


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 10 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/domain/domain.test.ts
bun test v1.4.0 (c4e09f268)

test/js/node/domain/domain.test.ts:
4 | 
5 | describe("domain.bind()", () => {
6 |   test("returns the callback's return value", () => {
7 |     const d = domain.create();
8 |     const bound = d.bind(() => 42);
9 |     expect(bound()).toBe(42);
                        ^
error: expect(received).toBe(expected)

Expected: 42
Received: undefined

      at <anonymous> (/workspace/bun/test/js/node/domain/domain.test.ts:9:21)
(fail) domain.bind() > returns the callback's return value [136.88ms]
13 |     const d = domain.create();
14 |     const receiver = { tag: "rx" };
15 |     const bound = d.bind(function (this: any, a: number, b: number) {
16 |       return [this, a, b];
17 |     });
18 |     expect(bound.call(receiver, 1, 2)).toEqual([receiver, 1, 2]);
                                            ^
error: expect(received).toEqual(expected)

- [
-   {
-     "tag": "rx",
-   },
-   1,
-   2,
- ]
+ undefined

- Expected  - 7
+ Received  + 1

      at <anonymous> (/workspace/bun
... (truncated)

release without fix: 11 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/domain/domain.test.ts:
4 | 
5 | describe("domain.bind()", () => {
6 |   test("returns the callback's return value", () => {
7 |     const d = domain.create();
8 |     const bound = d.bind(() => 42);
9 |     expect(bound()).toBe(42);
                        ^
error: expect(received).toBe(expected)

Expected: 42
Received: undefined

      at <anonymous> (/workspace/bun/test/js/node/domain/domain.test.ts:9:21)
(fail) domain.bind() > returns the callback's return value [0.56ms]
13 |     const d = domain.create();
14 |     const receiver = { tag: "rx" };
15 |     const bound = d.bind(function (this: any, a: number, b: number) {
16 |       return [this, a, b];
17 |     });
18 |     expect(bound.call(receiver, 1, 2)).toEqual([receiver, 1, 2]);
                                            ^
error: expect(received).toEqual(expected)

- [
-   {
-     "tag": "rx",
-   },
-   1,
-   2,
- ]
+ undefined

- Expected  - 7
+ Received  + 1

      at <anonymous> (/workspace/bun/test/js/node/domain/domain.test.ts:18:40)
(fail) domain.bind() > forwards the caller's this and arguments [0.36ms]
24 |     const bound = d.bind(() => {
25 |   
... (truncated)
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/domain/domain.test.ts
bun test v1.4.0 (c4e09f268)

test/js/node/domain/domain.test.ts:
(pass) domain.bind() > returns the callback's return value [42.61ms]
(pass) domain.bind() > forwards the caller's this and arguments [9.52ms]
(pass) domain.bind() > makes the domain active while the callback runs [9.35ms]
(pass) domain.bind() > sets .domain on the returned function [3.76ms]
(pass) domain.intercept() > returns the callback's return value [11.28ms]
(pass) domain.intercept() > drops the leading (error) argument before invoking the callback [6.63ms]
(pass) domain.intercept() > emits on the domain when the first argument is an Error [22.51ms]
(pass) domain.intercept() > does not treat a truthy non-Error first argument as an error [7.22ms]
(pass) domain.intercept() > makes the domain active while the callback runs [8.90ms]
(pass) domain.run() > returns the callback's return value and forwards arguments [9.59ms]
(pass) async-done style: d.bind(fn)() surfaces a returned promise [739.41ms]

 11 pass
 0 fail
 22 expect() calls
Ran
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 998ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/21] gen JS modules (bundle-modules)
Preprocess modules (12842ms)
Bundle modules (80ms)
Postprocesss modules (184ms)
Bundle Functions (1010ms)
Generate Code (34ms)

[14.17s] Bundled "src/js" for production
  2113 kb
  167 internal modules
  13 native modules
  90 internal functions across 19 files
[1/5] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Blocking�[0m waiting for file lock on build directory
�[1m�[92m    Finished�[0m `release` profile [optimized + debuginfo] target(s) in 1m 10s
[2/5] link bun-profile
[3/5] bun-profile --revision
1.4.0-canary.1+c4e09f268
[5/5] strip bun
[build] done
bun test v1.4.0-canary.1 (c4e09f268)

test/js/node/domain/domain.test.ts:
(pass) domain.bind() > returns the callback's return value [1.68ms]
(pass) domain.bind() > forwards the caller's this and arguments [0.12ms]
... (truncated)
diff hotspot
src/js/node/domain.ts              |  68 ++++++++++-------
 test/js/node/domain/domain.test.ts | 150 +++++++++++++++++++++++++++++++++++++
 2 files changed, 192 insertions(+), 26 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                reads  edits  tests
src/js/node/domain.ts                   2      2      0
test/js/node/domain/domain.test.ts      1      4      0

The wrappers returned by d.bind(fn) and d.intercept(fn) called fn but
dropped its return value, and invoked it with this === null instead of
the caller's receiver. gulp's async-done wraps every task via
d.bind(task) and checks the return value to detect a promise or stream,
so under bun the promise was never seen and gulp reported
"Did you forget to signal async completion?".

Both wrappers now enter/exit the domain around the call, forward the
caller's this and arguments, and return the callback's result.
intercept() now only diverts to the domain's 'error' listener when the
first argument is an Error instance (with the domainBound/domainThrown/
domain properties Node sets), and bind() attaches the .domain property
to the returned function, matching Node.

Fixes #5923
Fixes #24287
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 10 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: abdb6e22-dfbb-4722-9b9e-d66d5b3b47e1

📥 Commits

Reviewing files that changed from the base of the PR and between 028f7a3 and c4e09f2.

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

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

@robobun

robobun commented Jul 24, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:16 PM PT - Jul 24th, 2026

@robobun, your commit c4e09f2 has 1 failures in Build #79767 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35444

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

bun-35444 --bun

@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.

LGTM — focused Node compat fix that makes bind()/intercept() return the callback's value and enter/exit the domain, unblocking gulp/async-done.

What was reviewed:

  • bind()/intercept() now forward the caller's this and return fn's result — cross-checked against Node's lib/domain.js (ReflectApply(cb, this, args) path).
  • intercept()'s instanceof Error gate and domainBound/domainThrown/domain tagging match Node's intercepted helper.
  • Considered whether intercept() should also stamp .domain on its wrapper and whether Node uses the domain as this — both examined and ruled out as blockers.
  • Tests cover return value, receiver forwarding, active-domain-during-call, the Error-vs-truthy branch, and the async-done regression shape.
Extended reasoning...

Overview

The PR touches two files: src/js/node/domain.ts (the bind() and intercept() wrapper implementations inside domain.create()) and a new test file test/js/node/domain/domain.test.ts. The functional change is that both wrappers now return fn.$apply(this, ...) inside a d.enter() / try…finally d.exit() block instead of calling fn.$apply(null, args) and discarding the result. bind() additionally defines a non-enumerable .domain property on the returned function, and intercept() now only diverts to the domain's 'error' listener when arguments[0] instanceof Error (previously any truthy value), tagging the error with domainBound / domainThrown / domain before emitting.

Security risks

None. node:domain is a deprecated in-process error-routing API; the change only adjusts return-value plumbing and this forwarding in a pure-JS builtin. No untrusted input parsing, no auth/crypto/permissions surface.

Level of scrutiny

Low-to-moderate. node:domain is a legacy/deprecated module and Bun's implementation is already a lightweight polyfill (it doesn't hook the microtask queue the way Node does). The change is small (~40 LOC), strictly moves behavior closer to Node, and is motivated by two real user reports (#5923, #24287) where gulp's async-done relies on d.bind(task)() returning the task's promise. I traced Node's Domain.prototype.bind / intercept in lib/domain.js: both call the callback via ReflectApply(cb, this, args) where this is the caller's receiver forwarded through FunctionPrototypeCall(…, this, …), and both return the callback's result — matching this PR. Node's intercepted helper also gates on arguments[0] instanceof Error and sets domainBound/domainThrown = false/domain on the error before emitting, which this PR replicates.

Other factors

Finders raised two candidate gaps that verifiers refuted: (1) intercept() not stamping .domain on its returned wrapper the way bind() does, and (2) whether Node passes the domain (not the caller's receiver) as this. On (2), Node's source confirms the caller's this is forwarded, so the PR is correct. On (1), even if Node does set it, it's a cosmetic property on a deprecated API and orthogonal to the return-value bug being fixed — not a blocker. The test file exercises each new behavior with tight assertions (exact return values, toBe identity checks, process.domain before/during/after), plus a subprocess-spawned async-done-shaped repro that would have failed on the old code. No existing tests were weakened. The remaining minor deviations from Node (e.g. emitError setting domainThrown = false on caught throws) are pre-existing and out of scope.

Addresses review:

- Hoist the enter/try/catch/finally/exit block into a single helper the
  three methods delegate to, matching Node's shape.
- Drop the bind()-throw test that asserted the shim's swallow-and-emit
  behaviour; Node lets the throw propagate to the caller, so that test
  pinned a divergence rather than parity.
- Add before/after process.domain checks to the intercept()
  active-domain test so the exit() call site is covered.

@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.

LGTM — focused Node compat fix that makes bind()/intercept()/run() return the callback's value and forward this, unblocking gulp/async-done.

What was reviewed

  • runInDomain helper: enter/exit balance via finally, return value and receiver forwarding match Node's bound/intercepted helpers.
  • intercept()'s new instanceof Error gate + domainBound/domainThrown/domain tagging — matches Node's lib/domain.js.
  • d.run refactor: still passes the domain as this and still enters/exits the closure-captured d, so no behavior change there.
  • Tests: serial for the process.domain assertions (no cross-test leakage), subprocess test drains pipes concurrently and asserts output before exit code.
Extended reasoning...

Overview

Two files: src/js/node/domain.ts (the built-in node:domain shim) and a new test file. The old bind()/intercept() wrappers called the user's callback with this === null and discarded its return value, which broke gulp's async-done (it wraps tasks via d.bind(task) and inspects the return for a thenable). The fix extracts a shared runInDomain(thisArg, fn, args) helper that enters the domain, applies fn with the caller's receiver, returns the result, routes throws to the domain's error event, and exits in finally. bind() now also sets .domain on the returned function, and intercept() now only treats an Error-instance first arg as an error (tagging it with domainBound/domainThrown/domain), matching Node's lib/domain.js.

Security risks

None. node:domain is a deprecated control-flow helper with no auth/crypto/filesystem surface; the change only affects how a user-supplied callback's return value and receiver are forwarded.

Level of scrutiny

Low-to-medium. This is a small, self-contained Node-compat fix in a deprecated module. I cross-checked the shape against Node's Domain.prototype.bind/intercept/run — the return-value forwarding, this forwarding, .domain property descriptor, and the er && er instanceof Error gate all line up. The one deliberate deviation (catching the throw locally instead of relying on process._fatalException) is documented in a comment and preserves the pre-existing behavior of run(). The d.run refactor is behavior-preserving: runInDomain calls d.enter()/d.exit() on the closure-captured d and applies with this (the domain when called as d.run(...)), same as before.

Other factors

The finder agents flagged that intercept() doesn't set .domain on its wrapper the way bind() does; verifiers ruled it out — it's a pre-existing minor gap, not a regression, and doesn't affect the gulp/async-done use case this PR targets. The new tests follow harness conventions (bunExe/bunEnv, concurrent pipe draining, output asserted before exit code, test.concurrent for the subprocess case) and the process.domain-mutating tests are serial so they can't race each other. No existing Bun-authored domain.test.ts existed, so creating test/js/node/domain/domain.test.ts is the right location.

@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.

LGTM — focused Node compat fix in the domain shim with good coverage.

What was reviewed:

  • runInDomain() correctly forwards this/return value and preserves the pre-existing enter/catch/exit shape; d.run's switch from this.enter() to closure-captured d.enter() is equivalent since each domain instance gets its own closure.
  • intercept()'s instanceof Error gate and domainBound/domainThrown/domain tagging match Node's implementation; verified it correctly emits directly rather than via emitError (which would set domainEmitter instead).
  • Confirmed Node does not set .domain on the intercept() wrapper — only bind() does, so the asymmetry is intentional.
  • Tests cover return value, receiver, active-domain, .domain, Error-vs-truthy, and the async-done regression; subprocess test drains both pipes and asserts exit code last.
Extended reasoning...

Overview

This PR fixes node:domain's bind() and intercept() wrappers, which previously called the wrapped function but discarded its return value and passed null as the receiver. The fix extracts a shared runInDomain(thisArg, fn, args) helper that enters the domain, applies the function with the caller's this and args, returns the result, catches synchronous throws into the domain's 'error' listener, and exits in finally. bind(), intercept(), and run() all delegate to it. bind() now also sets .domain on the returned function, and intercept() now only diverts to the error listener when the first argument is an Error instance (tagging it with domainBound/domainThrown/domain), matching Node. A new test file covers each of these behaviors plus an async-done-shaped subprocess regression test for #5923/#24287.

Security risks

None. node:domain is a deprecated error-routing shim with no auth, crypto, filesystem, or network surface. The change only affects how return values and receivers are forwarded through user-supplied callbacks.

Level of scrutiny

Low-to-moderate. The module is a ~100-line JS shim for a deprecated Node API, and the change is a straightforward compat fix cross-checked against Node v26.3.0. The refactor into runInDomain() is a net simplification (three copies of enter/try/catch/exit collapsed to one). The only behavioral changes beyond the headline fix — instanceof Error gating in intercept() and the .domain property on the bound function — are direct ports of Node's semantics and are each covered by a dedicated test.

Other factors

  • d.run previously called this.enter()/this.exit(); runInDomain calls d.enter()/d.exit() on the closure-captured domain. Since every method here is a per-instance closure created inside createDomain(), this === d in any sane use, so this is not a behavior change.
  • The intercept error path emits via d.emit("error", er) directly rather than the existing emitError helper — this is correct, because emitError sets domainEmitter whereas Node's intercept path sets domainBound instead.
  • Candidate issue "intercept() doesn't set .domain on the returned function" was checked against Node — Node's Domain.prototype.intercept does not set .domain on runIntercepted, only bind does, so the asymmetry is correct.
  • Tests follow harness conventions: bunExe()/bunEnv, concurrent subprocess test, both pipes drained via Promise.all, stderr/stdout asserted before exit code, issue URLs in the regression comment.
  • The pre-existing divergence (catching synchronous throws locally rather than via process._fatalException) is called out in a comment and left unchanged, which is the right scope boundary.

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

The new test/js/node/domain/domain.test.ts passes on every lane. The remaining CI failures across builds #79731 and #79767 are unrelated to this change (pure-JS edit to node:domain):

Ready for a maintainer to merge.

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.

gulp behavior with bun Build command in axios repo fails with bun

1 participant