Skip to content

fix(console): don't trigger proxy get trap for nodejs.util.inspect.custom#33730

Merged
bartlomieju merged 2 commits into
mainfrom
fix/console-proxy-inspect
May 1, 2026
Merged

fix(console): don't trigger proxy get trap for nodejs.util.inspect.custom#33730
bartlomieju merged 2 commits into
mainfrom
fix/console-proxy-inspect

Conversation

@bartlomieju

Copy link
Copy Markdown
Member

Summary

  • When inspecting a JS Proxy object, access Symbol.for("nodejs.util.inspect.custom") on the unwrapped proxy target (via core.getProxyDetails) instead of the proxy itself
  • This prevents triggering the proxy's get trap, which can cause side effects — e.g. grammy API proxies that return functions for any property access, leading to TypeError: Cannot convert a Symbol value to a string
  • Matches Node.js behavior which unwraps proxies before checking for custom inspect symbols
  • Non-proxy objects (e.g. nodejs-polars DataFrames using V8 interceptors) are unaffected since proxyDetails is null for them

Closes #33719

Test plan

  • Added unit test that verifies Deno.inspect does not trigger a proxy's get trap for Symbol.for("nodejs.util.inspect.custom")
  • All existing console tests pass

…stom

When inspecting a JS Proxy object, access the nodejs.util.inspect.custom
symbol on the unwrapped proxy target instead of the proxy itself. This
prevents triggering the proxy's get trap which can cause side effects
(e.g. grammy API proxies that return functions for any property access).
This matches Node.js behavior which unwraps proxies before checking for
custom inspect symbols.

Closes #33719

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

Substance is right. Walked the call site at 01_console.js:540–614:

  • proxyDetails = core.getProxyDetails(value) is already computed unconditionally at line 540, so the proxyDetails ? proxyDetails[0] : value switch is free.
  • For non-proxy objects, proxyDetails === nullinspectTarget = value — semantics unchanged.
  • For actual Proxy objects, accessing target[nodeCustomInspectSymbol] bypasses the get trap entirely. A grammy-style proxy with get(_, p) => () => {} no longer fabricates a "custom inspect" function under our feet.
  • FunctionPrototypeCall(maybeCustom, context, ...) still invokes with context = value (the proxy), so this inside a legitimate target-defined custom inspector is the proxy — matching Node.

This matches what Node's lib/internal/util/inspect.js does: peeks through proxies before consulting the inspect symbol. ✓

Test is well-targeted: a proxy with a tracking get trap; Deno.inspect(proxy); assert the inspect symbol was never accessed. Regression-tight to #33719.

One observation — non-blocking

The fix only redirects the third lookup branch (nodeCustomInspectSymbol). The branches above it still touch the proxy directly:

if (ReflectHas(value, customInspect) && typeof value[customInspect] === "function") {
  return String(value[customInspect](inspect, ctx));
} else if (ReflectHas(value, privateCustomInspect) && typeof value[privateCustomInspect] === "function") {
  return String(value[privateCustomInspect](inspect, ctx));
} else {
  // [the fixed branch]
}

For the grammy case specifically this is fine — those proxies don't override the has trap, so ReflectHas(value, customInspect) falls through to [[HasProperty]] on the underlying target and returns false, skipping the branch entirely. The fixed branch was the only one that actually fires.

But a more pathological proxy that does implement has to return true for everything would still trigger the get trap on value[customInspect] / value[privateCustomInspect]. If the philosophy is "Node unwraps proxies before consulting custom-inspect symbols, full stop," consider extending the same unwrap to the Deno-symbol branches:

const inspectTarget = proxyDetails ? proxyDetails[0] : value;
if (ReflectHas(inspectTarget, customInspect) && typeof inspectTarget[customInspect] === "function") {
  return String(value[customInspect](inspect, ctx));  // still call via `value` for `this` parity
}

Or document why Deno's symbols intentionally go through the proxy (e.g. allowing proxies to expose custom inspection without an underlying target object). Either way, worth a code comment so the asymmetry is intentional.

CI

1 fail (test integration (2/2) release linux-x86_64), 7 pending. The fail is the recurring sysroot/incremental-LTO setup step (exit code 2, 0 bytes copied, 10.0143 s, 0.0 kB/s — looks like a flaky artifact download), not a real test failure — the test step "Test (release)" never ran. Pending jobs (mostly macos-x86_64) should settle.

Holding to COMMENT until CI lands. Substance is clean; happy to flip to APPROVE once the macos shards finish and the integration job either succeeds on retry or is re-confirmed as the artifact-download flake.

Extend the proxy unwrap to the Deno.customInspect and
Deno.privateCustomInspect branches too, not just the
nodejs.util.inspect.custom one. This avoids triggering
both the get and has traps on proxies for all three
custom inspect symbols.

The call itself still uses the proxy as `this` so
legitimate custom inspectors work correctly.

@fibibot fibibot 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 at 77a32f2. The post-COMMENT delta extends the unwrap from just nodeCustomInspectSymbol to all three inspect symbols (Deno.customInspect, Deno.privateCustomInspect, nodejs.util.inspect.custom), which is the right scope. Verified at 01_console.js:548–614:

  • inspectTarget = proxyDetails ? proxyDetails[0] : value — single hoisted unwrap reused for both branches' ReflectHas and property lookup. Non-proxy paths get inspectTarget === value, semantics unchanged.
  • For Proxies: the has/get traps are bypassed for the lookup (which is where the grammy-proxy bug fires — a proxy returning () => {} for any prop would otherwise be detected as a custom-inspect function). The actual call still uses value[customInspect](inspect, ctx) to preserve this === value per Node's contract — that's intentional, and only fires when the underlying target has a real method.
  • Test at console_test.ts:2680–2711 was correctly expanded: has trap added (since ReflectHas triggers has on a non-extended proxy), and the assertion now covers all three inspect symbols by matching the symbol names defined at 01_console.js:456,462,4026.
  • CI: 134/136 success, 1 skip, 1 cla pending. No failures.

@bartlomieju
bartlomieju merged commit 0600ef2 into main May 1, 2026
136 checks passed
@bartlomieju
bartlomieju deleted the fix/console-proxy-inspect branch May 1, 2026 15:43
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.

Deno pollutes proxies with Symbol(nodejs.util.inspect.custom)

2 participants