Skip to content

Leave out deep subtrees instead of overflowing the stack - #55

Merged
FlorianRappl merged 6 commits into
AngleSharp:develfrom
michaelthyregod:fix/layout-stack-guard
Nov 12, 2026
Merged

FlorianRappl merged 6 commits into
AngleSharp:develfrom
michaelthyregod:fix/layout-stack-guard

Conversation

@michaelthyregod

Copy link
Copy Markdown
Contributor

Types of Changes

Prerequisites

Please make sure you can check the following two boxes:

  • I have read the CONTRIBUTING document
  • My code follows the code style of this project

Contribution Type

What types of changes does your code introduce? Put an x in all the boxes that apply:

  • Bug fix (non-breaking change which fixes an issue, please reference the issue id)
  • New feature (non-breaking change which adds functionality, make sure to open an associated issue first)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • My change requires a change to the documentation
  • I have updated the documentation accordingly
  • I have added tests to cover my changes
  • All new and existing tests passed

Description

Rendering a page with 19 nested tables ends the process with a stack overflow, which cannot be caught. A server rendering HTML it does not control (thumbnails, email previews) goes down with it; on Windows thread-pool threads have the same 1.5 MB of stack as the main thread.

var device = new DefaultRenderDevice { ViewPortWidth = 800, ViewPortHeight = 600 };
var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(device));
var html = String.Concat(Enumerable.Repeat("<table><tr><td>", 19)) + "x";
var document = await context.OpenAsync(r => r.Content(html));
new HtmlRenderer().RenderToPng(document, device); // Stack overflow.

Deepest nesting that renders (devel 48f8f38, Windows main thread with 1.5 MB); the parser keeps up to 511 levels:

div span flex grid table
levels 44 156 28 33 18

Every level goes through LayoutNode -> LayoutStickyAwareElement -> LayoutElement, about 21 KB of stack per div (the first JIT tier gives LayoutElement a 25 KB frame). The limit went down with every release; 0.7.0 rendered 158 divs, 101 flex containers and 300 tables.

The layout now checks RuntimeHelpers.TryEnsureSufficientExecutionStack() before each element, and in the recursive measuring helpers (intrinsic widths, the inline collector, margin collapsing), and leaves the rest of the subtree out when the stack runs low. Pages within the limits render as before. No new fields, options or public API.

DeepNestingTests renders the deepest parsable nesting (511 levels) of div, span, flex, grid, inline-block, float and table on a 1 MB thread; on devel the test host crashes.

Nested tables also take time doubling with every level (19 render in 13 s with this PR), which #54 addresses.

Benchmarks (AngleSharp.Renderer.Benchmarks, devel 5a52786 vs this PR, P-cores, 3 alternating rounds, median):

devel this PR
BuildDisplayList, net10.0 209.9 ms, 58.34 MB 212.0 ms, 58.35 MB
RenderToPng, net10.0 2326 ms, 64.34 MB 2297 ms, 64.34 MB
BuildDisplayList, net8.0 192.5 ms, 58.81 MB 181.5 ms, 58.81 MB
RenderToPng, net8.0 2359 ms, 64.82 MB 2261 ms, 64.82 MB

Allocation is unchanged; the time differences are within the spread of a single run (StdDev up to 15% of the mean for BuildDisplayList).

Layout, intrinsic sizing, inline collection and margin collapsing recurse per
nesting level with large frames, so 45 nested divs (18 tables, 28 flex
containers) overflowed a 1.5 MB stack and ended the process. Each of these
walks now checks RuntimeHelpers.TryEnsureSufficientExecutionStack and leaves
the rest of the subtree out instead.
Without the stack guard the test host crashes with a stack overflow.
@CLAassistant

CLAassistant commented Nov 10, 2026 •

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@FlorianRappl FlorianRappl added this to the v0.11.2 milestone Nov 11, 2026

@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.

LGTM!

@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.

The tests seem to never complete.

Each of their levels lays out the levels below it twice, so up to the
depth the stack allows the test never completed in CI. They come back
once the nested trial layouts are reused (AngleSharp#54).
@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Thanks! The inline-block and table cases at 511 levels need #54: without it, each level is laid out twice as often until the guard stops it, which never completes in CI. I removed both cases from this PR; they come back once #54 is in.

@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.

Great - the tests now complete, however, the MacOS test is failing. I guess that might be similar to the initial failure in #54?

…again

With the nested trial layouts reused (AngleSharp#54), they render in well under a
second.
@michaelthyregod

Copy link
Copy Markdown
Contributor Author

Thanks! With #54 merged, the nested table and inline-block cases are back in DeepNestingTests (devel merged in). The last macOS failure was HarnessDoesNotMultiplyPaintCost, which compares wall-clock times (interactive 419 ms against a limit of 384 ms); the stack guard runs the same in both modes, and the other jobs passed.

@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.

LGTM!

@FlorianRappl
FlorianRappl merged commit 7b9b16c into AngleSharp:devel Nov 12, 2026
7 checks passed
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.

3 participants