Skip to content

Stop a deeply recursive script from taking down the process - #124

Merged
FlorianRappl merged 2 commits into
AngleSharp:develfrom
lahma:stackoverflow
Jul 27, 2026
Merged

FlorianRappl merged 2 commits into
AngleSharp:develfrom
lahma:stackoverflow

Conversation

@lahma

@lahma lahma commented Jul 27, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #75.

The problem

EngineInstance built the Jint engine without any execution constraints, so Options.Constraints.MaxExecutionStackCount stayed 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 own EngineLimitTests measures ~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 StackOverflowException cannot be caught, so the try/catch AngleSharp already has around EvaluateScriptAsync in ScriptRequestProcessor never 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 ordinary JavaScriptException and gets tracked like any other script error via IBrowsingContext.TrackError.

How deep a script may go before that happens is what the new JsScriptingOptions carries, defaulting to 10000 — roughly what browsers allow, so this is not a compatibility cut:

var config = Configuration.Default
    .WithJs(new JsScriptingOptions
    {
        MaxCallStackDepth = 5000,
    });

WithJs() and new JsScriptingService() keep working unchanged, on the default options. This also makes the claim the README and docs/general/01-Basics.md have carried all along — that options can be passed to WithJs — 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 escape OpenAsync; a small MaxCallStackDepth produces the expected JavaScriptException; 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 StackOverflowException cannot 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 with Test Run Aborted instead of failing.

Not addressed

  • The follow-up comment on StackOverflow exception when trying to open link #75 (ReferenceError: Image is not defined on google.com) is stale: Image arrived in e8de221, and script errors already do not escape OpenAsync.
  • An infinite loop (while (true) {}) still hangs OpenAsync forever. JsScriptingOptions is the natural home for a TimeoutInterval / MaxStatements knob if that is ever wanted.

🤖 Generated with Claude Code

lahma and others added 2 commits July 27, 2026 10:46
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
lahma marked this pull request as ready for review July 27, 2026 07:56

@FlorianRappl FlorianRappl left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fantastic!

@FlorianRappl
FlorianRappl merged commit ce97757 into AngleSharp:devel Jul 27, 2026
5 checks passed
@lahma
lahma deleted the stackoverflow branch July 27, 2026 08:13
@FlorianRappl FlorianRappl added this to the v1.0 milestone Jul 27, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

StackOverflow exception when trying to open link

2 participants