From 1b0e8519ad5863c5a0f85b8288643da770b17fe9 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:30:43 +0100 Subject: [PATCH 1/5] Leave out deep subtrees when the stack runs low 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. --- .../HtmlRenderer.Inline.cs | 10 +++++++++- src/AngleSharp.Renderer/HtmlRenderer.cs | 20 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs index 269e447..4f57e1c 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.Inline.cs @@ -2,6 +2,7 @@ namespace AngleSharp.Renderer; using System.Globalization; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using AngleSharp.Css.Dom; @@ -544,7 +545,8 @@ public void Collect(IRenderNode node, RenderTextStyle style, int styleId, Inline private void CollectElement(ElementRenderNode element, RenderTextStyle parentStyle, InlineBox? parentBox) { - if (!GeneratesBoxes(element)) + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!GeneratesBoxes(element) || !RuntimeHelpers.TryEnsureSufficientExecutionStack()) { return; } @@ -2998,6 +3000,12 @@ private static IntrinsicWidths ComputeElementContribution(ElementRenderNode elem /// private static IntrinsicWidths ComputeContentIntrinsicWidths(ElementRenderNode element, RenderTextStyle textStyle, float percentageBase, LayoutContext context) { + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + return new IntrinsicWidths(0f, 0f); + } + var widths = ComputeElementContentWidths(element, textStyle, percentageBase, context); if (s_layoutPass.Value is { } pass) diff --git a/src/AngleSharp.Renderer/HtmlRenderer.cs b/src/AngleSharp.Renderer/HtmlRenderer.cs index eae739f..45b4302 100644 --- a/src/AngleSharp.Renderer/HtmlRenderer.cs +++ b/src/AngleSharp.Renderer/HtmlRenderer.cs @@ -1237,6 +1237,13 @@ private static void LayoutNode( LayoutTextNode(textNode.Ref, containingX, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, textStyle, context, displayList, maxY); return; case ElementRenderNode element: + // Every nesting level takes a large stack frame; when the stack runs low the rest of + // the subtree is left out instead of ending the process with a stack overflow. + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + return; + } + LayoutStickyAwareElement(element, containingX, containingY, containingWidth, ref cursorY, ref previousBlockMarginBottom, ref suppressNextBlockTopMargin, floats, ref textIndentConsumed, textStyle, context, displayList, maxY, isFlexItem, isRowDirection, flexMainSize, flexCrossSize, containingHeight, positionedContainingX, positionedContainingY, positionedContainingWidth, positionedContainingHeight, forcedBorderBoxWidth, isAtomicInline, forcedBorderBoxHeight, contentOffsetY, mainAxisMarginsPlaced, crossAxisMarginsPlaced); return; default: @@ -8304,6 +8311,13 @@ private static List GetAuthoredRules(IDocument document, string pr private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, float containingWidth, LayoutContext context, out float marginTop) { + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + marginTop = 0f; + return false; + } + var throughPositive = 0f; var throughNegative = 0f; var hasThrough = false; @@ -8386,6 +8400,12 @@ private static bool TryGetFirstCollapsibleChildTopMargin(ElementRenderNode node, /// private static bool IsSelfCollapsing(ElementRenderNode element, LayoutContext context) { + // a deep subtree is left out once the stack runs low (see LayoutNode) + if (!RuntimeHelpers.TryEnsureSufficientExecutionStack()) + { + return false; + } + var styleMap = GetOrCreateStyleMap(context, element.Ref, element.ComputedStyle); if (!IsFlowBlockDisplay(styleMap) || IsReplacedElement(element.Ref) || ResolveFormControlKind(element) != FormControlKind.None) From 75afd46eb47da9f248c2bb8b8db675896855e995 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 04:37:15 +0100 Subject: [PATCH 2/5] Test rendering the deepest parsable nesting on a 1 MB stack Without the stack guard the test host crashes with a stack overflow. --- .../DeepNestingTests.cs | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 src/AngleSharp.Renderer.Tests/DeepNestingTests.cs diff --git a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs new file mode 100644 index 0000000..93cc321 --- /dev/null +++ b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs @@ -0,0 +1,70 @@ +namespace AngleSharp.Renderer.Tests; + +using System.Text; +using System.Threading; +using AngleSharp; +using AngleSharp.Css; + +/// +/// Deeply nested documents: layout recurses per nesting level, so a deep enough tree must leave +/// its innermost content out rather than overflow the stack (which ends the whole process). +/// +public sealed class DeepNestingTests +{ + private static readonly DefaultRenderDevice Device = new() { ViewPortWidth = 300, ViewPortHeight = 200, FontSize = 16 }; + + // The HTML parser keeps at most 511 levels below the body, so this is the deepest markup can get. + private const int ParserDepthLimit = 511; + + // A thread-pool thread or a desktop app's thread commonly has 1 MB of stack. + private const int StackSize = 1024 * 1024; + + [Theory] + [InlineData("
", "
")] + [InlineData("x", "")] + [InlineData("
", "
")] + [InlineData("
", "
")] + [InlineData("
", "
")] + [InlineData("
", "
")] + [InlineData("
", "
")] + public async Task DeepestParsableNesting_RendersWithoutOverflowingTheStack(string open, string close) + { + var markup = new StringBuilder(""); + + for (var i = 0; i < ParserDepthLimit; i++) + { + markup.Append(open); + } + + markup.Append("leaf"); + + for (var i = 0; i < ParserDepthLimit; i++) + { + markup.Append(close); + } + + markup.Append(""); + var context = BrowsingContext.New(Configuration.Default.WithCss().WithRenderDevice(Device)); + var document = await context.OpenAsync(request => request.Content(markup.ToString())); + byte[]? png = null; + Exception? error = null; + var thread = new Thread(() => + { + try + { + png = new HtmlRenderer().RenderToPng(document, Device).Data; + } + catch (Exception ex) + { + error = ex; + } + }, StackSize); + + thread.Start(); + thread.Join(); + + Assert.Null(error); + Assert.NotNull(png); + Assert.NotEmpty(png); + } +} From 31e9c81b615e4d7239cc32384641f0f69ee46431 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Tue, 10 Nov 2026 18:00:30 +0100 Subject: [PATCH 3/5] Add the CHANGELOG entry for #55 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eb7fc8f..20fd319 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Released on Wednesday, November 11 2026. - Fixed adjacent text nodes (e.g. inserted by a script or a framework like Blazor) each starting a line of their own in a block, table cell or flex item: they are laid out as one text, like the single node the parser makes of the same text - Fixed the intrinsic width of a row flex container counting each of adjacent text nodes as an item of its own (with a gap between them) +- Improved deeply nested pages: what is nested too deep for the stack is left out instead of ending the process with a stack overflow (#55) @michaelthyregod # 0.11.1 From 0d0d459ce3fe0e80175017af1f91d68d3b1ef2c7 Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Thu, 12 Nov 2026 04:40:47 +0100 Subject: [PATCH 4/5] Leave nested tables and inline-blocks out of the deep nesting test 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 (#54). --- src/AngleSharp.Renderer.Tests/DeepNestingTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs index 93cc321..5843949 100644 --- a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs +++ b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs @@ -19,14 +19,14 @@ public sealed class DeepNestingTests // A thread-pool thread or a desktop app's thread commonly has 1 MB of stack. private const int StackSize = 1024 * 1024; + // Nested tables and inline-blocks are not here yet: each level lays out the levels below it + // twice (a trial and the real layout), so up to the depth the stack allows that takes hours. [Theory] [InlineData("
", "
")] [InlineData("x", "")] [InlineData("
", "
")] [InlineData("
", "
")] - [InlineData("
", "
")] [InlineData("
", "
")] - [InlineData("
", "
")] public async Task DeepestParsableNesting_RendersWithoutOverflowingTheStack(string open, string close) { var markup = new StringBuilder(""); From bc0a1021c42fa3310112320dad10f7010d37a73c Mon Sep 17 00:00:00 2001 From: Michael Thyregod <2386496+michaelthyregod@users.noreply.github.com> Date: Thu, 12 Nov 2026 09:33:17 +0100 Subject: [PATCH 5/5] Test nested tables and inline-blocks at the deepest parsable nesting again With the nested trial layouts reused (#54), they render in well under a second. --- src/AngleSharp.Renderer.Tests/DeepNestingTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs index 5843949..93cc321 100644 --- a/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs +++ b/src/AngleSharp.Renderer.Tests/DeepNestingTests.cs @@ -19,14 +19,14 @@ public sealed class DeepNestingTests // A thread-pool thread or a desktop app's thread commonly has 1 MB of stack. private const int StackSize = 1024 * 1024; - // Nested tables and inline-blocks are not here yet: each level lays out the levels below it - // twice (a trial and the real layout), so up to the depth the stack allows that takes hours. [Theory] [InlineData("
", "
")] [InlineData("x", "")] [InlineData("
", "
")] [InlineData("
", "
")] + [InlineData("
", "
")] [InlineData("
", "
")] + [InlineData("
", "
")] public async Task DeepestParsableNesting_RendersWithoutOverflowingTheStack(string open, string close) { var markup = new StringBuilder("");