fix(console): don't trigger proxy get trap for nodejs.util.inspect.custom#33730
Conversation
…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
left a comment
There was a problem hiding this comment.
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 theproxyDetails ? proxyDetails[0] : valueswitch is free.- For non-proxy objects,
proxyDetails === null→inspectTarget = value— semantics unchanged. - For actual
Proxyobjects, accessingtarget[nodeCustomInspectSymbol]bypasses thegettrap entirely. A grammy-style proxy withget(_, p) => () => {}no longer fabricates a "custom inspect" function under our feet. FunctionPrototypeCall(maybeCustom, context, ...)still invokes withcontext = value(the proxy), sothisinside 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
left a comment
There was a problem hiding this comment.
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'ReflectHasand property lookup. Non-proxy paths getinspectTarget === value, semantics unchanged.- For Proxies: the
has/gettraps 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 usesvalue[customInspect](inspect, ctx)to preservethis === valueper Node's contract — that's intentional, and only fires when the underlying target has a real method. - Test at
console_test.ts:2680–2711was correctly expanded:hastrap added (sinceReflectHastriggershason a non-extended proxy), and the assertion now covers all three inspect symbols by matching the symbol names defined at01_console.js:456,462,4026. - CI: 134/136 success, 1 skip, 1 cla pending. No failures.
Summary
Proxyobject, accessSymbol.for("nodejs.util.inspect.custom")on the unwrapped proxy target (viacore.getProxyDetails) instead of the proxy itselfgettrap, which can cause side effects — e.g. grammy API proxies that return functions for any property access, leading toTypeError: Cannot convert a Symbol value to a stringproxyDetailsis null for themCloses #33719
Test plan
Deno.inspectdoes not trigger a proxy'sgettrap forSymbol.for("nodejs.util.inspect.custom")