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;