Skip to content

Release 1.3.0 - #43

Merged
FlorianRappl merged 16 commits into
mainfrom
devel
Nov 2, 2026
Merged

FlorianRappl merged 16 commits into
mainfrom
devel

Conversation

@FlorianRappl

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

This release introduces version 1.3.0 of AngleSharp.Xml, focusing on improved performance, security, and compatibility. The most significant updates include raising the minimum AngleSharp dependency to 1.9, adding a hard entity expansion limit to prevent resource exhaustion, implementing explicit-stack algorithms to avoid stack overflows, and introducing comprehensive benchmarks to track and enforce performance improvements. Additionally, the CI pipeline is updated for trusted NuGet publishing and AOT compatibility is enforced.

FlorianRappl and others added 8 commits October 31, 2026 12:42
- Require AngleSharp 1.9.0 (csproj default and CI)
- Mark the net8.0 and net10.0 targets IsAotCompatible, so the trim and AOT
  analyzers run on every build
- Walk trees with an explicit stack in XmlElement.TextContent and the DTD,
  internal-subset and identity validation walks; deeply nested documents no
  longer overflow the call stack and end the process
- Speed up TextContent (no allocation for a single text or CDATA child) and
  DTD validation (declarations looked up by name, content models parsed once
  per document, identity walk skipped without ID declarations)
- Cap the characters produced by expanding declared entities with the new
  XmlParserOptions.MaxCharactersFromEntities (default 10,000,000, 0 for no
  limit); beyond it parsing throws EntityExpansionLimitExceeded, or keeps the
  references as text when errors are suppressed
- Add tests for deep nesting, content models, identity, TextContent and
  entity expansion
- Add a BenchmarkDotNet project with Compare-Builds.ps1 to compare builds
- Update AGENTS.md, CHANGELOG.md and the API and DTD docs
ValidateElementsAgainstDtd only runs when DtdParser produced element
declarations, and DtdParser currently fails on the first token of every
subset, so the lookup by name never runs. The measured gains come from
parsing content models once and skipping the identity walk.
Adopt AngleSharp 1.9, deep-nesting hardening and an entity expansion cap
… text

The fallback expansion replaced <!\[CDATA\[(.*?)\]\]> in the whole text of a
character token whenever the document declared any entity. That stripped
the markers from escaped text such as &lt;![CDATA[b]]&gt; (giving 'b'), and
took quadratic time on many unterminated starts: 240 KB of &lt;![CDATA[
took 33 s.

The CDATA sections of each entity's replacement text are now unwrapped
once, in a single pass, when the entity is registered, and that text is
used where an entity contributes to element content (tokenizer and
nested expansion). Attribute values keep the replacement text as before.

A 60,000-result differential fuzz against the previous code differs only
for escaped CDATA-looking text (the fix), CDATA sections split across
entities, recursive entities, and CDATA entities whose content references
another CDATA entity, where the previous code left CDATA markers behind.
Unwrap CDATA sections in entity replacement text, not in the expanded text
XmlParserOptions.MaximumDepth mirrors core's HtmlParserOptions.MaximumDepth:
the deepest level the parser nests elements at, counting the root as
level 1, defaulting to 512; zero or a negative value restores the default
and Int32.MaxValue lifts the limit. An element beyond it throws
MaximumDepthExceeded (0x314), since nesting it elsewhere changes an XML
document; with IsSuppressingErrors it is attached at the limit as a
sibling of the deepest element, as the HTML parser does.

The deep-nesting tests and the deep benchmark shapes go beyond 512 levels
to exercise the explicit-stack walks, so they lift the limit.
@FlorianRappl FlorianRappl added this to the v1.3.0 milestone Nov 1, 2026
michaelthyregod and others added 4 commits November 2, 2026 06:48
Limit how deep the parser nests elements, as the HTML parser does
The external subset a DOCTYPE names was read with File.ReadAllText from
any path the document chose, with default options, including UNC paths
(\\server\share), which connect to the server while parsing.

It is now fetched like the document's other resources: through the
browsing context's IResourceLoader, which only exists when resource
loading is enabled, and its requesters, which decide the protocols
(file: through e.g. AngleSharp.Io's requester). Relative SYSTEM
identifiers resolve against the document URL. Without a loader nothing
is loaded. LoadExternalSubsetAsync is the one loading path: the
asynchronous parse awaits it, the synchronous parse waits for it.

DtdParser no longer reads external entities from disk; it fails on the
first token of every subset today, so this path did not run, but it would
have read any file a document named once it did.

Tests use a mock requester registered as the only requester, so no
request reaches the network. The benchmark's External rows load their
DTD through a file: requester and check that it is applied.
The External rows name the plain path again, which older builds read
directly; as a URL its drive letter is the protocol, so the benchmark's
requester serves single-letter protocols from the disk, without Uri.
michaelthyregod and others added 4 commits November 2, 2026 09:44
Write-Summary stripped everything but digits and dots from the Mean and
Allocated cells and read them as ns and B. BenchmarkDotNet writes the
unit into each cell and picks it per row (952.654 us, 1,280.41 KB), so
such rows were off by 1000x or 1024x, and a ratio was wrong whenever two
builds of a case landed in different units.

Each cell is now parsed as a value and a unit (ns, us with the Greek mu,
the micro sign or u, ms, s; B, KB, MB, GB) and converted; NA (a failed
benchmark) drops the row, - counts as no allocation, and an unknown unit
throws. Reports are only read from this run's round-* folders, as a
report left in a baseline source tree would otherwise count as a build.

On the reports of an earlier three-way run the summary is unchanged; with
one build's cells rewritten into us/ms and KB/MB it matches up to the
rounding of the rewritten values, where the previous parsing got 20 of 24
rows wrong.
Read the units BenchmarkDotNet writes into the CSV cells
Loading the external subset no longer interrupts the parse. The download
starts at the DOCTYPE and the parse goes on; the tokenizer keeps every
reference to a non-built-in entity as written and records its offset
(XmlEntityReference), and the builder records where it lands (text node
or attribute value). When the tree is built, CompleteAsync waits for the
download (the synchronous parse blocks there), builds the DTD from the
external and internal subsets, replaces the recorded references by their
offsets, and validates. Both parse paths complete the same way.

The replacement uses the recorded offsets, never a search of the text.
That fixes escaped references: &amp;e; read as &e; and was expanded as
well when e was declared. An empty entity now resolves to nothing; strict
parsing threw on it before. An unknown entity still throws the same error
at the same position, only at the end of the parse. OnCreated sees
attribute values with their references as written.

A 60,000-result differential fuzz against the previous #41 code differs
only for escaped references to declared entities and for empty entities.
Load the external DTD subset through the resource loader and requesters
@michaelthyregod

Copy link
Copy Markdown
Contributor

Before you merge this: I'm running the benchmarks for the 1.3.0 changes tonight (Danish time). Also, AngleSharp 1.9.1 is out with AngleSharp/AngleSharp#1401 (AppendChild into deep trees), so I'd raise the minimum AngleSharp to 1.9.1 for this release: csproj default, CI and AGENTS.md, plus a CHANGELOG line. That's a small PR I can open shortly. Would you like to wait for both, or release 1.3.0 now and take them in the next version?

@FlorianRappl

Copy link
Copy Markdown
Contributor Author

Sure - let's bring those into 1.3.0

@michaelthyregod

michaelthyregod commented Nov 2, 2026 •

Copy link
Copy Markdown
Contributor

Benchmarks for 1.3.0 are done: devel (68d437c) against 17202f0 (devel after #38), both on AngleSharp 1.9.1, 3 alternating rounds pinned to the performance cores.

  • Parsing with validation is 6–22% faster in every case and allocates 20–24% less. For example, a 1000-item catalog without a DOCTYPE went from 2.87 ms to 2.33 ms, and from 4.9 MB to 3.9 MB allocated. The improvement holds for catalogs of 500, 1000 and 2000 items and for documents nested 10 to 2000 levels deep.
  • The allocation saving comes from building the attributes without LINQ (in Load the external DTD subset through the resource loader and requesters #41). The old Where(...).ToList() split allocated 240–328 B per element; the loop allocates nothing. That accounts for about 98% of the saving.
  • TextContent is unchanged (its code didn't change; the differences are within the noise).
  • The External rows go through the resource loader now, while the old build read the file directly, so those rows aren't strictly comparable. The new path is still about 10–20% faster.

From my side 1.3.0 is good to go.

@FlorianRappl

Copy link
Copy Markdown
Contributor Author

Awesome - let's ship it!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants