Skip to content

Commit c5853e7

Browse files
authored
Interop: two engines in one process do not decide each other's conversions and operators (backport of #3521 and #3526) (#3559)
Three process-wide interop caches were keyed on less than the answer they hold depends on, so whichever engine reached one first decided for every engine after it. `DefaultTypeConverter` memoizes the CLR delegate a JavaScript function converts to - it has to, since `host.on(f)` and `host.off(f)` only pair up if both conversions hand the host the same `Delegate` instance. The memo was keyed on the function instance, and one level below it on that function's AST node. Neither carries the target delegate `Type`, which is the one thing `BuildTargetBinderDelegate` bakes in, so the second target type a function was converted for was served the first one's delegate and the reflection invoke behind the call rejected it. Both caches are type-keyed now, through an append-only `TypeKeyedCache<T>` published with one `Interlocked.CompareExchange`, since a shared `Prepared<Script>` is documented as usable from several engines at once. The AST node is process-wide state that outlives every engine that ran the preparation, and the binder it held baked the *converting* engine in as a constant. So a second engine running the same preparation marshalled its host callback's arguments through the first engine's realm - an `instanceof Object` that answers `false` - and kept that engine, its realm and its intrinsics alive for the life of the AST. The binder reads the engine off the target function instead, which is what makes the entry shareable at all. `JintBinaryExpression._knownOperators` remembers which CLR operator a `(name, left type, right type)` triple selects, and the selection runs `InteropHelper.FindBestMatch`, which reads two things the embedder configures: `Options.Interop.ValueCoercion`, which the gray-zone scoring rule consults, and the installed `ITypeConverter`, whose answer is the last rule outright. The coercion setting is a value, so it goes in the key and engines that share it go on sharing entries; the converter is a host object the engine's factory was handed, so keying a process-lived static on it would pin that converter and its engine forever - it selects the table instead, an engine with its own converter keeping its resolutions on itself where they die with it. Fixes #3434 and #3424 on 4.x.
1 parent a943ec0 commit c5853e7

9 files changed

Lines changed: 769 additions & 25 deletions

File tree

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
#nullable enable
2+
3+
namespace Jint.Tests.PublicInterface;
4+
5+
/// <summary>
6+
/// Pins that the CLR delegate a JavaScript function converts to is the one built for the target type the
7+
/// host asked for, in every engine that asks (sebastienros/jint#3434).
8+
/// </summary>
9+
/// <remarks>
10+
/// <para>
11+
/// The conversion is memoized so <c>host.on(f)</c> and <c>host.off(f)</c> hand the host the same
12+
/// <see cref="Delegate"/> instance, which is the identity <c>-=</c> needs. The memo used to be keyed on the
13+
/// function alone, and one level down on that function's AST node — neither of which carries the delegate
14+
/// type the compiled binder was built for. The AST node is process-wide state and
15+
/// <see cref="Engine.PrepareScript"/> is documented as shareable across engines, so whichever engine
16+
/// evaluated a shared preparation first decided the target type for every engine after it, and the
17+
/// reflection invoke that followed rejected the delegate it was handed with
18+
/// <c>ArgumentException: Object of type 'Notify' cannot be converted to type 'Transform'</c>.
19+
/// </para>
20+
/// <para>
21+
/// This is the embedder-visible half of it: two engines, different host types, one cached
22+
/// <c>Prepared&lt;Script&gt;</c> — the shape the README recommends for production.
23+
/// </para>
24+
/// </remarks>
25+
public class HostDelegateConversionTests
26+
{
27+
public delegate void Notify(int value);
28+
29+
public delegate string Transform(string value);
30+
31+
public sealed class NotifyHost
32+
{
33+
public string Call(Notify f)
34+
{
35+
f(1);
36+
return "notify";
37+
}
38+
}
39+
40+
public sealed class TransformHost
41+
{
42+
public string Call(Transform f) => "transform:" + f("x");
43+
}
44+
45+
public delegate string Feed(Payload value);
46+
47+
public sealed class Payload
48+
{
49+
public int Value => 42;
50+
}
51+
52+
public sealed class PayloadHost
53+
{
54+
public string Call(Feed f) => f(new Payload());
55+
}
56+
57+
private const string Source = "host.call(function (x) { return String(x); });";
58+
59+
[Fact]
60+
public void TwoEnginesSharingOnePreparedScript()
61+
{
62+
var prepared = Engine.PrepareScript(Source);
63+
64+
var first = new Engine();
65+
first.SetValue("host", new NotifyHost());
66+
first.Evaluate(prepared).AsString().Should().Be("notify");
67+
68+
var second = new Engine();
69+
second.SetValue("host", new TransformHost());
70+
second.Evaluate(prepared).AsString().Should().Be("transform:x");
71+
}
72+
73+
[Fact]
74+
public void TwoEnginesSharingOnePreparedScriptInTheOtherOrder()
75+
{
76+
var prepared = Engine.PrepareScript(Source);
77+
78+
var first = new Engine();
79+
first.SetValue("host", new TransformHost());
80+
first.Evaluate(prepared).AsString().Should().Be("transform:x");
81+
82+
var second = new Engine();
83+
second.SetValue("host", new NotifyHost());
84+
second.Evaluate(prepared).AsString().Should().Be("notify");
85+
}
86+
87+
/// <summary>
88+
/// The other half of what the shared AST-node cache holds: the compiled binder marshals the host's
89+
/// arguments into the realm of the engine that is running, not of whichever engine compiled it. One
90+
/// delegate type is enough here — the two engines share the binder by design — so this is the case the
91+
/// target-type key alone does not cover.
92+
/// </summary>
93+
[Fact]
94+
public void ASharedBinderMarshalsIntoTheRunningEnginesRealm()
95+
{
96+
var prepared = Engine.PrepareScript("host.call(function (x) { return String(x instanceof Object) + ':' + x.Value; });");
97+
98+
var first = new Engine();
99+
first.SetValue("host", new PayloadHost());
100+
first.Evaluate(prepared).AsString().Should().Be("true:42");
101+
102+
var second = new Engine();
103+
second.SetValue("host", new PayloadHost());
104+
second.Evaluate(prepared).AsString().Should().Be("true:42",
105+
"an object built by the first engine's realm is not an Object in the second engine's");
106+
}
107+
108+
/// <summary>
109+
/// The same preparation alternating between the two engines: each engine keeps answering for its own
110+
/// host type however many times the other one has run in between.
111+
/// </summary>
112+
[Fact]
113+
public void AlternatingEnginesOnOnePreparedScript()
114+
{
115+
var prepared = Engine.PrepareScript(Source);
116+
117+
var notify = new Engine();
118+
notify.SetValue("host", new NotifyHost());
119+
var transform = new Engine();
120+
transform.SetValue("host", new TransformHost());
121+
122+
for (var i = 0; i < 5; i++)
123+
{
124+
notify.Evaluate(prepared).AsString().Should().Be("notify");
125+
transform.Evaluate(prepared).AsString().Should().Be("transform:x");
126+
}
127+
}
128+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
#nullable enable
2+
3+
using System.Diagnostics.CodeAnalysis;
4+
using Jint.Runtime.Interop;
5+
6+
namespace Jint.Tests.PublicInterface;
7+
8+
/// <summary>
9+
/// Which operator overload a <c>+</c> over host types selects is resolved once and remembered, and the
10+
/// resolution reads two things the embedder configures: <see cref="Options.InteropOptions.ValueCoercion"/>
11+
/// and the installed <see cref="ITypeConverter"/>. Two engines configured differently must therefore not
12+
/// answer one another's operator resolutions.
13+
/// </summary>
14+
/// <remarks>
15+
/// One host type per test — the resolution cache never evicts, so a type reused across tests would make
16+
/// them order-dependent on each other rather than self-contained.
17+
/// </remarks>
18+
public class OperatorOverloadResolutionCacheTests
19+
{
20+
#region converter-decided resolution
21+
22+
/// <summary>
23+
/// A host type whose only <c>+</c> is <c>(T, T)</c>. Evaluating <c>'s' + v</c> therefore asks whether a
24+
/// string can become a <c>T</c> — a question no structural scoring rule can answer, so the installed
25+
/// converter answers it and decides between an operator call and plain string concatenation.
26+
/// </summary>
27+
public sealed class MoneyA
28+
{
29+
public static string operator +(MoneyA left, MoneyA right) => "operator";
30+
}
31+
32+
public sealed class MoneyB
33+
{
34+
public static string operator +(MoneyB left, MoneyB right) => "operator";
35+
}
36+
37+
public sealed class MoneyC
38+
{
39+
public static string operator +(MoneyC left, MoneyC right) => "operator";
40+
}
41+
42+
public sealed class MoneyD
43+
{
44+
public static string operator +(MoneyD left, MoneyD right) => "operator";
45+
}
46+
47+
/// <summary>
48+
/// Converts a string to whatever target type is asked for by handing back that type's default instance,
49+
/// and defers everything else to the stock conversions. That is the shape of a host converter that
50+
/// teaches the engine one of its own types — and it is what makes <c>'s' + v</c> resolve to the operator.
51+
/// </summary>
52+
private sealed class StringToHostTypeConverter : DefaultTypeConverter
53+
{
54+
public StringToHostTypeConverter(Engine engine) : base(engine)
55+
{
56+
}
57+
58+
public override bool TryConvert(object? value, Type type, IFormatProvider formatProvider, [NotNullWhen(true)] out object? converted)
59+
{
60+
if (value is string && (type == typeof(MoneyA) || type == typeof(MoneyB) || type == typeof(MoneyC) || type == typeof(MoneyD)))
61+
{
62+
converted = Activator.CreateInstance(type)!;
63+
return true;
64+
}
65+
66+
return base.TryConvert(value, type, formatProvider, out converted);
67+
}
68+
}
69+
70+
private static Engine StockEngine() => new(options => options.Interop.AllowOperatorOverloading = true);
71+
72+
private static Engine ConverterEngine() => new(options =>
73+
{
74+
options.Interop.AllowOperatorOverloading = true;
75+
options.SetTypeConverter(engine => new StringToHostTypeConverter(engine));
76+
});
77+
78+
private static string Add(Engine engine, object host)
79+
{
80+
engine.SetValue("v", host);
81+
return engine.Evaluate("'s' + v").AsString();
82+
}
83+
84+
[Fact]
85+
public void StockEngineAloneConcatenates()
86+
{
87+
Add(StockEngine(), new MoneyA()).Should().NotBe("operator");
88+
}
89+
90+
[Fact]
91+
public void ConverterEngineAloneCallsTheOperator()
92+
{
93+
Add(ConverterEngine(), new MoneyB()).Should().Be("operator");
94+
}
95+
96+
[Fact]
97+
public void AConverterEngineDoesNotDecideForAStockEngine()
98+
{
99+
Add(ConverterEngine(), new MoneyC()).Should().Be("operator");
100+
Add(StockEngine(), new MoneyC()).Should().NotBe("operator");
101+
}
102+
103+
[Fact]
104+
public void AStockEngineDoesNotDecideForAConverterEngine()
105+
{
106+
Add(StockEngine(), new MoneyD()).Should().NotBe("operator");
107+
Add(ConverterEngine(), new MoneyD()).Should().Be("operator");
108+
}
109+
110+
#endregion
111+
112+
#region coercion-decided resolution
113+
114+
/// <summary>
115+
/// A host type whose only <c>+</c> takes a <see cref="string"/> on the right. Handing it an opaque host
116+
/// object is a pair no structural rule recognizes, so whether the candidate survives at all is decided
117+
/// by <see cref="Options.InteropOptions.ValueCoercion"/>'s string rule.
118+
/// </summary>
119+
public sealed class CoercedA
120+
{
121+
public static string operator +(CoercedA left, string right) => "operator";
122+
}
123+
124+
public sealed class CoercedB
125+
{
126+
public static string operator +(CoercedB left, string right) => "operator";
127+
}
128+
129+
public sealed class CoercedC
130+
{
131+
public static string operator +(CoercedC left, string right) => "operator";
132+
}
133+
134+
public sealed class CoercedD
135+
{
136+
public static string operator +(CoercedD left, string right) => "operator";
137+
}
138+
139+
/// <summary>Carries nothing the engine can convert, so only the coercion rule can bind it to a string.</summary>
140+
public sealed class Opaque;
141+
142+
private static Engine CoercingEngine() => new(options => options.Interop.AllowOperatorOverloading = true);
143+
144+
private static Engine NonCoercingEngine() => new(options =>
145+
{
146+
options.Interop.AllowOperatorOverloading = true;
147+
options.Interop.ValueCoercion = ValueCoercionType.None;
148+
});
149+
150+
private static string AddOpaque(Engine engine, object host)
151+
{
152+
engine.SetValue("v", host);
153+
engine.SetValue("o", new Opaque());
154+
return engine.Evaluate("v + o").AsString();
155+
}
156+
157+
[Fact]
158+
public void CoercingEngineAloneCallsTheOperator()
159+
{
160+
AddOpaque(CoercingEngine(), new CoercedA()).Should().Be("operator");
161+
}
162+
163+
[Fact]
164+
public void NonCoercingEngineAloneConcatenates()
165+
{
166+
AddOpaque(NonCoercingEngine(), new CoercedB()).Should().NotBe("operator");
167+
}
168+
169+
[Fact]
170+
public void ACoercingEngineDoesNotDecideForANonCoercingOne()
171+
{
172+
AddOpaque(CoercingEngine(), new CoercedC()).Should().Be("operator");
173+
AddOpaque(NonCoercingEngine(), new CoercedC()).Should().NotBe("operator");
174+
}
175+
176+
[Fact]
177+
public void ANonCoercingEngineDoesNotDecideForACoercingOne()
178+
{
179+
AddOpaque(NonCoercingEngine(), new CoercedD()).Should().NotBe("operator");
180+
AddOpaque(CoercingEngine(), new CoercedD()).Should().Be("operator");
181+
}
182+
183+
#endregion
184+
}

0 commit comments

Comments
 (0)