Stop a deeply recursive script from taking down the process - #124
Merged
Merged
Conversation
The engine was built without execution constraints, and Jint spends the native stack on JavaScript frames - roughly a thousand of them fit in the 1 MB a thread gets by default, where a browser offers around eleven thousand. A script written for a browser could therefore exhaust the stack, and a StackOverflowException cannot be caught: the surrounding try/catch AngleSharp already has around script evaluation never gets to run, and the whole process dies. Give the engine Jint's stack guard instead, so that such a script reports the usual "Maximum call stack size exceeded" error, which AngleSharp tracks like any other script error. How deep a script may go before that happens is what the new JsScriptingOptions carries, defaulting to what a browser allows. The event loop thread gets a stack to match, so that the depth is reached on the thread itself rather than by the engine continuing the call on a borrowed one. A 32 bit process keeps the default, having little address space to spare once a few loops exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An engine is built per window, long after the options were handed over, so reading them then let a later edit of the caller's object decide how the next document behaves. Take a copy when the service is created instead. A record with init-only properties would say this in the type itself, but init needs an IsExternalInit shim on the netstandard2.0, net462 and net472 targets, and every options type in AngleSharp - LoaderOptions, StyleOptions, HtmlParserOptions - is a plain mutable one. Not worth diverging over a single property that only has to be read once. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lahma
marked this pull request as ready for review
July 27, 2026 07:56
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.
Fixes #75.
The problem
EngineInstancebuilt the Jint engine without any execution constraints, soOptions.Constraints.MaxExecutionStackCountstayed at Jint's default of-1— the stack guard off. Jint spends the native stack on JavaScript frames, and the 1 MB a thread gets by default holds roughly a thousand of them (Jint's ownEngineLimitTestsmeasures ~990 in Release). A browser offers around eleven thousand, so a script written for one could exhaust the stack here.That is what makes the reported symptom so blunt: a
StackOverflowExceptioncannot be caught, so thetry/catchAngleSharp already has aroundEvaluateScriptAsyncinScriptRequestProcessornever runs and the whole process dies.The fix
Enable the stack guard. Such a script now reports the usual
RangeError: Maximum call stack size exceeded, which is an ordinaryJavaScriptExceptionand gets tracked like any other script error viaIBrowsingContext.TrackError.How deep a script may go before that happens is what the new
JsScriptingOptionscarries, defaulting to 10000 — roughly what browsers allow, so this is not a compatibility cut:WithJs()andnew JsScriptingService()keep working unchanged, on the default options. This also makes the claim the README anddocs/general/01-Basics.mdhave carried all along — that options can be passed toWithJs— true.The guard only fires once the native stack is nearly out; below the configured depth Jint continues the call on a fresh stack.
JsEventLoop's thread therefore gets a stack to match, so the depth is reached on the thread itself rather than by borrowing thread-pool ones. A 32 bit process keeps the default, having little address space to spare once a few loops exist.Tests
StackGuardTests— runaway recursion in page script does not escapeOpenAsync; a smallMaxCallStackDepthproduces the expectedJavaScriptException; and 1500-deep recursion still returns the right value, so the default is not a regression for scripts that legitimately recurse past one thread's stack.The first two are worth a note: a
StackOverflowExceptioncannot be caught, so there is nothing weaker to assert than "we got here at all". I checked they are not vacuous by disabling the guard and re-running — the test host dies withTest Run Abortedinstead of failing.Not addressed
ReferenceError: Image is not definedon google.com) is stale:Imagearrived in e8de221, and script errors already do not escapeOpenAsync.while (true) {}) still hangsOpenAsyncforever.JsScriptingOptionsis the natural home for aTimeoutInterval/MaxStatementsknob if that is ever wanted.🤖 Generated with Claude Code