Skip to content

node:inspector: emit full RemoteObject, stackTrace, and count/time*/assert/dirxml/clear for in-process Session consoleAPICalled - #35743

Open
robobun wants to merge 7 commits into
mainfrom
farm/4ff5e462/inspector-console-remoteobject
Open

node:inspector: emit full RemoteObject, stackTrace, and count/time*/assert/dirxml/clear for in-process Session consoleAPICalled#35743
robobun wants to merge 7 commits into
mainfrom
farm/4ff5e462/inspector-console-remoteobject

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Repro

import inspector from "node:inspector";
const s = new inspector.Session(); s.connect();
const events = []; s.on("Runtime.consoleAPICalled", m => events.push(m.params));
await new Promise((ok, no) => s.post("Runtime.enable", {}, e => (e ? no(e) : ok())));
console.log([1, 2, 3]); console.error(new Error("boom")); console.trace("t");
console.count("cnt"); console.time("tm"); console.timeEnd("tm");
console.assert(false, "msg"); console.dirxml({ x: 1 });
await new Promise(r => setImmediate(r)); s.disconnect();
console.log(events.map(e => e.type));
console.log(JSON.stringify(events[0].args[0]));
console.log("stackTrace:", !!events[2].stackTrace);
// bun before: ["log","error","trace"]
//             {"type":"object","description":"[object Array]"}
//             stackTrace: false
// node:       ["log","error","trace","count","timeEnd","assert","dirxml"]
//             {"type":"object","subtype":"array","className":"Array",
//              "description":"Array(3)","objectId":"...","preview":{...}}
//             stackTrace: true

Cause

The in-process Session's console hook in src/js/node/inspector.ts built each argument with a fall-through Object.prototype.toString.call(arg) description and nothing else, never captured a stack, and only hooked the methods whose CDP args are the raw JS args (log/error/trace/...). This is a separate implementation from the WebSocket CDP path in src/js/internal/inspector/cdp.ts, which gets subtype/className/preview/objectId/stackTrace from JSC's Console.messageAdded.

Fix

  • RemoteObject builder: classifyObject() detects subtype/className via node:util/types predicates and JSC intrinsics (so a hostile Proxy or getter cannot derail classification), builds a per-subtype description (Array(3), Map(1), Uint8Array(2), the error's stack, the regexp's source, ...), and buildPreview() fills a bounded preview.properties / preview.entries (5-item cap, overflow set past that). Objects and functions get a synthetic objectId so the shape matches V8; the in-process Session has no Runtime.getProperties backend to dereference it, but consumers commonly test for the field to tell primitives from objects.
  • stackTrace: captureCDPStackTrace() uses Error.captureStackTrace with a prepareStackTrace that emits CDP {functionName, scriptId, url, lineNumber, columnNumber} frames (0-based), starting above the hook so the top frame is the user's call site.
  • Missing methods: dirxml/clear are added to the pass-through table; assert emits only on a falsy condition; count/countReset/time/timeLog/timeEnd get bespoke hooks backed by SafeMap-tracked shadow counters/timers so the "label: N" / "label: X ms" strings match what V8's inspector emits.

Verification

bun bd test test/js/node/inspector/ passes 71/71. Four new subprocess tests in inspector-profiler.test.ts cover the RemoteObject shape across array/error/map/set/typed-array/date/regexp/promise/plain-object/function, the stackTrace frame shape, the count/time*/assert/dirxml/clear event stream, and robustness against revoked proxies / null-prototype objects. With src/js/node/inspector.ts reverted to main those four fail on subtype/stackTrace/missing-events while the rest of the suite still passes.

Two existing inspector.test.ts cases are updated to match the new behavior: the prototype-pollution guard now checks console.profile (since count is legitimately hooked), and the revoked-proxy case now asserts the event is delivered with subtype:"proxy" and no process warning (the builder handles it instead of throwing).

Related: #35736 fixes the WebSocket CDP path's timestamp unit and its count/time forwarding in ConsoleObject.cpp; this PR is the in-process Session path and does not overlap at the file level.

…ssion consoleAPICalled, hook count/time/assert/dirxml/clear

The in-process Session's Runtime.consoleAPICalled mirroring serialized every
non-primitive argument as an opaque {type:'object', description:'[object Foo]'}
string with no subtype/className/preview/objectId, never attached a stackTrace,
and never emitted events for console.count/time/timeLog/timeEnd/assert/dirxml/
clear, so consumers that hook consoleAPICalled (APM SDKs, loggers) saw nothing
useful for objects and nothing at all for half the console surface.

The fix rebuilds toRemoteObject into a proper CDP RemoteObject builder
(util.types + JSC intrinsics for subtype detection; className via the
constructor chain; per-subtype description; a bounded property/entry preview;
synthetic objectId for shape parity), captures a CDP-shaped stackTrace per
event via Error.captureStackTrace with a prepareStackTrace that emits
{functionName, scriptId, url, lineNumber, columnNumber} frames, and adds hooks
for assert/dirxml/clear plus count/time/timeLog/timeEnd backed by local
SafeMap-tracked counters and timers so the 'label: N' and 'label: X ms'
strings match what V8's inspector emits.
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:39 AM PT - Jul 25th, 2026

@robobun, your commit 1e17700 is building: #81231

@github-actions

Copy link
Copy Markdown
Contributor

Found 1 issue this PR may fix:

  1. support node:inspector #2445 - Tracking issue for node:inspector support; this PR advances it by emitting full CDP RemoteObject payloads, stack traces, and hooking missing console methods (count, time, assert, dirxml, clear) in the in-process Session API

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

Fixes #2445

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

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

Next review available in: 4 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: 3d78ea5f-7c7b-4a87-a781-3f0ca1da8453

📥 Commits

Reviewing files that changed from the base of the PR and between eb5ca66 and 1e17700.

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

Walkthrough

Changes

Runtime console events now produce richer CDP-compatible RemoteObjects, previews, object IDs, classifications, and stack traces. Specialized hooks add assert, counter, and timer semantics, with cleanup for shadow state. Tests cover metadata, stack frames, formatting, hostile values, and prototype pollution.

Runtime console events

Layer / File(s) Summary
RemoteObject metadata and stack traces
src/js/node/inspector.ts
Caches safe primordials, classifies console values, builds previews, assigns object IDs, and attaches CDP-style call frames.
Specialized console method hooks
src/js/node/inspector.ts
Implements assertion, counter, and timer event formatting, installation, and state cleanup.
Console event coverage
test/js/node/inspector/inspector-profiler.test.ts, test/js/node/inspector/inspector.test.ts
Validates emitted metadata, stack traces, special methods, hostile values, and prototype-pollution handling.

Possibly related PRs

  • oven-sh/bun#31823: Related Runtime.consoleAPICalled RemoteObject metadata and stack-trace handling.
  • oven-sh/bun#34719: Related CDP-style inspector stack and call-frame behavior.

Suggested reviewers: cirospaciari, dylan-conway, jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title is specific and matches the main change: richer inspector consoleAPICalled output for in-process Sessions.
Description check ✅ Passed The description is detailed and covers what changed plus verification, though it uses different section headings than the template.
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.

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

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/inspector.ts`:
- Line 497: Reduce the stack-trace limit assigned in the hooked console-call
path around Error.stackTraceLimit to approximately 30, or reuse the existing
Error.stackTraceLimit when it is already configured, instead of forcing 200 on
every call. Preserve the Runtime domain’s stack reporting while avoiding
unnecessary CallSite and CDP frame creation.
- Line 198: Update src/js/node/inspector.ts at lines 198-198, 331-331, 340-340,
and 465-465 to destructure each repeatedly accessed property once before its
guard: use descriptor.value for the callable check and name read, info.subtype
for the entry assignment, p.subtype for the out assignment, and info.subtype for
the remote assignment. Preserve the existing guard and assignment behavior while
eliminating duplicate conditional property access.
- Around line 490-511: Update captureCDPStackTrace so every access to
Error.prepareStackTrace, including saving and restoring its value, is protected
by the existing best-effort error handling. Ensure throwing getters, setters, or
non-writable properties cannot escape the function or make console logging
throw, while preserving normal stack-trace capture and restoration behavior.
- Around line 520-541: Update emitConsoleAPICalled to return immediately when
runtimeEnabledSessions has no sessions, before capturing the timestamp or stack
trace. Preserve per-session isolation by cloning the captured stack-trace data,
including a distinct callFrames array, when assigning params.stackTrace for each
session; keep args rebuilding and existing message delivery behavior unchanged.
- Around line 208-210: Update truncate to avoid emitting a lone surrogate when
the length limit cuts through a surrogate pair: after slicing at
MAX_DESCRIPTION_LENGTH, remove the trailing high surrogate if present before
appending the ellipsis. Preserve the existing behavior for strings within the
limit and for truncation at non-surrogate boundaries.

In `@test/js/node/inspector/inspector-profiler.test.ts`:
- Around line 729-823: Extend the concurrent test around collect to log null,
undefined, bigint, symbol, -0, NaN, and positive/negative infinity, then assert
each primitive’s type, description, and applicable unserializableValue according
to toRemoteObject behavior. Add two distinct object arguments and verify their
objectId values are strings and differ, while preserving the existing
object-shape assertions.
- Around line 866-906: Remove the redundant test-body comments `// count`, `//
assert`, `// dirxml`, and the value-label comments that merely describe the
following assertions. Keep comments documenting non-obvious CDP behavior and the
hostile-value rationale, including the explanations near timeLog/timeEnd and
hostile arguments.
🪄 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: 558b4a42-5926-4fea-bc16-f6334bc3681a

📥 Commits

Reviewing files that changed from the base of the PR and between df6c7ee and eb5ca66.

📒 Files selected for processing (3)
  • src/js/node/inspector.ts
  • test/js/node/inspector/inspector-profiler.test.ts
  • test/js/node/inspector/inspector.test.ts

Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts
Comment thread test/js/node/inspector/inspector-profiler.test.ts
Comment thread test/js/node/inspector/inspector-profiler.test.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts
Comment thread src/js/node/inspector.ts Outdated
…guard every Error.prepareStackTrace/stackTraceLimit touch, clone callFrames per session, lower stack limit to 30, use captured ArrayPrototypeSlice, surrogate-safe truncate
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts
Comment thread src/js/node/inspector.ts
Comment thread src/js/node/inspector.ts

@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/inspector.ts:654-660for (const method of CONSOLE_API_SPECIAL) iterates a plain Array via the iterable protocol, so a tampered Array.prototype[Symbol.iterator] makes session.post("Runtime.enable") throw after the first for...in loop has populated hookedConsoleMethods — a retry then early-returns on hookedConsoleMethods.length > 0 and the assert/count/time* hooks are never installed. This is another sibling of the timeLog spread comment above (same Array.prototype[Symbol.iterator] tamper class); use an index loop like removeConsoleHooks() does, or make CONSOLE_API_SPECIAL a null-proto record and iterate with for...in like the loop directly above.

    Extended reasoning...

    What the bug is

    installConsoleHooks() iterates the new special-method list with a plain for...of over an ordinary Array literal:

    for (const method of CONSOLE_API_SPECIAL) {
      const original = consoleObject[method];
      ...
    }

    for...of on an Array evaluates GetIteratorGetMethod(arr, @@iterator)Array.prototype[Symbol.iterator], which is user-tamperable. This is a distinct sibling site of the timeLog ...extra spread already flagged in the open comment at line 641 — same tamper class, different location, and a different (arguably worse) failure mode: instead of making a single console.timeLog() throw, it corrupts the module-level hook-installation state.

    Code path that triggers it

    1. User tampers Array.prototype[Symbol.iterator] (deletes it, or replaces it with a throwing function) before enabling Runtime.
    2. session.post("Runtime.enable")#handleMethod runs runtimeEnabledSessions.add(this) first, then installConsoleHooks().
    3. The first loop — for (const method in CONSOLE_API_TYPES) — is iterator-safe (null-proto record, for...in uses [[OwnPropertyKeys]], not the iterable protocol). It succeeds and populates hookedConsoleMethods with the pass-through hooks for log/error/…
    4. The second loop evaluates CONSOLE_API_SPECIAL[Symbol.iterator]() → looks up on Array.prototype → throws.
    5. post() calls this.#handleMethod(...) outside any try/catch, so the exception surfaces synchronously to the user's session.post("Runtime.enable") call (regardless of whether a callback was passed).

    Why this leaves partial state

    • runtimeEnabledSessions already contains the session (added before the throw).
    • hookedConsoleMethods.length > 0 (populated by the first loop).
    • The special hooks (assert/count/countReset/time/timeLog/timeEnd) are never installed.

    If the user retries session.post("Runtime.enable"), installConsoleHooks() early-returns on if (hookedConsoleMethods.length > 0) return; — so the special hooks stay missing until every session disconnects and removeConsoleHooks() clears the array. The pass-through hooks work, but count/time*/assert silently emit no CDP events for the rest of the session.

    Why existing safeguards don't help

    The file explicitly hardens against this class elsewhere: SafeSet for the runtimeEnabledSessions for-of (with a comment stating exactly why), for...in over the null-proto CONSOLE_API_TYPES for the sibling loop directly above, an index loop in removeConsoleHooks() directly below, and the captured ArrayPrototypeSlice added in 950aaae for the assert hook. This new loop is the only iteration in installConsoleHooks() that dispatches through a tamperable prototype method, and it was introduced by this PR.

    (One caveat on the "regression" framing: installConsoleHooks() was never fully hardened — hookedConsoleMethods.push(...) already dispatches through Array.prototype.push before this PR — so this is more accurately a new internal inconsistency in new code than a strict behavioral regression.)

    Step-by-step proof

    delete Array.prototype[Symbol.iterator];
    const s = new (require("node:inspector").Session)();
    s.connect();
    s.post("Runtime.enable");   // throws TypeError: undefined is not a function (@@iterator)

    At the throw point: runtimeEnabledSessions.size === 1, hookedConsoleMethods.length === 14 (the pass-through set), console.count === <original native>. A subsequent s.post("Runtime.enable") returns {} (early-return), and console.count("x") never fires Runtime.consoleAPICalled.

    Impact

    Nit — the trigger is deliberate exotic tampering of Array.prototype[Symbol.iterator] before Runtime.enable, which is outside the file's stated invariant ("console.* itself never throws from the hook" — this is session.post() throwing at setup, not a console.* call), and the resulting state is graceful degradation (log/error/etc. still emit; only the special methods are silent). But per REVIEW.md's "Fix the whole class in the same PR — grep for every sibling site sharing the pattern", this belongs alongside the timeLog fix, and it's inconsistent with both neighboring loops.

    Fix

    Either an index loop:

    for (let i = 0; i < CONSOLE_API_SPECIAL.length; i++) {
      const method = CONSOLE_API_SPECIAL[i];
      ...
    }

    or make CONSOLE_API_SPECIAL a null-proto record and iterate with for...in, matching CONSOLE_API_TYPES directly above.

  • 🟡 src/js/node/inspector.ts:674-675 — Clearing consoleCounts/consoleTimers on Runtime.disable resets the shadow state while Bun's native C++ console counters/timers persist, so after disable→re-enable the CDP event args diverge from stdout — console.count('x') emits 'x: 1' while native prints 'x: 3', and a timeEnd for a timer started before disable emits no event at all. V8 keeps m_counterMap/m_timerMap on the per-context V8Console (not the session), so Node never resets on Runtime.disable; dropping the two .clear() calls preserves parity across the disable→(nothing)→re-enable case for free.

    Extended reasoning...

    What the bug is

    removeConsoleHooks() — called when the last Runtime-enabled session posts Runtime.disable or disconnects — clears the shadow consoleCounts / consoleTimers maps:

    hookedConsoleMethods.length = 0;
    consoleCounts.clear();
    consoleTimers.clear();

    But Bun's native C++ console state (the counters/timers these maps shadow — see the comment on line 570: "Shadow counters/timers parallel to Bun's native C++ console state, which is not readable from JS") is not cleared by Runtime.disable. So after a disable → re-enable cycle, the shadow restarts from zero while native retains its counters, and the emitted Runtime.consoleAPICalled args no longer match what the underlying console.count / console.timeEnd actually printed to stdout.

    Step-by-step proof

    Counter divergence:

    1. s.post('Runtime.enable')installConsoleHooks() runs, shadow maps empty.
    2. console.count('x') → shadow=1, native=1; CDP event args ['x: 1'], stdout x: 1
    3. console.count('x') → shadow=2, native=2; CDP event args ['x: 2'], stdout x: 2
    4. s.post('Runtime.disable') → last session drops → removeConsoleHooks()consoleCounts.clear().
    5. s.post('Runtime.enable') → hooks reinstalled, shadow empty, native still holds x → 2.
    6. console.count('x') → shadow computes (undefined ?? 0) + 1 = 1, native increments to 3; CDP event args ['x: 1'], stdout x: 3

    Timer event dropped entirely:

    1. enable → console.time('t') → both shadow and native record the start.
    2. disable → consoleTimers.clear().
    3. enable → console.timeEnd('t')consoleTimers.get('t') === undefined → the if (start !== undefined) guard skips emitConsoleAPICalled entirely, no timeEnd event is emitted, but native still prints t: <elapsed> ms.

    Why this differs from Node

    In V8 the console counters and timers live on the per-context V8Console object (m_counterMap / m_timerMap in v8/src/inspector/v8-console.cc), not on the inspector session. Runtime.disable tears down session-scoped state only, so those maps survive. Running the sequence above in Node, step 6 emits {args:[{value:'x: 3'}]} matching stdout, and the timeEnd after a disable/re-enable cycle still fires with the original start time.

    Why removing the clear is a strict improvement

    The shadow can already drift from native if console.count/console.time are called between disable and re-enable — that gap is unavoidable given native state is unreadable from JS. But clearing makes it drift even when nothing happens between disable and re-enable, which is the one case where parity would otherwise be preserved for free. For any sequence of operations, drift-without-clear ≤ drift-with-clear: calls between disable and enable increment native but not shadow either way, and not clearing preserves the pre-disable count that would otherwise be discarded. (The one contrary case — a countReset between disable and enable — is symmetrically approximate either way, so it doesn't argue for keeping the clear.) If unbounded growth is a concern, note that native Bun__ConsoleObject__count/time grow the same way, so parity argues for keeping the shadow in step.

    Impact and fix

    Nit — the trigger (toggling the Runtime domain while count/time state is live) is niche, nothing crashes, and the shadow design is inherently approximate. The fix is a two-line deletion of consoleCounts.clear() and consoleTimers.clear() from removeConsoleHooks().

Comment thread src/js/node/inspector.ts Outdated
Comment thread src/js/node/inspector.ts Outdated
…o native, per-session frame object clone, keep shadow maps across Runtime.disable
Comment thread src/js/node/inspector.ts Outdated
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

Diff is ready. Local verification at 1e17700: bun bd test test/js/node/inspector/ passes 71/71 on three consecutive ASAN runs and on the release build; bun lint is clean; fail-before with src/js/node/inspector.ts reverted to main produces 5 failures as expected.

CI (build 81231): four build-bun steps timed out (darwin aarch64, linux x64, linux x64-musl, windows aarch64) and everything downstream is waiting_failed/expired; no test-failure annotations anywhere. This PR touches bundled JS and test files only, so build-step timeouts are unrelated.

Gate: the internal gate reports one ASAN-with-fix failure but the output is truncated before the failing test name; the junit it wrote to disk is from the later release lane and shows 0 failures. Given the four new subprocess tests here do not bind ports and pass 3/3 locally, this looks like one of the pre-existing inspector.open() WebSocket tests in inspector.test.ts timing out while the gate's four build lanes contend for the same ports. The gate's release-without-fix lane also ran a stale binary (sha 13ae06993, an earlier commit on this branch that still contains the fix) rather than rebuilding with src/ stashed.

Comment thread src/js/node/inspector.ts
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