From 2d755f2d590447da0eb60125017563eee7e7fb7d Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Wed, 22 Jul 2026 12:32:09 +0300 Subject: [PATCH] Decline the compiled-invoker fast lane for custom type converters and foreign receivers Two behavior-parity gaps in the #2733 exact-type fast lane found in pre-release review: - The lane's gate checked custom IObjectConverters but not the engine's user-replaceable ITypeConverter. The reflection path consults the converter for some exact-type argument conversions (e.g. bool under default value coercion), so a custom converter installed via SetTypeConverter was silently bypassed. The lane now requires the exact DefaultTypeConverter. - An extracted instance method invoked with a wrong-typed this (f.call(foreignObject)) surfaced InvalidCastException from the compiled receiver cast instead of the reflection path's TargetException, which host code and Interop.ExceptionHandler predicates key on. The lane now declines when the receiver is not an instance of the declaring type so the slow path surfaces the original exception shape. Both new tests fail without the gate changes. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0115tQFNyyQqc1HQGPLUgZND --- .../Runtime/InteropCompiledInvokerTests.cs | 55 +++++++++++++++++++ Jint/Runtime/Interop/MethodInfoFunction.cs | 11 +++- 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/Jint.Tests/Runtime/InteropCompiledInvokerTests.cs b/Jint.Tests/Runtime/InteropCompiledInvokerTests.cs index 86f94d41a..6dab2a182 100644 --- a/Jint.Tests/Runtime/InteropCompiledInvokerTests.cs +++ b/Jint.Tests/Runtime/InteropCompiledInvokerTests.cs @@ -200,4 +200,59 @@ public bool TryConvert(Engine engine, object value, [System.Diagnostics.CodeAnal return false; } } + + [Fact] + public void CustomTypeConverterStillIntercepts() + { + // a user-installed ITypeConverter participates in some exact-type argument conversions on + // the slow path (e.g. bool); the fast lane must decline so it keeps being consulted + var engine = new Engine(options => options.SetTypeConverter(e => new BoolVetoingTypeConverter(e))); + engine.SetValue("host", new Host()); + + var ex = Assert.Throws(() => engine.Evaluate("host.And(true, true)")); + Assert.Contains("No public methods", ex.Message); + + // conversions the veto does not touch keep working + Assert.Equal(5, engine.Evaluate("host.AddInt(2, 3)").AsNumber()); + } + + private sealed class BoolVetoingTypeConverter : Jint.Runtime.Interop.DefaultTypeConverter + { + public BoolVetoingTypeConverter(Engine engine) : base(engine) + { + } + + public override bool TryConvert(object? value, Type type, IFormatProvider formatProvider, [System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out object? converted) + { + if (type == typeof(bool)) + { + converted = null; + return false; + } + + return base.TryConvert(value, type, formatProvider, out converted); + } + } + + [Fact] + public void WrongTypedThisSurfacesReflectionPathException() + { + // an extracted method invoked with a foreign receiver must decline the fast lane so the + // host observes the same TargetException the reflection path always produced (not the + // compiled cast's InvalidCastException) — ExceptionHandler predicates key on the type + var engine = new Engine(options => options.Interop.ExceptionHandler = _ => false); + engine.SetValue("host", new Host()); + engine.SetValue("other", new OtherHost()); + + var ex = Assert.ThrowsAny(() => engine.Evaluate("var f = host.TimesTwo; f.call(other, 21)")); + Assert.IsAssignableFrom(ex); + + // a correctly-typed extracted call still uses the fast lane and works + Assert.Equal(42, engine.Evaluate("var g = host.TimesTwo; g.call(host, 21)").AsNumber()); + } + + public sealed class OtherHost + { + public int Unrelated => 1; + } } diff --git a/Jint/Runtime/Interop/MethodInfoFunction.cs b/Jint/Runtime/Interop/MethodInfoFunction.cs index bd7fe9625..943907e18 100644 --- a/Jint/Runtime/Interop/MethodInfoFunction.cs +++ b/Jint/Runtime/Interop/MethodInfoFunction.cs @@ -173,8 +173,15 @@ protected internal override JsValue Call(JsValue thisObject, JsCallArguments jsA #if NET8_0_OR_GREATER // exact-type fast lane: a compiled delegate binds and invokes without the object?[] // parameter array, argument boxes, boxed return, and return-mapper lookup. Skipped - // when custom object converters are registered because those must see return values. - if (_engine._objectConverters is null && method.GetCompiledInvoker() is { } compiledInvoker) + // when custom object converters are registered because those must see return values, + // and when a custom ITypeConverter is installed because the slow path consults it for + // some exact-type conversions (e.g. bool) that the compiled lane performs directly. + // A wrong-typed receiver (extracted method invoked via .call on a foreign this) also + // declines so the reflection path surfaces the same TargetException it always did. + if (_engine._objectConverters is null + && _engine.TypeConverter.GetType() == typeof(DefaultTypeConverter) + && method.GetCompiledInvoker() is { } compiledInvoker + && (method.Method.IsStatic || method.Method.DeclaringType?.IsInstanceOfType(thisObj) == true)) { JsValue compiledResult = null!; bool handled;