Skip to content

Add ProxyHandler for implementing Proxy traps in .NET code#2678

Merged
lahma merged 1 commit into
sebastienros:mainfrom
lahma:proxy-clr-handler
Jul 13, 2026
Merged

Add ProxyHandler for implementing Proxy traps in .NET code#2678
lahma merged 1 commit into
sebastienros:mainfrom
lahma:proxy-clr-handler

Conversation

@lahma

@lahma lahma commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator

Adds the CLR side of the Proxy story: a public Jint.Runtime.Interop.ProxyHandler abstract class with 13 virtual trap methods, created via Engine.Advanced.CreateProxy(target, handler) / CreateRevocableProxy(...). Closes the long-standing interop gap (#1468): nothing in Options.Interop could intercept method calls or property writes on objects handed to a script — MemberAccessor is read-only, and full interception required subclassing ObjectInstance by hand.

class AuditHandler : ProxyHandler
{
    public override JsValue? Get(ObjectInstance target, JsValue property, JsValue receiver)
    {
        Console.WriteLine($"read {property}");
        return null; // CLR null = "no trap" -> forward to target, exactly like an absent JS trap
    }

    public override bool? Set(ObjectInstance target, JsValue property, JsValue value, JsValue receiver)
    {
        Console.WriteLine($"write {property}");
        return target.Set(property, value, receiver);
    }
}

var proxy = engine.Advanced.CreateProxy(ObjectWrapper.Create(engine, service), new AuditHandler());
engine.SetValue("service", proxy);

Design points:

  • Traps return nullable types; CLR null means "trap not implemented — forward", mirroring absent JS traps, and enabling per-invocation conditional forwarding. Typed signatures (bool?, PropertyDescriptor?, ObjectInstance? for construct) enforce spec result types at compile time.
  • Dispatch rides the existing funnel (Refactor Proxy trap dispatch into a shared skeleton with lazy argument construction #2674): a _clrHandler branch ahead of the JS trap fetch feeds the same Validate*TrapResult invariant code — CLR handlers cannot create spec-impossible object states, and the CLR path skips trap-name lookup, argument arrays, and apply/construct array packing entirely.
  • Apply/Construct traps receive an element-wise copy of the arguments — the engine's internal arrays are pooled and may be Unsafe.As-reinterpreted, so the raw array is never exposed (documented as private to the invocation).
  • Revocation unified on the target slot (JsProxy.Revoke(), shared with Proxy.revocable); GetFunctionRealm switched from a _handler null-test to IsRevoked (would have misfired on CLR-handled proxies).
  • Trap exceptions honor Options.Interop.ExceptionHandler with ClrFunction's exact semantics (bubble by default; convertible to catchable JS errors), via allocation-free catch filters.
  • XML docs call out the key semantics trap authors trip over: proxy.method(x) fires the get trap then a plain call — the apply trap only fires when the proxy itself is invoked, so method interception means returning a (memoized) wrapping function from Get. The test suite includes that exact recipe against a wrapped CLR object, including proxy.add === proxy.add identity and this unwrapping.
  • README gains an "Intercepting access to .NET objects" section with the SetWrapObjectHandler composition for automatic proxying.

Verification: 27 new tests in Jint.Tests.PublicInterface (per-trap coverage vs JsObject and ObjectWrapper targets, forward sentinel, invariant enforcement against lying CLR traps, revocation, receiver pass-through via Reflect.get, exception routing, WrapObjectHandler composition + unwrap-into-CLR-method). Full test262: 99,429 passed / 0 failed — the JS-handler path is regression-free. Zero warnings on all TFMs.

Perf: JS-handler path flat — ProxyBenchmark medians (launchCount 5, same base): TrapGet 22.4→20.6 ms, TrapSet 30.5→28.4 ms, ApplyTrap 53.8→55.7 ms (overlapping distributions); allocations identical except +8 B/proxy instance for the two new fields. Non-proxy paths untouched.

Supersedes #1569 (see closing rationale there).

🤖 Generated with Claude Code

https://claude.ai/code/session_01EsHuKuE4UihKZp5HYapDHE

New public API in Jint.Runtime.Interop:

- abstract class ProxyHandler with one virtual method per ECMAScript Proxy
  trap (get/set/has/deleteProperty/getOwnPropertyDescriptor/defineProperty/
  ownKeys/apply/construct/getPrototypeOf/setPrototypeOf/isExtensible/
  preventExtensions). A trap returning CLR null means "forward to the
  target", exactly like an absent trap on a JavaScript handler object;
  bool?/PropertyDescriptor?/ObjectInstance? return types map trap results
  without JsValue round-trips.
- readonly struct RevocableProxy mirroring Proxy.revocable().
- Engine.Advanced.CreateProxy / CreateRevocableProxy factories.

JsProxy integration:

- CLR-handled proxies dispatch through the same per-trap funnel and the
  exact same Validate*TrapResult invariant enforcement as JavaScript
  handler objects; only the trap acquisition/invocation differs.
- Revocation unified on _target being null (IsRevoked + internal Revoke(),
  now also used by Proxy.revocable); external reader in
  Function.GetFunctionRealm switched from _handler to IsRevoked so
  CLR-handled proxies are not mistaken for revoked ones.
- Every trap entry point asserts non-revocation up front and captures the
  target before the observable GetMethod step (matching spec order);
  GetTrap no longer re-asserts.
- apply/construct CLR traps receive a private element-wise copy of the
  argument array (interpreter arrays may be pooled or Unsafe.As-
  reinterpreted object[] instances and must not escape to user code).

Exception routing mirrors ClrFunction: with the default handler, CLR trap
exceptions bubble untouched (the catch filter never engages); with
Options.Interop.ExceptionHandler configured they can become catchable
JavaScript errors.

Docs spell out the get-vs-apply method-call semantics: proxy.method(x)
fires the get trap and then a plain call on the result, so intercepting
method calls means returning a memoized wrapping function from Get. README
gains an "Intercepting access to .NET objects" interop subsection showing
composition with SetWrapObjectHandler.

Tests (Jint.Tests.PublicInterface/ClrProxyHandlerTests.cs): all 13 traps
individually, forward-sentinel semantics (null vs JsValue.Undefined),
invariant violations from lying traps, the memoized method-interception
recipe with this passthrough and identity, revocation (idempotent, typeof
stays "function"), WrapObjectHandler composition with round-trip
unwrapping, Reflect.get receiver, exception routing both modes, and
argument-null validation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EsHuKuE4UihKZp5HYapDHE
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.

1 participant