Repository navigation
Backport #4172, #4184, #4187 to 4.x: stop instanceof and eval recursions from killing the host - #4221
Merged
Merged
Conversation
…@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>
…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>
…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
force-pushed
the
backport/4x-stackguard
branch
from
October 5, 2026 09:42
49ccd5e to
626ee44
Compare
This was referenced Oct 7, 2026
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.
This backports three fixes from main to 4.x. Each one stops a recursion that script controls from ending the host process, or from running on new threads forever. Each commit is a cherry-pick of the main PR, adapted to 4.x. No public API changes and no default changes. The public API Verify snapshots are byte-identical.
7b0649bc55a7534069)instanceofover a bound function now asks every bound link for its own@@hasInstance, as OrdinaryHasInstance step 2 requires. Before, it jumped to the innermost target, so a bound class'sstatic [Symbol.hasInstance]was never called, no lookup was observable, and a bound proxy target was refused as "not callable". The walk is still a loop. A link that inherits%Function.prototype[@@hasInstance]%is one step of the loop. Any other method is called behind a native stack probe.df8395454e880b428a)JsValue.InstanceofOperatorprobes the native stack before it calls a target's own@@hasInstance, unless that method is the intrinsic. Without the probe,class C { static [Symbol.hasInstance](v) { return v instanceof C; } },Function.prototype.callused as the method, orevalused as the method ended the process.49ccd5e23553cc7c45)PerformEvalprobes before it parses. On theMaxExecutionStackCountlane, a directeval(…)oreval?.(…)now counts as a call. Before,var s = 'eval(s)'; eval(s)and its indirect, optional-call and callback forms ended the process. On the count lane, the direct and optional forms hopped to a new thread at every exhaustion and never threw.Adapted for 4.x
StackOverflowGuardis opt-in on 4.x (main turns it on by default). Every new probe is gated on it, like every existing 4.x probe:EnsureNativeStackHeadroomin the instanceof paths is gated on the guard flag alone.EnsureStackHeadroominPerformEvalis gated on the guard, and is off on the count lane.So the fixes protect engines that ask for the guard, and a default 4.x engine behaves as before. Every test engine sets the guard. The
MaxExecutionStackCountrows set both the guard and the count, which is the configuration a default engine on main has when that lane is chosen. TheInstanceofOperatorremarks say that the probe is gated on the guard. TheStackOverflowGuarddoc summary keeps 4.x's "Defaults to false" and adds only main's "or into eval source".The direct-eval count does not depend on the guard. It applies on the
MaxExecutionStackCountlane whatever the guard setting. So the counted-eval rows run twice, once with the guard on and once with it off. This is the one behaviour change on the count lane: a recursion through directevalnow reaches the configured count, where before it was never counted. That is the fix itself; main documents it onMaxExecutionStackCount.BindFunctiondiffers on 4.x. It derives fromObjectInstance, and the[[Call]]check is spelledIsCallablewhere main hasHasCall. The loop usesIsCallable. Main's Walk bound-function chains in IsConstructor and GetFunctionRealm instead of recursing #4165 walk, already on 4.x, provides theIsConstructorloop that the new remarks refer to.bindbuilds names eagerly on 4.x. It writes"bound " + nameon every bind, so a chain of 200,000 links would build quadratically long names. The new deep chains (inBoundFunctionChainWalkTestsand the cross-realmFunctionTestsrow) delete each link's ownname, as the existing 4.xBoundChainalready does. The chains are otherwise unchanged.Order. On main, Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop #4187 landed before Probe the native stack before instanceof calls a target's own @@hasInstance method #4184. Here the order is Ask each bound target for its own Symbol.hasInstance in instanceof #4172, Probe the native stack before instanceof calls a target's own @@hasInstance method #4184, Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop #4187. So Probe the native stack before instanceof calls a target's own @@hasInstance method #4184's follow-up edit to a remark on Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop #4187's rows is taken in its final form in the Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop #4187 commit, and Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop #4187's "eval as @@hasInstance" row is already bounded by Probe the native stack before instanceof calls a target's own @@hasInstance method #4184's probe.
Jint/Constraints/AGENTS.mddoes not exist on 4.x. Bound eval recursion with a catchable RangeError instead of a native stack overflow or an endless thread hop #4187's hunk to it is dropped.Tests are transcribed from NUnit to xUnit v3. Main's
TestCases/TestCaseSourcebecomeTheoryData/MemberData.TestBudgets.WedgeCeilingbecomes a local two-minute constant, as inHostModuleGraphDepthTests. Every deep row runs on a 1 MBDedicatedThread, as on main.How the process-death tests are isolated
Main's tests run each body on a small dedicated thread, and this PR mirrors that. That thread only makes the overflow quick and deterministic: a native stack overflow still kills the whole test host. To keep one dying row from hiding the others, the "unfixed" evidence below runs each theory row in its own process. It uses
-preEnumerateTheories -id <row id>against the test executable, and records the exit code and the repeated top frame. A regression in the committed suite would still show as a dead test host (TestPipelineException), the same as on main.Evidence (Windows x64, Release)
Unfixed means: the new tests, run against the engine files as they were before the commit that ports each fix. The earlier backports in this PR are applied.
FunctionTests.Instanceof*(4 new)@@hasInstancenot called ("false"…), link reads"2,1,0"not observed, bound proxy refused, cross-realm bottom method skippedFunctionTests76/76)BoundFunctionChainWalkTests(4 new rows)"false"whereRangeErrorexpected, both lanes); the 2 deep-chainAnswersrows pass (they pin that the walk stays a loop)AHasInstanceMethodThatAsksInstanceofOfItsOwnTarget…(6 rows)Stack overflow.withJsValue.InstanceofOperator→ScriptFunction.CallOncerepeated ×916. 2 pass (guard-only lane, where the callee probes)Process is terminated due to StackOverflowException.)ARecursionThroughEvaluatedSource…(7 rows)0xC00000FD,JintCallExpression.HandleEval×531 for direct). 3 pass (eval as @@hasInstance, already bounded by #4184; bothFunctionconstructor rows, never broken)OnTheExecutionStackCountLane…StopsAtTheCount(10 rows)OnTheExecutionStackCountLaneAFiniteRecursionThroughEval…(2 rows)With all three commits,
HostNativeRecursionGuardTests,BoundFunctionChainWalkTests,HostStackOverflowGuardTestsandCallStackTeststogether pass 82/82 on both net10.0 and net472.Skipped
None. All three source PRs apply to 4.x.
Test totals (all three commits,
dotnet test -c Release, no--timeout)🤖 Generated with Claude Code