Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions Jint.Benchmark/InteropWrapperChurnBenchmark.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,10 @@ namespace Jint.Benchmark;

/// <summary>
/// Measures ObjectWrapper churn: a CLR member returning the SAME object reference repeatedly.
/// With the default TrackObjectWrapperIdentity=false every crossing allocates a fresh
/// ObjectWrapper; with the opt-in identity flag the ConditionalWeakTable returns the cached one.
/// The two benchmarks bracket the design space for cheaper wrapper reuse.
/// Since 4.14 the default engine keeps a small bounded cache of recently wrapped objects
/// (CacheRecentObjectWrappers), so repeated crossings reuse the wrapper; the NoCache rows opt out
/// to measure the fresh-wrapper-per-crossing path, and the identity-flag rows measure the
/// ConditionalWeakTable variant. Together they bracket the design space for wrapper reuse.
/// </summary>
[MemoryDiagnoser]
public class InteropWrapperChurnBenchmark
Expand All @@ -31,6 +32,7 @@ public sealed class Payload
}

private Engine _engineDefault = null!;
private Engine _engineNoCache = null!;
private Engine _engineIdentity = null!;
private Engine _engineRecentCache = null!;
private Engine _engineCopy = null!;
Expand All @@ -40,18 +42,26 @@ public void GlobalSetup()
{
var holder = new Holder(new Payload { Value = 42 });

// the out-of-the-box engine: ArrayConversion defaults to LiveView since 4.14
// the out-of-the-box engine: since 4.14 ArrayConversion defaults to LiveView and
// CacheRecentObjectWrappers defaults to true (repeated crossings reuse the wrapper)
_engineDefault = new Engine();
_engineDefault.SetValue("h", holder);
_engineDefault.Execute("h.Payload.Value");

// opt out of the recent-wrapper cache: every crossing builds a fresh wrapper
_engineNoCache = new Engine(cfg => cfg.Interop.CacheRecentObjectWrappers = false);
_engineNoCache.SetValue("h", holder);
_engineNoCache.Execute("h.Payload.Value");

// the identity flags are most interesting on top of Copy, where they let the otherwise
// per-read deep-copied JsArray snapshot be reused across crossings (pin Copy so the array
// traversal rows below measure exactly that; the object-churn rows are array-mode agnostic)
// traversal rows below measure exactly that; the object-churn rows are array-mode agnostic).
// The recent cache is disabled in the CWT lane so each row measures a single mechanism.
_engineIdentity = new Engine(cfg =>
{
cfg.Interop.ArrayConversion = ArrayConversionMode.Copy;
cfg.Interop.TrackObjectWrapperIdentity = true;
cfg.Interop.CacheRecentObjectWrappers = false;
});
_engineIdentity.SetValue("h", holder);
_engineIdentity.Execute("h.Payload.Value");
Expand All @@ -65,7 +75,12 @@ public void GlobalSetup()
_engineRecentCache.Execute("h.Payload.Value");

// the pre-4.14 default: every array crossing deep-copies into a fresh JsArray snapshot
_engineCopy = new Engine(cfg => cfg.Interop.ArrayConversion = ArrayConversionMode.Copy);
// (the cache opt-out keeps this row measuring the copy itself)
_engineCopy = new Engine(cfg =>
{
cfg.Interop.ArrayConversion = ArrayConversionMode.Copy;
cfg.Interop.CacheRecentObjectWrappers = false;
});
_engineCopy.SetValue("h", holder);
_engineCopy.Execute("h.Payload.Value");
}
Expand All @@ -76,6 +91,12 @@ public void SameObjectReturnedInLoop_DefaultFlag()
for (var i = 0; i < OperationsPerInvoke; i++) _engineDefault.Execute("h.Payload.Value");
}

[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
public void SameObjectReturnedInLoop_NoCache()
{
for (var i = 0; i < OperationsPerInvoke; i++) _engineNoCache.Execute("h.Payload.Value");
}

[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
public void SameObjectReturnedInLoop_IdentityFlag()
{
Expand All @@ -100,6 +121,12 @@ public void ArrayMemberTraversal_DefaultLiveView()
_engineDefault.Execute(_arrayTraversal);
}

[Benchmark]
public void ArrayMemberTraversal_LiveViewNoCache()
{
_engineNoCache.Execute(_arrayTraversal);
}

// The README-recommended pattern: hoist the collection into a local so the wrapper is created
// once and the loop measures pure element-read cost (index dispatch + item conversion).
private static readonly Prepared<Script> _arrayHoistedTraversal = Engine.PrepareScript(
Expand Down
15 changes: 15 additions & 0 deletions Jint.Tests/Runtime/InteropExplicitTypeTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,21 @@ public void ExplicitInterfaceFromIndexer()
Assert.Equal(holder.IndexerI1[0].Name, _engine.Evaluate("holder.IndexerI1[0].Name"));
}

[Fact]
public void RecentWrapperCacheKeepsExposedTypeViewsSeparate()
{
// the default recent-wrapper cache (see #2729) reuses wrappers by object identity; the same
// instance exposed under different CLR types must still resolve each view's own members
Assert.Equal("CI1", _engine.Evaluate("holder.ci1.Name").AsString());
Assert.Equal("CI1 as I1", _engine.Evaluate("holder.i1.Name").AsString());
Assert.Equal("Super", _engine.Evaluate("holder.super.Name").AsString());

// and in reverse order against the now-populated cache slots
Assert.Equal("Super", _engine.Evaluate("holder.super.Name").AsString());
Assert.Equal("CI1 as I1", _engine.Evaluate("holder.i1.Name").AsString());
Assert.Equal("CI1", _engine.Evaluate("holder.ci1.Name").AsString());
}


[Fact]
public void SuperClassFromField()
Expand Down
9 changes: 7 additions & 2 deletions Jint.Tests/Runtime/InteropTests.ArrayIdentityCache.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,13 @@ public class ArrayConversionHost
[Fact]
public void ArrayConversionCreatesFreshCopyInCopyMode()
{
// Copy is no longer the default (LiveView since 4.14), so select it explicitly to lock its snapshot contract
var engine = new Engine(options => options.Interop.ArrayConversion = ArrayConversionMode.Copy);
// Copy is no longer the default (LiveView since 4.14) and the recent-wrapper cache would
// reuse the first snapshot; opt out of both to lock the fresh-snapshot-per-crossing contract
var engine = new Engine(options =>
{
options.Interop.ArrayConversion = ArrayConversionMode.Copy;
options.Interop.CacheRecentObjectWrappers = false;
});
engine.SetValue("host", new ArrayConversionHost());

Assert.False(engine.Evaluate("host.Numbers === host.Numbers").AsBoolean());
Expand Down
11 changes: 9 additions & 2 deletions Jint.Tests/Runtime/InteropTests.ClrArrayLiveView.cs
Original file line number Diff line number Diff line change
Expand Up @@ -82,11 +82,18 @@ private static Engine CreateCopyEngine(Action<Options> additionalConfiguration =
[Fact]
public void CopyModeArrayIsJsArraySnapshot()
{
// opting back into Copy restores the pre-4.14 behavior: each crossing produces an independent JsArray snapshot
var engine = CreateCopyEngine();
// full pre-4.14 behavior needs Copy plus opting out of the recent-wrapper cache: each
// crossing then produces an independent JsArray snapshot
var engine = CreateCopyEngine(options => options.Interop.CacheRecentObjectWrappers = false);
engine.SetValue("h", new ArrayHolder());
Assert.True(engine.Evaluate("Array.isArray(h.Numbers)").AsBoolean());
Assert.False(engine.Evaluate("h.Numbers === h.Numbers").AsBoolean());

// with the default (cached) Copy engine the same snapshot is reused while cached
var cachedEngine = CreateCopyEngine();
cachedEngine.SetValue("h", new ArrayHolder());
Assert.True(cachedEngine.Evaluate("Array.isArray(h.Numbers)").AsBoolean());
Assert.True(cachedEngine.Evaluate("h.Numbers === h.Numbers").AsBoolean());
}

[Fact]
Expand Down
11 changes: 8 additions & 3 deletions Jint/Options.cs
Original file line number Diff line number Diff line change
Expand Up @@ -348,9 +348,14 @@ public class InteropOptions
/// alternative to <see cref="TrackObjectWrapperIdentity"/> that cannot grow memory unbounded;
/// identity is only preserved for objects still present in the cache. Covers CLR arrays the same
/// way <see cref="TrackObjectWrapperIdentity"/> does.
/// Defaults to false.
/// </summary>
public bool CacheRecentObjectWrappers { get; set; }
/// Defaults to true since 4.14: repeated crossings of a recently seen object return the same
/// wrapper instance, so script-attached state (freeze/defineProperty/expandos) stays stable and
/// the per-crossing wrapper allocation disappears. Note that under <see cref="ArrayConversionMode.Copy"/>
/// this also means repeated reads of the same CLR array reuse the first JsArray snapshot while it
/// stays cached (CLR-side mutations are not re-copied); set this to false for a fresh snapshot per
/// crossing (the pre-4.14 behavior).
/// </summary>
public bool CacheRecentObjectWrappers { get; set; } = true;

/// <summary>
/// How CLR arrays (<c>T[]</c>) are exposed to script code, defaults to <see cref="Jint.ArrayConversionMode.LiveView"/>
Expand Down
43 changes: 30 additions & 13 deletions Jint/Runtime/Interop/DefaultObjectConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -144,12 +144,15 @@ public static bool TryConvert(Engine engine, object value, Type? type, [NotNullW
}
else
{
// check global cache, have we already wrapped the value?
if (engine._objectWrapperCache?.TryGetValue(value, out var cached) == true)
// check global cache, have we already wrapped the value? A cached wrapper is only
// valid for the same exposed CLR type — the same object may also cross as an
// explicit interface/superclass view, which resolves a different member set.
if (engine._objectWrapperCache?.TryGetValue(value, out var cached) == true
&& (cached is not ObjectWrapper cachedWrapper || cachedWrapper.ClrType == valueType))
{
result = cached;
}
else if (engine._recentObjectWrapperCache?.TryGet(value) is { } recentlyWrapped)
else if (engine._recentObjectWrapperCache?.TryGet(value, valueType) is { } recentlyWrapped)
{
result = recentlyWrapped;
}
Expand All @@ -170,6 +173,9 @@ public static bool TryConvert(Engine engine, object value, Type? type, [NotNullW
if (engine.Options.Interop.TrackObjectWrapperIdentity)
{
engine._objectWrapperCache ??= new ConditionalWeakTable<object, ObjectInstance>();
// the table may hold a wrapper for a different exposed view of the same
// object (the type-guarded lookup above missed it) — last view wins
engine._objectWrapperCache.Remove(value);
engine._objectWrapperCache.Add(value, wrapped);
}
else if (engine.Options.Interop.CacheRecentObjectWrappers)
Expand Down Expand Up @@ -238,18 +244,21 @@ private static bool TryConvertConvertible(Engine engine, IConvertible convertibl

private static JsValue ConvertArray(Engine e, object v)
{
// The identity caches (flag-gated, both default off) also cover arrays: repeated
// conversions of the same CLR array instance return the same result (JsArray snapshot under
// Copy, wrapper view under LiveView) instead of re-converting on every property read. With
// the flags off each conversion still produces a fresh copy/wrapper. All option checks must
// stay inside this method — _typeMappers is a static cross-engine memoization, so per-engine
// option state can never be baked into the memoized mapper.
if (e._objectWrapperCache?.TryGetValue(v, out var cached) == true)
// The identity caches (CacheRecentObjectWrappers default on since 4.14, the identity map
// opt-in) also cover arrays: repeated conversions of the same CLR array instance return the
// same result (JsArray snapshot under Copy, wrapper view under LiveView) instead of
// re-converting on every property read. With caching disabled each conversion still produces
// a fresh copy/wrapper. All option checks must stay inside this method — _typeMappers is a
// static cross-engine memoization, so per-engine option state can never be baked into the
// memoized mapper.
var arrayType = v.GetType();
if (e._objectWrapperCache?.TryGetValue(v, out var cached) == true
&& (cached is not ObjectWrapper cachedWrapper || cachedWrapper.ClrType == arrayType))
{
return cached;
}

if (e._recentObjectWrapperCache?.TryGet(v) is { } recentlyConverted)
if (e._recentObjectWrapperCache?.TryGet(v, arrayType) is { } recentlyConverted)
{
return recentlyConverted;
}
Expand Down Expand Up @@ -339,14 +348,22 @@ internal sealed class RecentObjectWrapperCache
private readonly ObjectInstance[] _wrappers = new ObjectInstance[Capacity];
private int _next;

public ObjectInstance? TryGet(object target)
public ObjectInstance? TryGet(object target, Type clrType)
{
var targets = _targets;
for (var i = 0; i < targets.Length; i++)
{
if (ReferenceEquals(targets[i], target))
{
return _wrappers[i];
// the same object can cross under different exposed CLR types (explicit interface /
// superclass views resolve different members), so a wrapper is only reusable for the
// same exposed type. A mismatched slot is skipped rather than a terminal miss —
// wrappers for both views can coexist in the ring.
var wrapper = _wrappers[i];
if (wrapper is not ObjectWrapper objectWrapper || objectWrapper.ClrType == clrType)
{
return wrapper;
}
}
}

Expand Down