Skip to content

Fix completion value clobbered by re-entrant Evaluate() during module execution#2493

Merged
lahma merged 2 commits into
sebastienros:mainfrom
lahma:fix-2492-completion-value-reentrancy
Jun 3, 2026
Merged

Fix completion value clobbered by re-entrant Evaluate() during module execution#2493
lahma merged 2 commits into
sebastienros:mainfrom
lahma:fix-2492-completion-value-reentrancy

Conversation

@lahma

@lahma lahma commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Fixes #2492 — a regression where a script's completion value could be clobbered by a re-entrant Evaluate() triggered from module execution, so a previous evaluation returned the wrong object (reported as Cannot read property 'generateRandomText' of undefined).

Root cause

Engine.ScriptEvaluation stored the script's completion value in the engine-level _completionValue field and then drained the shared event loop:

_completionValue = result.GetValueOrDefault();
RunAvailableContinuations();   // can re-enter the engine
return this;                   // caller then reads engine._completionValue

RunAvailableContinuations() can re-enter the engine: an awaited dynamic import() runs a module whose body calls a CLR function that itself calls Evaluate(). Because the event loop has an _isProcessing re-entrancy guard, the middle Evaluate() is the active drainer; the deeper Evaluate() writes _completionValue before the middle frame reads it back — so the middle Evaluate() returns the deeper frame's value. (Surfaces only when the imported module has its own import, which shifts microtask timing.)

Fix

Carry the completion value out as a per-frame local instead of a shared field — a local is immune to re-entrancy by construction:

  • ScriptEvaluation now returns the JsValue (captured before the drain, returned after).
  • Evaluate(in Prepared<Script>) and Execute(in Prepared<Script>) funnel through a shared ExecuteForCompletion(...) helper.
  • ExecuteAsync(string,...) reads via Evaluate(...).
  • The _completionValue field is removed.
  • The invariant for the remaining ambient fields (_error, _lastSyntaxElement) is documented; they are safe because they're produced and consumed synchronously with no re-entrant drain in between.

No public API signatures change.

Tests

Two regression tests, both verified red on main, green after:

  • EngineTests.NestedEvaluateDuringContinuationDrainDoesNotClobberOuterResult — minimal deterministic repro (Promise.resolve().then queues a microtask drained by a nested Evaluate()); no modules needed.
  • ModuleTests.NestedEvaluateFromModuleExecutionDoesNotClobberCompletionValue — faithful repro of the issue (dynamic import + import * as + an importLibrary CLR callback).

Full Jint.Tests passes (3030); Test262 unchanged (99,206 passed; only the pre-existing timing-flaky Atomics.waitAsync differs, confirmed flaky on main too).

Performance

The change removes a field rather than growing any hot struct (e.g. ExecutionContext, which is full-copied on every push / scope change), so no regression is expected. MinimalScriptBenchmark (BenchmarkDotNet ShortRun, net10.0), base vs fix:

Method Base Mean Fix Mean Base Alloc Fix Alloc
Execute 2.892 us 2.917 us 17.24 KB 17.23 KB
Execute_ParsedScript 1.792 us 1.766 us 15.33 KB 15.32 KB

Means are within the measurement error; allocations are flat-to-marginally-lower.

🤖 Generated with Claude Code

lahma and others added 2 commits June 3, 2026 19:35
… module execution

ScriptEvaluation stored the script's completion value in the engine-level
_completionValue field and then drained the shared event loop via
RunAvailableContinuations(). When a microtask (e.g. an awaited dynamic import
whose module body calls a CLR function that itself calls Evaluate()) re-entered
the engine, the deeper ScriptEvaluation overwrote _completionValue before the
outer Evaluate() read it back, so the outer call returned the wrong value
(manifesting as "Cannot read property '...' of undefined").

Carry the completion value out as a per-frame local instead: ScriptEvaluation
now returns the JsValue, Evaluate()/Execute() funnel through a shared
ExecuteForCompletion() helper, ExecuteAsync() reads via Evaluate(), and the
_completionValue field is removed. A local survives re-entrancy, so this class
of bug cannot recur. The invariant for the remaining ambient fields (_error,
_lastSyntaxElement) is documented; they are safe because they are produced and
consumed synchronously with no re-entrant drain in between.

No struct grew (a field was removed); MinimalScriptBenchmark shows flat time and
allocations base vs fix.

Fixes sebastienros#2492.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@lahma
lahma merged commit 564134e into sebastienros:main Jun 3, 2026
4 checks passed
@lahma
lahma deleted the fix-2492-completion-value-reentrancy branch June 3, 2026 18:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

_completionValue gets overwritten during nested Evaluate() calls from module execution

1 participant