Repository navigation
Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop - #4187
Merged
Conversation
…stack overflow or an endless thread hop Evaluating eval's source re-enters the interpreter without entering a function, so no function-entry probe saw it: `var s = 'eval(s)'; eval(s)`, `(0, eval)(s)`, `eval?.(s)`, `[s].forEach(eval)` and eval installed as @@hasInstance all ended the host with a native stack overflow on net10.0, net8.0 and net472 under the default StackOverflowGuard. PerformEval now probes before it parses, gated like the ScriptFunction entries (off on the MaxExecutionStackCount lane). On that lane a direct eval and eval?.() were the one call a call expression dispatches without a call-stack frame, so a recursion of them never grew the count and hopped to a fresh thread at every exhaustion without ever throwing; they now count as the call they are, through a counter rather than a frame because a frame is observable in error.stack, the debugger and MaxRecursionDepth. Follow-up to sebastienros#4076 / sebastienros#4078 / sebastienros#4165 / sebastienros#4176. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
to lahma/jint
that referenced
this pull request
Sep 26, 2026
sebastienros#4187's counted-eval rows noted @@hasInstance as the lane's documented gap. With the operator probing before any method but the intrinsic, that route is a catchable RangeError on this lane too, pinned by the @@hasInstance rows above, so the remark points there instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Sep 26, 2026
…stance method (#4184) * Probe the native stack before instanceof calls a target's own @@hasInstance method InstanceofOperator step 3 calls whatever GetMethod(target, @@hasInstance) finds, so a method that asks instanceof of its own target - a class's static [Symbol.hasInstance](v) { return v instanceof C; } - is a recursion script controls, one native frame per level, with no call expression in it. JsValue.InstanceofOperator made that call with no probe and relied on the callee to probe for itself, which does not hold on two routes: on the MaxExecutionStackCount lane a script function does not probe on entry, and eval as the method re-enters the operator without entering any function at all, on either lane. Both ended the process with a native stack overflow. The call is now behind EnsureNativeStackHeadroom for every method except %Function.prototype[@@hasInstance]%, which is what an ordinary function inherits and so what nearly every instanceof finds; that one is still called unprobed, since its whole behaviour is OrdinaryHasInstance. This is the same placement and the same intrinsic check BindFunction's walk uses for the bound form of the same step (#4172), so the check moves to FunctionPrototype where both call it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Say what bounds the @@hasInstance route on the count lane now #4187's counted-eval rows noted @@hasInstance as the lane's documented gap. With the operator probing before any method but the intrinsic, that route is a catchable RangeError on this lane too, pinned by the @@hasInstance rows above, so the remark points there instead. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
to lahma/jint
that referenced
this pull request
Oct 5, 2026
…anceof calls a target's own @@hasInstance method
InstanceofOperator step 3 calls whatever GetMethod(target, @@hasInstance)
finds, so a method that asks instanceof of its own target - a class's
static [Symbol.hasInstance](v) { return v instanceof C; } - is a
recursion script controls, one native frame per level, with no call
expression in it. JsValue.InstanceofOperator made that call with no probe
and relied on the callee to probe for itself, which does not hold on two
routes: on the MaxExecutionStackCount lane a script function does not
probe on entry, and eval as the method re-enters the operator without
entering any function at all. Both ended the process with a native stack
overflow.
The call is now behind EnsureNativeStackHeadroom for every method except
%Function.prototype[@@hasInstance]%, the same placement and intrinsic
check BindFunction's walk uses (sebastienros#4172), so the check moves to
FunctionPrototype where both call it.
Adapted for 4.x:
- StackOverflowGuard is opt-in on 4.x and EnsureNativeStackHeadroom is
gated on it, so the fix bounds engines that ask for the guard (alone or
with MaxExecutionStackCount); a default 4.x engine is unchanged. The
InstanceofOperator remarks say so, and every test engine sets the guard,
the MaxExecutionStackCount rows included.
- Main's second commit only edits a remark on sebastienros#4187's counted-eval rows,
which arrive with the sebastienros#4187 backport after this one; that remark is
taken there in its final form.
- Tests transcribed from NUnit to xUnit v3.
(cherry picked from commit e880b42)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
to lahma/jint
that referenced
this pull request
Oct 5, 2026
…ble RangeError instead of a native stack overflow or an endless thread hop
Evaluating eval's source re-enters the interpreter without entering a
function, so no function-entry probe saw it: `var s = 'eval(s)'; eval(s)`,
`(0, eval)(s)`, `eval?.(s)` and `[s].forEach(eval)` ended the host with a
native stack overflow on net10.0 and net472 even with StackOverflowGuard on.
PerformEval now probes before it parses, gated like the ScriptFunction
entries (off on the MaxExecutionStackCount lane). On that lane a direct
eval and eval?.() were the one call a call expression dispatches without a
call-stack frame, so a recursion of them never grew the count and hopped to
a fresh thread at every exhaustion without ever throwing; they now count as
the call they are, through a counter rather than a frame because a frame
is observable in error.stack, the debugger and MaxRecursionDepth.
Adapted for 4.x:
- StackOverflowGuard is opt-in on 4.x, so the PerformEval probe bounds
engines that ask for it; a default 4.x engine is unchanged. The
StackOverflowGuard summary keeps 4.x's wording and default ("false") and
gains only main's "or into eval source".
- The direct-eval count on the MaxExecutionStackCount lane does not depend
on the guard, so the counted rows run with the guard on (main's default
configuration) and off (4.x's default).
- Jint/Constraints/AGENTS.md does not exist on 4.x; that hunk is dropped.
- sebastienros#4184 landed first here, so the eval-as-@@hasInstance row is already
bounded by the operator's probe, and the counted rows' remark is taken in
its post-sebastienros#4184 form.
- Tests transcribed from NUnit to xUnit v3; TestBudgets.WedgeCeiling is a
local two-minute constant, as in HostModuleGraphDepthTests.
(cherry picked from commit 553cc7c)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
lahma
added a commit
that referenced
this pull request
Oct 5, 2026
…ons from killing the host (#4221) * Backport #4172 to 4.x: Ask every bound link for its own @@hasInstance in instanceof OrdinaryHasInstance step 2 answers a bound function with ? InstanceofOperator(O, BC) for its [[BoundTargetFunction]], which does GetMethod(BC, @@hasInstance) and calls whatever it finds before falling back to OrdinaryHasInstance. BindFunction instead walked straight to the innermost non-bound target and ran OrdinaryHasInstance on it, so a bound target's own @@hasInstance (a class's static method, a link given one with defineProperty) was never called, no link's lookup was observable, and a proxy target was refused as "not callable". The walk stays a loop, so a deep chain still answers: a link whose GetMethod finds %Function.prototype[@@hasInstance]% (any realm's) is a step of the loop. Any other method is called behind a native stack probe. Adapted for 4.x: - BindFunction derives from ObjectInstance here and 4.x spells [[Call]] presence IsCallable rather than HasCall; the loop uses IsCallable. - StackOverflowGuard is opt-in on 4.x and the probe is gated on it like every other native-stack probe, so the new MaxExecutionStackCount row asks for the guard too (main's default has it on). - bind writes "bound " + name eagerly on 4.x, so the new 200,000-link chains delete each link's own name, as the existing BoundChain does. - Tests transcribed from NUnit to xUnit v3. (cherry picked from commit 5a75340) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Backport #4184 to 4.x: Probe the native stack before instanceof calls a target's own @@hasInstance method InstanceofOperator step 3 calls whatever GetMethod(target, @@hasInstance) finds, so a method that asks instanceof of its own target - a class's static [Symbol.hasInstance](v) { return v instanceof C; } - is a recursion script controls, one native frame per level, with no call expression in it. JsValue.InstanceofOperator made that call with no probe and relied on the callee to probe for itself, which does not hold on two routes: on the MaxExecutionStackCount lane a script function does not probe on entry, and eval as the method re-enters the operator without entering any function at all. Both ended the process with a native stack overflow. The call is now behind EnsureNativeStackHeadroom for every method except %Function.prototype[@@hasInstance]%, the same placement and intrinsic check BindFunction's walk uses (#4172), so the check moves to FunctionPrototype where both call it. Adapted for 4.x: - StackOverflowGuard is opt-in on 4.x and EnsureNativeStackHeadroom is gated on it, so the fix bounds engines that ask for the guard (alone or with MaxExecutionStackCount); a default 4.x engine is unchanged. The InstanceofOperator remarks say so, and every test engine sets the guard, the MaxExecutionStackCount rows included. - Main's second commit only edits a remark on #4187's counted-eval rows, which arrive with the #4187 backport after this one; that remark is taken there in its final form. - Tests transcribed from NUnit to xUnit v3. (cherry picked from commit e880b42) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> * Backport #4187 to 4.x: Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop Evaluating eval's source re-enters the interpreter without entering a function, so no function-entry probe saw it: `var s = 'eval(s)'; eval(s)`, `(0, eval)(s)`, `eval?.(s)` and `[s].forEach(eval)` ended the host with a native stack overflow on net10.0 and net472 even with StackOverflowGuard on. PerformEval now probes before it parses, gated like the ScriptFunction entries (off on the MaxExecutionStackCount lane). On that lane a direct eval and eval?.() were the one call a call expression dispatches without a call-stack frame, so a recursion of them never grew the count and hopped to a fresh thread at every exhaustion without ever throwing; they now count as the call they are, through a counter rather than a frame because a frame is observable in error.stack, the debugger and MaxRecursionDepth. Adapted for 4.x: - StackOverflowGuard is opt-in on 4.x, so the PerformEval probe bounds engines that ask for it; a default 4.x engine is unchanged. The StackOverflowGuard summary keeps 4.x's wording and default ("false") and gains only main's "or into eval source". - The direct-eval count on the MaxExecutionStackCount lane does not depend on the guard, so the counted rows run with the guard on (main's default configuration) and off (4.x's default). - Jint/Constraints/AGENTS.md does not exist on 4.x; that hunk is dropped. - #4184 landed first here, so the eval-as-@@hasInstance row is already bounded by the operator's probe, and the counted rows' remark is taken in its post-#4184 form. - Tests transcribed from NUnit to xUnit v3; TestBudgets.WedgeCeiling is a local two-minute constant, as in HostModuleGraphDepthTests. (cherry picked from commit 553cc7c) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Defect
Options.Constraints.StackOverflowGuard(on by default) is meant to turn deep native recursion into a catchableRangeError. Recursion through eval went around it. Each of these ended the host process with0xC00000FD(native stack overflow) on net10.0, net8.0 and net472, on a 1 MB thread with default options:On the
MaxExecutionStackCountlane, direct eval andeval?.()recursion never threw. They hopped to a fresh thread-pool thread each time the stack ran low, left the thread below blocked, and never reached the limit. In the harness this was still running after 20 s with 57–66 threads on every TFM.new Function(s)()andFunction.prototype.constructor(s)()re-entry was already guarded on both lanes, because the constructed function is entered through a normal, probed call.CreateDynamicFunctionevaluates nothing itself, so it gets no probe. AShadowRealm.prototype.evaluaterecursion also already raised a catchable error on main, so this PR does not change it.Mechanism
Evaluating eval's source re-enters the interpreter (
PerformEval→JintScript.Execute) without entering a function. None of the function-entry probes inScriptFunctionsees it. The routes that reachEvalFunction.Callhave no dispatcher probe above them either:JintCallExpression's native branch, a callback dispatch, andInstanceofOperator's directICallable.Call.On the count lane, the only check is
TryEnterOnCurrentStackat the call expression, and it comparesCallStack.Countwith the limit.JintCallExpression.HandleEvalis the one call a call expression dispatches without pushing a call-stack frame, so a recursion made only of direct evals never grew the count. That means hop, hop, hop.Fix
EvalFunction.PerformEvalcallsStackGuard.EnsureStackHeadroom()before it parses. This is the same function-entry backstop the fourScriptFunctionentries use, so it is off on the count lane.EvalFunction.Callis unchanged, so the IL pinExactlyTheInteropAndForwardingFunctionsProbeTheNativeStackis untouched.StackGuard._unframedEvalDepthcounts the evalsHandleEvaldispatches without a frame (direct eval andeval?.()).TryEnterOnCurrentStackadds it toCallStack.Count, so on the count lane a direct eval counts as the call it is, the same way an indirect eval, whoseEvalFunctionthe call expression pushes, always has. It is a counter rather than a pushed frame because a frame is observable inerror.stack, the debugger's call stack andMaxRecursionDepth.Why the function-entry gate and not
EnsureNativeStackHeadroom(decided by measurement)I prototyped the other option first: the native-backstop probe, which stays on under the count lane. It bounded the pathological rows, but only by racing the call expression's hop. The probe sits a few frames below the hop, so whichever finds the stack low first decides, and that moves with JIT frame sizes. In the harness, direct eval on the count lane ended with 8 threads (probe won, no hop) while
eval?.()hopped to 16. It also broke what the lane exists for. A finitefunction f(n) { return n === 0 ? 0 : eval('f(n - 1)') + 1; } f(3000)withMaxExecutionStackCount = 100_000:3000(hops)3000(hops)300030003000The function-entry gate plus the count is deterministic: the count lane hops until the count, which now includes unframed evals, passes the limit, and then throws.
Residual, documented rather than new. On the count lane, eval reached with no call expression in the loop (the
@@hasInstancerow) still overflows. That is the gapMaxExecutionStackCount's docs already state for every non-call route into a function body. The harness shows the same crash on main for an accessor recursion and for a script-function@@hasInstancerecursion on that lane.ValidateSecurityConfigurationalready flags the lane (StackOverflowGuardShadowed).Fail-before / pass-after
New cases in
Jint.Tests.PublicInterface/HostNativeRecursionGuardTests.cs:ARecursionThroughEvaluatedSourceRaisesACatchableErrorAndTheEngineRecovers: 7 rows on the default lane. Rows: direct, indirect,eval?.(),forEach(eval),@@hasInstance,new Function,Function.prototype.constructor.Zero tests ran … Exit code: -1073741571 Error output: Stack overflow. at Jint.Engine.EvalDeclarationInstantiation … at Jint.Native.Function.EvalFunction.PerformEval … at Jint.Runtime.Interpreter.Expressions.JintCallExpression.HandleEvalExit code: -1073741571 Process is terminated due to StackOverflowException.OnTheExecutionStackCountLaneARecursionThroughEvaluatedSourceStopsAtTheCount: 5 rows withMaxExecutionStackCount = 500andTestBudgets.WedgeCeilingas the join ceiling. Unfixed, net10.0:failed … ("direct eval", …) (2m 00s 009ms) 'direct eval' did not stop at the configured count within 00:02:00; the lane is hopping threads without bound, the same foroptional-call eval, and the other 3 rows passed (they were bounded already).OnTheExecutionStackCountLaneAFiniteRecursionThroughEvalDeeperThanTheThreadReturns: 2 rows. They pin the lane decision. I broke it on purpose by swapping the probe toEnsureNativeStackHeadroom, and the direct-eval row failed on net10.0 withJavaScriptException : Maximum call stack size exceeded.With the fix, the class passes 55/55 on net10.0, net8.0 and net472.
Verification (Release, base
85571424b)Cost, and the rows to gate
Per eval invocation there is one inlined
booltest plus one out-of-lineTryEnsureSufficientExecutionStackFCall, and on direct eval anintincrement and decrement in atry/finally. Per eval, the existing work already includes a source-keyed cache lookup,ParserOptionsequality, an environment,EvalDeclarationInstantiationand an execution-context push and pop. Nothing is added to calls, statements ornew Function. I ran no benchmark. The rows that exercise the changed path are:DromaeoBenchmarkdromaeo-core-evalEngineComparisonBenchmarkdromaeo-core-eval-modernEvalCacheBenchmarks.EvalSameSource,EvalCacheBenchmarks.EvalDistinctSources(smallest eval bodies, so most sensitive)EvalExecutionBenchmarks.EvalHotReusedEngine,EvalExecutionBenchmarks.EvalFreshEngineWhat could break
Only an engine on the
MaxExecutionStackCountlane that recurses through direct eval sees a change. Each level now counts the function and the eval, as an indirect eval always did, so such a recursion reaches the configured count at about half the depth. The default lane only changes where the process used to die. I added no migration-guide section. Say if you want one.Follow-up to #4076 / #4078 / #4165 / #4176.
🤖 Generated with Claude Code