From fa3523e2d51094cfb5c945be5ea5ff67087c340e Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sun, 7 Jun 2026 09:51:16 +0300 Subject: [PATCH 1/8] Fix NullReferenceException for compound assignment to an uninitialized binding The identifier fast path stored the null TDZ sentinel as the original left value and computed with it, surfacing a NullReferenceException instead of the spec-required ReferenceError for code like `{ x += 1; let x; }`. Detect the uninitialized binding and take the Reference-based path, which produces the proper error. Co-Authored-By: Claude Opus 4.8 (1M context) --- Jint.Tests/Runtime/NumberTests.cs | 5 +++++ .../Interpreter/Expressions/JintAssignmentExpression.cs | 3 ++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/Jint.Tests/Runtime/NumberTests.cs b/Jint.Tests/Runtime/NumberTests.cs index 01cd1a62a..33eb714fe 100644 --- a/Jint.Tests/Runtime/NumberTests.cs +++ b/Jint.Tests/Runtime/NumberTests.cs @@ -212,5 +212,10 @@ public void CompoundAssignmentMatchesBinaryOperatorSemantics() Assert.Throws(() => engine.Evaluate("var b = 3n; b &= 1;")); Assert.Throws(() => engine.Evaluate("var b = 3; b &= 1n;")); Assert.Throws(() => engine.Evaluate("var b = 1n; b >>>= 1n;")); + + // compound assignment to an uninitialized (TDZ) binding must be a ReferenceError, + // not a NullReferenceException from the identifier fast path + var tdz = Assert.Throws(() => engine.Evaluate("(function() { { x += 1; let x; } })()")); + Assert.Equal("ReferenceError", tdz.Error.AsObject().Get("constructor").AsObject().Get("name").AsString()); } } diff --git a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs index 10ddf609a..7e66a4fd4 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintAssignmentExpression.cs @@ -62,7 +62,8 @@ protected override object EvaluateInternal(EvaluationContext context) _leftIdentifier.Identifier, strict, out var identifierEnvironment, - out var temp)) + out var temp) + && temp is not null) // an uninitialized (TDZ) binding reports null; the Reference path below produces the proper ReferenceError { originalLeftValue = temp; lref = engine._referencePool.Rent(identifierEnvironment, _leftIdentifier.Identifier.Value, strict, thisValue: null); From 3e77b06a157ca07d22693e4888a6c7a05374693d Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sun, 7 Jun 2026 09:55:14 +0300 Subject: [PATCH 2/8] Fix NullReferenceException for update expressions on uninitialized bindings Same bug class as the previous commit: UpdateIdentifier read the null TDZ sentinel and dereferenced it, surfacing a NullReferenceException instead of the spec-required ReferenceError for code like `{ y++; let y; }`. Co-Authored-By: Claude Opus 4.8 (1M context) --- Jint.Tests/Runtime/NumberTests.cs | 3 +++ Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs | 3 ++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Jint.Tests/Runtime/NumberTests.cs b/Jint.Tests/Runtime/NumberTests.cs index 33eb714fe..e6b4c22e7 100644 --- a/Jint.Tests/Runtime/NumberTests.cs +++ b/Jint.Tests/Runtime/NumberTests.cs @@ -217,5 +217,8 @@ public void CompoundAssignmentMatchesBinaryOperatorSemantics() // not a NullReferenceException from the identifier fast path var tdz = Assert.Throws(() => engine.Evaluate("(function() { { x += 1; let x; } })()")); Assert.Equal("ReferenceError", tdz.Error.AsObject().Get("constructor").AsObject().Get("name").AsString()); + + var tdzUpdate = Assert.Throws(() => engine.Evaluate("(function() { { y++; let y; } })()")); + Assert.Equal("ReferenceError", tdzUpdate.Error.AsObject().Get("constructor").AsObject().Get("name").AsString()); } } diff --git a/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs b/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs index 9b309d1aa..ec9fc695c 100644 --- a/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs +++ b/Jint/Runtime/Interpreter/Expressions/JintUpdateExpression.cs @@ -117,7 +117,8 @@ private JsValue UpdateNonIdentifier(EvaluationContext context) name, strict, out var environmentRecord, - out var value)) + out var value) + && value is not null) // an uninitialized (TDZ) binding reports null; the Reference path produces the proper ReferenceError { if (_evalOrArguments && strict) { From 92f615295a481bc7a05aa21ff404ac1ee24240d7 Mon Sep 17 00:00:00 2001 From: Marko Lahma Date: Sun, 7 Jun 2026 11:02:31 +0300 Subject: [PATCH 3/8] Add unboxed number storage to declarative environment slot bindings Binding gains a raw-double representation alongside the materialized JsValue: flags are packed into a byte and an UnboxedNumber flag marks slots whose value lives in a double field instead of a heap JsNumber. Reads stay on their existing instruction stream: the unboxed check nests inside the pre-existing null/TDZ branch (HasReferenceValue == today's IsInitialized), and a materializing read converts through JsNumber.Create with write-back caching so repeated reads observe a single instance and read-many patterns never cost more than the one allocation per write that the boxed representation already paid. TryGetNumberSlot/SetNumberSlot/FindSlotIndex expose the raw representation to the numeric read-modify-write fast paths added in a follow-up commit; nothing produces unboxed state yet, so this commit is behavior-neutral. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../FunctionLocalNumberLoopBenchmark.cs | 98 ++++ Jint.Tests/Runtime/CompletionValueTests.cs | 90 ++++ Jint.Tests/Runtime/UnboxedBindingTests.cs | 141 ++++++ Jint/Runtime/Environments/Binding.cs | 80 +++- .../Environments/DeclarativeEnvironment.cs | 89 +++- Jint/Runtime/Interpreter/EvaluationContext.cs | 9 + .../Expressions/JintAssignmentExpression.cs | 426 +++++++++--------- .../Interpreter/Expressions/JintExpression.cs | 10 + .../Expressions/JintIdentifierExpression.cs | 5 +- .../Expressions/JintUpdateExpression.cs | 50 ++ Jint/Runtime/Interpreter/JintStatementList.cs | 18 + .../Statements/JintExpressionStatement.cs | 17 +- .../Statements/JintForStatement.cs | 15 +- Jint/Runtime/TypeConverter.cs | 14 + 14 files changed, 827 insertions(+), 235 deletions(-) create mode 100644 Jint.Benchmark/FunctionLocalNumberLoopBenchmark.cs create mode 100644 Jint.Tests/Runtime/CompletionValueTests.cs create mode 100644 Jint.Tests/Runtime/UnboxedBindingTests.cs diff --git a/Jint.Benchmark/FunctionLocalNumberLoopBenchmark.cs b/Jint.Benchmark/FunctionLocalNumberLoopBenchmark.cs new file mode 100644 index 000000000..499be63ca --- /dev/null +++ b/Jint.Benchmark/FunctionLocalNumberLoopBenchmark.cs @@ -0,0 +1,98 @@ +using BenchmarkDotNet.Attributes; +using Jint.Native; + +namespace Jint.Benchmark; + +/// +/// Function-local numeric loop patterns: accumulators and counters held in declarative +/// environment slots. These are the workloads where transient JsNumber allocations dominate +/// (values outside the int cache allocate per write), which the rest of the suite under-covers +/// because its loops live at script top level where bindings are global-object properties. +/// +[MemoryDiagnoser] +public class FunctionLocalNumberLoopBenchmark +{ + private Engine _engine = null!; + private Prepared