Skip to content

console: publish to diagnostics_channel console.* built-in channels - #36226

Closed
robobun wants to merge 5 commits into
mainfrom
farm/7d391af5/console-diagnostics-channel
Closed

robobun wants to merge 5 commits into
mainfrom
farm/7d391af5/console-diagnostics-channel

Conversation

@robobun

@robobun robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator

What this fixes

Node.js publishes the argument array to the console.log, console.info, console.debug, console.warn, and console.error diagnostics_channel channels on every corresponding console.* call (documented under diagnostics_channel > Built-in Channels > Console). This is the monkeypatch-free way to intercept console output for log capture / log shipping.

Bun's global console.log/info/debug/warn/error are native JSC host functions, so subscribers to these channels received nothing.

Repro

const dc = require("node:diagnostics_channel");
const NAMES = ["console.log","console.info","console.debug","console.warn","console.error"];
const counts = {}, held = [];
for (const n of NAMES) { counts[n] = 0; const ch = dc.channel(n); const f = () => counts[n]++; ch.subscribe(f); held.push([ch, f]); }
console.log("a", 1); console.info("i"); console.debug("d"); console.warn("w"); console.error("e");
process.stdout.write(JSON.stringify(counts) + "\n");
// node v26.3.0: {"console.log":1,"console.info":1,"console.debug":1,"console.warn":1,"console.error":1}
// bun before:  {"console.log":0,"console.info":0,"console.debug":0,"console.warn":0,"console.error":0}

Fix

  • node:diagnostics_channel: when a console.* channel first becomes active (markActive, i.e. first subscribe/bindStore), wrap globalThis.console[m] once with a computed shorthand method that publishes args when ch.hasSubscribers and delegates to the original. The closure holds the Channel strongly, so the WeakRefMap keeps returning the same instance and the method is never re-wrapped; a wrappedConsoleMethods guard keeps inactive→active transitions idempotent. Console stays fully native until someone actually subscribes; once wrapped, the per-call overhead is one hasSubscribers read. The shorthand-method wrapper keeps .name === method and is not constructible, matching the native host function.
  • console.Console constructor instances go through the JS path in src/js/builtins/ConsoleObject.ts; log/info/debug/warn/error are now separate methods that each publish to their own channel before writing, mirroring Node's lib/internal/console/constructor.js. These call channel() (not subscribe), so reading console.Console does not mutate the global console.

Known caveat: a console.log reference captured before the first subscribe bypasses the wrapper (the lazy approach trades this for keeping console native in the common case). The global console.trace/assert/count/dirxml/group/timeLog are native and do not delegate through the wrapped console.error/warn/log, so unlike Node they do not publish; the five documented direct channels are the scope here.

Tests

Four new cases in test/js/node/diagnostics_channel/diagnostics_channel.test.ts (subprocess-based so the runner's own console output isn't affected): global methods publish their raw args array with object identity preserved; Console constructor instances publish; the global console stays native across require/channel()/reading console.Console and only changes after subscribe; materializing console.log does not affect other console methods. They fail on main and pass on this build. test-console-methods.js, inspector-profiler.test.ts, all 113 existing console tests and all 35 vendored test-diagnostics-channel-* node-parallel tests pass.


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

fails on main (without fix)
ASAN without fix: 4 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
bun test v1.4.0 (c28bf9b6b)

test/js/node/diagnostics_channel/diagnostics_channel.test.ts:
(pass) Channel > can have subscribers [23.33ms]
(pass) Channel > can have symbol as name [15.94ms]
(pass) Channel > does not throw when unsubscribed [10.45ms]
(pass) Channel > can publish and subscribe [15.97ms]
(pass) Channel > can publish and subscribe using object [11.37ms]
(todo) Channel > can handle subscriber errors
(todo) Channel > can use bind store
(pass) Channel > references are not leaked [304.50ms]
(todo) TracingChannel > TODO
381 |       stderr: "pipe",
382 |     });
383 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
384 | 
385 |     const line = stdout.split("\n").find(l => l.startsWith("RESULT "));
386 |     expect(line).toBeDefined();
                       ^
error: expect(received).toBeDefined()

Received: undefined

      at <anonymous> (/workspace/bun/test/js/node/diagnostics_channel/diagnostics_chann
... (truncated)

release without fix: 4 failed, 3 skipped
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/diagnostics_channel/diagnostics_channel.test.ts:
(pass) Channel > can have subscribers [0.33ms]
(pass) Channel > can have symbol as name [0.31ms]
(pass) Channel > does not throw when unsubscribed [0.18ms]
(pass) Channel > can publish and subscribe [0.23ms]
(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.50ms]
(todo) TracingChannel > TODO
381 |       stderr: "pipe",
382 |     });
383 |     const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
384 | 
385 |     const line = stdout.split("\n").find(l => l.startsWith("RESULT "));
386 |     expect(line).toBeDefined();
                       ^
error: expect(received).toBeDefined()

Received: undefined

      at <anonymous> (/workspace/bun/test/js/node/diagnostics_channel/diagnostics_channel.test.ts:386:18)
(fail) built-in console.* channels > global console methods publish their arguments [19.59ms]
(pass) built-in console.* channels > materializing the console.log channel does
... (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
bun test v1.4.0 (c28bf9b6b)

test/js/node/diagnostics_channel/diagnostics_channel.test.ts:
(pass) Channel > can have subscribers [25.10ms]
(pass) Channel > can have symbol as name [16.87ms]
(pass) Channel > does not throw when unsubscribed [10.79ms]
(pass) Channel > can publish and subscribe [15.47ms]
(pass) Channel > can publish and subscribe using object [11.84ms]
(todo) Channel > can handle subscriber errors
(todo) Channel > can use bind store
(pass) Channel > references are not leaked [341.09ms]
(todo) TracingChannel > TODO
(pass) built-in console.* channels > global console methods publish their arguments [1269.19ms]
(pass) built-in console.* channels > global console stays native until a console channel has a subscriber [1621.64ms]
(pass) built-in console.* channels > subscribe on a console channel does not throw when console is frozen [1579.55ms]
(pass) built-in console.* channels > Console constructor instances publish to the same channels [1711.89ms]
(pass) built-in 
... (truncated)

release with fix: 3 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 953ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/132] gen generated_host_exports.rs
generated_host_exports.rs: 94 exports (host=3, lazy=10, generic=81, rust=0); 240 extern-C blocks audited
[2/132] gen JS modules (bundle-modules)
Preprocess modules (10085ms)
Bundle modules (51ms)
Postprocesss modules (169ms)
Bundle Functions (753ms)
Generate Code (33ms)

[11.11s] Bundled "src/js" for production
  2572 kb
  193 internal modules
  13 native modules
  90 internal functions across 19 files
[2/132] 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   Compiling�[0m gimli v0.34.0
�[1m�[92m   Compiling�[0m object v0.39.1
�[1m�[92m   Compiling�[0m addr2line v0.27.0
�[1m�[92m   Compiling�[0m std v0.0.0 (/root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/rust/library/std)
�[1m�[92m   Compiling�[0m proc_macro v0.0.0 (/root/.rustup/toolchains/nightly-2026-07-20-x86_64-unknown-linux-gnu/lib/rustlib/src/r
... (truncated)
diff hotspot
src/js/builtins/ConsoleObject.ts                   |  37 +++-
 src/js/node/diagnostics_channel.ts                 |  35 ++++
 .../diagnostics_channel.test.ts                    | 193 +++++++++++++++++++++
 3 files changed, 262 insertions(+), 3 deletions(-)

gate history · 6 passed · 1 rejected · iteration 4

evidence per changed file
file                                                      reads  edits  tests
src/js/builtins/ConsoleObject.ts                              1      3      0
src/js/node/diagnostics_channel.ts                            5      9      0
…js/node/diagnostics_channel/diagnostics_channel.test.ts      1      8      0

@coderabbitai

coderabbitai Bot commented Jul 28, 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: 7 seconds

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: 0b42f4b8-b2e1-495c-8e9a-c73121c8a78b

📥 Commits

Reviewing files that changed from the base of the PR and between e532ad9 and c28bf9b.

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

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.

Beyond the inline notes, I checked that InternalModuleRegistry::Field::InternalConsoleDiagnostics is auto-generated from the presence of src/js/internal/console_diagnostics.ts (no manual registry edit needed), that clearExceptionExceptTermination() has precedent in bindings and preserves termination exceptions (not the raw clearException() REVIEW.md forbids), and that the args passed to JSC::call are held in a MarkedArgumentBuffer so they stay rooted across the publish call.

Extended reasoning...

This PR wires the global native console and Console constructor instances into Node's built-in console.* diagnostics channels. It touches the console.log hot path in C++ with new exception-scope handling and adds a codegen-dependent internal module, and the inline finding about indirect methods (trace/assert/dirxml/etc.) is a real Node-compat scope question a maintainer should decide on — so deferring rather than approving. The note above records the extra checks done beyond the two inline nits so a later pass doesn't re-derive them.

Comment thread src/jsc/bindings/ConsoleObject.cpp Outdated
Comment thread test/js/node/diagnostics_channel/diagnostics_channel.test.ts Outdated
Comment thread src/js/node/diagnostics_channel.ts Outdated
Comment thread src/js/node/diagnostics_channel.ts Outdated
@robobun
robobun force-pushed the farm/7d391af5/console-diagnostics-channel branch from 9e661d1 to d32d00a Compare July 28, 2026 14:34
Comment thread src/js/builtins/ConsoleObject.ts
Comment thread src/js/node/diagnostics_channel.ts Outdated
Comment thread src/js/node/diagnostics_channel.ts
Comment thread src/js/node/diagnostics_channel.ts Outdated
Comment thread src/js/node/diagnostics_channel.ts Outdated

@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 in the current revision — all three earlier findings (channel()-time wrapping mutating the global, uncaptured Object.defineProperty, double-publish when globalThis.console is replaced) are addressed with tests. Deferring to a human because the lazy-wrap design mutates globalThis.console[method] on first subscribe and intentionally diverges from Node for captured refs and indirect methods (trace/assert/count/…); that trade-off vs. publishing from native is worth a maintainer sign-off. One unresolved comment-cop flag remains on the two-line comment at diagnostics_channel.ts:237.

Extended reasoning...

Overview

Adds Node's built-in console.{log,info,debug,warn,error} diagnostics_channel support in two places: (1) src/js/node/diagnostics_channel.ts — markActive() now lazily replaces globalThis.console[method] with a shorthand-method wrapper that publishes args before delegating to the captured native function, guarded by a per-method idempotency flag and a globalThis.console === nativeConsole identity check; (2) src/js/builtins/ConsoleObject.ts — Console.prototype.log/info/debug/warn/error are now five distinct methods that each publish to their own channel (previously info/debug aliased log and error aliased warn). Five new subprocess tests cover the global path, the constructor path, no-mutation-until-subscribe, no-double-publish after replacing the global, and non-leakage into dir/trace/assert.

What was checked this run

  • The closure-pins-Channel invariant: once nativeConsole[method] is reassigned to the wrapper, the wrapper's closure holds ch strongly, so the WeakRefMap keeps returning the same instance and wrappedConsoleMethods[method] makes subsequent inactive→active transitions no-ops. If wrapping is skipped (replaced console / non-function), the flag is not set, so a later subscribe after restoring the native console can still wrap.
  • consoleChannelMethods has __proto__: null, so channel names like "toString" or "constructor" don't accidentally match.
  • The Console constructor instances test subscribes before reading console.Console, so both the wrapper and the in-method publish exist — but only c.log() runs, so no double-count.
  • nativeConsole is captured at module-load; new console.Console(...) necessarily loads this module first (via createConsoleConstructor's require), so the capture is always the real native console in the double-publish scenario.

Security risks

None — no untrusted input parsing, no auth/crypto/fs. The wrapper reads/writes globalThis.console, which is inherently user-mutable.

Level of scrutiny

Medium-high. The change is small (~65 LOC of runtime code) but touches the global console object at runtime and lives in src/js/builtins/ + src/js/node/, which have tamper-resistance and hot-path constraints. The PR went through three design iterations with a real bug found each round, which suggests the interaction surface is subtle.

Other factors

  • Design trade-off not yet signed off: the lazy-wrap approach keeps console fully native until first subscribe, at the cost of two documented divergences from Node — a console.log reference captured before subscribe bypasses the channel, and native console.trace/assert(false)/count/dirxml/group/timeLog do not publish (whereas Node's do, via delegation). The PR description calls both out, but a maintainer should confirm this is the preferred trade-off vs. publishing from the native messageWithTypeAndLevel path.
  • Outstanding automated comment: the comment-cop flag on diagnostics_channel.ts:237 is unresolved. The author has twice pushed back on identical flags for the adjacent invariant comment; this one targets the new two-line "replaced console" comment. It's a linter, not a human, but it's technically open.
  • Test coverage: new tests are subprocess-isolated, drain both pipes concurrently, use describe.concurrent, and assert exact structured output. They fail on the pre-fix debug build per the evidence block.

robobun added 3 commits July 28, 2026 21:01
Node.js publishes the argument array to the console.log, console.info,
console.debug, console.warn, and console.error diagnostics_channel channels on
every corresponding console.* call. This is the documented monkeypatch-free way
to intercept console output for log capture / log shipping.

Bun's global console methods are native JSC host functions, so subscribers to
these channels received nothing.

In node:diagnostics_channel, the first time channel('console.<m>') is
materialized (directly or via subscribe), wrap globalThis.console[m] once to
publish the args array before delegating to the original. The closure holds the
Channel strongly so the WeakRefMap keeps returning the same instance and the
method is never re-wrapped. Console stays fully native until a console channel
is materialized; once wrapped, the per-call overhead is one hasSubscribers read
(constant false on an inactive Channel via the prototype swap).

Console constructor instances go through src/js/builtins/ConsoleObject.ts;
log/info/debug/warn/error are now separate methods that each publish to their
own channel before writing, mirroring Node's lib/internal/console/constructor.js.
…apper

Wrapping at channel() caused two problems:
- Reading console.Console (which calls channel('console.*') internally to
  set up Console.prototype.log/info/debug/warn/error) replaced the native
  global console methods as a side effect, breaking
  inspector.console.log === globalThis.console.log and similar identity
  checks.
- A plain function expression wrapper is constructible, so
  new console.log() stopped throwing (test-console-methods.js).

Wrapping from markActive() instead defers the global-console mutation to
the first subscribe/bindStore on a console.* channel. A wrappedConsoleMethods
guard keeps inactive -> active transitions idempotent.

The wrapper is now a computed shorthand method, so it keeps .name === method
and is not constructible, matching the native host function it replaces.

Extends the 'stays native' test to assert that require(), channel() and
reading console.Console do not mutate globalThis.console.log.
A user who did globalThis.console = new console.Console(...) before
subscribing would get every call published twice: once from the wrapper
installed by markActive and again from Console.prototype.<method>'s own
onLog.publish. Capture the native console at module load and only install
the wrapper on that object; a replaced console either already publishes
(Console instance) or is user-owned.
@robobun
robobun force-pushed the farm/7d391af5/console-diagnostics-channel branch from 07f2b29 to 30c5835 Compare July 28, 2026 21:01
Comment thread src/js/node/diagnostics_channel.ts Outdated
Comment thread src/js/node/diagnostics_channel.ts Outdated
The globalThis.console !== nativeConsole guard was redundant: the wrap
targets nativeConsole[method], and nativeConsole is captured before any
Console instance can exist (createConsoleConstructor requires this module
first), so orig can never be a function that already publishes. With the
guard removed the 'publishes once' test still passes because console.log
dispatches to the replacing instance while the wrapper on the original
console is unreachable. The guard also silently skipped the wrap in the
swap-subscribe-restore sequence, leaving the restored native console
unwrapped with no retry path.

The assignment to nativeConsole[method] is now in a try/catch so a
frozen / non-writable console does not cause subscribe() to throw (it
was previously infallible and is infallible in Node). The idempotency
flag is set only after the assignment succeeds.

@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 issues found in the final revision — all earlier findings (side-effect on console.Console access, double-publish when the global is a Console instance, frozen-console throw, redundant identity guard) are addressed and covered by tests. Deferring to a human for sign-off on the architectural choice: lazy JS-side wrapping of nativeConsole[method] on first subscribe (vs. publishing from the native console path) mutates the global console and leaves the documented caveats (pre-subscribe captured refs; native trace/assert/count/etc. not routing through the wrapped methods).

What was reviewed:

  • markActive → wrapConsoleMethodForChannel idempotency across subscribe→unsubscribe→resubscribe: closure pins the Channel, wrappedConsoleMethods guard holds — no re-wrap, same instance returned.
  • createConsoleConstructor's channel() calls no longer mutate the global (wrap moved to markActive); dirxml still aliased to log matches Node.
  • Replaced-console / frozen-console paths: wrapper targets captured nativeConsole, try/catch keeps subscribe() infallible; publishes-once test still holds without the dropped identity guard.
  • consoleChannelMethods lookup is __proto__: null and gated on typeof name === "string" — no Symbol/proto pollution.
Extended reasoning...

Overview

Adds Node's built-in console.{log,info,debug,warn,error} diagnostics_channel support. Two coordinated changes: (1) src/js/node/diagnostics_channel.ts — markActive() now checks for a console.* channel name and lazily replaces nativeConsole[method] (captured at module load) with a shorthand-method wrapper that publishes then delegates; a __proto__:null idempotency map plus the closure pinning the Channel in the WeakRefMap prevent re-wrapping. (2) src/js/builtins/ConsoleObject.ts — Console.prototype.{log,info,debug,warn,error} are now distinct methods that each publish to their own channel before writing (previously debug/info aliased log and error aliased warn). Six subprocess-based tests added.

Security risks

None identified. No user-controlled data reaches a security-sensitive sink. The consoleChannelMethods lookup uses __proto__: null and is gated on typeof channel.name === "string", so Symbol names and prototype pollution can't reach wrapConsoleMethodForChannel. The wrapper uses orig.$apply (primordial-safe). The only mutation is to nativeConsole[method], wrapped in try/catch so a hardened/frozen console can't turn subscribe() into a throw.

Level of scrutiny

Medium-high. console.log is one of the most-called functions in any program, and this PR mutates it (once, on first subscribe). The interaction between the two publish paths (global-console wrapper vs. Console.prototype in-method publish) took three prior review rounds to converge — each round found a real ordering/coupling issue (channel() side-effect on console.Console read; double-publish when global is a Console instance; strict-mode throw on frozen console; redundant-and-harmful identity guard). The final state is clean and each fix is covered by a dedicated subprocess test.

Other factors

The core reason to defer rather than approve: this is an architectural choice, not a mechanical fix. The original iteration published from the C++ ConsoleObject.cpp path (no global mutation, no captured-ref caveat, indirect methods like trace/assert covered for free) and was replaced with the JS-side lazy-wrap approach. The current approach keeps console fully native until someone subscribes — a reasonable perf trade — but leaves documented gaps (refs captured before first subscribe bypass the wrapper; native console.trace/assert/count/dirxml/group/timeLog don't publish because they don't delegate through the wrapped methods). A maintainer should confirm that trade-off is the one Bun wants, since it's user-observable Node divergence and would be harder to change once shipped.

Test coverage is solid (six describe.concurrent subprocess tests draining both pipes, asserting exact JSON payloads and object identity, plus the frozen-console and replaced-console edge cases). The PR description reports test-console-methods.js, all 113 console tests, and all 35 vendored test-diagnostics-channel-* node-parallel tests pass.

@robobun

robobun commented Jul 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 11:07 PM PT - Jul 28th, 2026

❌ @robobun, your commit c28bf9b has 2 failures in Build #84579 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36226

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

bun-36226 --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.

All prior review feedback has been addressed and I found nothing new on this revision, but I'd like a maintainer to sign off on the design: subscribing to a console.* diagnostics channel now monkeypatches globalThis.console[method] (lazily, on first markActive), with the documented caveat that a console.log reference captured before the first subscribe bypasses publishing.

Checked: wrapping is idempotent across unsubscribe→resubscribe (wrappedConsoleMethods guard); the closure pins the Channel so the WeakRefMap never hands back a fresh instance; channel() and console.Console no longer mutate the global; frozen-console assignment is caught so subscribe() stays infallible; Console.prototype.dirxml still aliasing log matches Node (dirxml publishes to console.log there too).

Extended reasoning...

Overview

Adds Node's built-in console.{log,info,debug,warn,error} diagnostics_channel channels. Two halves: (1) src/js/node/diagnostics_channel.ts — markActive() now lazily wraps nativeConsole[method] (captured at module load) with a shorthand-method closure that publishes args before delegating; (2) src/js/builtins/ConsoleObject.ts — Console.prototype.{log,info,debug,warn,error} are now distinct methods that each publish to their own channel (previously debug/info aliased log and error aliased warn). Six subprocess tests added.

Security risks

None. No auth/crypto/permissions/parsing of untrusted input. The consoleChannelMethods lookup table is null-prototype so a Symbol or polluted-prototype channel name can't reach the wrap path, and typeof channel.name === "string" guards the lookup.

Level of scrutiny

Medium-high. console.log is one of the most-called functions in any JS program, and this PR reassigns it on the global object as a side effect of dc.subscribe("console.log", fn). The approach is deliberately lazy to keep the native fast path for the common case, but that's exactly the kind of tradeoff (and the pre-subscribe-capture caveat, plus the acknowledged divergence for native trace/assert/count/etc. not delegating through the wrapped methods) that a maintainer should confirm they're comfortable with rather than have auto-approved.

Other factors

This PR went through five revisions responding to my earlier findings (wrap moved from channel() to markActive(); Object.defineProperty replaced with a computed shorthand method; the redundant globalThis.console !== nativeConsole guard dropped; assignment wrapped in try/catch with the idempotency flag set only on success). All threads are resolved and the current diff reflects those fixes. Test coverage is solid — subprocess-isolated, covers global console, Console constructor instances, identity preservation until subscribe, replaced-console-publishes-once, and frozen-console. I re-verified the remaining invariants in the message above; the one narrow edge case I previously called out as unfixable by the identity guard (console.log = new console.Console(...).log on the native object → double-publish) remains, but it's obscure and orthogonal to this design.

@robobun

robobun commented Jul 29, 2026 •

Copy link
Copy Markdown
Collaborator Author

CI status across the two most recent builds:

  • build #84376: 194/196 passed. Reds were a :darwin: 26 aarch64 whole-lane timeout and test/cli/run/no-orphans.test.ts perl-daemon-reaping timeout on :darwin: 14 aarch64; neither touches console or diagnostics_channel. All other annotations were yellow (passed on retry).
  • build #84579 (retrigger): 189 passed, 6 expired (darwin/debian-asan agents never picked the jobs up). One lane (:darwin: 14 x64) is red on test/cli/hot/hot.test.ts (file-rename hot-reload timing) and test/cli/run/no-orphans.test.ts (the same perl-daemon timeout). All other test annotations are yellow; none on files this PR touches.

The six new tests in test/js/node/diagnostics_channel/diagnostics_channel.test.ts pass on every lane that ran them. test-console-methods.js, inspector-profiler.test.ts, all console tests and all vendored test-diagnostics-channel-* node-parallel tests pass. The diff is JS-only (3 files, no native changes) and is ready; the remaining red is darwin CI infra (expired agents and darwin-14 fsevents/process-reaping timing), not this change.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Closing: #37128 reworks the console and lists this PR as superseded (the native console publishes console.log / warn / error / debug / info to diagnostics_channel while subscribed, driven by a subscriber bitmask that node:diagnostics_channel maintains, and it has a test for it).

It does not publish from console.Console instances, which this PR also did (node publishes from those too); that is noted over there. If that half is not picked up once #37128 lands, this can be reopened for it.

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

2 participants