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
91 changes: 91 additions & 0 deletions Jint.Tests/Runtime/ScriptStatementListReuseTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
namespace Jint.Tests.Runtime;

/// <summary>
/// Pins the semantics of the per-engine top-level (Program) statement-list cache: re-evaluating the
/// same prepared script on one engine reuses the top-level handler tree (and, through it, top-level
/// function-expression definitions and their warm inline caches) while every closure, environment and
/// per-run value stays strictly per evaluation. A first evaluation - the fresh-engine-per-op shape -
/// builds the tree fresh and caches nothing, so those hosts stay byte-identical to the historical path;
/// these tests exercise the RE-evaluation branch that the cache actually engages on.
/// </summary>
public class ScriptStatementListReuseTests
{
// Each case is (source, expected string result). Re-evaluated many times on ONE engine: the result
// must be identical every time, matching a fresh engine — the reused top-level tree must not carry
// stale per-run state.
public static TheoryData<string, string> Cases() => new()
{
{ "var x = 1 + 1 === 2; '' + x;", "true" },
{ "var t = 0; for (var i = 0; i < 10; i++) { t += i; } '' + t;", "45" },
{ "var s = 0; { let y = 3; const z = y * 2; s = y + z; } '' + s;", "9" },
{ "var out = ''; for (var k of [1, 2, 3]) { out += k; } out;", "123" },
{ "var o = { a: 1, b: 2 }; var ks = ''; for (var p in o) { ks += p; } ks;", "ab" },
{ "var r; try { r = 1; throw 'e'; } catch (e) { r = 2; } finally { r += 10; } '' + r;", "12" },
{ "'use strict'; var q = (function () { return 42; })(); '' + q;", "42" },
{ "var m = [1, 2, 3].map(function (v) { return v * v; }).reduce(function (a, b) { return a + b; }, 0); '' + m;", "14" },
{ "function g(n) { var r = 0; for (var i = 0; i < n; i++) { r += i; } return r; } '' + g(100);", "4950" },
{ "var sw = ''; switch (2) { case 1: sw = 'one'; break; case 2: sw = 'two'; break; default: sw = 'd'; } sw;", "two" },
};

[Theory]
[MemberData(nameof(Cases))]
public void RepeatedEvaluationMatchesFreshEngine(string source, string expected)
{
var prepared = Engine.PrepareScript(source);

// Baseline: a first evaluation on a fresh engine (the uncached path).
Assert.Equal(expected, new Engine().Evaluate(prepared).ToString());

// Re-evaluations on a single engine must produce the identical value every time.
var engine = new Engine();
for (var i = 0; i < 5; i++)
{
Assert.Equal(expected, engine.Evaluate(prepared).ToString());
}
}

[Fact]
public void ReusedTopLevelFunctionExpressionRecapturesCurrentState()
{
// A top-level function expression's interpreter definition is reused across evaluations once the
// top-level tree is cached, but each evaluation must still produce a closure over THIS run's
// freshly re-declared global state — not a stale binding from an earlier run.
var engine = new Engine();
var prepared = Engine.PrepareScript("""
var base = seed * 10;
var add = function (n) { return base + n; };
add(seed);
""");

engine.SetValue("seed", 1);
Assert.Equal(11, engine.Evaluate(prepared).AsNumber()); // base=10, add(1)=11

engine.SetValue("seed", 5);
Assert.Equal(55, engine.Evaluate(prepared).AsNumber()); // base=50, add(5)=55

engine.SetValue("seed", 9);
Assert.Equal(99, engine.Evaluate(prepared).AsNumber()); // base=90, add(9)=99
}

[Fact]
public void ReusedTopLevelIifeKeepsIndependentPerRunState()
{
// The dominant embedding shape (module-pattern library) is a single top-level IIFE. Caching the
// top-level list reuses the IIFE's body tree across runs; each run must still get fresh internal
// state (the counter starts at 0 every evaluation).
var engine = new Engine();
var prepared = Engine.PrepareScript("""
(function () {
var count = 0;
function bump() { count += 1; return count; }
bump(); bump(); bump();
return count;
})();
""");

for (var i = 0; i < 4; i++)
{
Assert.Equal(3, engine.Evaluate(prepared).AsNumber());
}
}
}
57 changes: 48 additions & 9 deletions Jint/Engine.cs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,38 @@ public sealed partial class Engine : IDisposable
// above only engages on RE-evaluation (see GlobalDeclarationInstantiation).
private readonly HashSet<Script> _evaluatedScripts = new();

// Per-engine cache of the top-level (Program) statement handler tree, keyed on the stable AST.
// Engine-owned for the same lifetime reasons as _functionDefinitions: the tree accumulates
// engine-affine per-node inline caches, so it must not be shared across engines via the AST.
// Lazily allocated and only populated on RE-evaluation, so fresh-engine-per-op hosts never
// touch it and stay byte-identical. See GetOrBuildScriptStatementList.
private Dictionary<Script, JintStatementList>? _scriptStatementLists;

private JintStatementList GetOrBuildScriptStatementList(Script script, bool reEvaluation)
{
if (!reEvaluation)
{
// First evaluation on this engine (fresh-engine-per-op hosts only ever hit this):
// build fresh and cache nothing, byte-identical to the historical path.
return new JintStatementList(null, script.Body);
}

var cache = _scriptStatementLists ??= new Dictionary<Script, JintStatementList>();
if (!cache.TryGetValue(script, out var list))
{
// backstop mirroring CacheFunctionDefinition: bound growth for hosts streaming endless
// distinct sources through one long-lived engine.
if (cache.Count >= 2048)
{
cache.Clear();
}

cache[script] = list = new JintStatementList(null, script.Body);
}

return list;
}

internal JintFunctionDefinition GetOrCreateFunctionDefinition(FunctionDeclaration declaration)
{
if (!TryGetFunctionDefinition(declaration, out var definition))
Expand Down Expand Up @@ -558,9 +590,14 @@ private JsValue ScriptEvaluation(ScriptRecord scriptRecord, ParserOptions parser
try
{
var script = scriptRecord.EcmaScriptCode;
GlobalDeclarationInstantiation(script, globalEnv);
var reEvaluation = GlobalDeclarationInstantiation(script, globalEnv);

var list = new JintStatementList(null, script.Body);
// On re-evaluation reuse this engine's cached top-level handler tree (and its warm
// per-node inline caches) instead of rebuilding it; a first evaluation - the fresh-
// engine-per-run shape - takes plain construction so those hosts stay byte-identical.
// Engine-owned like the function-definition cache above (dies with the engine, so it
// never pins a dropped engine through the shared prepared AST).
var list = GetOrBuildScriptStatementList(script, reEvaluation);

Completion result;
try
Expand Down Expand Up @@ -1169,7 +1206,7 @@ internal JsValue ResolveThisBinding()
/// <summary>
/// https://tc39.es/ecma262/#sec-globaldeclarationinstantiation
/// </summary>
private void GlobalDeclarationInstantiation(
private bool GlobalDeclarationInstantiation(
Script script,
GlobalEnvironment env)
{
Expand All @@ -1182,14 +1219,14 @@ private void GlobalDeclarationInstantiation(

var realm = Realm;

// The definition / statement-list caches pay only when the same script runs on this engine
// again (the cached-Prepared<Script> embedding pattern); a first evaluation - fresh-engine-
// per-run hosts never see a second one - takes plain construction so one-shot scripts stay
// byte-identical to the uncached path.
var reEvaluation = !_evaluatedScripts.Add(script);

if (functionDeclarations != null)
{
// The definition cache pays only when the same script runs on this engine again (the
// cached-Prepared<Script> embedding pattern); a first evaluation - fresh-engine-per-run
// hosts never see a second one - takes plain construction so one-shot scripts stay
// byte-identical to the uncached path.
var reEvaluation = !_evaluatedScripts.Add(script);

for (var i = functionDeclarations.Count - 1; i >= 0; i--)
{
var d = functionDeclarations[i];
Expand Down Expand Up @@ -1288,6 +1325,8 @@ private void GlobalDeclarationInstantiation(
}
}
}

return reEvaluation;
}

/// <summary>
Expand Down