ReviewOS

zig-utils/zig-js

Proxy: deep no-trap chains segfault on call, construct and getOwnPropertyDescriptor, and build in quadratic time

#968
Closed glennmichael123 opened this 10 days ago ·
10 days ago

Found in the post-#933 survey and re-executed today on a Debug build of main (538a667a), with node v24.4.1 as the oracle. Each program builds a chain of no-trap proxies, p = new Proxy(p, {}) repeated N times over function(){ return 42; }, then applies one operation to the outermost proxy. #934 was closed with proxy chains reported as no longer overflowing, but these paths still crash:

operationNnodezig-js
p()4,00042Segmentation fault
new p()4,000RangeErrorSegmentation fault
Object.getOwnPropertyDescriptor(p, "length")4,000 / 100,000RangeErrorSegmentation fault
p()100,00042TypeError: p is not a function
building the chain100,000fast~35 s

Causes:

  • callValueWithThisAtSite forwards a no-trap proxy call by recursing on the target, one native frame per link, with no stack guard on that path. [[Construct]] and [[GetOwnProperty]] have the same shape.
  • Object.isCallableObject walks the proxy→target chain to find out whether a proxy is callable, and returns false after 10,000 hops. The Proxy constructor calls it for every new proxy, so a chain of N costs O(N²) to build. It also stores a wrong proxy_callable for every link past 10,000, so the call fails with a TypeError. Yet ProxyCreate fixes a proxy's [[Call]] at creation from IsCallable(target) (10.5.14), and zig-js already records exactly that in behavior.proxy_callable, so answering from the proxy's own flag is O(1) and exact.

Fix: answer callability from the flag, forward no-trap [[Call]]/[[Construct]] links in a loop (each step performs the same revocation check and trap lookup, in the same order as the recursion), and give the descriptor path the nesting guard the other traps use (#940), so a deep chain throws RangeError instead of crashing.

Done: no segfault on any operation at 4,000 or 100,000 links. Calls return 42 at both depths, typeof is function at any depth, and building 100,000 links is linear. Pinned as focused cases. Debug before/after --diag over test/built-ins/Proxy, Reflect, Function and Object, plus the call/new/typeof expression subtrees, shows 0 failures on both sides.

10 days ago

Taking this now; small fix, pushing within the hour.

10 days ago

Fixed in b5fb0ec.

  • Callability and [[Construct]] are now read from what ProxyCreate recorded on the proxy itself, in O(1). The [[Construct]] bit moved into the proxy's rare state beside proxy_callable.
  • Trap-less [[Call]] and [[Construct]] links are forwarded in a loop, with the same revocation check and trap lookup per step, so newTarget still reaches the target unchanged.
  • [[GetOwnProperty]] forwarding gets the #940 nesting guard.

Debug before (538a667) and after, against node v24.4.1:

operationNnodebeforeafter
p()4,00042segfault42
new p()4,000RangeErrorsegfaultRangeError
Object.getOwnPropertyDescriptor(p, "length")4,000RangeErrorsegfaultRangeError
typeof p10,001functionobjectfunction
p()100,00042TypeError: not a function42
new p()100,000RangeErrorsegfault after 110 sRangeError
Object.getOwnPropertyDescriptor(p, "length")100,000RangeErrorsegfaultRangeError
build 100,000 links, then p.length100,000RangeErrorRangeError after 35 sRangeError after 1.0 s

Pinned as a runtime focused case with twelve checks, all of which node passes. Corpus: 12,942 tests across Proxy, Reflect, Function, Object, Array/isArray, call/new/typeof and the class subtrees are identical before and after. Flips 0 cases.

While gating, I found that main itself now fails 32 negative tests in each class subtree, all of which passed at f2bafeb. They're filed separately.

Sign in to comment on this issue.