Skip to content

process: register the default 'warning' listener at process creation, like Node - #37344

Merged
cirospaciari merged 3 commits into
mainfrom
claude/process-default-warning-listener
Aug 10, 2026
Merged

cirospaciari merged 3 commits into
mainfrom
claude/process-default-warning-listener

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Aug 10, 2026 •

Copy link
Copy Markdown
Member

What does this PR do?

Registers the default 'warning' printer when process is created, the way Node's bootstrap does (lib/internal/process/pre_execution.js setupWarningHandler), instead of on the first emitWarning().

#31831 made the printer a real 'warning' listener but installed it lazily, which its description called out as a divergence: process.removeAllListeners('warning') at startup did not silence it. That idiom is how programs (CLIs rendering a TUI, test runners) opt out of the default print, and it silenced warnings on every Bun release before #31831 (a user listener suppressed the native print), so this is a regression for them:

process.removeAllListeners("warning");
process.on("warning", w => console.log("user listener got:", w.name));
process.emitWarning("hello");
runtime stderr
node v26.3.0 (and v24.18.0) (nothing)
bun 1.4.0-canary (before #31831) (nothing)
bun main (node:PID) Warning: hello + (Use \bun --trace-warnings ...`)`
this PR (nothing)

The fix is Process::installDefaultWarningListener, called from Process::finishCreation. To keep the printer's setup (--redirect-warnings, --disable-warning, require("node:fs")) off the startup path, what gets registered up front is a native onWarning stub; Process::ensureOnWarning builds the JS printer (createOnWarning, the former installOnWarningListener minus the prependListener call) on the first warning and the stub forwards to it. Startup cost is one native JSFunction and one listener-map entry per process object; the listener is added before onDidChangeListener is wired so it does not trigger the signal-table loading that hook does on every add.

Observable results, all matching Node (every script in the tests, plus the extra probes listed under verification, was run under node v26.3.0 and v24.18.0 and prints byte-identical output modulo pid/argv0):

The printer no longer reads process.noProcessWarnings at all (Node's doesn't; it is only a read-only alias of the flag). test/js/node/test/common, which used that read to apply a test's --no-warnings flag in-process, now removes the bootstrap listener instead — it loads before any test listener exists, so that is the same state the flag produces.

How did you verify your code works?

New describe("default 'warning' listener is registered at startup") block in test/js/node/process/process.test.js (8 cases). On the unfixed build the removeAllListeners-before-first-warning cases print the warning, listenerCount is 0, the worker case reports 0:1, and the bare-emit case prints nothing; all pass with bun bd test. Node's own suite has no test for the startup listener (which is how #31831 missed it), so these are hand-written; the existing warning-pipeline tests (test-process-warning*.js, test-process-warnings.mjs, test-process-emitwarning.js, test-env-var-no-warnings.js, test-process-redirect-warnings*.js, test-common-expect-warning.js, the max-listeners and timers warning tests) still pass.

Beyond the test scripts, these were also diffed against node v26.3.0 and v24.18.0 on the built binary and came out identical: no-arg removeAllListeners(), the --trace-warnings hint printed once across two warnings, calling process.listeners('warning')[0] directly with an Error and with a non-Error, --trace-warnings stacks, --redirect-warnings / NODE_REDIRECT_WARNINGS, and --disable-warning. The new native path was also exercised under BUN_JSC_validateExceptionChecks=1.

…ted, like Node

The default warning printer was installed lazily on the first emitWarning(),
so process.removeAllListeners('warning') at startup (the usual way to opt out
of the default print) had no effect, listenerCount('warning') was 0, and a bare
process.emit('warning', err) printed nothing. Node registers onWarning during
bootstrap, so all three behave the other way there.

Register a native "onWarning" stub as a real listener in Process::finishCreation
(before onDidChangeListener is wired, so it does not load the signal tables);
the JS printer is still built on the first warning and the stub forwards to it.
--no-warnings / NODE_NO_WARNINGS=1 register nothing, and a runtime
process.noProcessWarnings assignment is now honored inside the printer, which
the vendored Node test harness relies on.
@coderabbitai

coderabbitai Bot commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 05fa997f-5d08-4097-b1dc-a5105921be66

📥 Commits

Reviewing files that changed from the base of the PR and between d21bdcc and e9c949c.

📒 Files selected for processing (1)
  • .buildkite/ci.mjs

Walkthrough

Changes

The warning handler is now created by createOnWarning and registered through a native default listener. Process caches the handler and installs the listener during creation. Tests cover listener APIs, suppression settings, output ordering, direct events, and workers. Selected macOS CI targets are disabled.

Process warning listener

Layer / File(s) Summary
Warning handler creation
src/js/builtins/ProcessObjectInternals.ts
createOnWarning returns the warning handler. The handler suppresses output when process.noProcessWarnings is enabled.
Native listener lifecycle
src/jsc/bindings/BunProcess.h, src/jsc/bindings/BunProcess.cpp
Process lazily creates and caches the handler, installs a default warning listener during creation, forwards warning events, and retains the handler for garbage collection.
Warning behavior validation
test/js/node/process/process.test.js, test/js/node/test/common/index.js
Tests validate listener registration, removal, replacement, introspection, ordering, suppression, direct events, and worker isolation. Suppression handling was updated.

macOS CI targets

Layer / File(s) Summary
Disable offline macOS targets
.buildkite/ci.mjs
macOS test-platform entries and the darwin arm64 trace-order target are disabled. macOS artifact builds remain enabled.

Possibly related PRs

  • oven-sh/bun#31831: Both changes modify the Node-compatible process warning pipeline and listener handling.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and concisely describes the main change: registering the default warning listener during process creation.
Description check ✅ Passed The description includes both required sections and provides detailed change context, behavioral expectations, and verification results.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

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

I reviewed this PR and didn't find any bugs. Since it moves listener registration into Process::finishCreation (every process/worker startup) and adds new GC-visible C++ state, a human look would still be worthwhile.

What was reviewed:

  • m_onWarning WriteBarrier is visited in visitChildrenImpl; the native onWarning JSFunction is rooted via the EventEmitter's listener map.
  • ThrowScope discipline in ensureOnWarning and the trampoline (RETURN_IF_EXCEPTION after the factory call, RELEASE_AND_RETURN on the tail call).
  • installDefaultWarningListener runs before onDidChangeListener is wired, so the add doesn't load signal tables; the setThisObject(this) skipped under --no-warnings is harmless because the trampoline reads globalObject->processObject() directly and the JS-binding paths set it on first .on()/.emit() anyway.
  • The process.noProcessWarnings check moving from install-time to per-call keeps the vendored Node harness working and doesn't diverge from Node (which only ever seeds it read-only from --no-warnings).
Extended reasoning...

Overview

Moves the default 'warning' printer from lazy install (on first emitWarning()) to eager registration in Process::finishCreation, matching Node's setupWarningHandler. What's registered up front is a native host-function stub named onWarning; on first invocation it builds the real JS printer via createOnWarning (the renamed installOnWarningListener, now returning the closure instead of calling prependListener) and caches it in a new WriteBarrier<JSObject> m_onWarning. The lazy-install guard bool and the install call in emitWarningErrorInstance are removed. Tests add an 8-case subprocess block; the vendored test/common/index.js comment is updated to match the new per-call noProcessWarnings read.

Security risks

None. No untrusted input parsing, no auth/crypto, no filesystem writes on the new startup path (the --redirect-warnings require('node:fs') still happens only on the first actual warning).

Level of scrutiny

Medium-high: this runs during Process::finishCreation for every VM (main thread + every worker_threads Worker), and adds GC-visible C++ state. The change is small and follows established patterns (WriteBarrier + visitChildrenImpl, JSEventListener::create for native listeners, ThrowScope + RELEASE_AND_RETURN on the forwarding call), but startup-path changes to process deserve a second pair of eyes.

Other factors

  • The trampoline doesn't depend on callFrame->thisValue() — it fetches globalObject->processObject() — so the setThisObject(this) being skipped under --no-warnings (early return) has no effect on correctness; pre-PR finishCreation never called it either.
  • Test coverage is thorough: startup listenerCount, listener .name, remove-by-reference, removeAllListeners before first warning, print-before-user-listener ordering, bare process.emit('warning'), --no-warnings/NODE_NO_WARNINGS=1, and per-Worker registration. All subprocess-isolated with concurrent execution.
  • No prior reviews on the timeline.

dylan-conway
dylan-conway previously approved these changes Aug 10, 2026

@dylan-conway dylan-conway left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Direction looks right and matches Node's setupWarningHandler (eager registration, lazy printer). Startup cost is one host JSFunction + one JSEventListener + the listener-map entry, only when process is materialized — not a concern. A few notes inline.

return;
auto* globalObject = defaultGlobalObject(this->globalObject());
auto* onWarning = JSFunction::create(vm, globalObject, 1, "onWarning"_s, Process_functionDefaultOnWarning, ImplementationVisibility::Public);
wrapped().addListener(builtinNames(vm).warningPublicName(), WebCore::JSEventListener::create(*onWarning, *this, false, globalObject->world()), false, false);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This needs vm.writeBarrier(this, onWarning); after the add, same as the JS path does in JSEventEmitter::addListener (JSEventEmitter.cpp:288). JSEventListener::m_jsFunction is a Weak<> that's only kept alive through visitAdditionalChildren → visitJSEventListeners, and being inside finishCreation doesn't make the barrier unnecessary: JSFunction::create above is an allocation/safepoint between Process's own allocation and this store, and LazyProperty::callFunc only holds DeferTermination, not DeferGC. If an eden collection lands in that window, Process is old by the time the new function is stored into it unbarriered, and the next eden GC clears the Weak.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d21bdcc — vm.writeBarrier(this, onWarning) right after the add, mirroring JSEventEmitter::addListener.

return function onWarning(warning) {
// Node only seeds this from --no-warnings; also honoring a runtime assignment lets
// test/js/node/test/common apply a test's `--no-warnings` flag without re-spawning.
if (process.noProcessWarnings) return;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'd drop this. Node's onWarning never reads noProcessWarnings — it's a read-only alias seeded from the flag, and the only gate is whether the listener got registered at bootstrap. This per-emit check exists solely so test/js/node/test/common/index.js can fake --no-warnings in-process, but this PR is exactly what makes the real idiom work there: common loads before any test listener exists, so it can do

process.noProcessWarnings = true;
process.removeAllListeners('warning');

which is byte-for-byte the state --no-warnings produces in Node. Then the printer matches Node exactly and doesn't carry a branch (and a justifying comment) for a test shim.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Agreed, dropped in d21bdcc; the printer no longer reads it. The harness now removes the bootstrap listener instead (next thread), and noProcessWarnings = true alone prints again, same as node v26.3.0.

// process.noProcessWarnings alias read at that point, so set it here.
// printer re-reads Node's process.noProcessWarnings alias on every
// warning, so setting it here silences the print like the flag does.
process.noProcessWarnings = true;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

(see the comment on createOnWarning) — add process.removeAllListeners('warning'); here and the runtime noProcessWarnings read in the printer can go away.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d21bdcc — alias plus removeAllListeners('warning'). Spot-checked 10 --no-warnings vendored tests (incl. the expectWarning ones) through this branch, all pass.

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
auto* globalObject = defaultGlobalObject(lexicalGlobalObject);
auto& vm = JSC::getVM(globalObject);
auto scope = DECLARE_THROW_SCOPE(vm);
auto* process = globalObject->processObject();

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

minor: this re-resolves the target via defaultGlobalObject(lexicalGlobalObject)->processObject() rather than the Process the stub was registered on. Equivalent in practice since the function is created per-global, but jsDynamicCast<Process*>(callFrame->thisValue()) (the emitter always invokes with m_thisObject, and you setThisObject(this) at install) would be the direct route and also does the right thing if someone plucks it out of listeners('warning') and .call()s it on a worker's process.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Done in d21bdcc — dynamicDowncast(thisValue), printer built against that process's global; falls back to the realm's process only when called bare (f.call({}, err) still prints).

…ia this, drop the runtime noProcessWarnings read

Review follow-ups: the listener map holds the stub weakly and is marked
through the Process object, so the store needs the same barrier the JS
addListener path issues; the stub now prints for the Process it was invoked
on (falling back to the realm's process when called bare); and the printer no
longer reads process.noProcessWarnings per warning, since Node's does not.
The vendored test harness gets the state --no-warnings produces by removing
the bootstrap listener instead.
darwin-aarch64 / darwin-x64 still build (they cross-compile on the Linux
hosts) and still ship in canary; only the three macOS test entries and the
darwin trace-order step are dropped, since those are the only steps that
need a native macOS agent and they otherwise sit in the queue until the
build times out.

No-Verification-Needed: CI pipeline config only
Comment thread .buildkite/ci.mjs
@cirospaciari
cirospaciari merged commit 23d233b into main Aug 10, 2026
50 checks passed
@cirospaciari
cirospaciari deleted the claude/process-default-warning-listener branch August 10, 2026 23:45
alii added a commit that referenced this pull request Aug 11, 2026
…7364)

### What does this PR do?

#37344 commented out the three darwin `testPlatforms` entries and the
darwin `trace-order` target while the mac fleet was offline. #37354
(merged after it, but branched before it) routes darwin tests to
Buildkite-hosted agents instead; with the entries gone there is nothing
left to route, so **main currently runs no darwin tests at all** (see
build 91730: 0 darwin test steps).

This puts the entries back. #37354's filter still drops the two lanes
hosted agents can't run (aarch64 `previous`, x64) and gates the rest to
`main` / `[macos tests]`, so the effective result is one aarch64 macOS
26 lane on `test-darwin-hosted`, which build 91720 already showed
passing in ~13 min. Reverting #37354 when the fleet is back brings all
three lanes back with no further edit here.

### How did you verify your code works?

`node .buildkite/ci.mjs --dry-run` on top of current main:
- `main`: `darwin-aarch64-26-test-bun` + `darwin-aarch64-trace-order`,
both `queue: test-darwin-hosted`
- PR, plain subject: no darwin test steps
- PR with `[macos tests]`: the single darwin test step
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants