diff --git a/CHANGELOG.md b/CHANGELOG.md
index 47795402..b0d0f420 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,92 @@ Notable changes, newest first. Versions follow [semantic
versioning](https://semver.org): from 1.0.0 the exported API is stable, and a
breaking change means 2.0 with a new module path. See *Stability* below.
+## Unreleased
+
+A memory audit across parsing, XPath/XQuery, XSLT, validation and
+serialization. Output is byte-identical except where *Changed* says otherwise.
+Three new exported functions make this a minor release.
+
+### Added
+
+| Change | What it does | Commit |
+|---|---|---|
+| `xpath.SerializeJSONTo`, `xpath.SerializeAdaptiveTo` | The json and adaptive methods written to an `io.Writer` instead of returned as a string. | [`599b2f9`][599b2f9] |
+| `xdm.Node.AddNamespaces` | Sets several namespace bindings on an element in one call. | [`b42f04a`][b42f04a] |
+| `xdm.Node.Index`, `Node.SubtreeEnd`, `Tree.Len`, `Tree.NodeAt` | A node's dense, document-order position in its tree (attributes included, namespace nodes -1), stable once the node exists; size a table with `SubtreeEnd() - Index()` and look nodes up by position. | [`b37796b`][b37796b] |
+| `xdm.Node.InScopeNamespaceBindings` | Iterates an element's in-scope prefixes and URIs without making a namespace node for each. | [`73249e2`][73249e2] |
+| `xdm.ArrayItem.AppendMembers` | Appends an array's members to a slice without an intermediate copy. | [`59ac569`][59ac569] |
+| `xpath.CompileBounded`, `xpath.MaxRegexpProgram` | `regexp.Compile` that refuses a program over 100,000 instructions; used by XPath and XSD patterns. | [`c1c2bb9`][c1c2bb9] |
+| `xpath.Env.MaxDigits`, `xpath.MaxDigits` | Bounds the digits of an `xs:integer` or `xs:decimal` cast or arithmetic result; 0 is 10^6, negative is none. | [`8577906`][8577906] |
+| `xdm.AppendIntegers`, `xpath.Context.WithVars` | Appends a run of integers allocated a chunk at a time; binds several variables in one scope. | [`30c7c07`][30c7c07], [`5951f24`][5951f24] |
+| `xpath.Context.NodesRefused`, `ItemsRefused` | Whether the node budget was crossed, and whether a running item total is past the item bound, without charging either. | [`4e8f0a5`][4e8f0a5], [`21995a6`][21995a6] |
+
+### Changed
+
+| Change | What to do | Commit |
+|---|---|---|
+| `xsd` and `dtd` error messages clip document-supplied strings to 1 KB plus "..." | Nothing, unless you match messages longer than 1 KB. Message wording is not part of the contract. | [`20958aa`][20958aa], [`bd865e4`][bd865e4] |
+| json and adaptive serialization can leave partial output when an error is raised mid-document | Treat the writer's content as invalid on error, as the XML methods already required. | [`599b2f9`][599b2f9] |
+| New refusals: maps and arrays nested past 1,000 levels in json/adaptive output (XPDY0130), `deep-equal` and `map:find` (XPDY0001), with their own counter, apart from function recursion; regexes over 100,000 instructions (FORX0002, or a pattern error in XSD); picture widths over 65,536 (FOFD1340); `xsl:key` and `group-by` keys past 5,000,000 retained (XPDY0130); integers over 10^6 digits (FOCA0003, FOAR0002); sequences built by `,` or `!`, `xsl:next-iteration` values, `unparsed-text-lines` lines, `parse-json`/`json-doc` values and stored accumulator items past `MaxItems` (XPDY0130); `xsl:copy-of` of a document past `MaxNodes` (XPDY0130) | Nothing for real inputs: the largest W3C suite regex is 4,313 instructions. All wrap `xdm.ErrResourceLimit`. | [`702c6e6`][702c6e6], [`014a862`][014a862], [`c1c2bb9`][c1c2bb9], [`21995a6`][21995a6], [`8577906`][8577906], [`dcb49b2`][dcb49b2], [`c73a06b`][c73a06b], [`a3d8ef6`][a3d8ef6], [`40cd52c`][40cd52c], [`b35ec14`][b35ec14] |
+| The CLI drops its collector from `GOGC=200` to 100 once the live heap passes 128 MB | Nothing; set `GOGC` to keep a fixed value. Peak RSS −20% on DocBook's largest table, about 12% more user CPU. | [`f3449f7`][f3449f7] |
+| The `FileResolver` no longer evicts to admit a document that does not fit beside its cache | A document refused admission is parsed again on its next request, so across transforms it is not the same tree; within one transform it is. | [`f49ff92`][f49ff92] |
+| `xdm.ProcessXInclude` returns a parsed tree unchanged when it has no XInclude element | Nothing; a parsed tree is frozen. A built tree is still copied. | [`e139525`][e139525], [`9c3a983`][9c3a983] |
+
+### Fixed
+
+| Change | Problem → solution | Commit |
+|---|---|---|
+| `xsd` error messages kept attacker strings whole | A 1 MB namespace in 200 attributes kept 105 MB. `fail` and `attrName` clip to 1 KB: 0.13 MB. | [`20958aa`][20958aa] |
+| The DTD validator built every element's path, quadratic in depth | A valid 990-deep chain of 4 KB names allocated 2 GB. The path is rendered only for a failure, text unchanged. | [`8dcb07f`][8dcb07f] |
+| DTD validity messages kept `#FIXED`, enum and ID values whole | 200 mismatches on a 1 MB `#FIXED` value kept 105 MB. `fail` clips to 1 KB. | [`bd865e4`][bd865e4] |
+| The nested `fn:transform` cache kept compiles for dead resolvers | A fresh resolver per call kept 49 MB on DocBook. Resolvers are weak keys and dead entries are dropped: 0.3 MB. | [`a5b385d`][a5b385d] |
+| Sequences built by `,` or `!` were not counted against `MaxItems` | A 70-byte doubling `fold-left` built 134M items against the 5M limit, at 6.3 GB; through recursive `with-param` too. They are charged above 64 items. Also in v2.0.3. | [`dcb49b2`][dcb49b2] |
+| RELAX NG reported a valid document invalid | `(a?){13} a{13}` against 13 `` hit the derivative cap, naming a `oneOrMore` the schema lacks; `xmllint` accepts it. Choice chains merge alternative by alternative: N = 12, 13, 18, 30 validate. Also in v2.0.3. | [`44229aa`][44229aa] |
+| Serializing a sequence of atomic values was quadratic | `1 to 100000` took 21.6 s and 6.95 GB, joined by repeated concatenation. Each run is joined once: 0.056 s. Also in v2.0.3. | [`a9ad280`][a9ad280] |
+| Text lines, JSON values, DTD default values, integer digits and accumulator values grew past the limits | `unparsed-text-lines` kept 5.2 GB, 2 MB of DTD defaults 1.97 GB, a 4M-digit cast 34 GB, a 200 KB accumulator 12.4 GB. Each is charged, bounded, stored once or shared. Also in v2.0.3. | [`c73a06b`][c73a06b], [`be5afc3`][be5afc3], [`8577906`][8577906], [`40cd52c`][40cd52c] |
+| `xsl:copy-of` of a document was not counted against `MaxNodes` | It copied 5.7M nodes and exited 0, where shallow-copy is refused. It is charged before copying. | [`b35ec14`][b35ec14] |
+| Deeply nested maps and arrays crashed the process | `serialize(..., json)` or `deep-equal` on a 400,000-deep array from a 96-byte query overflowed the Go stack. They are refused with a resource-limit error; `xdm.Flatten` is iterative. Also in v2.0.3. | [`702c6e6`][702c6e6] |
+| A built-in `shallow-copy` rule returned a truncated result past `MaxNodes` with no error | A 1M-element identity copy emptied a third of its elements and exited 0. The transform now fails with XPDY0130 whenever the node budget was crossed. Also in v2.0.3. | [`4e8f0a5`][4e8f0a5] |
+| Sequential regex repeats compiled to huge programs | `\pL{1000}` repeated 150 times passes Go's 1,000-repeat check: 181 KB of patterns kept 984 MB. Programs over 100,000 instructions are refused in XPath and XSD. | [`c1c2bb9`][c1c2bb9] |
+| `fn:replace`, picture widths, `xsl:key` and `group-by` grew past their budgets | `replace` charged after building (12.8 GB for 500 KB in), `[D01,1000000000]` allocated 2.8 GB, and `use="1 to 1000"` over 100k nodes 3.2 GB. Each is charged or refused first. | [`c1c2bb9`][c1c2bb9], [`21995a6`][21995a6] |
+| Caches bounded by entries held gigabytes of long keys or documents | Regex, collation and char-atom keys over 1 KB are not cached; the `FileResolver` document cache is also bounded by 32 MB of source. | [`c1c2bb9`][c1c2bb9], [`de4a515`][de4a515] |
+
+### Changed — memory
+
+| Change | Problem → solution | Commit |
+|---|---|---|
+| Namespace URIs were tokeniser-arena strings | Each parsed tree kept a 32 KB arena block alive. URIs are copied at declaration: DocBook trees retain 31.6 → 12.3 MB. | [`18d374d`][18d374d] |
+| Every parse allocated a 32 KB string-arena block | The first block is sized to the input: DocBook corpus parse 37.6 → 19.6 MB a pass. | [`9e13f70`][9e13f70] |
+| `TrackPositions` kept the source text to count lines | Line starts are recorded as the text is read: a tracked 10 MB tree retains 50.4 → 41.5 MB. | [`ec1ac54`][ec1ac54] |
+| Parsed trees kept build-only indexes, slack and copied long text twice | Indexes dropped at freeze, the last text block trimmed, long text handed over: DocBook retains a further −10%. | [`2ad3a32`][2ad3a32], [`a5ab736`][a5ab736], [`4440665`][4440665] |
+| Each parse allocated three read windows | They are pooled: DocBook corpus parse 19.8 → 17.1 MB a pass. | [`6345114`][6345114] |
+| `isBase64Binary` copied each value into a `[]rune` | One byte pass: a 380 KB value 1.54 MB → 0 allocations. | [`5d8fceb`][5d8fceb] |
+| DTD content matching, ID bookkeeping and bound facets allocated per child or value | Two reused state sets, one ID map, bounds parsed once: `dtd` validation −47%, `xsd` instance −24%. | [`e056467`][e056467], [`f09c1a3`][f09c1a3], [`14c84b5`][14c84b5] |
+| XQuery FLWOR tuples copied names, rebuilt values and grew results by doubling | Shared names, own value, reserved results, version set once, no per-call library: XMark q1–q20 eval 194.9 → 119.0 MB. | [`ba9e7a0`][ba9e7a0], [`f890740`][f890740], [`a8d6e7b`][a8d6e7b], [`24b963c`][24b963c], [`73af7a8`][73af7a8], [`f148247`][f148247] |
+| `a to b` and `fn:distinct-values` used big arithmetic and eager promotion keys | int64 ranges, keys written once a double arrives: `distinct-values(1 to 100000)` 96.0 → 53.2 MB. | [`70ff980`][70ff980], [`ef5f064`][ef5f064] |
+| Compiled stylesheets held ~980 copies of one namespace map and pinned module trees | Interned by content; instructions keep the map, not the element: compiled CEN 9.1 → 6.1 MB, DocBook 12.4 → 9.3 MB. | [`490fc5a`][490fc5a], [`dcb15d1`][dcb15d1] |
+| XSLT calls built parameter maps, memo keys, namespace frames and error text per call | A slice of pairs, a reused key buffer, one-step frames, lazy text, shared global cells: DocBook section.001 −15%. | [`e3b6288`][e3b6288], [`b5aaa52`][b5aaa52], [`b42f04a`][b42f04a], [`b6d4e4e`][b6d4e4e], [`327b09e`][327b09e] |
+| The json, adaptive and text methods built the whole output as a string | They stream to the writer: xmark json 152.7 MB → 12.6 KB, text 41.7 MB → 33 KB. Normalized output keeps the string. | [`599b2f9`][599b2f9], [`cf65d62`][cf65d62] |
+| CDATA sections, character maps and `fn:serialize` escaping copied per node | Written straight to the sink: xmark CDATA 20.4 MB → 34 KB, character map 16.5 MB → 34 KB. | [`cf65d62`][cf65d62], [`599b2f9`][599b2f9] |
+| c14n `Bytes` and `Result.String` doubled a buffer from empty; `Serialize` allocated 32 KB a call | Output chunks are joined once and the write buffer is pooled: c14n 3.7× → 2.1× the output, a small result 33.6 → 0.7 KB. | [`f235795`][f235795], [`9496810`][9496810], [`37206b1`][37206b1], [`c501fc4`][c501fc4], [`1f18b1b`][1f18b1b] |
+| The `xslt` benchmarks read inputs no checkout has, so they always skipped | They run XRechnung and Peppol CEN from `testdata/bench`. | [`beb6072`][beb6072] |
+| `-xinclude` copied every tree, and included documents twice, even with nothing to include | A parsed tree with no XInclude element is returned as is and inclusions are built into the destination: `parse-100mb` 514 → 0 MB. | [`e139525`][e139525] |
+| RELAX NG copied its name memo on every store | Stores land in a pending map published as a snapshot, and after 8 locked hits: 1,024 stores 47.5 → under 2 MB, lookups lock-free. | [`9f0db9c`][9f0db9c], [`676ad52`][676ad52] |
+| Integer `mod`, `idiv`, negation and comparison used big arithmetic; ranges made 3 allocations an integer | int64 fast paths and chunked ranges: comparison 2 → 0 allocations, `sum(1 to 1e6)` 800k → 4.8k allocations. | [`40085f6`][40085f6], [`30c7c07`][30c7c07] |
+| Dynamic calls, FLWOR tuples and window clauses made a scope per variable; closures kept their whole scope | One scope per binding set, and a closure keeps only what its body reads: `$f($i)` over 1M 2,673 → 1,617 MB. | [`5951f24`][5951f24], [`a4f316d`][a4f316d], [`b5aaf06`][b5aaf06] |
+| The function result memo kept each key string | It keeps a 128-bit SHA-256 hash: DocBook `table-cals.049` live heap 359 → 172 MB, peak 1,150 → 533 MB. | [`15273c7`][15273c7] |
+| `fn:doc` read the file whole and copied it; `replace` copied and over-charged a non-matching input | `fn:doc` parses as it reads (16 MB file 82 → 29 MB); `replace` returns early with no match (64 MB: 256 → 0 MB). | [`a6f2eaa`][a6f2eaa], [`6bab64a`][6bab64a] |
+| Every c14n call allocated an 80 KiB buffer, and `Bytes` grew its result by doubling | The buffer is pooled (101 `Digest` calls on a tiny document 8.4 MB → 121 KB); `Bytes` and package-made node sets write into a buffer sized from the tree (about 1.1× the output, from 2.1–3.6×). | [`127b2f2`][127b2f2], [`5d6454a`][5d6454a] |
+| A tag with many unique attribute names grew its slices, name table and duplicate map step by step | They are sized in one step past 256 attributes: 10 MiB of unique attributes 99.5× → 37.6× allocated. Small documents are unchanged. | [`22d32ac`][22d32ac] |
+| Each namespace node rebuilt its element's whole scope to find its place; accumulators kept two maps per node | The slot is passed in (c14n over an XPath node set at 1 MiB 356 → 299 MB); accumulator values sit in slices indexed by `Node.Index` (about 27% less per node). | [`73249e2`][73249e2], [`b331aa1`][b331aa1] |
+| A predicate step heap-allocated its comparison, and an ancestor step grew its result one node at a time | Both are cut: `ancestor-or-self::*[@Id='x']` per node 384 B / 9.75 allocations → 304 B / 7. | [`567aa1a`][567aa1a] |
+| `xsl:sort`, `xsl:iterate`, `xsl:result-document`, built-in shallow-copy, namespace scopes and `on-empty` allocated per item | Smaller sort values in one array, parameter slices, set lookups, no focus copy without rules, frame walks, a lazy leaf walk. `result-document` is linear. | [`6ea432f`][6ea432f], [`a3d8ef6`][a3d8ef6], [`951f0d9`][951f0d9], [`f650a19`][f650a19], [`4b0b564`][4b0b564], [`469c95e`][469c95e] |
+| DTD defaults were scanned per element; XSD key scopes rebuilt their entries per level | Defaults indexed by element (188 → 5 MB); scopes extend in place (960 levels 84 → 1 MB). | [`6293e98`][6293e98], [`996b7db`][996b7db] |
+| Large regex programs were cached; `parse-json` objects and `serialize` refusals cost per key and item | Programs over 4,096 instructions are not cached; objects use `MapBuilder` (about 3.3× less); `serialize` stops at the first refusal. | [`331bc96`][331bc96], [`98811f6`][98811f6], [`6317d49`][6317d49] |
+| DTD validation built a state-set map, a name slice and an attribute map per element | Small sets dedup by scan, the slice is reused, no ATTLIST skips the map: 12,062 → 8,062 allocations for 6,001 elements. | [`5dc120c`][5dc120c] |
+| `xsdnet.HTTPResolver` built a new transport per fetch | One guarded transport is shared, so connections are reused; the SSRF guard is unchanged. | [`da05b16`][da05b16] |
+| Typed copies pinned dropped schemas; `array:append` copied twice; line index, interning key and memo key kept slack | Weak typing-name slots, one copy, clipped index, reused key buffer, oversized memo buffer dropped. | [`967dfb3`][967dfb3], [`59ac569`][59ac569], [`3b71501`][3b71501], [`69414ae`][69414ae], [`887d5d3`][887d5d3] |
+
## v2.0.3 — 2026-10-30
An XPath cost fix from an external audit. No API change.
@@ -1625,3 +1711,94 @@ here so every entry in this file sits under a release.
[b76c85d]: https://github.com/knroy/go-xml/commit/b76c85d
[8b7aab2]: https://github.com/knroy/go-xml/commit/8b7aab2
[4264de8]: https://github.com/knroy/go-xml/commit/4264de8
+[599b2f9]: https://github.com/knroy/go-xml/commit/599b2f9
+[b42f04a]: https://github.com/knroy/go-xml/commit/b42f04a
+[20958aa]: https://github.com/knroy/go-xml/commit/20958aa
+[bd865e4]: https://github.com/knroy/go-xml/commit/bd865e4
+[8dcb07f]: https://github.com/knroy/go-xml/commit/8dcb07f
+[a5b385d]: https://github.com/knroy/go-xml/commit/a5b385d
+[18d374d]: https://github.com/knroy/go-xml/commit/18d374d
+[9e13f70]: https://github.com/knroy/go-xml/commit/9e13f70
+[ec1ac54]: https://github.com/knroy/go-xml/commit/ec1ac54
+[2ad3a32]: https://github.com/knroy/go-xml/commit/2ad3a32
+[a5ab736]: https://github.com/knroy/go-xml/commit/a5ab736
+[4440665]: https://github.com/knroy/go-xml/commit/4440665
+[6345114]: https://github.com/knroy/go-xml/commit/6345114
+[5d8fceb]: https://github.com/knroy/go-xml/commit/5d8fceb
+[e056467]: https://github.com/knroy/go-xml/commit/e056467
+[f09c1a3]: https://github.com/knroy/go-xml/commit/f09c1a3
+[14c84b5]: https://github.com/knroy/go-xml/commit/14c84b5
+[ba9e7a0]: https://github.com/knroy/go-xml/commit/ba9e7a0
+[f890740]: https://github.com/knroy/go-xml/commit/f890740
+[a8d6e7b]: https://github.com/knroy/go-xml/commit/a8d6e7b
+[24b963c]: https://github.com/knroy/go-xml/commit/24b963c
+[73af7a8]: https://github.com/knroy/go-xml/commit/73af7a8
+[f148247]: https://github.com/knroy/go-xml/commit/f148247
+[70ff980]: https://github.com/knroy/go-xml/commit/70ff980
+[ef5f064]: https://github.com/knroy/go-xml/commit/ef5f064
+[490fc5a]: https://github.com/knroy/go-xml/commit/490fc5a
+[dcb15d1]: https://github.com/knroy/go-xml/commit/dcb15d1
+[e3b6288]: https://github.com/knroy/go-xml/commit/e3b6288
+[b5aaa52]: https://github.com/knroy/go-xml/commit/b5aaa52
+[b6d4e4e]: https://github.com/knroy/go-xml/commit/b6d4e4e
+[327b09e]: https://github.com/knroy/go-xml/commit/327b09e
+[cf65d62]: https://github.com/knroy/go-xml/commit/cf65d62
+[f235795]: https://github.com/knroy/go-xml/commit/f235795
+[9496810]: https://github.com/knroy/go-xml/commit/9496810
+[37206b1]: https://github.com/knroy/go-xml/commit/37206b1
+[c501fc4]: https://github.com/knroy/go-xml/commit/c501fc4
+[1f18b1b]: https://github.com/knroy/go-xml/commit/1f18b1b
+[beb6072]: https://github.com/knroy/go-xml/commit/beb6072
+[59ac569]: https://github.com/knroy/go-xml/commit/59ac569
+[c1c2bb9]: https://github.com/knroy/go-xml/commit/c1c2bb9
+[4e8f0a5]: https://github.com/knroy/go-xml/commit/4e8f0a5
+[21995a6]: https://github.com/knroy/go-xml/commit/21995a6
+[702c6e6]: https://github.com/knroy/go-xml/commit/702c6e6
+[e139525]: https://github.com/knroy/go-xml/commit/e139525
+[9c3a983]: https://github.com/knroy/go-xml/commit/9c3a983
+[de4a515]: https://github.com/knroy/go-xml/commit/de4a515
+[9f0db9c]: https://github.com/knroy/go-xml/commit/9f0db9c
+[5dc120c]: https://github.com/knroy/go-xml/commit/5dc120c
+[da05b16]: https://github.com/knroy/go-xml/commit/da05b16
+[967dfb3]: https://github.com/knroy/go-xml/commit/967dfb3
+[3b71501]: https://github.com/knroy/go-xml/commit/3b71501
+[69414ae]: https://github.com/knroy/go-xml/commit/69414ae
+[887d5d3]: https://github.com/knroy/go-xml/commit/887d5d3
+[8577906]: https://github.com/knroy/go-xml/commit/8577906
+[30c7c07]: https://github.com/knroy/go-xml/commit/30c7c07
+[5951f24]: https://github.com/knroy/go-xml/commit/5951f24
+[014a862]: https://github.com/knroy/go-xml/commit/014a862
+[dcb49b2]: https://github.com/knroy/go-xml/commit/dcb49b2
+[c73a06b]: https://github.com/knroy/go-xml/commit/c73a06b
+[a3d8ef6]: https://github.com/knroy/go-xml/commit/a3d8ef6
+[40cd52c]: https://github.com/knroy/go-xml/commit/40cd52c
+[b35ec14]: https://github.com/knroy/go-xml/commit/b35ec14
+[f3449f7]: https://github.com/knroy/go-xml/commit/f3449f7
+[f49ff92]: https://github.com/knroy/go-xml/commit/f49ff92
+[44229aa]: https://github.com/knroy/go-xml/commit/44229aa
+[a9ad280]: https://github.com/knroy/go-xml/commit/a9ad280
+[be5afc3]: https://github.com/knroy/go-xml/commit/be5afc3
+[676ad52]: https://github.com/knroy/go-xml/commit/676ad52
+[40085f6]: https://github.com/knroy/go-xml/commit/40085f6
+[a4f316d]: https://github.com/knroy/go-xml/commit/a4f316d
+[b5aaf06]: https://github.com/knroy/go-xml/commit/b5aaf06
+[15273c7]: https://github.com/knroy/go-xml/commit/15273c7
+[a6f2eaa]: https://github.com/knroy/go-xml/commit/a6f2eaa
+[6bab64a]: https://github.com/knroy/go-xml/commit/6bab64a
+[6ea432f]: https://github.com/knroy/go-xml/commit/6ea432f
+[951f0d9]: https://github.com/knroy/go-xml/commit/951f0d9
+[f650a19]: https://github.com/knroy/go-xml/commit/f650a19
+[4b0b564]: https://github.com/knroy/go-xml/commit/4b0b564
+[469c95e]: https://github.com/knroy/go-xml/commit/469c95e
+[6293e98]: https://github.com/knroy/go-xml/commit/6293e98
+[996b7db]: https://github.com/knroy/go-xml/commit/996b7db
+[331bc96]: https://github.com/knroy/go-xml/commit/331bc96
+[98811f6]: https://github.com/knroy/go-xml/commit/98811f6
+[6317d49]: https://github.com/knroy/go-xml/commit/6317d49
+[b37796b]: https://github.com/knroy/go-xml/commit/b37796b
+[73249e2]: https://github.com/knroy/go-xml/commit/73249e2
+[127b2f2]: https://github.com/knroy/go-xml/commit/127b2f2
+[5d6454a]: https://github.com/knroy/go-xml/commit/5d6454a
+[22d32ac]: https://github.com/knroy/go-xml/commit/22d32ac
+[b331aa1]: https://github.com/knroy/go-xml/commit/b331aa1
+[567aa1a]: https://github.com/knroy/go-xml/commit/567aa1a
diff --git a/README.md b/README.md
index 7a2b6837..6e6d2da2 100644
--- a/README.md
+++ b/README.md
@@ -139,7 +139,7 @@ and maintains it as a project of his own.
| **XSD 1.1** | 99.98% instance (26,217 of 26,222); **99.97%** schema-validity (15,350 of 15,354); opt-in via `Version11` |
| **RELAX NG** | 100.00% of James Clark's spectest (965 of 965 assertions); XML and compact syntax, from Go and from `go-xml validate -rng` |
| **DTD** | content models, attribute defaults, enumerations, `ID`/`IDREF`; external subset, parameter entities across both subsets, conditional sections — via `dtd.Load` with a caller-supplied resolver, nothing fetched by default |
-| **Tests** | 2,854 `func Test` declarations, clean under `-race` (a few subtests skip without the corpora below) |
+| **Tests** | 2,950 `func Test` declarations, clean under `-race` (a few subtests skip without the corpora below) |
| **Production schemas** | UBL 2.1, UN/CEFACT CII, Factur-X/ZUGFeRD, Peppol BIS 3.0 — 88 schemas load, instances validate clean |
| **API** | 1.2; the exported surface is stable and additive over 1.1, and a breaking change means 2.0 with a new module path |
@@ -669,7 +669,7 @@ exact signal.
`FileResolver`'s document cache exists because `fn:doc` is defined to return
the *same* node for the same URI within one execution, so `doc('x') is doc('x')`
-requires it. It is mutex-guarded, and now capped at 256 documents. It had been
+requires it. It is mutex-guarded, and now capped at 256 documents and 32 MB of source. It had been
unbounded on the reasoning that the directories the caller opened with
`-allow-dir` bound it in practice — true for the size of the *set*, but the
process holds every document it has ever parsed for its whole lifetime, and a
@@ -1388,7 +1388,7 @@ back, is in [docs/testing.md](docs/testing.md).
| method | what it catches | what it misses |
|---|---|---|
-| **Unit tests** (2,854 `func Test` declarations) | places where a plausible implementation is quietly wrong | anything nobody thought to write a test for |
+| **Unit tests** (2,950 `func Test` declarations) | places where a plausible implementation is quietly wrong | anything nobody thought to write a test for |
| **Spec inventories** | features absent entirely | features present but behaving wrongly |
| **Saxon differential** | subtle behavioural divergence on real stylesheets | constructs the corpora do not use |
| **W3C QT3 suite** | systematic conformance across 22,054 XPath and 30,517 XQuery cases | XSLT (it is an XPath suite) |
diff --git a/c14n/bytes_alloc_test.go b/c14n/bytes_alloc_test.go
new file mode 100644
index 00000000..55167720
--- /dev/null
+++ b/c14n/bytes_alloc_test.go
@@ -0,0 +1,47 @@
+package c14n
+
+import (
+ "runtime"
+ "testing"
+)
+
+// Bytes and BytesNodeSet grew a bytes.Buffer by doubling from empty, 3.7
+// times the canonical form in all, then kept the writer's chunks and joined
+// them, 2.1 times, and Equal paid it for its first operand. They now size
+// the result from the node set and write into it once, about 1.1 times.
+func TestBytesAllocation(t *testing.T) {
+ // 3 MB: doubling cost 3.1x the output here and 3.7x at 10 MB.
+ root := cachedDoc(t, 3<<20).FirstChild()
+ out, err := Bytes(root, Options{Algorithm: Inclusive10})
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tc := range []struct {
+ name string
+ f func()
+ }{
+ {"Bytes", func() { _, _ = Bytes(root, Options{Algorithm: Inclusive10}) }},
+ {"BytesNodeSet", func() { _, _ = BytesNodeSet(Subtree(root), Options{Algorithm: Inclusive10}) }},
+ {"Equal", func() { _, _ = Equal(root, root, Options{Algorithm: Inclusive10}) }},
+ } {
+ got := heapAllocated(tc.f)
+ t.Logf("%s: %d bytes allocated for %d bytes of output (%.2fx)", tc.name, got, len(out), float64(got)/float64(len(out)))
+ if got > uint64(len(out))*7/5 {
+ t.Errorf("%s allocated %d bytes for %d bytes of output, want under 1.4x", tc.name, got, len(out))
+ }
+ }
+}
+
+// heapAllocated is the heap f allocates, the least of three runs so that a
+// collection landing inside one does not count against it.
+func heapAllocated(f func()) uint64 {
+ best := ^uint64(0)
+ for range 3 {
+ var a, b runtime.MemStats
+ runtime.ReadMemStats(&a)
+ f()
+ runtime.ReadMemStats(&b)
+ best = min(best, b.TotalAlloc-a.TotalAlloc)
+ }
+ return best
+}
diff --git a/c14n/c14n.go b/c14n/c14n.go
index 8a7a3046..4eda2bd0 100644
--- a/c14n/c14n.go
+++ b/c14n/c14n.go
@@ -192,9 +192,95 @@ func FormatPrefixList(prefixes []string) string {
// descendants are, with the namespace and xml:* axis treatment the
// chosen algorithm specifies for a subset apex.
func Bytes(n *xdm.Node, opts Options) ([]byte, error) {
- var b bytes.Buffer
- err := Write(&b, n, opts)
- return b.Bytes(), err
+ if n == nil || (n.Kind() != xdm.KindDocument && n.Kind() != xdm.KindElement) {
+ return nil, ErrUnsupportedNode
+ }
+ return BytesNodeSet(Subtree(n), opts)
+}
+
+// chunkWriter keeps a copy of everything written to it.
+type chunkWriter struct{ chunks [][]byte }
+
+func (c *chunkWriter) Write(p []byte) (int, error) {
+ c.chunks = append(c.chunks, bytes.Clone(p))
+ return len(p), nil
+}
+
+// bytes joins the chunks. No output is nil, as from an empty bytes.Buffer.
+func (c *chunkWriter) bytes() []byte {
+ switch len(c.chunks) {
+ case 0:
+ return nil
+ case 1:
+ return c.chunks[0]
+ }
+ return bytes.Join(c.chunks, nil)
+}
+
+// sizedWriter collects everything written to it in one buffer, sized once
+// from the tree under the node set's root at the first write (after the options were validated, so
+// a refused call allocates nothing). The estimate runs 5% to 15% over the
+// output, so the result costs about 1.1 times its size where a buffer grown
+// by doubling cost 2.1 times and joined chunks twice; a short estimate (an
+// escaped value is longer than its text) costs one more copy.
+type sizedWriter struct {
+ ns NodeSet
+ opts Options
+ buf []byte
+}
+
+func (s *sizedWriter) Write(p []byte) (int, error) {
+ if s.buf == nil {
+ s.buf = make([]byte, 0, max(estimateSize(s.ns, s.opts), len(p)))
+ }
+ if need := len(s.buf) + len(p); need > cap(s.buf) {
+ // A short estimate: grow by a quarter, not by append's doubling.
+ s.buf = append(make([]byte, 0, need+need/4), s.buf...)
+ }
+ s.buf = append(s.buf, p...)
+ return len(p), nil
+}
+
+// estimateSize adds up the markup and values of ns's members. It reads the
+// tree once, a sixth of the time canonicalizing it takes. It counts every
+// declaration an element has and no escaping, and adds an eighth for the
+// escapes, so it is an estimate. It does not ask ns which nodes are members
+// (a NodeSet is asked once per node), so it is for the sets this package
+// made: a Subtree is counted exactly, an ExcludeSubtree with what it excludes.
+func estimateSize(ns NodeSet, opts Options) int {
+ root := ns.Root()
+ if root == nil {
+ return 0
+ }
+ comments := opts.Algorithm.WithComments()
+ n := 0
+ visit := func(d *xdm.Node) {
+ switch d.Kind() {
+ case xdm.KindElement:
+ q := d.Name()
+ n += 2*(len(q.Local)+len(q.Prefix)) + 6
+ for a := range d.Attrs() {
+ q := a.Name()
+ n += len(q.Local) + len(q.Prefix) + len(a.Value()) + 5
+ }
+ for p, u := range d.DeclaredNamespaces() {
+ n += len(p) + len(u) + 9
+ }
+ case xdm.KindComment:
+ if comments {
+ n += len(d.Value()) + 8
+ }
+ case xdm.KindPI:
+ n += len(d.Name().Local) + len(d.Value()) + 8
+ case xdm.KindText:
+ n += len(d.Value())
+ }
+ }
+ visit(root)
+ for d := range root.Descendants() {
+ visit(d)
+ }
+ return n + n/8
}
// Digest canonicalizes n and writes the result into h, returning h's sum.
@@ -277,9 +363,19 @@ func WriteNodeSet(w io.Writer, ns NodeSet, opts Options) error {
// BytesNodeSet mirrors Bytes for a subset.
func BytesNodeSet(ns NodeSet, opts Options) ([]byte, error) {
- var b bytes.Buffer
- err := WriteNodeSet(&b, ns, opts)
- return b.Bytes(), err
+ switch ns.(type) {
+ case subtree, excludeSet:
+ c := sizedWriter{ns: ns, opts: opts}
+ err := WriteNodeSet(&c, ns, opts)
+ return c.buf, err
+ }
+ // Any other set is the caller's, and Contains is called once per node:
+ // the output cannot be sized without calling it again, so it arrives in
+ // chunks of about outBufSize, kept and joined once, about twice the
+ // canonical form in all.
+ var c chunkWriter
+ err := WriteNodeSet(&c, ns, opts)
+ return c.bytes(), err
}
// DigestNodeSet mirrors Digest for a subset.
diff --git a/c14n/canon.go b/c14n/canon.go
index fe6d4425..e93fc5ca 100644
--- a/c14n/canon.go
+++ b/c14n/canon.go
@@ -6,6 +6,7 @@ import (
"regexp"
"slices"
"strings"
+ "sync"
"github.com/knroy/go-xml/v2/xdm"
)
@@ -96,7 +97,10 @@ type canon struct {
const noNode = "\x00"
func canonicalize(w io.Writer, ns NodeSet, opts Options) error {
- _, err := run(w, ns, opts)
+ c, err := run(w, ns, opts)
+ if c != nil {
+ c.w.release()
+ }
return err
}
@@ -108,7 +112,6 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
}
_, all := ns.(subtree)
c := &canon{
- w: newOutBuf(w),
set: ns,
all: all,
excl: opts.Algorithm.Exclusive(),
@@ -139,6 +142,7 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
c.scope.m[prefix] = uri
}
}
+ c.w = newOutBuf(w)
var err error
switch root.Kind() {
case xdm.KindDocument:
@@ -150,6 +154,7 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
c.leaf(root)
}
default:
+ c.w.release()
return nil, ErrUnsupportedNode
}
if err == nil {
@@ -170,12 +175,32 @@ func run(w io.Writer, ns NodeSet, opts Options) (*canon, error) {
// into nothing.
type outBuf struct {
buf []byte
+ pb *[]byte // the pool entry buf came from
w io.Writer
err error
}
+// outBufPool keeps the buffers between calls: a fixed 80 KiB allocated per
+// call was 81 KB of garbage for each Digest of a tiny document.
+var outBufPool = sync.Pool{New: func() any {
+ b := make([]byte, 0, outBufSize+outBufSize/4)
+ return &b
+}}
+
func newOutBuf(w io.Writer) *outBuf {
- return &outBuf{buf: make([]byte, 0, outBufSize+outBufSize/4), w: w}
+ pb := outBufPool.Get().(*[]byte)
+ return &outBuf{buf: (*pb)[:0], pb: pb, w: w}
+}
+
+// release returns the buffer to the pool unless output grew it far past its
+// size, so that one large text node does not stay allocated. o is not used
+// afterwards.
+func (o *outBuf) release() {
+ if cap(o.buf) <= 2*outBufSize {
+ *o.pb = o.buf[:0]
+ outBufPool.Put(o.pb)
+ }
+ o.buf, o.pb = nil, nil
}
// flush hands buf to w. A short write without an error is io.ErrShortWrite,
diff --git a/c14n/digest_alloc_test.go b/c14n/digest_alloc_test.go
new file mode 100644
index 00000000..f2541039
--- /dev/null
+++ b/c14n/digest_alloc_test.go
@@ -0,0 +1,27 @@
+package c14n
+
+import (
+ "crypto/sha256"
+ "testing"
+)
+
+// Every call allocated an 80 KiB output buffer, 81 KB of garbage for each
+// Digest of a tiny document. The buffers now come from a pool. The bound is half the unpooled
+// figure because the race detector makes a pool drop a quarter of its puts.
+func TestDigestSmallDocumentAllocation(t *testing.T) {
+ root := parse(t, `x`).FirstChild()
+ h := sha256.New()
+ const calls = 101
+ got := heapAllocated(func() {
+ for range calls {
+ h.Reset()
+ if _, err := Digest(h, root, Options{Algorithm: Inclusive10}); err != nil {
+ t.Fatal(err)
+ }
+ }
+ })
+ t.Logf("%d Digest calls allocated %d bytes", calls, got)
+ if got > 4<<20 {
+ t.Errorf("%d Digest calls allocated %d bytes, want under 4 MiB (8.3 MB with a fresh buffer each)", calls, got)
+ }
+}
diff --git a/cmd/go-xml/gc_test.go b/cmd/go-xml/gc_test.go
index 99744c68..4d62594b 100644
--- a/cmd/go-xml/gc_test.go
+++ b/cmd/go-xml/gc_test.go
@@ -23,3 +23,23 @@ func TestLowerGCRespectsGOGC(t *testing.T) {
t.Errorf("with GOGC=100: GC percent %d, want the environment's 100", got)
}
}
+
+// Past 128 MB of live heap the command drops from GOGC=200 to 100; below it
+// the setting is left alone.
+func TestRelaxGCOnBigLiveHeap(t *testing.T) {
+ orig := debug.SetGCPercent(cliGCPercent)
+ defer debug.SetGCPercent(orig)
+
+ if relaxGC(cliGCBigHeap) {
+ t.Error("relaxed at exactly the threshold, want only past it")
+ }
+ if got := debug.SetGCPercent(cliGCPercent); got != cliGCPercent {
+ t.Errorf("a small heap changed GC percent to %d", got)
+ }
+ if !relaxGC(cliGCBigHeap + 1) {
+ t.Error("did not relax past the threshold")
+ }
+ if got := debug.SetGCPercent(cliGCPercent); got != 100 {
+ t.Errorf("GC percent after a big heap = %d, want 100", got)
+ }
+}
diff --git a/cmd/go-xml/main.go b/cmd/go-xml/main.go
index 379052d6..b9022508 100644
--- a/cmd/go-xml/main.go
+++ b/cmd/go-xml/main.go
@@ -11,8 +11,11 @@ import (
"os"
"path"
"path/filepath"
+ "runtime"
"runtime/debug"
+ "runtime/metrics"
"strings"
+ "sync"
"time"
"github.com/knroy/go-xml/v2/xdm"
@@ -51,12 +54,51 @@ func main() {
// never in the library, whose callers own their process's GC policy.
const cliGCPercent = 200
+// cliGCBigHeap is the live heap, in bytes, past which the command drops to
+// GOGC=100. At 200 the heap may reach three times what is live, and past this
+// size that is what costs: peak RSS on DocBook table-cals.049 was 1,160 MB at
+// 200 and 789 MB at 100, and on a 100 MB identity transform 1,840-1,956 MB
+// against 1,360-1,400, for the same wall time and 5-10% more CPU. Below it a
+// small heap is cheap to hold and the extra CPU is what 200 saves. The size of
+// the input is no guide -- DocBook's is small -- so it is the live heap that
+// is read.
+const cliGCBigHeap = 128 << 20
+
// lowerGC applies cliGCPercent unless GOGC is set, so a user who tunes the
-// collector keeps their setting.
+// collector keeps their setting, and watches the live heap to relax it.
func lowerGC() {
if os.Getenv("GOGC") == "" {
debug.SetGCPercent(cliGCPercent)
+ watchLiveHeap.Do(armGCSentinel)
+ }
+}
+
+var watchLiveHeap sync.Once
+
+type gcSentinel struct{ _ [16]byte }
+
+// armGCSentinel runs relaxGC after each collection until it has acted. A
+// finalizer on an unreachable object runs once the cycle that found it has
+// marked, so the live-heap metric it reads is that cycle's.
+func armGCSentinel() {
+ runtime.SetFinalizer(new(gcSentinel), func(*gcSentinel) {
+ s := []metrics.Sample{{Name: "/gc/heap/live:bytes"}}
+ metrics.Read(s)
+ if s[0].Value.Kind() == metrics.KindUint64 && relaxGC(s[0].Value.Uint64()) {
+ return
+ }
+ armGCSentinel()
+ })
+}
+
+// relaxGC drops to GOGC=100 when live bytes are past cliGCBigHeap, and
+// reports whether it did.
+func relaxGC(live uint64) bool {
+ if live <= cliGCBigHeap {
+ return false
}
+ debug.SetGCPercent(100)
+ return true
}
// paramFlag collects repeated -p name=value flags.
diff --git a/docs/benchmark.md b/docs/benchmark.md
index 930d0690..83605b60 100644
--- a/docs/benchmark.md
+++ b/docs/benchmark.md
@@ -472,7 +472,8 @@ Peak RSS in cold runs (median / maximum over items):
For transforms v1 uses a fifth to two thirds of a JVM's memory, and v2 an
eighth to a quarter. The CLI runs its collector at `GOGC=200` unless `GOGC`
-is set, which trades some peak memory for CPU. For large documents v1's tree
+is set, which trades some peak memory for CPU, and drops to `GOGC=100` once
+the live heap passes 128 MB. For large documents v1's tree
cost about 23 bytes of RSS per byte of input, 1.6× libxml2's; v2's costs
under 4, about a quarter of libxml2's. Only `encoding/xml`, which builds no
tree, uses less.
diff --git a/docs/c14n.md b/docs/c14n.md
index 0a27e58e..89ed7bc4 100644
--- a/docs/c14n.md
+++ b/docs/c14n.md
@@ -409,7 +409,7 @@ against `xdm`'s public API as it stands:
| R1 | The namespace declarations made *on* each element, as written | `Node.NamespaceDecls()` — the declarations on that element only, an undeclaration (`xmlns=""`) carried as an empty value | yes |
| R2 | The bindings *in scope* at an element | `Node.InScopeNamespaces()` (a prefix-to-URI map, `xml` always present) and `Node.LookupPrefix(prefix)` for one prefix | yes |
| R3 | `xml:*` attributes, for inheritance and the 1.1 `xml:base` fix-up | ordinary `Node.Attrs()` whose `Name().URI == xdm.NSXML` | yes |
-| R4 | Document order | `Node.Compare` orders any two nodes. `Node.Order()` is a per-tree index assigned by `Tree.Finalize` in one pre-order walk, which every parser entry point calls; it is strictly increasing in document order within a finalized tree but **not dense** — `Finalize` reserves a slot for every in-scope binding of each element, for the namespace-axis nodes synthesized on demand, and adds a per-tree offset. `c14n` needs neither: it walks `Children` and `Attrs` in order and sorts what the specifications tell it to sort | yes |
+| R4 | Document order | `Node.Compare` orders any two nodes. `Node.Order()` is a per-tree index assigned by `Tree.Finalize` in one pre-order walk, which every parser entry point calls; it is strictly increasing in document order within a finalized tree but **not dense** — `Finalize` reserves a slot for every in-scope binding of each element, for the namespace-axis nodes synthesized on demand, and adds a per-tree offset. `Node.Index()` is the dense position, with `Tree.NodeAt` to go back. `c14n` needs neither: it walks `Children` and `Attrs` in order and sorts what the specifications tell it to sort | yes |
| R5 | Node identity, for node-set membership | pointer equality for every node the parser builds; `Node.Is` for the general case (it differs only for synthesized namespace nodes, which never reach `c14n`) | yes |
| R6 | Character data in canonical shape | the parser resolves character and entity references, merges CDATA sections with adjacent text into one text node, normalises CRLF and a lone CR to LF, keeps a `
` reference as a literal CR (which C14N then writes as `
`), and normalises attribute whitespace — including, now, the collapse for attributes declared non-CDATA; verified by the `c14n` tests | yes |
diff --git a/docs/conformance-gaps.md b/docs/conformance-gaps.md
index b693e744..281400a0 100644
--- a/docs/conformance-gaps.md
+++ b/docs/conformance-gaps.md
@@ -49,7 +49,7 @@ W3C disagreements: 0 + 0 + 0 + 1 + 8 + 23 + 30 + 31 + 0 = 93. Measured 2026-09-1
-The unit-test suite is 2,854 tests.
+The unit-test suite is 2,950 tests.
The last two rows are not W3C suites but real-world corpora — DocBook xslTNG's
diff --git a/docs/known-gaps.md b/docs/known-gaps.md
index 680b6a0a..cd8f4c54 100644
--- a/docs/known-gaps.md
+++ b/docs/known-gaps.md
@@ -105,6 +105,57 @@ tables being copied per scope and target nodes being walked again. Found by
an external audit of v2.0.0; the figures are the `BenchmarkIdentity*`
benchmarks in `xsd/`.
+### Memory: what the third memory audit left open (resource limits, performance)
+
+Found by the third memory audit (2026-11-01) and not fixed, each with the reason:
+
+- **No bound on the total across many `fn:doc` calls.** The per-document
+ `MaxBytes` holds, but 40 distinct 500k-element documents were held at 846 MB
+ live. A correct total needs a per-evaluation set of loaded URIs (the
+ `FileResolver` cache is shared, so charging on a miss undercounts), state
+ carried into nested `fn:transform`, and every reader covered. Lower
+ `FileResolver.MaxBytes` or the allowed roots meanwhile.
+- **`Env.MaxDigits` covers casts, constructors and binary arithmetic, not
+ `CastAtomic`, `fn:sum` or `fn:avg`**, which take no context.
+- **RELAX NG's derivative cap still refuses some valid documents.** The choice
+ merge fixed `(a?){n} a{n}` up to about n = 30. At n = 50 and 100 the cap is
+ reached again, though in 20–300 ms rather than 19.9 s. Hash-consing
+ derivatives would close it.
+- **`xsl:number level="any"` is quadratic in CPU** (40k items 44 s). A cached
+ prefix count is not valid, since `count` and `from` patterns may use
+ `current()` or per-call variables.
+- **`fold-left` appending one item a step is quadratic in allocation**
+ (40k items 12.4 GB, all garbage). Appending in place is unsound while other
+ references may share the prefix.
+- **Identity templates allocate about 3.5 KB a node of context copies**
+ (`withSel`, `hostWithCurrent`, the leaf-hook copy), and built-in shallow-copy
+ copies a document-level result twice; removing that copy changes an error
+ case into output.
+- **A single text value of a few to tens of MB allocates up to about 1.9× its
+ size while parsing** (10 MiB: 1.87×; the tree keeps 1.09×; 60 MiB: 1.13×).
+ The decoder's buffer grows in steps of eight. Sizing it to the input left
+ in one step reached 1.08×, but a document of eight 3 MiB values went from
+ 1.33× to 2.04×, so it was reverted.
+- **`c14n.BytesNodeSet` on a caller's own `NodeSet` still costs about 2.1× its
+ output.** Sizing it would call `Contains` twice per node, which the API
+ promises not to do; the package's `Subtree` and `ExcludeSubtree` are sized.
+- **XQuery inline functions capture their whole scope.** The XPath and XSLT
+ closure (`InlineFunctionExpr`) keeps only what its body reads;
+ `xquery/inline.go` has its own implementation.
+- **A regex program over 4,096 instructions is compiled on every call**,
+ because it is not cached; the largest QT3 pattern is 4,059.
+
+### `fn:replace` charges a backtracking or backreference result after building it (XPath, resource limits)
+
+The RE2 path of `fn:replace` charges `len(input) + matches × len(replacement)`
+before it builds the result, so a hostile replacement is refused without being
+allocated. The two other engines, the backreference matcher and the opt-in
+backtracking one (`-backtracking-regex`), still build the result and charge it
+afterwards. Their step budgets bound the number of matches, but not the size
+of each replacement, so a long replacement string can allocate past
+`MaxBytes` before the refusal. Found by the second memory audit
+(2026-10-31), which measured only the RE2 path.
+
### §19.8 streamability analysis is partially implemented (XSLT 3.0)
**3 of the 23 XSLT 3.0 failures** — `si-fork-902`, `si-fork-952` and
diff --git a/docs/migrating-to-v2.md b/docs/migrating-to-v2.md
index 29c763c9..a741babb 100644
--- a/docs/migrating-to-v2.md
+++ b/docs/migrating-to-v2.md
@@ -177,6 +177,7 @@ doc.Finalize()
The calls are `AppendElement`, `AppendText`, `AppendComment`, `AppendPI`,
`AppendAttr` (before the element's first child), `AddNamespace`,
+`AddNamespaces` (several bindings in one call),
`AppendCopy` (a deep copy) and `AppendShallowCopy` (the node alone, for a copy
you complete yourself). A node is open from the call that made it until you
append to something that is not it or inside it; appending to a closed node,
@@ -185,7 +186,7 @@ or adding an attribute after a child, panics. `Finalize` closes everything.
What may still change after a node is made is its scalar state: `SetName`,
`SetValue`, `AppendValue` (merging adjacent text), `SetBaseURI`,
`SetDocumentURI`, the typing setters, and the namespace declarations of an
-element (`AddNamespace`, `SetNamespaceDecl`, `RemoveNamespaceDecls`) of a tree
+element (`AddNamespace`, `AddNamespaces`, `SetNamespaceDecl`, `RemoveNamespaceDecls`) of a tree
that is not a parsed document. A parsed document is frozen.
`xdm.NewNode(kind, name, value)` still makes a parentless node; it belongs to
@@ -230,7 +231,8 @@ Namespace nodes exist once per element and prefix: the namespace axis, and
agree for them.
`xdm.ProcessXInclude` returns the included document as a new tree instead of
-editing the one it is given: `tree, err = xdm.ProcessXInclude(tree, opts)`.
+editing the one it is given (a parsed tree with nothing to include comes back
+as it is): `tree, err = xdm.ProcessXInclude(tree, opts)`.
## xdmbuild setters removed
diff --git a/docs/options.md b/docs/options.md
index 954f03c6..7701fffa 100644
--- a/docs/options.md
+++ b/docs/options.md
@@ -180,7 +180,7 @@ tree, err := xdm.ParseString(src, xdm.ParseOptions{
| `BaseURI` | `string` | none | Recorded on the document node; what `fn:base-uri`, `fn:doc` and relative `xsl:include` resolve against. Set it whenever the document came from somewhere addressable. |
| `StripSpace` | `func(QName) bool` | keep all | Drops whitespace-only text nodes for the element names it returns true for. A predicate rather than a bool because `xsl:strip-space` is per-element. |
| `AllowDOCTYPE` | `bool` | refuse | Permits a `DOCTYPE` declaration. See below. |
-| `TrackPositions` | `bool` | off | Records where each element starts, so a validation error can name a line. Retains the source text for the life of the tree — about 10% more memory, no extra parse time. |
+| `TrackPositions` | `bool` | off | Records where each element starts, so a validation error can name a line. Keeps each line's start offset and each element's column, not the source text: about 10% more retained memory (parse-10mb 37.2 → 41.5 MB), no extra parse time. |
| `MaxDepth` | `int` | `DefaultMaxDepth` = 1000 | Nesting limit. Deep input is the cheapest route to stack exhaustion. |
| `MaxBytes` | `int64` | `DefaultMaxBytes` = 64 MB | Source size limit, enforced on the read. |
| `MaxNodes` | `int` | `DefaultMaxNodes` = 10,000,000 | Node count limit, counting attributes and namespaces. |
@@ -688,6 +688,7 @@ functions or `WithEnv`):
| `RegexVersion`, `LibraryVersion` | `Version` | Raise the regular-expression dialect and the function library above the expression's version, without admitting new syntax. |
| `MaxDepth` | `int` | Recursion bound. Zero is `xpath.MaxDepth` (500). |
| `MaxItems` | `int` | Items an evaluation may materialise. Zero is `xpath.MaxItems` (5,000,000); negative is no bound; a nested evaluation adopts the caller's bound and cannot raise it. |
+| `MaxDigits` | `int` | Digits of an `xs:integer` or `xs:decimal` a cast or arithmetic may produce. Zero is `xpath.MaxDigits` (10^6), `FOCA0003` or `FOAR0002` past it; negative is no bound. |
| `QualifyVar`, `MissingVar` | funcs | Host hooks for variable references (XSLT packages, XQuery cycles). |
| `MapDuplicateCode` | `string` | Error code for a duplicate key in a map constructor; `XQDY0137` by default. |
@@ -703,7 +704,11 @@ evaluation's. A nested evaluation therefore never resets or raises a budget.
`WithStaticBaseURI` and `WithStaticHost` set the defaults an expression compiled
without them uses (a one-shot `xpath.Eval` compiles at the context's version).
A host whose expressions all carry one version or static host sets it once on
-the context, so evaluating them does not copy the context to install it.
+the context, so evaluating them does not copy the context to install it. This
+matters for a host that reuses one context: an expression compiled as XPath 3.1
+evaluated against `NewContext`'s 2.0 default copies the 96-byte context on every
+call, about a quarter of a simple filter's allocation per node, until the host
+calls `ctx = ctx.WithVersion(xpath.XPath31)` once.
`StaticNamespaces()` is installed only for an expression that calls or
references `fn:format-date`, `fn:format-dateTime`, `fn:format-time` or
`fn:function-lookup`; any other expression sees its caller's (nil at the top
@@ -871,7 +876,8 @@ tree, err = xdm.ProcessXInclude(tree, xdm.XIncludeOptions{
`ProcessXInclude` returns the included document as a new tree, built
top-down as every tree is; the tree it is given is left as it was, and node
-identities do not carry over.
+identities do not carry over. A parsed tree with no XInclude element has
+nothing to replace and is returned as it is.
**The resolver is the whole of the confinement.** `xdm` has no filesystem and
no network; it can read only what a resolver hands it. `xslt.FileResolver`
diff --git a/docs/profiling.md b/docs/profiling.md
index 294d61d3..181d57e5 100644
--- a/docs/profiling.md
+++ b/docs/profiling.md
@@ -131,6 +131,9 @@ The profile is flat. The largest single functions are `QName` equality
| `Tree.alloc` (result-tree records) | — | 6.2% |
| `evalParams` (cumulative) | — | 6.4% |
+This profile predates the memory audit: `evalParams` now passes a slice of
+pairs, not two maps, and section.001 allocates 15% less (CHANGELOG, *Unreleased*).
+
No source exceeds a tenth of the objects. DocBook's cost is many small
allocations spread over the whole evaluator, and the collector work they
cause.
@@ -201,7 +204,8 @@ invoice are 20 k and 1.95 MB.
Allocations: 42% of the bytes are node-record chunks (`Tree.alloc`), 22% the
harness's input copy and 16% the text store. By count, FLWOR tuple binding
-(`tuple.bind`) is 27%, but these are small objects and about 2% of CPU. XMark
+(`tuple.bind`) was 27% before the memory audit, which shares one names
+slice per tuple stream, but these are small objects and about 2% of CPU. XMark
at factor 0.1 is a parse benchmark.
### XSD validation and typed validation
@@ -248,9 +252,10 @@ objects. By bytes, `Tree.alloc` makes 21%, the clone 17% and the typing
48% of the bytes are the tokenizer's 32 KB string arena, one per document.
`newAfterPat` makes 37% of the objects and `strings.Fields` (token
normalisation) 12%. The documents are small, so that fixed arena dominates
-the bytes and drives the GC share. Sizing the arena to the input was measured
-and rejected because of how it moved GC pacing on DocBook (see
-[rejected](#measured-and-rejected)).
+the bytes and drives the GC share. Sizing the arena to the input was first
+rejected because of how it moved GC pacing on DocBook (see
+[rejected](#measured-and-rejected)); the memory audit adopted it once
+namespace URIs stopped pinning the arena (CHANGELOG, *Unreleased*).
### Parse and Canonical XML
@@ -379,7 +384,10 @@ any dropped passing count.
the same `GOGC`. A change that cuts retained memory can cost CPU elsewhere:
starting the string arena small cut small documents' retained heap 20–37%
and cost DocBook 5% CPU (85 GC cycles against 77). Count GC cycles a pass
- in both arms.
+ in both arms. The memory audit raised DocBook xslTNG (593 inputs, one
+ process) from about 142 to 174 cycles, the same with or without the sized
+ arena, with peak RSS 1.24–1.35 GB → 1.07–1.15 GB and user CPU within noise
+ over five alternating rounds.
- **The memory profiler costs CPU.** A binary that links `runtime/pprof`
samples allocations unless `MemProfileRate` is 0. The CLI does not link it,
so a harness that does measures a slightly different program.
@@ -477,7 +485,7 @@ longer holds.
| Smaller first record chunk (2 records, V15) | Most fragments hold one node, but bytes moved −0.4% to +0.2% and allocations up to +0.6%: the next chunk comes sooner for every other tree |
| Chunked result nodes | −0.9% allocations at most, +0.4–0.7% bytes |
| String arena starting small | Retained heap −20–37% on small documents, but DocBook +5% CPU: the smaller live heap ran the collector more often (85 cycles against 77). Reverted in `629cf48` |
-| String arena sized to the input (V18) | 40 small RELAX NG documents: parse bytes −52%, CPU −20%, retained heap −72%. But DocBook ran about 7% more GC cycles (29.3 → 31.3 a pass), as above. Only the read windows were sized (`9ddd2bc6`) |
+| String arena sized to the input (V18) | 40 small RELAX NG documents: parse bytes −52%, CPU −20%, retained heap −72%. But DocBook ran about 7% more GC cycles (29.3 → 31.3 a pass), as above. Only the read windows were sized (`9ddd2bc6`). **Adopted later** in `9e13f70`: with namespace URIs copied out (`18d374d`) the arena no longer pinned trees, and an A/B with and without it gave DocBook the same GC count |
| Attribute values passed to xdm without the arena copy (V40) | Parse bytes −2.3 to −2.6%, but `regex-syntax` +2.6% bytes, XRechnung +26 to +53 allocations a pass and more GC cycles (7.2 → 7.6); no CPU gain. Reviving it needs the copy at `textStore.add`, since the values sit in a reused buffer |
| End tags checked against a stack of open names (V35) | After the tokenizer's name cache (V34), parse +0.8 to +1.7% CPU and 7 more allocations a parse |
| SWAR scan in the tokenizer's `text()` | No gain (round 3, and again on v2): it is already a tight table loop |
diff --git a/docs/security.md b/docs/security.md
index 34dbbcf6..c71a9bbf 100644
--- a/docs/security.md
+++ b/docs/security.md
@@ -610,6 +610,8 @@ what actually drives them:
the resolver. `resolverCacheMax = 256` **cleared on full** rather than
evicting, so past 257 distinct URIs it stopped being a bound and became an
amplifier; it now evicts one entry at a time, which is also fixed below.
+ Since the memory audit it is also bounded by `resolverCacheMaxBytes`, 32 MB
+ of source (about 5x that in trees); a `Preload`ed tree is charged 0.
Two further sites — `fn:transform`'s `nestedParseOptions`
(`xslt/fntransform.go:531`) and `relaxng/resolve.go:141` — have the same shape
@@ -1252,7 +1254,12 @@ itself is rejected without it. See
Go's `regexp` is RE2. `matches('aaaa…!', '^(a+)+$')` is flat at 2–6 µs from
n=24 to n=40. Go also *rejects* repeat counts over 1000, so `{1,1000000}` cannot
be used to force an expansion; the XSD pattern translator allocates nothing on
-nested quantifiers or 200-deep groups.
+nested quantifiers or 200-deep groups. That check bounds one repeat and nested
+products, **not sequential repeats**: `\pL{1000}` written 150 times compiled to
+a program of millions of instructions, and 181 KB of such patterns retained
+984 MB. Every pattern now goes through `xpath.CompileBounded`, which counts the
+program before building it and refuses past `MaxRegexpProgram` (100,000; the
+largest in the W3C suites is 4,313), in XPath and in XSD pattern facets.
**Backreferences do not change this, by default.** XPath 2.0 has them and RE2
does not, and the usual way to bridge that is a backtracking engine — which is
@@ -1721,7 +1728,8 @@ and facets still apply to the substituted type.
static context an environment of its own; the four reads are marked in the
file.
- The `xpath` regex cache is bounded at 1024, as is the backtracking engine's
- single-character-atom cache; the UCA collation cache is bounded at 256. All
+ single-character-atom cache; the UCA collation cache is bounded at 256. The
+ bound is on entries, so a key over 1 KB (`maxCachedKey`) is never cached. All
three hold their bound under concurrent use, not merely on a single goroutine:
each is a `boundedCache` (`xpath/cache.go`) that performs the full-check and
the insert under one lock hold. An earlier form checked an atomic size counter
@@ -1857,6 +1865,24 @@ Each line names the **direction** of the defect, because that is what decides
who was exposed: a *false accept* let an invalid input through, a *false
reject* refused a legal one, and *cost* produced the right answer too slowly.
+**Nineteenth pass — a third memory audit, 2026-11-01.** The findings and figures are in the audit's own write-up; the retention and allocation fixes are in CHANGELOG under *Changed — memory*.
+
+- **Sequences built by `,` and `!` were not counted against `MaxItems`** — availability, driven by a query or stylesheet. `fold-left(1 to 26,(1,2),function($s,$i){($s,$s)})` (70 bytes) built 134M items against the 5M limit at 6.3 GB, and the same doubling through a recursive `xsl:with-param` reached 4.6 GB on v2.0.3. `SequenceExpr` and `!` charge the copies beyond their widest operand above 64 items, and `xsl:next-iteration` values are checked against the bound. Pinned by `TestSequenceCopiesAreCharged`.
+- **RELAX NG rejected a valid document** — correctness. `(a?){13} a{13}` against 13 `` crossed the 100,000-node derivative cap, and the message named a `oneOrMore` the schema does not have; `xmllint` accepts it. Choice chains now merge alternative by alternative, under a 1,024-pair budget per merge so a hostile schema stays bounded. Pinned by `TestOptionalsBeforeRequiredStayValid`. Larger N still reaches the cap (docs/known-gaps.md).
+- **Serializing atomic values was quadratic** — cost, driven by result size. `xslt/seqnorm.go` joined adjacent atomics by repeated concatenation, so `1 to 100000` took 21.6 s and 6.95 GB for 588 KB of output. Each run is joined once. Pinned by `TestJoinAdjacentAtomicsIsLinear`.
+- **Several producers were uncharged or unbounded** — cost, driven by document or query data. `unparsed-text-lines`, `parse-json` and `json-doc` built tens of millions of items past `MaxItems` (5.2 GB from a 67 MB file); DTD default values were copied into every element unseen by `MaxNodes` or `MaxBytes` (2 MB retained 1.97 GB); an `xs:integer` had no size bound (a 4M-digit cast allocated 34 GB); a sequence-valued accumulator copied its value into every node (200 KB held 12.4 GB). Lines and JSON values are charged, defaults are stored once, `Env.MaxDigits` (10^6) refuses with FOCA0003 or FOAR0002, and accumulator values share their prefix and are charged.
+- **`xsl:copy-of` of a document bypassed `MaxNodes`** — the inconsistency, not data loss: it copied 5.7M nodes and exited 0 where shallow-copy is refused. It is charged before the copy.
+
+**Eighteenth pass — a memory audit, 2026-10-31.** The rest of that audit was retention and allocation with no hostile driver; it is in CHANGELOG under *Changed — memory*.
+
+- **`xsd` error messages kept document strings whole** — cost, driven by document data. `fail` and `attrName` quoted names and values in full and the validator keeps up to 100 errors, so 200 foreign attributes in a 1 MB namespace retained 105 MB, and one 15 MB value under a failing rule set retained 1.57 GB (audit 20). Strings and the formatted message are clipped to 1 KB plus "...", and the pattern and list-item errors quote through `truncate`: 0.13 MB. Pinned by `TestValidationMessagesClipDocumentStrings` in `xsd/message_clip_test.go`.
+- **The DTD validator built every element's path, quadratic in depth** — cost, driven by document data. The path string was rebuilt at each element whether or not anything failed: a *valid* 990-deep chain of 4 KB names allocated 2 GB. It is rendered from the ancestors only when a failure is recorded, and an IDREF keeps its element rather than a path. Its `#FIXED`, enum and ID values are clipped to 1 KB too. Pinned by `TestPathBuiltOnlyForFailures` and `TestValidityMessagesClipLongValues` in `dtd/`.
+- **Deeply nested maps and arrays overflowed the Go stack** — availability, driven by a query or stylesheet. `serialize(..., map{"method":"json"})`, the adaptive method, `fn:deep-equal` and `map:find` recursed once per level, so `fold-left(1 to 400000, [], function($a,$i){[$a]})` (96 bytes) ended the process with `fatal error: stack overflow`, which no `recover` can catch; v2.0.3 has it too. Serialization refuses past 1,000 levels (the `parse-json` limit) with XPDY0130, `deep-equal` and `map:find` past 1,000 levels on a counter of their own (XPDY0001), so function recursion does not lower it, and `xdm.Flatten` is iterative. Pinned by `TestDeepNestedValuesAreRefused` in `xpath/deepnest_test.go`.
+- **A built-in rule returned a truncated result tree as a success** — correctness, driven by document size. The result builder latches a `MaxNodes` refusal, but only instruction bodies checked it, so `` over 1M elements emptied a third of them and exited 0; v2.0.3 has it too. The transform now fails with XPDY0130 whenever the node budget was crossed anywhere, through `xpath.Context.NodesRefused`. Pinned by `TestBuiltInRuleResultTreeOverrunIsRefused`.
+- **`fn:replace`, picture widths, `xsl:key` and `group-by` grew past their budgets** — cost, driven by document or stylesheet data. `replace` charged its result after building it (12.8 GB for a 500 KB input and a 4 KB replacement); a `format-date` width of `[D01,1000000000]` (52 bytes) allocated 2.8 GB through `strings.Repeat`; `use="1 to 1000"` over 100k nodes built 100M key entries at 3.2 GB peak, since the item counter resets per expression. `replace` charges first on the RE2 path (the other two engines are a known gap), widths over 65,536 are FOFD1340, and keys are totalled per index and grouping against `MaxItems`.
+- **Caches bounded by entry count held gigabytes** — cost, driven by document data or the host. 1,024 cached regexes of 200 KB, 256 failed 1 MB collation URIs, or 256 large `FileResolver` documents each held hundreds of MB to GB. Keys over 1 KB are not cached, and the document cache is also bounded by 32 MB of source.
+- **The nested `fn:transform` cache kept compiles for collected resolvers** — cost, driven by the host. The key held each resolver by identity, so a service that made one per request kept up to 16 dead compiles a stylesheet: 49 MB on DocBook after 20 resolvers. Resolvers are weak keys and `put` drops entries whose resolver is gone: 0.3 MB. Pinned by `TestTransformCache/dead_resolver_dropped`.
+
**Seventeenth pass — normalize-space built a list of every token, 2026-10-30.**
- **`fn:normalize-space` allocated per whitespace token** — cost, driven by document data. It was `strings.Join(splitXMLSpace(s), " ")`, so a value of n tokens built an n-element slice and then the result; a rule set evaluating it 180 times on one 15 MB value allocated 133.5 GB in 20.6 s, and `MaxBytes` charges only the result. It and `fn:tokenize#1` now use `collapseXMLSpace`, one pass into a builder, which returns an already-collapsed value as it is: 0 GB in 2.6 s, and 2.5 GB in 4.2 s for a value that needs collapsing. Pinned by `TestCollapseXMLSpaceMatchesSplitJoin` and `TestCollapseXMLSpaceAllocation` in `xpath/xml_space_test.go`. Fixed in v2.0.3.
diff --git a/docs/stats.md b/docs/stats.md
index b9784dbe..d8837195 100644
--- a/docs/stats.md
+++ b/docs/stats.md
@@ -23,7 +23,7 @@ claim is only as good as the command behind it.
| figure | count | counted by |
|---|---:|---|
-| Unit tests | 2,854 | `grep -rn '^func Test' --include='*_test.go' . \| grep -v '/\.claude/worktrees/' \| grep -vc '^\./bench/'` |
+| Unit tests | 2,950 | `grep -rn '^func Test' --include='*_test.go' . \| grep -v '/\.claude/worktrees/' \| grep -vc '^\./bench/'` |
| Fuzz targets | 12 | `grep -rn '^func Fuzz' --include='*_test.go' . \| grep -v '/\.claude/worktrees/' \| grep -vc '^\./bench/'` |
| Limit boundary tests | 14 | `grep -hc '^func Test' ./*/limits_boundary*_test.go \| awk '{n += $1} END {print n + 0}'` |
diff --git a/docs/testing.md b/docs/testing.md
index 2eea7e20..093e85c3 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -21,7 +21,7 @@ let something through, and the column that matters is the last one.
| layer | count | catches | misses |
|---|---:|---|---|
-| **Unit tests** | 2,854 | a plausible implementation that is quietly wrong | anything nobody thought to write a test for |
+| **Unit tests** | 2,950 | a plausible implementation that is quietly wrong | anything nobody thought to write a test for |
| **Limit boundary tests** | 14 tests | an off-by-one or an overflow at the edge of a configurable limit | a limit nobody added to the inventory |
| **Race detector** | same tests | shared state a single-goroutine run never reveals | a data race on a path no test walks |
| **W3C conformance suites** | 152,241 cases | systematic divergence from the specification | what the suites do not ask about — see below |
diff --git a/docs/todo.md b/docs/todo.md
index 8c7001db..52ce996e 100644
--- a/docs/todo.md
+++ b/docs/todo.md
@@ -23,7 +23,7 @@ Current position:
| XSLT 3.0 | 99.80% — 11,495 of 11,518 in scope (23 failing); 3 of those need more of the §19.8 streamability analysis |
| RELAX NG | 100.00% — 965 of 965 |
| Schemas wrongly refused | 7 — 6 on XSD 1.0, 1 on 1.1 |
-| Tests | 2,854 `func Test` declarations, clean under `-race` |
+| Tests | 2,950 `func Test` declarations, clean under `-race` |
Every one of those failures, and why it is still open, is catalogued in
[known-gaps.md](known-gaps.md). This file is the forward-looking half — what
@@ -150,7 +150,8 @@ silently replaced the character — which is the corruption the MUST exists to
prevent, not merely a conformance divergence. `xslt/serialize.go` had the rule
right all along, so this was again one rule with two implementations.
-Both functions now delegate to `escapeXMLRunes`, which walks runes and decides
+Both functions now delegate to `escapeXMLRunes` (since the memory audit, content
+escaping streams through `writeEscaped`, its writer form), which walks runes and decides
by range rather than by a fixed list: adding U+0085 and U+2028 as two more
replacer entries would have closed the named half and left the ranges open.
`#x0` is deliberately not escaped — it is not a valid XML character at any
diff --git a/docs/xsd.md b/docs/xsd.md
index 9f5e85f3..93aecfb2 100644
--- a/docs/xsd.md
+++ b/docs/xsd.md
@@ -42,7 +42,9 @@ if errors.As(err, &invalid) {
The code — `cvc-complex-type.2.4.a`, `cvc-datatype-valid.1.2.1` — is what
lets a caller tell the kinds of failure apart without matching on message
-text. Message wording is not part of the contract; codes are.
+text. Message wording is not part of the contract; codes are. A name or value
+the document supplies is clipped to 1 KB plus "..." in the message, so a
+hostile input cannot make the retained errors large.
There are four ways in, differing only in where the schema comes from:
diff --git a/dtd/alloc_perelement_test.go b/dtd/alloc_perelement_test.go
new file mode 100644
index 00000000..c3c58e57
--- /dev/null
+++ b/dtd/alloc_perelement_test.go
@@ -0,0 +1,35 @@
+package dtd
+
+import (
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// With no ATTLIST for an element, checkAttributes built a map anyway, and the
+// child-name slice was rebuilt for every element.
+func TestValidateAllocsPerElement(t *testing.T) {
+ d, err := Parse(`
+ ]>`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ const n = 2000
+ tree, err := xdm.ParseString(""+strings.Repeat("
", n)+"", xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m0, m1 runtime.MemStats
+ runtime.ReadMemStats(&m0)
+ err = Validate(tree.Root, d, Options{})
+ runtime.ReadMemStats(&m1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // 6001 elements: 8062 allocations with the fix, 12062 without.
+ if got := m1.Mallocs - m0.Mallocs; got > 10000 {
+ t.Errorf("%d allocations for %d elements, want at most 10000", got, 3*n+1)
+ }
+}
diff --git a/dtd/message_clip_test.go b/dtd/message_clip_test.go
new file mode 100644
index 00000000..f9de1fbe
--- /dev/null
+++ b/dtd/message_clip_test.go
@@ -0,0 +1,36 @@
+package dtd
+
+import (
+ "errors"
+ "strings"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// A value quoted in a message is clipped: 200 mismatches against a 1 MB
+// #FIXED value kept 105 MB in the errors. The message keeps its prefix.
+func TestValidityMessagesClipLongValues(t *testing.T) {
+ big := strings.Repeat("v", 1<<20)
+ d, err := Parse(`
+ ]>`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tree, err := xdm.ParseString(""+strings.Repeat(``, 200)+"", xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var errs *Errors
+ if err := Validate(tree.Root, d, Options{}); !errors.As(err, &errs) || len(errs.Errors) == 0 {
+ t.Fatalf("expected Errors, got %v", err)
+ }
+ for _, e := range errs.Errors {
+ if len(e.Message) > 1024+len("...") {
+ t.Fatalf("message is %d bytes, want at most 1027", len(e.Message))
+ }
+ if !strings.HasPrefix(e.Message, `attribute a is #FIXED "vvvv`) || !strings.HasSuffix(e.Message, "...") {
+ t.Fatalf("message lost its prefix or its clip marker: %.200q", e.Message)
+ }
+ }
+}
diff --git a/dtd/path_alloc_test.go b/dtd/path_alloc_test.go
new file mode 100644
index 00000000..c44c1ae1
--- /dev/null
+++ b/dtd/path_alloc_test.go
@@ -0,0 +1,52 @@
+package dtd
+
+import (
+ "runtime"
+ "strings"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// The path is rendered only for a recorded failure. Concatenated at every
+// element it cost the square of the depth: a valid 990-deep chain of 4 KB
+// names allocated 2 GB. The text of a reported path is unchanged, including
+// for an IDREF, which is resolved after the walk has left the element.
+func TestPathBuiltOnlyForFailures(t *testing.T) {
+ d, err := Parse(`
+ ]>`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tree, err := xdm.ParseString(``, xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ const want = "2 validity errors:\n" +
+ " /r/a/b: attribute x is #FIXED \"0\" but is \"1\"\n" +
+ " /r/a/c: IDREF \"nope\" matches no ID in the document"
+ if err := Validate(tree.Root, d, Options{}); err == nil || err.Error() != want {
+ t.Fatalf("got\n%v\nwant\n%s", err, want)
+ }
+
+ nm := "n" + strings.Repeat("x", 4095)
+ d, err = Parse(`]>`)
+ if err != nil {
+ t.Fatal(err)
+ }
+ tree, err = xdm.ParseString(strings.Repeat("<"+nm+">", 990)+strings.Repeat(""+nm+">", 990), xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var m0, m1 runtime.MemStats
+ runtime.ReadMemStats(&m0)
+ err = Validate(tree.Root, d, Options{})
+ runtime.ReadMemStats(&m1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := m1.TotalAlloc - m0.TotalAlloc; got > 16<<20 {
+ t.Errorf("validating a valid deep chain allocated %d MB, want under 16 MB", got>>20)
+ }
+}
diff --git a/dtd/validate.go b/dtd/validate.go
index 6e6692d8..e95fb948 100644
--- a/dtd/validate.go
+++ b/dtd/validate.go
@@ -2,6 +2,7 @@ package dtd
import (
"fmt"
+ "slices"
"strings"
"github.com/knroy/go-xml/v2/xdm"
@@ -133,7 +134,8 @@ func Validate(doc *xdm.Node, d *DTD, opts Options) error {
}
}
}
- v.walk(root, "")
+ v.root = root
+ v.walk(root)
v.checkIDRefs()
if len(v.errs) == 0 {
return nil
@@ -147,6 +149,7 @@ type validator struct {
allowUndec bool
errs []*Error
matchers map[string]*xsd.SequenceMatcher
+ names []xdm.QName // child-name buffer reused across elements
// ids are every ID value seen, for uniqueness and for resolving IDREF.
ids map[string]bool
// partial records that the DTD was read from the internal subset alone
@@ -161,40 +164,77 @@ type validator struct {
// refs are IDREF values and where they appeared, checked once the whole
// document has been read — a reference may point forward.
refs []idref
+ // root is the element the walk started from, where a path begins.
+ root *xdm.Node
}
type idref struct {
value string
- path string
+ el *xdm.Node
}
-func (v *validator) fail(path, format string, args ...any) {
+func (v *validator) fail(el *xdm.Node, format string, args ...any) {
if v.max > 0 && len(v.errs) >= v.max {
return
}
- v.errs = append(v.errs, &Error{Path: path, Message: fmt.Sprintf(format, args...)})
+ // A #FIXED or enumerated value comes from the DTD or the document and
+ // may be megabytes; 200 mismatches against a 1 MB #FIXED value kept
+ // 105 MB. Arguments are clipped before formatting, the message after.
+ for i, a := range args {
+ switch x := a.(type) {
+ case string:
+ args[i] = clip(x)
+ case error:
+ args[i] = clip(x.Error())
+ }
+ }
+ v.errs = append(v.errs, &Error{Path: v.pathOf(el), Message: clip(fmt.Sprintf(format, args...))})
+}
+
+// clip shortens s to 1 KB, marking the cut with "..." as the xsd package
+// does. The concatenation copies, so the result never pins the original.
+func clip(s string) string {
+ const max = 1024
+ if len(s) <= max {
+ return s
+ }
+ return s[:max] + "..."
}
-func (v *validator) walk(el *xdm.Node, parentPath string) {
- path := parentPath + "/" + el.Name().Local
- v.checkContent(el, path)
- v.checkAttributes(el, path)
+// pathOf renders the path from the root to el, as "/root/child". It is built
+// only for a recorded failure: concatenating it at every element cost the
+// square of the depth, 2 GB allocated for a valid 990-deep chain of 4 KB names.
+func (v *validator) pathOf(el *xdm.Node) string {
+ var names []string
+ for n := el; ; n = n.Parent() {
+ names = append(names, n.Name().Local)
+ if n == v.root {
+ break
+ }
+ }
+ slices.Reverse(names)
+ return "/" + strings.Join(names, "/")
+}
+
+func (v *validator) walk(el *xdm.Node) {
+ v.checkContent(el)
+ v.checkAttributes(el)
for c := range el.Children() {
if c.Kind() == xdm.KindElement {
- v.walk(c, path)
+ v.walk(c)
}
}
}
// checkContent applies the element's content model.
-func (v *validator) checkContent(el *xdm.Node, path string) {
+func (v *validator) checkContent(el *xdm.Node) {
decl, ok := v.dtd.Elements[el.Name().Local]
if !ok {
// An undeclared element is a validity error in a DTD-validated
// document — unlike XSD, where a wildcard may admit it. A caller
// working with a deliberately partial internal subset can say so.
if !v.allowUndec {
- v.fail(path, "element %s is not declared", el.Name().Local)
+ v.fail(el, "element %s is not declared", el.Name().Local)
}
return
}
@@ -204,7 +244,7 @@ func (v *validator) checkContent(el *xdm.Node, path string) {
case ContentEmpty:
for c := range el.Children() {
if c.Kind() == xdm.KindElement || (c.Kind() == xdm.KindText && strings.TrimSpace(c.Value()) != "") {
- v.fail(path, "element %s is declared EMPTY but has content",
+ v.fail(el, "element %s is declared EMPTY but has content",
el.Name().Local)
return
}
@@ -212,7 +252,7 @@ func (v *validator) checkContent(el *xdm.Node, path string) {
case ContentMixed:
for c := range el.Children() {
if c.Kind() == xdm.KindElement && !decl.Mixed[c.Name().Local] {
- v.fail(path, "element %s is not permitted in the mixed "+
+ v.fail(el, "element %s is not permitted in the mixed "+
"content of %s", c.Name().Local, el.Name().Local)
}
}
@@ -221,28 +261,29 @@ func (v *validator) checkContent(el *xdm.Node, path string) {
// unexpected elements: "(a, b)" does not admit text between them.
for c := range el.Children() {
if c.Kind() == xdm.KindText && strings.TrimSpace(c.Value()) != "" {
- v.fail(path, "element %s has element-only content but "+
+ v.fail(el, "element %s has element-only content but "+
"contains character data", el.Name().Local)
break
}
}
m, err := v.matcherFor(decl)
if err != nil {
- v.fail(path, "content model of %s: %v", el.Name().Local, err)
+ v.fail(el, "content model of %s: %v", el.Name().Local, err)
return
}
- var names []xdm.QName
+ names := v.names[:0]
for c := range el.Children() {
if c.Kind() == xdm.KindElement {
names = append(names, xdm.QName{Local: c.Name().Local})
}
}
+ v.names = names
if ok, at := m.Match(names); !ok {
if at < len(names) {
- v.fail(path, "element %s is not permitted here in the "+
+ v.fail(el, "element %s is not permitted here in the "+
"content of %s", names[at].Local, el.Name().Local)
} else {
- v.fail(path, "the content of %s is incomplete", el.Name().Local)
+ v.fail(el, "the content of %s is incomplete", el.Name().Local)
}
}
}
@@ -261,8 +302,11 @@ func (v *validator) matcherFor(decl *Element) (*xsd.SequenceMatcher, error) {
}
// checkAttributes applies the ATTLIST declarations for an element.
-func (v *validator) checkAttributes(el *xdm.Node, path string) {
+func (v *validator) checkAttributes(el *xdm.Node) {
decls := v.dtd.Attributes[el.Name().Local]
+ if len(decls) == 0 {
+ return
+ }
present := map[string]string{}
for a := range el.Attrs() {
name := a.Name().Local
@@ -280,16 +324,16 @@ func (v *validator) checkAttributes(el *xdm.Node, path string) {
switch d.Default {
case AttrRequired:
if !have {
- v.fail(path, "required attribute %s is missing", d.Name)
+ v.fail(el, "required attribute %s is missing", d.Name)
continue
}
case AttrFixed:
if have && val != d.Value {
- v.fail(path, "attribute %s is #FIXED %q but is %q",
+ v.fail(el, "attribute %s is #FIXED %q but is %q",
d.Name, d.Value, val)
}
case AttrInvalid:
- v.fail(path, "attribute %s has an unrecognised default "+
+ v.fail(el, "attribute %s has an unrecognised default "+
"declaration %s, so its presence was not checked",
d.Name, d.Value)
}
@@ -297,7 +341,7 @@ func (v *validator) checkAttributes(el *xdm.Node, path string) {
continue
}
if len(d.Enum) > 0 && !containsString(d.Enum, val) {
- v.fail(path, "attribute %s = %q is not one of %s",
+ v.fail(el, "attribute %s = %q is not one of %s",
d.Name, val, strings.Join(d.Enum, ", "))
}
switch d.Type {
@@ -309,34 +353,34 @@ func (v *validator) checkAttributes(el *xdm.Node, path string) {
// new here, and it is a property of the DECLARATION rather than
// of this element's value — so it is reported once per
// declaration, not once per occurrence.
- v.checkNotationDecl(path, d)
+ v.checkNotationDecl(el, d)
case "ENTITY":
// §3.3.1: the value must be the name of an unparsed entity —
// declared, and declared with an NDATA notation. A parsed
// entity's name is as wrong here as an undeclared one.
- v.checkEntityValue(path, d.Name, val)
+ v.checkEntityValue(el, d.Name, val)
case "ENTITIES":
// The same rule over a whitespace-separated list. An empty list
// is not a Names, so it fails the type as surely as an unknown
// name would.
names := strings.Fields(val)
if len(names) == 0 {
- v.fail(path, "attribute %s is ENTITIES but is empty, which "+
+ v.fail(el, "attribute %s is ENTITIES but is empty, which "+
"names no unparsed entity", d.Name)
}
for _, n := range names {
- v.checkEntityValue(path, d.Name, n)
+ v.checkEntityValue(el, d.Name, n)
}
case "ID":
if v.ids[val] {
- v.fail(path, "duplicate ID %q", val)
+ v.fail(el, "duplicate ID %q", val)
}
v.ids[val] = true
case "IDREF":
- v.refs = append(v.refs, idref{val, path})
+ v.refs = append(v.refs, idref{val, el})
case "IDREFS":
for _, r := range strings.Fields(val) {
- v.refs = append(v.refs, idref{r, path})
+ v.refs = append(v.refs, idref{r, el})
}
case "CDATA", "NMTOKEN", "NMTOKENS", "ENUMERATION":
// Declared, and either unconstrained (CDATA) or constrained by
@@ -348,7 +392,7 @@ func (v *validator) checkAttributes(el *xdm.Node, path string) {
// Without this branch an unrecognised type — "IDREFF" for
// "IDREFS" — falls through to no check at all, and the caller
// cannot tell an attribute that passed from one never examined.
- v.fail(path, "attribute %s is declared with the unrecognised "+
+ v.fail(el, "attribute %s is declared with the unrecognised "+
"type %s, so its value was not checked", d.Name, d.Type)
}
}
@@ -361,7 +405,7 @@ func (v *validator) checkAttributes(el *xdm.Node, path string) {
// It is reported once per declaration rather than once per element carrying
// the attribute, because the fault is in the DTD and repeating it for every
// instance would bury the finding under its own copies.
-func (v *validator) checkNotationDecl(path string, d *Attribute) {
+func (v *validator) checkNotationDecl(el *xdm.Node, d *Attribute) {
if v.partial {
// The declarations were never all read, so a name absent from
// Notations may simply be in the half that was not fetched.
@@ -376,20 +420,20 @@ func (v *validator) checkNotationDecl(path string, d *Attribute) {
v.notationsChecked[key] = true
for _, n := range d.Enum {
if !v.dtd.Notations[n] {
- v.fail(path, "attribute %s permits the notation %s, which no "+
+ v.fail(el, "attribute %s permits the notation %s, which no "+
" declares", d.Name, n)
}
}
}
// checkEntityValue applies §3.3.1's Entity Name constraint to one name.
-func (v *validator) checkEntityValue(path, attr, name string) {
+func (v *validator) checkEntityValue(el *xdm.Node, attr, name string) {
if v.partial {
// As for notations: half a DTD cannot say an entity is undeclared.
return
}
if !v.dtd.Unparsed[name] {
- v.fail(path, "attribute %s = %q names no unparsed entity", attr, name)
+ v.fail(el, "attribute %s = %q names no unparsed entity", attr, name)
}
}
@@ -398,7 +442,7 @@ func (v *validator) checkEntityValue(path, attr, name string) {
func (v *validator) checkIDRefs() {
for _, r := range v.refs {
if !v.ids[r.value] {
- v.fail(r.path, "IDREF %q matches no ID in the document", r.value)
+ v.fail(r.el, "IDREF %q matches no ID in the document", r.value)
}
}
}
diff --git a/internal/xmltok/xmltok.go b/internal/xmltok/xmltok.go
index cf987ee6..3a85fd5e 100644
--- a/internal/xmltok/xmltok.go
+++ b/internal/xmltok/xmltok.go
@@ -162,7 +162,8 @@ type Decoder struct {
// Size, when set, is about how many bytes the stream holds. A long text
// run that outgrows the scratch sizes it from what is left, rather than
- // growing it by append's steps window by window.
+ // growing it by append's steps window by window, and the string arena's
+ // first block is no larger than it.
Size int
src io.Reader
@@ -213,17 +214,22 @@ func NewDecoder(r io.Reader) *Decoder {
return &Decoder{Strict: true, src: r, buf: make([]byte, 0, bufSize)}
}
-// NewDecoderSize is NewDecoder for a stream known to hold about size bytes:
-// its read window is no larger than the input needs, where a small document
-// otherwise paid for 4 KB. The string arena is not sized down: a smaller
-// arena retains less heap per tree, which made the collector run more often
-// (docs/profiling.md, "Measured and rejected").
-func NewDecoderSize(r io.Reader, size int) *Decoder {
- return &Decoder{Strict: true, src: r, buf: make([]byte, 0, min(max(size, minWindow), bufSize))}
+// NewDecoderBuffer is NewDecoder reading through buf, whose capacity is the
+// read window, so that a caller parsing many documents can reuse one. The
+// Decoder writes into buf until the caller is done with it and with every
+// token it returned.
+func NewDecoderBuffer(r io.Reader, buf []byte) *Decoder {
+ return &Decoder{Strict: true, src: r, buf: buf[:0]}
}
-// minWindow is the smallest read window NewDecoderSize uses.
-const minWindow = 512
+// TakeText hands the caller the CharData last returned, to keep: the Decoder
+// builds its next token in a new buffer rather than over it. It is called
+// only directly after RawToken returned a CharData.
+func (d *Decoder) TakeText() []byte {
+ b := d.tokText
+ d.scratch = nil
+ return b
+}
// IsVersion11 reports whether the document declared version="1.1". The
// version comes from the XML declaration and nowhere else, so there is no
@@ -418,6 +424,7 @@ func (d *Decoder) startTag() (Token, error) {
return nil, d.orSyntaxError("expected element name after <")
}
attrs := d.attrs[:0]
+ start := d.InputOffset()
for {
spaced := d.space()
b, ok := d.mustgetc()
@@ -463,6 +470,15 @@ func (d *Decoder) startTag() (Token, error) {
if !ok {
return nil, d.err
}
+ if d.arena.buf == nil && d.Size > 0 {
+ // A document smaller than a block gets a first block its own
+ // size; the values of a larger one, or what entities expand
+ // to, go on to full blocks.
+ d.arena.buf = make([]byte, 0, min(d.Size, arenaBlock))
+ }
+ if len(attrs) == cap(attrs) && len(attrs) >= bigTag {
+ attrs = d.growAttrs(attrs, start)
+ }
attrs = append(attrs, Attr{an, d.arena.String(v)})
}
d.attrs = attrs
@@ -470,6 +486,25 @@ func (d *Decoder) startTag() (Token, error) {
return &d.tokStart, nil
}
+// bigTag is how many attributes a start tag has before startTag stops growing
+// the slice of them by append's steps (1.25x at this size, so a chain of them
+// allocates 5x the final slice).
+const bigTag = 256
+
+// growAttrs makes room in attrs for the rest of a tag with many attributes.
+// The input left, over the bytes an attribute has taken so far, bounds how many
+// remain; ponytail: capped at 8x the slice, so that a tag early in a huge
+// document takes at most that, and at least doubling.
+func (d *Decoder) growAttrs(attrs []Attr, start int64) []Attr {
+ est := len(attrs)
+ if rest := d.Size - int(d.InputOffset()); rest > 0 {
+ if used := int(d.InputOffset() - start); used > 0 {
+ est = rest / max(used/len(attrs), 1)
+ }
+ }
+ return append(make([]Attr, 0, len(attrs)+max(len(attrs), min(est, 8*len(attrs)))), attrs...)
+}
+
// procInst reads [16] PI after "", and treats target "xml" as [23] XMLDecl.
// The target is followed by white space or "?>", and the body is checked
// against [2] Char.
diff --git a/internal/xpathleaf/locals.go b/internal/xpathleaf/locals.go
index 112ac438..4aec00f3 100644
--- a/internal/xpathleaf/locals.go
+++ b/internal/xpathleaf/locals.go
@@ -108,6 +108,18 @@ func (l *Locals) Frozen() *Locals {
vals: append([]xdm.Sequence(nil), l.vals[l.base:]...)}
}
+// FrozenOnly is Frozen keeping only the bindings keep accepts.
+func (l *Locals) FrozenOnly(keep func(uri, local string) bool) *Locals {
+ f := &Locals{}
+ for i := l.base; i < len(l.names); i++ {
+ if n := l.names[i]; keep(n.uri, n.local) {
+ f.names = append(f.names, n)
+ f.vals = append(f.vals, l.vals[i])
+ }
+ }
+ return f
+}
+
// Package xpath sets these in its init; ctx and base are *xpath.Context.
// WithLocals returns a child scope of ctx resolving names through l.
// WithBindingsOf returns a copy of ctx whose variable bindings are base's.
diff --git a/relaxng/choice_dedupe_test.go b/relaxng/choice_dedupe_test.go
index 780590b2..23d39cfa 100644
--- a/relaxng/choice_dedupe_test.go
+++ b/relaxng/choice_dedupe_test.go
@@ -59,3 +59,33 @@ func TestChoiceMergesEqualAlternatives(t *testing.T) {
t.Error("choice(value, value) merged two values")
}
}
+
+// (a?){n} a{n} against n is valid. The derivative is a choice of
+// choices whose chains share nearly every alternative; merging only a
+// whole chain left the copies in, and from n = 13 the pattern passed the
+// 100,000-node bound and the valid document was refused. xmllint agrees
+// that it validates for n = 12, 13, 18 and 30.
+func TestOptionalsBeforeRequiredStayValid(t *testing.T) {
+ for _, n := range []int{12, 13, 18, 30} {
+ var b strings.Builder
+ b.WriteString(``)
+ for i := 0; i < n; i++ {
+ b.WriteString(``)
+ }
+ for i := 0; i < n; i++ {
+ b.WriteString(``)
+ }
+ b.WriteString(``)
+ s, err := compileString(t, b.String())
+ if err != nil {
+ t.Fatalf("compile: %v", err)
+ }
+ doc, err := xdm.ParseString(""+strings.Repeat("", n)+"", xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := s.Validate(doc.Root); err != nil {
+ t.Errorf("n=%d: %v", n, err)
+ }
+ }
+}
diff --git a/relaxng/derive.go b/relaxng/derive.go
index bfd26766..e848d38e 100644
--- a/relaxng/derive.go
+++ b/relaxng/derive.go
@@ -210,22 +210,56 @@ func (pb *patBuilder) choice(a, b pattern) pattern {
if patEq(a, b) || pb.inChoice(a, b) {
return a
}
+ // A choice on the right is merged alternative by alternative. Taking the
+ // derivative of a choice of choices yields chains that share most of their
+ // alternatives, and testing the whole chain against a only finds a chain
+ // that is identical: (a?){13} a{13} then grew past the pattern bound while
+ // holding a few dozen distinct alternatives.
+ //
+ // The merge compares every pair of alternatives, so it is skipped when
+ // the chains are long enough to make that dearer than the copies it
+ // saves: alternatives that never compare equal (values, data) would
+ // otherwise buy a quadratic cost per choice from a hostile document.
+ // ponytail: a fixed pair budget; a hash of the alternatives if a schema
+ // needs more.
+ if c, ok := b.(*choicePat); ok && altCount(a)*altCount(b) <= maxMergePairs {
+ return pb.choice(pb.choice(a, c.Left), c.Right)
+ }
if pb != nil {
return pb.mk(kChoice, a, b)
}
return newChoicePat(a, b)
}
+// maxMergePairs bounds the pairs of alternatives choice compares when it
+// merges one chain into another.
+const maxMergePairs = 1 << 10
+
+// altCount is the number of alternatives in a chain of choices.
+func altCount(p pattern) int {
+ n := 1
+ for {
+ c, ok := unstatic(p).(*choicePat)
+ if !ok {
+ return n
+ }
+ n += altCount(c.Right)
+ p = c.Left
+ }
+}
+
// inChoice reports whether b is already one of a's alternatives. A chain of
// choices is built leftwards, so the alternatives are the Right of each link
// and the Left of the last.
func (pb *patBuilder) inChoice(a, b pattern) bool {
+ // Equal patterns have equal sizes, so a size mismatch skips the walk.
+ sb := patternSize(b)
for {
c, ok := unstatic(a).(*choicePat)
if !ok {
- return patEq(a, b)
+ return patternSize(a) == sb && patEq(a, b)
}
- if patEq(c.Right, b) {
+ if patternSize(c.Right) == sb && patEq(c.Right, b) {
return true
}
a = c.Left
diff --git a/relaxng/namememo_test.go b/relaxng/namememo_test.go
new file mode 100644
index 00000000..4502b927
--- /dev/null
+++ b/relaxng/namememo_test.go
@@ -0,0 +1,54 @@
+package relaxng
+
+import (
+ "fmt"
+ "runtime"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// store used to copy the whole map every time: 1000 stores allocated tens of
+// megabytes. Now the copies sum to O(n). Concurrent readers must stay safe
+// (run under -race) and the cap must hold.
+func TestNameMemoStoreAmortised(t *testing.T) {
+ var c nameMemo
+ var m0, m1 runtime.MemStats
+ runtime.ReadMemStats(&m0)
+ for i := 0; i < maxOpenMemo; i++ {
+ c.store(xdm.QName{Local: fmt.Sprintf("n%d", i)}, emptyPat{})
+ }
+ runtime.ReadMemStats(&m1)
+ if got := m1.TotalAlloc - m0.TotalAlloc; got > 2<<20 {
+ t.Errorf("%d stores allocated %d KB, want under 2048 KB", maxOpenMemo, got>>10)
+ }
+ if _, ok := c.load(xdm.QName{Local: "n0"}); !ok {
+ t.Error("first entry not published")
+ }
+ c.store(xdm.QName{Local: "over"}, emptyPat{})
+ c.store(xdm.QName{Local: "over2"}, emptyPat{})
+ n := len(*c.m.Load()) + len(c.pending)
+ if n > maxOpenMemo {
+ t.Errorf("%d entries, cap is %d", n, maxOpenMemo)
+ }
+}
+
+// Pending entries are published after a few locked hits, so a steady
+// lookup stream becomes lock-free instead of staying on the slow path.
+func TestNameMemoPendingPublishedOnHits(t *testing.T) {
+ var c nameMemo
+ for _, n := range []string{"a", "b"} { // 2nd store leaves one pending
+ c.store(xdm.QName{Local: n}, emptyPat{})
+ }
+ if c.np.Load() == 0 {
+ t.Fatal("setup: expected a pending entry")
+ }
+ for i := 0; i < pendingHitsPublish; i++ {
+ if _, ok := c.load(xdm.QName{Local: "b"}); !ok {
+ t.Fatal("pending entry lost")
+ }
+ }
+ if c.np.Load() != 0 || len(*c.m.Load()) != 2 {
+ t.Errorf("pending not published: np=%d snapshot=%d", c.np.Load(), len(*c.m.Load()))
+ }
+}
diff --git a/relaxng/pattern.go b/relaxng/pattern.go
index f4510ecf..df99bffc 100644
--- a/relaxng/pattern.go
+++ b/relaxng/pattern.go
@@ -175,25 +175,47 @@ type staticInfo struct {
// nameMemo is a copy-on-write map from a name to a pattern, shared by every
// validation of a schema: a lookup is one atomic load and a map read with no
-// boxing, and a store copies the map under a lock. The entries are capped at
+// boxing, and a store lands in a small pending map under a lock and is published as a
+// new snapshot once the pending entries outgrow it. The entries are capped at
// maxOpenMemo so that a document cannot grow it without bound by inventing
// names, which also bounds each copy.
type nameMemo struct {
- m atomic.Pointer[map[nameKey]pattern]
- mu sync.Mutex
+ m atomic.Pointer[map[nameKey]pattern]
+ mu sync.Mutex
+ pending map[nameKey]pattern // stored but not yet published in m; under mu
+ np atomic.Int32 // len(pending), so a load skips the lock when empty
+ hits int // locked pending hits since the last publish; under mu
}
type nameKey struct{ ns, local string }
func (c *nameMemo) load(n xdm.QName) (pattern, bool) {
- m := c.m.Load()
- if m == nil {
+ k := nameKey{n.URI, n.Local}
+ if m := c.m.Load(); m != nil {
+ if p, ok := (*m)[k]; ok {
+ return p, true
+ }
+ }
+ if c.np.Load() == 0 {
return nil, false
}
- p, ok := (*m)[nameKey{n.URI, n.Local}]
+ c.mu.Lock()
+ p, ok := c.pending[k]
+ // A pending hit costs a lock; after a few, publish so later lookups
+ // are lock-free again.
+ if ok {
+ if c.hits++; c.hits >= pendingHitsPublish {
+ c.publishLocked()
+ }
+ }
+ c.mu.Unlock()
return p, ok
}
+// pendingHitsPublish is how many locked lookups of pending entries trigger
+// their publication.
+const pendingHitsPublish = 8
+
func (c *nameMemo) store(n xdm.QName, p pattern) {
c.mu.Lock()
defer c.mu.Unlock()
@@ -201,15 +223,42 @@ func (c *nameMemo) store(n xdm.QName, p pattern) {
if o := c.m.Load(); o != nil {
old = *o
}
- if len(old) >= maxOpenMemo {
- return
+ k := nameKey{n.URI, n.Local}
+ if _, ok := c.pending[k]; !ok {
+ if len(old)+len(c.pending) >= maxOpenMemo {
+ return
+ }
+ }
+ if c.pending == nil {
+ c.pending = make(map[nameKey]pattern)
+ }
+ c.pending[k] = p
+ c.np.Store(int32(len(c.pending)))
+ // Publish once the pending entries outgrow the snapshot, so the copies
+ // sum to O(n) over n stores. A load that misses the snapshot checks the
+ // pending map.
+ if len(c.pending) > len(old) {
+ c.publishLocked()
}
- m := make(map[nameKey]pattern, len(old)+1)
+}
+
+// publishLocked folds the pending entries into a new snapshot.
+func (c *nameMemo) publishLocked() {
+ var old map[nameKey]pattern
+ if o := c.m.Load(); o != nil {
+ old = *o
+ }
+ m := make(map[nameKey]pattern, len(old)+len(c.pending))
for k, v := range old {
m[k] = v
}
- m[nameKey{n.URI, n.Local}] = p
+ for k, v := range c.pending {
+ m[k] = v
+ }
c.m.Store(&m)
+ c.pending = nil
+ c.np.Store(0)
+ c.hits = 0
}
type patBox struct {
diff --git a/relaxng/pattern_size_test.go b/relaxng/pattern_size_test.go
index 5bc3c760..8775fefc 100644
--- a/relaxng/pattern_size_test.go
+++ b/relaxng/pattern_size_test.go
@@ -110,7 +110,7 @@ func TestPlainOneOrMoreStillValidates(t *testing.T) {
// at twelve, which still finishes, rather than hanging at fourteen.
func TestAttributePatternSizeIsBounded(t *testing.T) {
const sch = `
-`
+`
schTree, err := xdm.ParseString(sch, xdm.ParseOptions{})
if err != nil {
t.Fatal(err)
diff --git a/relaxng/validate.go b/relaxng/validate.go
index d93ffcd5..0ed1518e 100644
--- a/relaxng/validate.go
+++ b/relaxng/validate.go
@@ -111,8 +111,8 @@ func (s *Schema) ValidateWithOptions(doc *xdm.Node, opts ValidateOptions) error
return &Error{
Path: v.deepPath,
Message: fmt.Sprintf(
- "the derivative pattern exceeds %d nodes; a oneOrMore nested "+
- "inside a oneOrMore grows the pattern multiplicatively in "+
+ "the derivative pattern exceeds %d nodes; nested repetition "+
+ "or optional content grows the pattern multiplicatively in "+
"the number of children", maxPattern),
}
}
diff --git a/xdm/atomic.go b/xdm/atomic.go
index f062320e..4041874d 100644
--- a/xdm/atomic.go
+++ b/xdm/atomic.go
@@ -669,3 +669,44 @@ func NewGregorian(dt *DateTime, t TypeCode) *Atomic {
func NewDuration(d *Duration, t TypeCode) *Atomic {
return &Atomic{Type: t, ext: &atomicExt{dur: d}, str: d.Lexical(t)}
}
+
+// integerChunk is how many xs:integers AppendIntegers allocates at once.
+const integerChunk = 128
+
+// AppendIntegers appends the count integers lo, lo+1, ... to out. It allocates
+// the Atomics, rationals and numerator words a chunk at a time rather than
+// three allocations per value, which is what NewInteger costs; the values
+// are indistinguishable from NewInteger's. The caller must ensure lo+count-1
+// does not overflow int64.
+func AppendIntegers(out Sequence, lo int64, count int) Sequence {
+ var (
+ atoms []Atomic
+ rats []big.Rat
+ words []big.Word
+ )
+ for i := 0; i < count; i++ {
+ v := lo + int64(i)
+ if v >= smallIntMin && v < smallIntMin+int64(len(smallInts)) {
+ out = append(out, NewInteger(v))
+ continue
+ }
+ if len(atoms) == 0 {
+ n := min(integerChunk, count-i)
+ atoms, rats, words = make([]Atomic, n), make([]big.Rat, n), make([]big.Word, n)
+ }
+ a, r := &atoms[0], &rats[0]
+ if v < 0 {
+ words[0] = big.Word(-uint64(v))
+ } else {
+ words[0] = big.Word(v)
+ }
+ r.Num().SetBits(words[:1:1])
+ if v < 0 {
+ r.Num().Neg(r.Num())
+ }
+ *a = Atomic{Type: TypeInteger, dec: r}
+ atoms, rats, words = atoms[1:], rats[1:], words[1:]
+ out = append(out, a)
+ }
+ return out
+}
diff --git a/xdm/build.go b/xdm/build.go
index 559916f5..a1401791 100644
--- a/xdm/build.go
+++ b/xdm/build.go
@@ -261,10 +261,10 @@ func (t *Tree) CopySourceFrom(src *Tree) {
}
t.CopyDTDFrom(src)
t.XMLVersion = src.XMLVersion
- if s := src.srcText(); s != "" {
- t.ownSource().src = s
+ if l := src.lineIdx(); l != nil {
+ t.ownSource().lines = l
} else if t.source != nil {
- t.source.src = ""
+ t.source.lines = nil
}
}
@@ -279,7 +279,7 @@ func CopyPosition(dst, src *Node) {
if !ok {
return
}
- if _, foreign := src.tree.foreign(src.self); !foreign && dst.tree.srcText() == src.tree.srcText() {
+ if _, foreign := src.tree.foreign(src.self); !foreign && dst.tree.lineIdx() == src.tree.lineIdx() {
dst.setOffset(src.offset())
return
}
diff --git a/xdm/clone_test.go b/xdm/clone_test.go
index 6a967d00..5f3abbbd 100644
--- a/xdm/clone_test.go
+++ b/xdm/clone_test.go
@@ -105,8 +105,8 @@ func TestCloneInternsPastSmallNames(t *testing.T) {
}
got := xdmclone.Clone(tree.Root, xdmclone.Options{})(tree.Root).(*Node)
c := got.Tree()
- if c == nil || c.nameIx == nil {
- t.Fatalf("clone has no name index to exercise (tree %v)", c)
+ if c == nil || len(c.names) <= smallNames {
+ t.Fatalf("clone has too few names to need an index (tree %v)", c)
}
old := c.intern(QName{Local: "e3"})
if c.names[old] != (QName{Local: "e3"}) {
diff --git a/xdm/concurrency_test.go b/xdm/concurrency_test.go
index 2ea88bb7..b05e790a 100644
--- a/xdm/concurrency_test.go
+++ b/xdm/concurrency_test.go
@@ -1,14 +1,15 @@
package xdm
import (
+ "fmt"
"strings"
"sync"
"testing"
)
-// Position resolution builds a line index lazily on first use, guarded by a
-// sync.Once. A parsed tree is shared across concurrent transforms, so several
-// goroutines can reach that initialisation at the same moment.
+// Position resolution reads the tree's line index. A parsed tree is shared
+// across concurrent transforms, so several goroutines resolve positions in it
+// at the same moment.
func TestConcurrentPositionLookup(t *testing.T) {
var b strings.Builder
b.WriteString("\n")
@@ -78,3 +79,31 @@ func TestConcurrentDocumentOrder(t *testing.T) {
}
wg.Wait()
}
+
+// Parses share pooled read windows (readBufs). Concurrent parses of
+// different documents, through readers that hand data over a few bytes at a
+// time, must each build their own tree; run under -race this also checks
+// that no window is used by two parses at once.
+func TestConcurrentParsesShareNoWindow(t *testing.T) {
+ var wg sync.WaitGroup
+ for g := range 8 {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ doc := fmt.Sprintf("%s", g, strings.Repeat(fmt.Sprintf("%d\r\n", g), 500))
+ want := strings.Repeat(fmt.Sprintf("%d\n", g), 500)
+ for range 20 {
+ tree, err := Parse(&chunkReader{s: doc, n: 7}, ParseOptions{})
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if got := tree.Root.StringValue(); got != want {
+ t.Errorf("goroutine %d parsed %.40q, want %.40q", g, got, want)
+ return
+ }
+ }
+ }()
+ }
+ wg.Wait()
+}
diff --git a/xdm/dtd_attnames_test.go b/xdm/dtd_attnames_test.go
index e2fd0eb9..96841dfb 100644
--- a/xdm/dtd_attnames_test.go
+++ b/xdm/dtd_attnames_test.go
@@ -45,9 +45,10 @@ func TestDTDAttributeTypingDoesNotBuildNames(t *testing.T) {
}
el := NewNode(KindElement, QName{Prefix: "schema", Local: "element-with-a-long-name-x"}, "")
el.AppendAttr(QName{Prefix: "prefix", Local: "identifier-with-a-long-name"}, "a")
+ ix := indexDecls(len(types), func(i int) string { return types[i].element })
if n := testing.AllocsPerRun(100, func() {
- normalizeAttTokens(tok, types)
- applyAttTypes(el, types)
+ normalizeAttTokens(tok, types, ix)
+ applyAttTypes(el, types, ix)
}); n != 0 {
t.Errorf("typing an element against 40 declarations allocated %.0f times, want 0", n)
}
diff --git a/xdm/dtd_defaults.go b/xdm/dtd_defaults.go
index 722c328f..e46df8c9 100644
--- a/xdm/dtd_defaults.go
+++ b/xdm/dtd_defaults.go
@@ -34,6 +34,49 @@ type attDefault struct {
entities map[string]int
}
+// declIndex maps the element name an ATTLIST declaration was written with to
+// the positions of its declarations, in order. Every element is matched
+// against the declarations, so scanning them all cost a document of n elements
+// and m declarations n*m.
+type declIndex map[string][]int
+
+func indexDecls(n int, element func(i int) string) declIndex {
+ ix := declIndex{}
+ for i := range n {
+ e := element(i)
+ ix[e] = append(ix[e], i)
+ }
+ return ix
+}
+
+// at returns the positions of the declarations that apply to an element
+// written prefix:local: those naming it as written and those naming its local
+// name alone, in declaration order.
+func (ix declIndex) at(prefix, local string) []int {
+ byLocal := ix[local]
+ if prefix == "" {
+ return byLocal
+ }
+ var buf [64]byte // stays on the stack for a name that fits
+ b := append(append(append(buf[:0], prefix...), ':'), local...)
+ lexical := ix[string(b)]
+ if len(byLocal) == 0 {
+ return lexical
+ }
+ if len(lexical) == 0 {
+ return byLocal
+ }
+ out := make([]int, 0, len(lexical)+len(byLocal))
+ for len(lexical) > 0 && len(byLocal) > 0 {
+ if lexical[0] < byLocal[0] {
+ out, lexical = append(out, lexical[0]), lexical[1:]
+ } else {
+ out, byLocal = append(out, byLocal[0]), byLocal[1:]
+ }
+ }
+ return append(append(out, lexical...), byLocal...)
+}
+
// parseAttListDefaults extracts the defaulted attributes from a DOCTYPE
// internal subset.
//
@@ -193,16 +236,14 @@ func unquote(s string) string {
// that was omitted, it does not override one that was given. Even a #FIXED
// default only constrains what may be written, and validating that constraint
// is DTD validation, which this parser does not do.
-func applyAttDefaults(t xml.StartElement, defs []attDefault) xml.StartElement {
+func applyAttDefaults(t xml.StartElement, defs []attDefault, ix declIndex) xml.StartElement {
var add []xml.Attr
- for _, d := range defs {
- // A DTD is not namespace-aware: an ATTLIST names the element as
- // written, prefix included. Matching the local name alone missed
- // "p:doc", and with it a defaulted xmlns:p the element's own name
- // needs. The local-name match stays, as applyAttTypes has it.
- if d.element != attrLexical(t.Name) && d.element != t.Name.Local {
- continue
- }
+ // A DTD is not namespace-aware: an ATTLIST names the element as
+ // written, prefix included. Matching the local name alone missed
+ // "p:doc", and with it a defaulted xmlns:p the element's own name
+ // needs. The local-name match stays, as applyAttTypes has it.
+ for _, i := range ix.at(t.Name.Space, t.Name.Local) {
+ d := defs[i]
if hasAttrNamed(t.Attr, d.name) {
continue
}
@@ -274,12 +315,10 @@ func lexicalIs(prefix, local, s string) bool {
// Recording the annotation here puts a DTD-validated document on the same
// footing as a schema-validated one, so fn:id finds the same attributes in
// both.
-func applyAttTypes(el *Node, types []attDeclaredType) {
+func applyAttTypes(el *Node, types []attDeclaredType, ix declIndex) {
en := el.Name()
- for _, t := range types {
- if !lexicalIs(en.Prefix, en.Local, t.element) && t.element != en.Local {
- continue
- }
+ for _, i := range ix.at(en.Prefix, en.Local) {
+ t := types[i]
for a := range el.Attrs() {
if an := a.Name(); !lexicalIs(an.Prefix, an.Local, t.attr) && an.Local != t.attr {
continue
diff --git a/xdm/dtd_defaults_test.go b/xdm/dtd_defaults_test.go
index bc58c06e..f32b0748 100644
--- a/xdm/dtd_defaults_test.go
+++ b/xdm/dtd_defaults_test.go
@@ -1,6 +1,11 @@
package xdm
-import "testing"
+import (
+ "fmt"
+ "runtime"
+ "strings"
+ "testing"
+)
// An ATTLIST default is supplied to every matching element, including a
// namespace declaration — "xmlns:p CDATA #FIXED '...'" is how a DTD supplies
@@ -218,3 +223,99 @@ func TestAttListFirstDeclarationBinds(t *testing.T) {
t.Errorf("s/@a = %q, want z1: the first-binds rule is per element", got)
}
}
+
+// A default is declared once and applies to every element. Its value used to
+// be copied into the tree for each element that received it, so 2 MB of input
+// retained 1.9 GB (250 attributes of 8,000 bytes on 1,000 elements). The
+// value is stored once and aliased: here 20 defaults of 4,000 bytes on 200
+// elements need 80 KB, and 16 MB when copied.
+func TestAttListDefaultValuesAreShared(t *testing.T) {
+ const attrs, elems, size = 20, 200, 4000
+ var dtd, doc strings.Builder
+ dtd.WriteString("")
+ for i := 0; i < attrs; i++ {
+ // Half are NMTOKENS, whose value is collapsed per element.
+ typ, val := "CDATA", strings.Repeat("x", size)
+ if i%2 == 1 {
+ typ, val = "NMTOKENS", strings.Repeat("x ", size/2-1)+"xx"
+ }
+ fmt.Fprintf(&dtd, ``, i, typ, val)
+ }
+ doc.WriteString("")
+ for i := 0; i < elems; i++ {
+ doc.WriteString("")
+ }
+ doc.WriteString("")
+ src := doc.String()
+ var tree *Tree
+ var err error
+ var m0, m1 runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&m0)
+ tree, err = ParseString(src, ParseOptions{AllowDOCTYPE: true})
+ runtime.GC()
+ runtime.ReadMemStats(&m1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := int64(m1.HeapAlloc) - int64(m0.HeapAlloc); got > 4<<20 {
+ t.Errorf("tree retains %d KB, want under 4096 KB", got>>10)
+ }
+ n := 0
+ for r := range tree.Root.Children() {
+ for e := range r.Children() {
+ for a := range e.Attrs() {
+ if len(a.Value()) != size {
+ t.Fatalf("attribute %s has %d bytes, want %d", a.Name().Local, len(a.Value()), size)
+ }
+ n++
+ }
+ }
+ }
+ if n != attrs*elems {
+ t.Errorf("%d defaulted attributes, want %d", n, attrs*elems)
+ }
+ runtime.KeepAlive(tree)
+}
+
+// Each element was matched against every ATTLIST declaration, building the
+// element's lexical name for each: 1.4 MB of declarations and elements took
+// 11.8 s. The declarations are now indexed by element name, so the cost per
+// element does not depend on how many others are declared. Measured: 5 MB
+// for 2,000 declarations and 2,000 prefixed elements, against 188 MB scanning
+// them all.
+func TestAttListDefaultsAreIndexedByElement(t *testing.T) {
+ const n = 2000
+ var dtd, doc strings.Builder
+ for i := 0; i < n; i++ {
+ fmt.Fprintf(&dtd, ``, i, i)
+ }
+ doc.WriteString("`)
+ for i := 0; i < n; i++ {
+ fmt.Fprintf(&doc, ``, i)
+ }
+ doc.WriteString("")
+ var tree *Tree
+ var err error
+ b := allocated(func() { tree, err = ParseString(doc.String(), ParseOptions{AllowDOCTYPE: true}) })
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b > 24<<20 {
+ t.Errorf("parse allocated %d MB, want under 24", b>>20)
+ } else {
+ t.Logf("parse allocated %d KB", b>>10)
+ }
+ i := 0
+ for r := range tree.Root.Children() {
+ for e := range r.Children() {
+ if got := e.Attr("", "d"); got == nil || got.Value() != fmt.Sprintf("v%d", i) {
+ t.Fatalf("element %d: default d = %v", i, got)
+ }
+ i++
+ }
+ }
+ if i != n {
+ t.Errorf("%d elements, want %d", i, n)
+ }
+}
diff --git a/xdm/dtd_external.go b/xdm/dtd_external.go
index 310ceb37..1c3e3afb 100644
--- a/xdm/dtd_external.go
+++ b/xdm/dtd_external.go
@@ -1,6 +1,7 @@
package xdm
import (
+ "bufio"
"bytes"
"fmt"
"io"
@@ -148,7 +149,7 @@ func (t *entityTable) fetchExternal(systemID, publicID, base string) (string, st
// UTF-16 entity included by a UTF-8 document is UTF-16 still. The same
// detection the document entity goes through decodes it, and drops the
// byte order mark, which is not part of the replacement text.
- dec, err := decodeReader(bytes.NewReader(data), readWindow(len(data)))
+ dec, err := decodeReader(bufio.NewReaderSize(bytes.NewReader(data), readWindow(len(data))))
if err == nil {
data, err = io.ReadAll(dec)
}
diff --git a/xdm/encoding.go b/xdm/encoding.go
index 4faa6635..ab7d3a67 100644
--- a/xdm/encoding.go
+++ b/xdm/encoding.go
@@ -29,8 +29,7 @@ import (
// The BOM is consumed when present. A UTF-8 BOM is also removed: it is legal
// and carries no information, but encoding/xml treats it as content and a
// document beginning with one would fail to parse.
-func decodeReader(r io.Reader, window int) (io.Reader, error) {
- br := bufio.NewReaderSize(r, window)
+func decodeReader(br *bufio.Reader) (io.Reader, error) {
prefix, err := br.Peek(4)
if err != nil && err != io.EOF && !errIsShort(err) {
return nil, err
diff --git a/xdm/index_test.go b/xdm/index_test.go
new file mode 100644
index 00000000..40724f2d
--- /dev/null
+++ b/xdm/index_test.go
@@ -0,0 +1,110 @@
+package xdm
+
+import (
+ "bytes"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "testing"
+)
+
+// checkIndex walks every node of t, attributes included, in document order and
+// checks that Index counts 0, 1, 2, ... with no gap, that NodeAt inverts it,
+// that a subtree's SubtreeEnd is one past its last index, and that namespace
+// nodes have no index.
+func checkIndex(t *testing.T, tr *Tree) {
+ t.Helper()
+ next := 0
+ var walk func(n *Node)
+ walk = func(n *Node) {
+ if got := n.Index(); got != next {
+ t.Fatalf("%v %v: Index %d, want %d", n.Kind(), n.Name(), got, next)
+ }
+ if tr.NodeAt(next) != n {
+ t.Fatalf("NodeAt(%d) is not the node whose Index is %d", next, next)
+ }
+ next++
+ for ns := range n.NamespaceNodes() {
+ if ns.Index() != -1 || ns.SubtreeEnd() != -1 {
+ t.Fatalf("namespace node has Index %d", ns.Index())
+ }
+ }
+ for a := range n.Attrs() {
+ if a.Index() != next || a.SubtreeEnd() != next+1 {
+ t.Fatalf("attribute %v: Index %d End %d, want %d", a.Name(), a.Index(), a.SubtreeEnd(), next)
+ }
+ if tr.NodeAt(next) != a {
+ t.Fatalf("NodeAt(%d) is not the attribute", next)
+ }
+ next++
+ }
+ for c := range n.Children() {
+ walk(c)
+ }
+ if n.SubtreeEnd() != next {
+ t.Fatalf("%v: SubtreeEnd %d, want %d", n.Name(), n.SubtreeEnd(), next)
+ }
+ }
+ walk(tr.Root)
+ if tr.Len() != next {
+ t.Fatalf("Len %d, want %d", tr.Len(), next)
+ }
+ if tr.NodeAt(-1) != nil || tr.NodeAt(next) != nil || tr.NodeAt(next+1<<20) != nil {
+ t.Fatal("NodeAt out of range is not nil")
+ }
+}
+
+func TestNodeIndexDense(t *testing.T) {
+ var ns strings.Builder
+ ns.WriteString(``)
+ for i := 0; i < 50; i++ {
+ ns.WriteString(`t`)
+ }
+ ns.WriteString(``)
+ docs := map[string][]byte{"namespaces": []byte(ns.String())}
+ for name, glob := range map[string]string{
+ "docbook": "xsltng/src/test/resources/xml/*.xml",
+ "xmark": "bench/xmark/auction-0.01.xml",
+ } {
+ paths, _ := filepath.Glob(filepath.Join("..", "testdata", filepath.FromSlash(glob)))
+ sort.Strings(paths)
+ if len(paths) > 20 {
+ paths = paths[:20]
+ }
+ for _, p := range paths {
+ if data, err := os.ReadFile(p); err == nil {
+ docs[name+":"+filepath.Base(p)] = data
+ }
+ }
+ }
+ for name, data := range docs {
+ tr, err := Parse(bytes.NewReader(data), ParseOptions{MaxBytes: -1})
+ if err != nil {
+ continue // a corpus file that needs more than defaults
+ }
+ t.Run(name, func(t *testing.T) { checkIndex(t, tr) })
+ }
+}
+
+// A tree still being built numbers what it has so far, and taking back the
+// last subtree frees its indices.
+func TestNodeIndexBuilt(t *testing.T) {
+ root := NewNode(KindElement, QName{Local: "r"}, "")
+ a := root.AppendElement(QName{Local: "a"})
+ if root.Index() != 0 || a.Index() != 1 || root.SubtreeEnd() != 2 {
+ t.Fatalf("index %d %d end %d", root.Index(), a.Index(), root.SubtreeEnd())
+ }
+ tr := root.tree
+ if tr.NodeAt(1) != a || tr.NodeAt(2) != nil {
+ t.Fatal("NodeAt on a built tree")
+ }
+ b := root.AppendElement(QName{Local: "b"})
+ if b.Index() != 2 || root.SubtreeEnd() != 3 || tr.Len() != 3 {
+ t.Fatalf("after append: %d end %d len %d", b.Index(), root.SubtreeEnd(), tr.Len())
+ }
+ root.RemoveLastChild()
+ if tr.Len() != 2 || tr.NodeAt(2) != nil {
+ t.Fatalf("after take-back: len %d", tr.Len())
+ }
+}
diff --git a/xdm/integers_test.go b/xdm/integers_test.go
new file mode 100644
index 00000000..0708ae04
--- /dev/null
+++ b/xdm/integers_test.go
@@ -0,0 +1,33 @@
+package xdm
+
+import (
+ "math"
+ "testing"
+)
+
+// AppendIntegers gives the values NewInteger gives, including across the
+// shared small-integer range, negatives and the int64 edges, in far fewer
+// allocations.
+func TestAppendIntegers(t *testing.T) {
+ for _, lo := range []int64{-300, -17, 0, 250, 1000, math.MaxInt64 - 299, math.MinInt64} {
+ const n = 300
+ got := AppendIntegers(nil, lo, n)
+ if len(got) != n {
+ t.Fatalf("lo=%d: %d items", lo, len(got))
+ }
+ for i, it := range got {
+ want := NewInteger(lo + int64(i))
+ a := it.(*Atomic)
+ if a.Type != TypeInteger || a.String() != want.String() || a.Rat().Cmp(want.Rat()) != 0 || a.Int64() != lo+int64(i) {
+ t.Fatalf("lo=%d item %d: got %v want %v", lo, i, a, want)
+ }
+ }
+ }
+ if got := AppendIntegers(nil, math.MaxInt64, 1); got[0].(*Atomic).Int64() != math.MaxInt64 {
+ t.Error("MaxInt64")
+ }
+ chunked := testing.AllocsPerRun(5, func() { AppendIntegers(nil, 1000, 1280) })
+ if chunked > 100 { // NewInteger per value is 3 x 1280
+ t.Errorf("%v allocations for 1280 integers", chunked)
+ }
+}
diff --git a/xdm/lineend.go b/xdm/lineend.go
index 8a404336..0dd9040a 100644
--- a/xdm/lineend.go
+++ b/xdm/lineend.go
@@ -38,8 +38,9 @@ type lineEndReader struct {
err error
}
-func newLineEndReader(r io.Reader, window int) *lineEndReader {
- return &lineEndReader{src: r, scratch: make([]byte, 0, window)}
+// newLineEndReader reads r through scratch, whose capacity is the window.
+func newLineEndReader(r io.Reader, scratch []byte) *lineEndReader {
+ return &lineEndReader{src: r, scratch: scratch[:0]}
}
func (l *lineEndReader) Read(p []byte) (int, error) {
diff --git a/xdm/lineend_test.go b/xdm/lineend_test.go
index f3d86b7c..e543d843 100644
--- a/xdm/lineend_test.go
+++ b/xdm/lineend_test.go
@@ -20,7 +20,7 @@ func TestLineEndReader(t *testing.T) {
}
for _, tc := range cases {
for chunk := 1; chunk <= len(tc.in); chunk++ {
- r := newLineEndReader(&chunkReader{s: tc.in, n: chunk}, 4096)
+ r := newLineEndReader(&chunkReader{s: tc.in, n: chunk}, make([]byte, 0, 4096))
got, err := io.ReadAll(r)
if err != nil {
t.Fatal(err)
diff --git a/xdm/lineindex_clip_test.go b/xdm/lineindex_clip_test.go
new file mode 100644
index 00000000..ad3d42c4
--- /dev/null
+++ b/xdm/lineindex_clip_test.go
@@ -0,0 +1,30 @@
+package xdm
+
+import (
+ "strings"
+ "testing"
+)
+
+// The line index is built by append, which leaves its slack allocated; a
+// parse must hand back an index with none (TrackPositions on a 100 MB
+// document kept 0.9 MB unused).
+func TestTrackPositionsLineIndexHasNoSlack(t *testing.T) {
+ for _, n := range []int{1000, 5000} { // 5000 lands mid-doubling
+ src := "" + strings.Repeat("\n", n) + ""
+ opts := ParseOptions{TrackPositions: true}
+ fromString, err := ParseString(src, opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ fromReader, err := Parse(strings.NewReader(src), opts)
+ if err != nil {
+ t.Fatal(err)
+ }
+ for _, tree := range []*Tree{fromString, fromReader} {
+ l := tree.lineIdx()
+ if len(l.starts) < n || cap(l.starts) != len(l.starts) {
+ t.Errorf("n=%d: line index len %d cap %d", n, len(l.starts), cap(l.starts))
+ }
+ }
+ }
+}
diff --git a/xdm/maparray.go b/xdm/maparray.go
index 4ca2093a..f7fc93ff 100644
--- a/xdm/maparray.go
+++ b/xdm/maparray.go
@@ -434,18 +434,40 @@ func (a *ArrayItem) Members() []Sequence {
return out
}
+// AppendMembers appends the members to dst and returns it. It is Members
+// without the intermediate copy, for a caller that is building a new slice
+// anyway.
+func (a *ArrayItem) AppendMembers(dst []Sequence) []Sequence {
+ return append(dst, a.members...)
+}
+
// Flatten replaces every array in a sequence with its members, recursively,
// which is what array:flatten and the function conversion rules do.
func Flatten(seq Sequence) Sequence {
out := make(Sequence, 0, len(seq))
- for _, it := range seq {
- arr, ok := it.(*ArrayItem)
- if !ok {
- out = append(out, it)
+ // An explicit stack rather than recursion: an array nested a million deep
+ // is a value a query can build, and recursing on it overflows the Go stack.
+ type frame struct {
+ seqs []Sequence // the member sequences still to walk
+ i int // next item within seqs[0]
+ }
+ stack := []frame{{seqs: []Sequence{seq}}}
+ for len(stack) > 0 {
+ f := &stack[len(stack)-1]
+ if len(f.seqs) == 0 {
+ stack = stack[:len(stack)-1]
continue
}
- for _, m := range arr.members {
- out = append(out, Flatten(m)...)
+ if f.i == len(f.seqs[0]) {
+ f.seqs, f.i = f.seqs[1:], 0
+ continue
+ }
+ it := f.seqs[0][f.i]
+ f.i++
+ if arr, ok := it.(*ArrayItem); ok {
+ stack = append(stack, frame{seqs: arr.members})
+ } else {
+ out = append(out, it)
}
}
return out
diff --git a/xdm/node.go b/xdm/node.go
index 4d0384d1..3f0fb209 100644
--- a/xdm/node.go
+++ b/xdm/node.go
@@ -4,7 +4,7 @@ import (
"errors"
"math"
"math/big"
- "sort"
+ "slices"
"strconv"
"strings"
@@ -598,7 +598,7 @@ func (n *Node) FirstElement(uri, local string) *Node {
//
// A caller that caches trees needs it: one parsed without positions cannot
// serve a request that needs them, and the only way to tell is to ask.
-func (t *Tree) HasPositions() bool { return t != nil && t.srcText() != "" }
+func (t *Tree) HasPositions() bool { return t != nil && t.lineIdx() != nil }
// positionAt converts a byte offset into a 1-based line and column.
//
@@ -606,25 +606,19 @@ func (t *Tree) HasPositions() bool { return t != nil && t.srcText() != "" }
// decoder, and a caller pointing an editor at the failure wants the same
// units the decoder used.
func (t *Tree) positionAt(off int) (line, col int, ok bool) {
- s := t.source
- if s == nil || s.src == "" || off < 0 || off > len(s.src) {
+ l := t.lineIdx()
+ if l == nil || off < 0 || off > l.size {
return 0, 0, false
}
- s.lineOnce.Do(func() {
- // Line 1 starts at offset 0; every byte after a newline starts another.
- s.lineStarts = append(s.lineStarts, 0)
- for i := 0; i < len(s.src); i++ {
- if s.src[i] == '\n' {
- s.lineStarts = append(s.lineStarts, i+1)
- }
- }
- })
// The line is the last one starting at or before off.
- i := sort.SearchInts(s.lineStarts, off+1) - 1
+ i, found := slices.BinarySearch(l.starts, int32(off))
+ if !found {
+ i--
+ }
if i < 0 {
return 0, 0, false
}
- return i + 1, off - s.lineStarts[i] + 1, true
+ return i + 1, off - int(l.starts[i]) + 1, true
}
// The six functions below are the name-only face of the type derivation
diff --git a/xdm/nsbindings.go b/xdm/nsbindings.go
new file mode 100644
index 00000000..db4df358
--- /dev/null
+++ b/xdm/nsbindings.go
@@ -0,0 +1,52 @@
+package xdm
+
+// InScopeNamespaceBindings iterates the prefix-to-URI bindings in scope on
+// an element, the same set InScopeNamespaces returns, without making a
+// namespace node for any of them or a map to hold them. Other kinds have
+// none.
+//
+// Each in-scope prefix is yielded once, with the URI the innermost
+// declaration gives it, and the xml prefix is always among them. A prefix
+// undeclared by xmlns:p="" (or a default undeclared by xmlns="") is not
+// yielded. The order is unspecified; use NamespaceNodes for the namespace
+// axis' prefix order.
+//
+// The cost of each binding is a scan of the ones yielded before it, so the
+// iteration suits a scope of the usual size.
+func (n *Node) InScopeNamespaceBindings() func(yield func(prefix, uri string) bool) {
+ return func(yield func(prefix, uri string) bool) {
+ if n.kind != uint8(KindElement) || n.flags&fSide != 0 {
+ return
+ }
+ if !yield("xml", NSXML) {
+ return
+ }
+ var buf [16]string
+ seen := buf[:0] // prefixes already decided: yielded or undeclared
+ for cur := n; cur != nil; cur = cur.Parent() {
+ if cur.kind != uint8(KindElement) || cur.flags&fSide != 0 {
+ continue
+ }
+ f := cur.frame()
+ for i := len(f) - 1; i >= 0; i-- { // the last declaration wins
+ b := f[i]
+ if b.prefix == "xml" || containsString(seen, b.prefix) {
+ continue
+ }
+ seen = append(seen, b.prefix)
+ if b.uri != "" && !yield(b.prefix, b.uri) {
+ return
+ }
+ }
+ }
+ }
+}
+
+func containsString(s []string, x string) bool {
+ for _, v := range s {
+ if v == x {
+ return true
+ }
+ }
+ return false
+}
diff --git a/xdm/nsbindings_test.go b/xdm/nsbindings_test.go
new file mode 100644
index 00000000..ee0182e4
--- /dev/null
+++ b/xdm/nsbindings_test.go
@@ -0,0 +1,80 @@
+package xdm
+
+import (
+ "maps"
+ "runtime"
+ "testing"
+)
+
+func TestInScopeNamespaceBindings(t *testing.T) {
+ tr, err := ParseString(``, ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var walk func(n *Node)
+ walk = func(n *Node) {
+ if n.Kind() == KindElement {
+ got := map[string]string{}
+ for p, u := range n.InScopeNamespaceBindings() {
+ if _, dup := got[p]; dup {
+ t.Errorf("%s: prefix %q yielded twice", n.Name().Local, p)
+ }
+ got[p] = u
+ }
+ if want := n.InScopeNamespaces(); !maps.Equal(got, want) {
+ t.Errorf("%s: got %v, want %v", n.Name().Local, got, want)
+ }
+ }
+ for c := n.FirstChild(); c != nil; c = c.NextSibling() {
+ walk(c)
+ }
+ }
+ walk(tr.Root)
+ // Yielding stops when asked, and no namespace node is made on the way.
+ r := tr.Root.FirstChild()
+ k := 0
+ for range r.InScopeNamespaceBindings() {
+ k++
+ break
+ }
+ if k != 1 {
+ t.Errorf("break did not stop the iteration")
+ }
+ if len(tr.side) != 0 {
+ t.Errorf("iterating made %d namespace nodes", len(tr.side))
+ }
+ if a := testing.AllocsPerRun(10, func() {
+ for range r.InScopeNamespaceBindings() {
+ }
+ }); a != 0 {
+ t.Errorf("iteration allocated %v times", a)
+ }
+}
+
+// Making the namespace nodes of an element must not rebuild its scope for
+// each one: that was quadratic in the bindings in scope.
+func TestNamespaceNodesAllocation(t *testing.T) {
+ doc := ``
+ tr, err := ParseString(doc, ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ r := tr.Root.FirstChild()
+ n := 0
+ var a, b runtime.MemStats
+ runtime.ReadMemStats(&a)
+ for range r.NamespaceNodes() {
+ n++
+ }
+ runtime.ReadMemStats(&b)
+ if n != 41 {
+ t.Fatalf("got %d namespace nodes, want 41", n)
+ }
+ if m := b.Mallocs - a.Mallocs; m > 200 { // a scope rebuild per node cost over 1000
+ t.Errorf("%d allocations for 41 namespace nodes", m)
+ }
+}
diff --git a/xdm/parse.go b/xdm/parse.go
index e3f3b835..6fbcb9ba 100644
--- a/xdm/parse.go
+++ b/xdm/parse.go
@@ -1,12 +1,14 @@
package xdm
import (
+ "bufio"
"bytes"
"fmt"
"io"
"math"
"net/url"
"strings"
+ "sync"
"unsafe"
xml "github.com/knroy/go-xml/v2/internal/xmltok"
@@ -175,7 +177,7 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
// that costs more than accepting. Wrapped here the ReadAll hits the
// limited reader and stops at the bound.
// A reader that knows its length (strings.Reader, bytes.Reader) lets the
- // position-tracking copy below be sized once instead of grown by doubling.
+ // tokeniser's buffers be sized to it.
sizeHint := 0
if l, ok := r.(interface{ Len() int }); ok {
sizeHint = l.Len()
@@ -197,19 +199,16 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
r = &countingReader{r: io.LimitReader(r, lim), max: maxBytes}
}
- // Position tracking needs the source text to count lines in, and the
- // reader is consumed by the decoder. Tee it into a buffer rather than
- // reading it all up front, so that a parse failing early on a huge
- // document does not first pull the whole thing into memory.
// UTF-16 is decoded to UTF-8 first. XML 1.0 §4.3.3 makes both encodings
// mandatory, and encoding/xml reads only UTF-8 — so without this a
// UTF-16 document fails with "invalid UTF-8" rather than being read.
// This happens before the tee, so that position tracking counts lines
// in the text the decoder actually sees.
- // The read windows are sized to a document known to be small: a 2 KB
- // document otherwise paid for three 4 KB buffers it never filled.
- window := readWindow(sizeHint)
- decoded, err := decodeReader(r, window)
+ // The three read windows are reused parse to parse (see readBufs).
+ bufs := readBufPool.Get().(*readBufs)
+ defer bufs.release()
+ bufs.br.Reset(r)
+ decoded, err := decodeReader(bufs.br)
if err != nil {
return nil, fmt.Errorf("parse XML: %w", err)
}
@@ -219,31 +218,35 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
// parsing. See xdm/lineend.go. Attribute-value normalization (section
// 3.3.3) is the tokeniser's, which still sees "
" apart from a
// newline the author typed.
- r = newLineEndReader(r, window)
+ r = newLineEndReader(r, bufs.lineEnd)
trackPos := opts.TrackPositions
var srcBuf strings.Builder
- // The source is kept when positions are tracked, and also when a DOCTYPE
- // is permitted: an entity whose replacement text holds markup forces a
- // re-parse of the substituted source, and by the time that is known the
- // reader is partly consumed and the decoder has buffered ahead into it.
+ // The source is copied when a DOCTYPE is permitted: an entity whose
+ // replacement text holds markup forces a re-parse of the substituted
+ // source, and by the time that is known the reader is partly consumed and
+ // the decoder has buffered ahead into it.
//
// The copy is needed only until the document element opens: a re-parse
// can start only at the DOCTYPE, which must come first, so past that point
- // the tee stops and its copy is dropped unless positions are tracked.
- // entitiesExpanded marks the second parse, which has no entities left to
- // find and so needs no copy at all.
- // Only a tracked copy is kept to the end, so only it is sized up front;
- // never past MaxBytes, which the reader will refuse to go beyond.
+ // the tee stops copying and the copy is dropped. entitiesExpanded marks
+ // the second parse, which has no entities left to find and so needs no
+ // copy at all.
+ //
+ // Positions need only where the source's lines start, which the tee
+ // records as the text goes by.
if !verbatim(whole) {
whole = ""
}
- if whole == "" && trackPos && sizeHint > 0 && (maxBytes <= 0 || int64(sizeHint) <= maxBytes) {
- srcBuf.Grow(sizeHint)
- }
var tee *srcTee
if whole == "" && (trackPos || (opts.AllowDOCTYPE && !opts.entitiesExpanded)) {
- tee = &srcTee{r: r, buf: &srcBuf}
+ tee = &srcTee{r: r}
+ if opts.AllowDOCTYPE && !opts.entitiesExpanded {
+ tee.buf = &srcBuf
+ }
+ if trackPos {
+ tee.lines = &lineIndex{}
+ }
r = tee
}
@@ -262,7 +265,7 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
r = charger
}
- dec := xml.NewDecoderSize(r, window)
+ dec := xml.NewDecoderBuffer(r, bufs.dec)
dec.Size = sizeHint
dec.CharsetReader = charsetReader
// Leave Strict on: a validator must not silently accept malformed input.
@@ -319,6 +322,9 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
// the common case is none at all.
var attDefaults []attDefault
var attTypes []attDeclaredType
+ // The declarations by element name, built when the first element needs
+ // them, after the DOCTYPE has settled both lists.
+ var defIdx, typeIdx declIndex
// Element names whose DTD content model is element-only. Whitespace-only
// text in such an element is ignorable (XML §2.10) and is stripped
// regardless of what the stylesheet declares (XSLT 2.0 §4.4).
@@ -381,7 +387,7 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
// kept for one is dropped; and with no entity table installed
// by now none ever will be, so the charge reader has nothing
// to buffer for.
- if tee != nil && !trackPos {
+ if tee != nil {
tee.buf = nil
srcBuf = strings.Builder{}
}
@@ -391,10 +397,16 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
}
}
if len(attDefaults) > 0 {
- t = applyAttDefaults(t, attDefaults)
+ if defIdx == nil {
+ defIdx = indexDecls(len(attDefaults), func(i int) string { return attDefaults[i].element })
+ }
+ t = applyAttDefaults(t, attDefaults, defIdx)
}
if len(attTypes) > 0 {
- t = normalizeAttTokens(t, attTypes)
+ if typeIdx == nil {
+ typeIdx = indexDecls(len(attTypes), func(i int) string { return attTypes[i].element })
+ }
+ t = normalizeAttTokens(t, attTypes, typeIdx)
}
if err := validateStartElement(t, &ns, dec.IsVersion11()); err != nil {
return nil, err
@@ -420,7 +432,7 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
}
}
if len(attTypes) > 0 {
- applyAttTypes(el, attTypes)
+ applyAttTypes(el, attTypes, typeIdx)
}
// Attributes and namespaces are nodes too, and a document made
// of elements carrying many attributes allocates most of its
@@ -468,7 +480,7 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
sawPrologToken = true
continue
}
- run.add(cur, t)
+ run.add(cur, t, dec)
case *xml.Comment:
t := *tok
@@ -636,6 +648,11 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
if attDefaults, err = normalizeAttDefaults(attDefaults, ents, dec.IsVersion11(), wfc); err != nil {
return nil, fmt.Errorf("parse XML: %w", err)
}
+ // Each default lands in every matching element; store its
+ // value once so that they all alias it.
+ for _, d := range attDefaults {
+ tree.text.share(d.value)
+ }
if !wfc {
declared := ents
dec.Undeclared = func(name string) bool { return !declared.declares(name) }
@@ -709,16 +726,29 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
}
tree.closeAll()
tree.frozen = true
+ tree.text.trim()
+ // The indexes only building uses go; a tree renamed or cloned later
+ // rebuilds the ones it needs.
+ tree.nameIx, tree.nameHot = nil, nil
+ tree.frameOne, tree.frameMany = nil, nil
+ tree.open = nil
if trackPos {
// The decoder stops reading at the end of the root element, so the
- // tee holds everything up to there — which is all any offset can
+ // tee has seen everything up to there — which is all any offset can
// point into.
- src := srcBuf.String()
+ lines := &lineIndex{}
if whole != "" {
- src = whole
+ lines.add(unsafe.Slice(unsafe.StringData(whole), len(whole)))
+ } else {
+ lines = tee.lines
}
- tree.ownSource().src = src
+ // append's doubling leaves up to a third of the index unused, kept
+ // for the life of the tree.
+ if cap(lines.starts) > len(lines.starts) {
+ lines.starts = append(make([]int32, 0, len(lines.starts)), lines.starts...)
+ }
+ tree.ownSource().lines = lines
}
tree.XMLVersion = "1.0"
if dec.IsVersion11() {
@@ -727,6 +757,30 @@ func parse(r io.Reader, whole string, opts ParseOptions) (*Tree, error) {
return tree, nil
}
+// readBufs are a parse's read windows: decodeReader's look at the byte order
+// mark, line-end folding's and the tokeniser's. Nothing read through them
+// outlives the parse -- the tree holds copies of what the tokens held -- so
+// they are pooled rather than allocated per document, which for small
+// documents was a sixth of what a parse allocated.
+type readBufs struct {
+ br *bufio.Reader
+ lineEnd, dec []byte
+}
+
+var readBufPool = sync.Pool{New: func() any {
+ return &readBufs{
+ br: bufio.NewReaderSize(nil, 4096),
+ lineEnd: make([]byte, 0, 4096),
+ dec: make([]byte, 0, 4096),
+ }
+}}
+
+// release returns b to the pool, without the caller's reader.
+func (b *readBufs) release() {
+ b.br.Reset(nil)
+ readBufPool.Put(b)
+}
+
// readWindow is the read buffer size for an input of size bytes (0:
// unknown): the input's length, within [512, 4096]. 512 leaves room for
// decodeReader's look at the XML declaration.
@@ -781,6 +835,9 @@ func buildElement(t xml.StartElement, parent *Node, ns *nsScope, names *nameCach
// validateStartElement has refused an unbound prefix.
uri, _ := ns.lookup(t.Name.Space)
el.name = names.intern(tree, QName{Prefix: t.Name.Space, Local: t.Name.Local, URI: uri})
+ if len(t.Attr) >= bigTag {
+ tree.reserveNames(len(t.Attr))
+ }
for _, a := range t.Attr {
if a.Name.Space == "xmlns" || (a.Name.Space == "" && a.Name.Local == "xmlns") {
continue
@@ -795,7 +852,7 @@ func buildElement(t xml.StartElement, parent *Node, ns *nsScope, names *nameCach
if a.Name.Space == "xml" {
switch a.Name.Local {
case "base":
- el.SetBaseURI(resolveBase(el.BaseURI(), a.Value))
+ el.SetBaseURI(resolveBase(el.BaseURI(), strings.Clone(a.Value))) // out of the arena, as nsScope.declare
case "id":
// xml:id is an ID by definition, and the xml:id
// Recommendation requires its value to be normalised as one,
@@ -870,7 +927,7 @@ type textRun struct {
stored bool // the run did not start with white space and is in the store
}
-func (r *textRun) add(parent *Node, b []byte) {
+func (r *textRun) add(parent *Node, b []byte, dec *xml.Decoder) {
if len(b) == 0 {
return
}
@@ -884,7 +941,14 @@ func (r *textRun) add(parent *Node, b []byte) {
// A run that starts with anything but white space can be neither
// stripped nor shared, so it goes straight to the store.
if !onlySpace(b) {
- r.node.v0, r.node.v1 = r.node.tree.text.addBytes(b, 0)
+ if len(b) >= textOwnFrom && cap(b)-len(b) <= len(b)/8 {
+ // Long enough for a block of its own, and not much larger
+ // than its value: the decoder's buffer becomes the block,
+ // rather than being copied into one.
+ r.node.v0, r.node.v1 = r.node.tree.text.keep(dec.TakeText())
+ } else {
+ r.node.v0, r.node.v1 = r.node.tree.text.addBytes(b, 0)
+ }
r.stored = true
return
}
@@ -1212,10 +1276,12 @@ func charsetReader(charset string, input io.Reader) (io.Reader, error) {
return nil, fmt.Errorf("unsupported encoding %q", charset)
}
-// srcTee copies what is read from r into buf until buf is set to nil.
+// srcTee copies what is read from r into buf until buf is set to nil, and
+// indexes its lines in lines when that is set.
type srcTee struct {
- r io.Reader
- buf *strings.Builder
+ r io.Reader
+ buf *strings.Builder
+ lines *lineIndex
}
func (t *srcTee) Read(p []byte) (int, error) {
@@ -1223,6 +1289,9 @@ func (t *srcTee) Read(p []byte) (int, error) {
if t.buf != nil {
t.buf.Write(p[:n])
}
+ if t.lines != nil {
+ t.lines.add(p[:n])
+ }
return n, err
}
diff --git a/xdm/parsewindow_test.go b/xdm/parsewindow_test.go
index a19f99c1..d2b11103 100644
--- a/xdm/parsewindow_test.go
+++ b/xdm/parsewindow_test.go
@@ -7,7 +7,7 @@ import (
"testing"
)
-// A reader that knows its length gets read buffers and a first arena block
+// A reader that knows its length gets a first arena block and a text scratch
// sized to it. The tree must not depend on that: each document here parses
// to the same tree through a reader that hides its length (default sizes),
// including tokens longer than the window and values that overflow the first
@@ -39,9 +39,9 @@ func TestSizedParseMatchesUnsized(t *testing.T) {
}
// A small document's parse allocated three 4 KB read windows whatever its
-// size. Measured on this document: 47,576 bytes a parse with them, 36,942
-// with windows sized to it (most of the rest is the 32 KB string arena,
-// which is kept: see docs/profiling.md, "Measured and rejected").
+// size, and a 32 KB string arena block. Measured on this document: 47,576
+// bytes a parse with them, 36,942 with windows sized to it, 4,424 with the
+// arena's first block sized to it too, and 2,879 with the windows pooled.
func TestSmallParseAllocatesLittle(t *testing.T) {
if raceEnabled {
t.Skip("allocation sizes are not stable under -race")
@@ -58,7 +58,8 @@ func TestSmallParseAllocatesLittle(t *testing.T) {
runtime.ReadMemStats(&m1)
per := (m1.TotalAlloc - m0.TotalAlloc) / n
t.Logf("%d bytes per parse", per)
- if per > 42<<10 {
- t.Errorf("%d bytes per parse of a %d-byte document, want at most 42 KB", per, len(doc))
+ // Midway between the pooled windows and windows sized to the document.
+ if per > 3650 {
+ t.Errorf("%d bytes per parse of a %d-byte document, want at most 3,650", per, len(doc))
}
}
diff --git a/xdm/recentnames_weak_test.go b/xdm/recentnames_weak_test.go
new file mode 100644
index 00000000..b09d3b7e
--- /dev/null
+++ b/xdm/recentnames_weak_test.go
@@ -0,0 +1,33 @@
+package xdm
+
+import (
+ "runtime"
+ "testing"
+ "weak"
+)
+
+// The cache of shared typing names must not keep a TypeEnvironment, and so a
+// dropped schema, alive: a process that reloads schemas and validates typed
+// copies grew by about 23 MB before the slots were weak.
+func TestRecentNamesDoNotPinTypeEnvironment(t *testing.T) {
+ var nt nodeTyping
+ env := &TypeEnvironment{}
+ w := weak.Make(env)
+ setNames(&nt, typingNames{annotation: "t:one", env: env})
+ if nt.get().env != env {
+ t.Fatal("names lost their environment")
+ }
+ // A second set made while the first is in use must still share it.
+ var other nodeTyping
+ setNames(&other, typingNames{annotation: "t:one", env: env})
+ if other.names != nt.names {
+ t.Error("equal names in use were not shared")
+ }
+ nt, other, env = nodeTyping{}, nodeTyping{}, nil
+ for i := 0; i < 3 && w.Value() != nil; i++ {
+ runtime.GC()
+ }
+ if w.Value() != nil {
+ t.Error("TypeEnvironment still reachable after every node dropped its names")
+ }
+}
diff --git a/xdm/record.go b/xdm/record.go
index c583c3c0..1ad02678 100644
--- a/xdm/record.go
+++ b/xdm/record.go
@@ -1,13 +1,18 @@
package xdm
import (
+ "bytes"
"iter"
+ "maps"
+ "math"
"math/bits"
+ "slices"
"sort"
"strconv"
"sync"
"sync/atomic"
"unsafe"
+ "weak"
)
// The node record.
@@ -161,15 +166,10 @@ type treeSource struct {
//
// Empty unless ParseOptions.ExternalEntities permitted the read.
externalSubset string
- // src is the document text, retained only when the caller asks for
- // positions. It is what makes Position able to count lines.
- src string
- // lineStarts holds the byte offset of each line, built on first use.
- // Resolving a position is then a binary search rather than a scan from
- // the start of the document, which matters when a validator reports
- // thousands of failures over one large file.
- lineStarts []int
- lineOnce sync.Once
+ // lines is where the document's lines start, kept only when the caller
+ // asks for positions. It is what makes Position able to count lines; the
+ // text itself was kept for that once, at a byte per input byte.
+ lines *lineIndex
offsets [][]int32 // by chunk: source offset + 1, made on first use
foreignPos map[uint32][2]int32 // positions copied from another document
}
@@ -182,12 +182,40 @@ func (t *Tree) ownSource() *treeSource {
return t.source
}
-// srcText is the retained document text, or "".
-func (t *Tree) srcText() string {
+// lineIdx is the document's line index, or nil.
+func (t *Tree) lineIdx() *lineIndex {
if t.source == nil {
- return ""
+ return nil
+ }
+ return t.source.lines
+}
+
+// lineIndex holds the offset each line of a source text starts at, so that
+// resolving a position is a binary search rather than a scan from the start
+// of the document, which matters when a validator reports thousands of
+// failures over one large file. Offsets past math.MaxInt32 are not recorded:
+// no node offset (encodeOffset) points there.
+type lineIndex struct {
+ starts []int32 // line 1 starts at 0; every byte after a newline starts another
+ size int // how many bytes of text were indexed
+}
+
+// add indexes the next len(b) bytes of the text.
+func (l *lineIndex) add(b []byte) {
+ if l.starts == nil {
+ l.starts = []int32{0}
+ }
+ for i := 0; ; {
+ j := bytes.IndexByte(b[i:], '\n')
+ if j < 0 {
+ break
+ }
+ i += j + 1
+ if off := l.size + i; off < math.MaxInt32 {
+ l.starts = append(l.starts, int32(off))
+ }
}
- return t.source.src
+ l.size += len(b)
}
// extSubset is the external subset text, or "".
@@ -256,7 +284,12 @@ func (t *nodeTyping) get() *typingNames {
//
// Each set of names has two slots, so that two in steady use that hash alike
// do not keep evicting each other.
-var recentNames [1024]atomic.Pointer[typingNames]
+//
+// The slots are weak: a set of names holds its TypeEnvironment, and a strong
+// slot would keep the schema of a process that reloads schemas alive until
+// 1024 later sets pushed it out (about 23 MB for a 500-type schema). A set
+// no node uses any more is simply made again.
+var recentNames [1024]atomic.Pointer[weak.Pointer[typingNames]]
// recentSlots is the index of v's first slot in recentNames; the second is
// the one after it.
@@ -333,6 +366,11 @@ func (t *Tree) intern(q QName) uint32 {
if q == (QName{}) {
return 0
}
+ // A parsed tree drops its index when it is frozen, and a clone of one
+ // copies none: it is rebuilt here, on first use.
+ if t.nameIx == nil && len(t.names) > smallNames {
+ t.indexNames()
+ }
// A small tree -- a constructed fragment, mostly -- has a handful of
// names, and finding one by scanning costs less than building a map.
if t.nameIx == nil {
@@ -347,11 +385,7 @@ func (t *Tree) intern(q QName) uint32 {
i := uint32(len(t.names))
t.names = append(t.names, q)
if len(t.names) > smallNames {
- t.nameIx = make(map[QName]uint32, 2*len(t.names))
- t.nameHot = new([64]uint32)
- for j, x := range t.names[1:] {
- t.nameIx[x] = uint32(j + 1)
- }
+ t.indexNames()
}
return i
}
@@ -374,6 +408,44 @@ func (t *Tree) intern(q QName) uint32 {
return i
}
+// bigTag is how many attributes a start tag carries before buildElement sizes
+// the name table for them in one step, rather than letting each new name
+// grow it.
+const bigTag = 256
+
+// reserveNames makes room for n more names, the most a start tag of n
+// attributes can add, so that a tag of many distinct names grows the table
+// and its index once instead of through every doubling. The index is
+// rebuilt only when n would at least double it, so a run of big tags that
+// repeat their names rebuilds nothing.
+func (t *Tree) reserveNames(n int) {
+ if t.names == nil {
+ t.names = make([]QName, 1, n+1)
+ } else {
+ t.names = slices.Grow(t.names, n)
+ }
+ if t.nameIx == nil {
+ t.nameIx = make(map[QName]uint32, len(t.names)+n)
+ t.nameHot = new([64]uint32)
+ for j, x := range t.names[1:] {
+ t.nameIx[x] = uint32(j + 1)
+ }
+ } else if n > len(t.nameIx) {
+ ix := make(map[QName]uint32, len(t.nameIx)+n)
+ maps.Copy(ix, t.nameIx)
+ t.nameIx = ix
+ }
+}
+
+// indexNames builds the name index over the names the tree holds.
+func (t *Tree) indexNames() {
+ t.nameIx = make(map[QName]uint32, 2*len(t.names))
+ t.nameHot = new([64]uint32)
+ for j, x := range t.names[1:] {
+ t.nameIx[x] = uint32(j + 1)
+ }
+}
+
// nameHash is a cheap hash of q for nameHot: the lengths and the ends of the
// local name, which tell most names in a document apart.
func nameHash(q QName) int {
@@ -738,7 +810,7 @@ func (n *Node) NumNamespaceDecls() int { return len(n.frame()) }
// NamespaceDeclAt returns the namespace node for the i'th declaration held on
// n itself. It is the node the namespace axis gives for that prefix.
func (n *Node) NamespaceDeclAt(i int) *Node {
- return n.sideNode(n.v0, uint32(i), n.self)
+ return n.sideNode(n.v0, uint32(i), n.self, -1)
}
// NamespaceDecls iterates over the namespace nodes for the declarations held
@@ -951,20 +1023,25 @@ func setNames(t *nodeTyping, v typingNames) {
}
i := recentSlots(&v)
a, b := &recentNames[i], &recentNames[i+1]
- pa := a.Load()
- if pa != nil && *pa == v {
- t.names = pa
- return
+ wa := a.Load()
+ if wa != nil {
+ if pa := wa.Value(); pa != nil && *pa == v {
+ t.names = pa
+ return
+ }
}
- if pb := b.Load(); pb != nil && *pb == v {
- t.names = pb
- return
+ if wb := b.Load(); wb != nil {
+ if pb := wb.Value(); pb != nil && *pb == v {
+ t.names = pb
+ return
+ }
}
p := new(typingNames)
*p = v
+ w := weak.Make(p)
// The newest goes first; the first moves to second.
- b.Store(pa)
- a.Store(p)
+ b.Store(wa)
+ a.Store(&w)
t.names = p
}
@@ -1050,8 +1127,9 @@ func (t *Tree) internFrame(ns []nsBinding) uint32 {
}
return t.addFrame(ns)
}
- if len(t.frames) == smallFrames+1 && t.frameOne == nil && t.frameMany == nil {
- // Past the scan's reach: index what is there.
+ if len(t.frames) > smallFrames && t.frameOne == nil && t.frameMany == nil {
+ // Past the scan's reach: index what is there. A parsed tree drops
+ // the indexes when it is frozen, so this also rebuilds them.
for i := 1; i < len(t.frames); i++ {
t.indexFrame(uint32(i))
}
@@ -1148,6 +1226,25 @@ func (n *Node) AddNamespace(prefix, uri string) {
n.setFrame(append(ns, nsBinding{prefix, uri}))
}
+// AddNamespaces is AddNamespace for each prefix and URI pair in turn, given
+// as prefix, uri, prefix, uri, ...: n's frame is built and interned once
+// rather than once per binding.
+func (n *Node) AddNamespaces(pairs ...string) {
+ if n.kind != uint8(KindElement) || n.flags&fSide != 0 || len(pairs) < 2 {
+ return
+ }
+ if n.tree.frozen {
+ panic("xdm: declaring a namespace in a parsed document")
+ }
+ old := n.frame()
+ ns := make([]nsBinding, len(old), len(old)+len(pairs)/2)
+ copy(ns, old)
+ for i := 0; i+1 < len(pairs); i += 2 {
+ ns = append(ns, nsBinding{pairs[i], pairs[i+1]})
+ }
+ n.setFrame(ns)
+}
+
// SetNamespaceDecl binds prefix to uri on n: the declaration n already holds
// for prefix is changed in place, and otherwise one is added.
func (n *Node) SetNamespaceDecl(prefix, uri string) {
@@ -1266,8 +1363,8 @@ func (n *Node) NamespaceNodes() iter.Seq[*Node] {
if n.kind != uint8(KindElement) || n.flags&fSide != 0 {
return
}
- for _, s := range n.scopeSlots() {
- if !yield(n.sideNode(s.frame, s.index, s.owner)) {
+ for i, s := range n.scopeSlots() {
+ if !yield(n.sideNode(s.frame, s.index, s.owner, i)) {
return
}
}
@@ -1309,8 +1406,9 @@ func (n *Node) scopeSlots() []scopeSlot {
// sideNode returns the namespace node of n for the binding at (frame,
// index), making it on first use. Making it is safe from any number of
-// goroutines reading one tree.
-func (n *Node) sideNode(frame, index, owner uint32) *Node {
+// goroutines reading one tree. slot is the node's place among n's, or -1 to
+// work it out from n's scope, which costs a rebuild of that scope.
+func (n *Node) sideNode(frame, index, owner uint32, slot int) *Node {
t := n.tree
var prefix string
if index == xmlBinding {
@@ -1327,14 +1425,16 @@ func (n *Node) sideNode(frame, index, owner uint32) *Node {
// The slot places the node among n's others, by prefix, for document
// order; an undeclaring declaration, which has no place on the axis,
// takes the place its prefix would have.
- slot := uint32(0)
- for _, s := range n.scopeSlots() {
- if s.prefix < prefix {
- slot++
+ if slot < 0 {
+ slot = 0
+ for _, s := range n.scopeSlots() {
+ if s.prefix < prefix {
+ slot++
+ }
}
}
s := &Node{tree: t, kind: uint8(KindNamespace), flags: fSide,
- self: uint32(len(t.side)), parent: n.self, end: slot, prev: owner,
+ self: uint32(len(t.side)), parent: n.self, end: uint32(slot), prev: owner,
v0: frame, v1: index}
if t.side == nil {
t.side = map[sideKey]*Node{}
@@ -1403,6 +1503,53 @@ func (n *Node) Identity() IdentityKey { return IdentityKey{n} }
// relative position must use Compare: this value says nothing across trees.
func (n *Node) Order() int { return int(n.orderKey()) }
+// Index returns n's position in its tree, or -1 for a namespace node.
+//
+// The document, element, attribute, text, comment and processing-instruction
+// nodes of one tree are numbered 0, 1, 2, ... without gaps, in document order:
+// an element comes before its attributes, which come before its children, and
+// a parent before everything below it. A tree made by a constructor holds
+// several parentless roots; they are numbered in the order they were made.
+// Attributes have indices. Namespace nodes are made on demand, outside the
+// numbering, and have none. Indices of different trees are unrelated.
+//
+// An index never changes once the node exists, so it is stable for the whole
+// life of a parsed tree. A tree still being built only appends, so the indices
+// it has handed out stay valid, except that taking back the last subtree
+// appended (RemoveLastChild, ReplaceLastChild) frees its indices, and later nodes reuse them. Len
+// grows as nodes are appended.
+func (n *Node) Index() int {
+ if n.flags&fSide != 0 {
+ return -1
+ }
+ return int(n.self)
+}
+
+// SubtreeEnd returns one more than the largest Index in n's subtree, its
+// attributes included, so that [n.Index(), n.SubtreeEnd()) is the numbering of
+// the subtree and a table over it needs SubtreeEnd()-Index() entries. A leaf
+// or attribute gives Index()+1. For a node still being built it is the end so
+// far and grows as children are appended. A namespace node gives -1.
+func (n *Node) SubtreeEnd() int {
+ if n.flags&fSide != 0 {
+ return -1
+ }
+ return int(n.tree.endOf(n))
+}
+
+// Len returns the number of indexed nodes in the tree: valid Index values are
+// 0 to Len()-1. For a tree still being built it is the count so far.
+func (t *Tree) Len() int { return int(t.n) }
+
+// NodeAt returns the node whose Index is i, or nil when i is negative or not
+// less than Len. It never returns a namespace node.
+func (t *Tree) NodeAt(i int) *Node {
+ if i < 0 || i >= int(t.n) {
+ return nil
+ }
+ return t.rec(uint32(i))
+}
+
// generateID returns the string fn:generate-id() gives n: "N", the tree id,
// "x", and n's position in the tree.
//
@@ -1478,10 +1625,24 @@ func (n *Node) setOffset(v int32) {
// a value handed out as a string never changes under its reader.
type textStore struct {
blocks [][]byte
- cur int // the ordinary block values are appended to, + 1 (0: none)
- size int // capacity of the last ordinary block made
+ cur int32 // the ordinary block values are appended to, + 1 (0: none)
+ size int32 // capacity of the last ordinary block made
+ // shared maps a string registered by share to where it is stored, so that
+ // add hands the same range to every value that is that string.
+ shared map[sharedKey][2]uint32
}
+// sharedKey identifies a string by where it lives: the same storage is the
+// same text, because strings are immutable.
+type sharedKey struct {
+ p *byte
+ n int
+}
+
+// shareMin is the shortest value share and add consult the table for; a short
+// one costs less to copy than to look up.
+const shareMin = 64
+
// Ordinary blocks stop at 32 KB for the reason record chunks do.
const (
textShift = 15
@@ -1504,17 +1665,45 @@ func (s *textStore) add(v string) (uint32, uint32) {
if v == "" {
return 0, 0
}
+ if s.shared != nil && len(v) >= shareMin {
+ if r, ok := s.shared[sharedKey{unsafe.StringData(v), len(v)}]; ok {
+ return r[0], r[1]
+ }
+ }
// A string is immutable, so a long one is kept as its own block rather
// than copied: its capacity is its length, so nothing appends into it.
// Every unsafe.String in the module is over append-only bytes (the
// tokeniser's arena, this store), which keeps that true.
if len(v) >= textOwnFrom {
- s.newBlock(unsafe.Slice(unsafe.StringData(v), len(v)))
- return uint32(len(s.blocks)-1) << textShift, uint32(len(v))
+ return s.keep(unsafe.Slice(unsafe.StringData(v), len(v)))
}
return s.addBytes(unsafe.Slice(unsafe.StringData(v), len(v)), 0)
}
+// share stores v once and makes every later add of that very string (the same
+// storage, not merely equal text) return the stored range. Bytes are only
+// appended and extend writes only past the end of its own value, so a range
+// held by many values stays valid.
+func (s *textStore) share(v string) {
+ if len(v) < shareMin {
+ return
+ }
+ if s.shared == nil {
+ s.shared = map[sharedKey][2]uint32{}
+ }
+ k := sharedKey{unsafe.StringData(v), len(v)}
+ if _, ok := s.shared[k]; !ok {
+ off, n := s.add(v)
+ s.shared[k] = [2]uint32{off, n}
+ }
+}
+
+// keep stores b, which nothing else writes to, as a block of its own.
+func (s *textStore) keep(b []byte) (uint32, uint32) {
+ s.newBlock(b)
+ return uint32(len(s.blocks)-1) << textShift, uint32(len(b))
+}
+
// addBytes stores b with room for extra bytes after it, which extend uses
// while nothing else has been stored behind it.
func (s *textStore) addBytes(b []byte, extra int) (uint32, uint32) {
@@ -1526,9 +1715,9 @@ func (s *textStore) addBytes(b []byte, extra int) (uint32, uint32) {
return uint32(len(s.blocks)-1) << textShift, uint32(len(b))
}
if s.cur == 0 || cap(s.blocks[s.cur-1])-len(s.blocks[s.cur-1]) < need {
- s.size = min(max(2*s.size, 64, need), textBlock)
+ s.size = int32(min(max(2*int(s.size), 64, need), textBlock))
s.newBlock(make([]byte, 0, s.size))
- s.cur = len(s.blocks)
+ s.cur = int32(len(s.blocks))
}
k := s.cur - 1
off := len(s.blocks[k])
@@ -1536,6 +1725,18 @@ func (s *textStore) addBytes(b []byte, extra int) (uint32, uint32) {
return uint32(k)< len(b) {
+ s.blocks[s.cur-1] = append([]byte(nil), b...)
+ }
+}
+
func (s *textStore) newBlock(b []byte) {
if len(s.blocks) >= 1<<(32-textShift) {
panic("xdm: a tree's text exceeds the text store's addressable blocks")
diff --git a/xdm/retain_test.go b/xdm/retain_test.go
new file mode 100644
index 00000000..3f0e5b5f
--- /dev/null
+++ b/xdm/retain_test.go
@@ -0,0 +1,126 @@
+package xdm
+
+import (
+ "fmt"
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// retainedPerTree is the heap still live, per tree, with the trees of n
+// calls of parse kept, after a collection.
+func retainedPerTree(t *testing.T, n int, parse func() (*Tree, error)) int64 {
+ t.Helper()
+ trees := make([]*Tree, n)
+ var m0, m1 runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&m0)
+ for i := range trees {
+ tr, err := parse()
+ if err != nil {
+ t.Fatal(err)
+ }
+ trees[i] = tr
+ }
+ runtime.GC()
+ runtime.ReadMemStats(&m1)
+ runtime.KeepAlive(trees)
+ return (int64(m1.HeapAlloc) - int64(m0.HeapAlloc)) / int64(n)
+}
+
+// A namespace URI was the xmlns attribute's value as the tokeniser returned
+// it, a string in its arena; the tree keeps it in every name it resolves, so
+// one declaration kept a whole arena block alive per document. Measured on
+// the DocBook corpus: 31.6 MB retained for 591 trees before, 12.3 MB after.
+// The two documents differ only in whether one attribute is a declaration.
+func TestNamespaceURIDoesNotPinArena(t *testing.T) {
+ if raceEnabled {
+ t.Skip("heap sizes are not stable under -race")
+ }
+ // Attribute values fill the arena; each is copied into the tree, so
+ // neither document should keep the arena itself.
+ var vals string
+ for i := range 9 {
+ vals += ` a` + string(rune('0'+i)) + `="` + strings.Repeat("v", 900) + `"`
+ }
+ withNS := ``
+ without := ``
+ const n = 200
+ plain := retainedPerTree(t, n, func() (*Tree, error) { return ParseString(without, ParseOptions{}) })
+ ns := retainedPerTree(t, n, func() (*Tree, error) { return ParseString(withNS, ParseOptions{}) })
+ t.Logf("retained per tree: %d bytes without a declaration, %d with", plain, ns)
+ if ns-plain > 2<<10 {
+ t.Errorf("a namespace declaration costs %d bytes per kept tree, want under 2 KB", ns-plain)
+ }
+}
+
+// A text value long enough for a block of its own was built in the
+// tokeniser's buffer and then copied into the block: two allocations of its
+// size. The buffer is handed over now, when it is not much larger than the
+// value. Measured on the e-invoice corpus, whose attachments are base64 text:
+// 4.10 MB allocated a pass before, 3.49 MB after.
+func TestLongTextIsNotCopied(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation sizes are not stable under -race")
+ }
+ long := strings.Repeat("QUJDRA==", 25000) // 200 KB
+ doc := `short` + long + `after`
+ var tree *Tree
+ per := allocated(func() {
+ var err error
+ if tree, err = Parse(strings.NewReader(doc), ParseOptions{}); err != nil {
+ t.Fatal(err)
+ }
+ })
+ t.Logf("%d bytes allocated for a %d-byte text", per, len(long))
+ // The buffer's growth to the value's size is most of the rest: 323 KB
+ // measured, against 528 KB with the copy.
+ if per > uint64(len(long))*2 {
+ t.Errorf("parsing a %d-byte text allocated %d bytes, want under 2x: one buffer, not a buffer and a copy", len(long), per)
+ }
+ e := kids(kids(tree.Root)[0])
+ if kids(e[0])[0].Value() != "short" || kids(e[1])[0].Value() != long || kids(e[2])[0].Value() != "after" {
+ t.Errorf("values read back wrong")
+ }
+}
+
+// A parsed tree's last text block was kept at the size it doubled to, on
+// average a quarter empty. Measured on the DocBook corpus: 12.29 MB retained
+// for 591 trees before the trim, 12.03 MB after.
+func TestParsedTextBlockIsTrimmed(t *testing.T) {
+ tree, err := ParseString(``+strings.Repeat(`some text
`, 200)+``, ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ s := &tree.text
+ b := s.blocks[s.cur-1]
+ // Rounding to the allocator's size class may leave a little.
+ if slack := cap(b) - len(b); slack > len(b)/8 {
+ t.Errorf("last text block holds %d bytes in %d", len(b), cap(b))
+ }
+}
+
+// The name and frame indexes serve only building; a parsed tree kept them
+// for its life. Measured on the DocBook corpus: 12.03 MB retained for 591
+// trees with them, 11.07 MB without. A tree changed after the parse rebuilds
+// what it needs.
+func TestFrozenTreeDropsBuildIndexes(t *testing.T) {
+ var b strings.Builder
+ b.WriteString("")
+ for i := range 2 * smallNames {
+ fmt.Fprintf(&b, ``, i, i, i)
+ }
+ b.WriteString("")
+ tree, err := ParseString(b.String(), ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tree.nameIx != nil || tree.nameHot != nil || tree.frameOne != nil || tree.frameMany != nil || tree.open != nil {
+ t.Errorf("a parsed tree kept its build indexes")
+ }
+ e := kids(kids(tree.Root)[0])
+ e[0].SetName(QName{Local: "e5"})
+ if e[0].name != e[5].name || tree.nameIx == nil {
+ t.Errorf("renaming to an existing name gave index %d, want %d (index rebuilt: %v)", e[0].name, e[5].name, tree.nameIx != nil)
+ }
+}
diff --git a/xdm/source_copy_test.go b/xdm/source_copy_test.go
index b35bcc73..ac26be58 100644
--- a/xdm/source_copy_test.go
+++ b/xdm/source_copy_test.go
@@ -97,31 +97,41 @@ func TestSourceCopyStillServesItsUsers(t *testing.T) {
})
}
-// TrackPositions keeps the whole source to count lines in. A reader that
-// knows its length lets that copy be allocated once at its final size,
-// rather than grown by doubling to about twice the document in all.
-func TestTrackedCopyIsSizedOnce(t *testing.T) {
- doc := "\n" + bigBody(100000) + "\n"
- parse := func(track bool) func() {
- return func() {
- if _, err := ParseString(doc, ParseOptions{TrackPositions: track, MaxBytes: -1, MaxNodes: -1}); err != nil {
- t.Fatal(err)
- }
+// TrackPositions kept the whole source text, a byte per input byte for the
+// life of the tree, to count lines in. It keeps where the lines start now:
+// four bytes a line. Measured on parse-10mb, a tracked tree went from 50.4 MB
+// to 41.5 MB.
+func TestTrackedTreeKeepsNoSourceText(t *testing.T) {
+ if raceEnabled {
+ t.Skip("heap sizes are not stable under -race")
+ }
+ doc := "\n" + bigBody(20000) + "\n"
+ parse := func(track bool) func() (*Tree, error) {
+ return func() (*Tree, error) {
+ // A reader, not ParseString, whose string the tree may share.
+ return Parse(strings.NewReader(doc), ParseOptions{TrackPositions: track, MaxBytes: -1, MaxNodes: -1})
}
}
- plain := allocated(parse(false))
- tracked := allocated(parse(true))
- extra := int64(tracked) - int64(plain)
- // Besides the copy, tracking keeps each node's source offset: four bytes
- // a record, in a column a parse without positions does not have.
- tr, err := ParseString(doc, ParseOptions{MaxBytes: -1, MaxNodes: -1})
+ plain := retainedPerTree(t, 4, parse(false))
+ tracked := retainedPerTree(t, 4, parse(true))
+ tr, err := parse(true)()
if err != nil {
t.Fatal(err)
}
- column := 4 * int64(tr.n)
- t.Logf("tracked copy cost %d bytes for a %d-byte document (offset column %d)", extra, len(doc), column)
- if extra > int64(len(doc))*5/4+column {
- t.Errorf("TrackPositions allocated %d bytes more than without (document %d bytes), want about one copy",
+ // Tracking keeps each element's source offset, four bytes a record in a
+ // column a parse without positions does not have, and the line starts.
+ column := int64(0)
+ for _, ch := range tr.chunks {
+ column += 4 * int64(cap(ch))
+ }
+ lines := 8 * int64(strings.Count(doc, "\n")+1) // a slice grown by doubling
+ extra := tracked - plain
+ t.Logf("tracking kept %d bytes for a %d-byte document (offset column %d, line starts %d)", extra, len(doc), column, lines)
+ if extra > column+lines+int64(len(doc))/8 {
+ t.Errorf("a tracked tree kept %d bytes more than an untracked one (document %d bytes), want no copy of the text",
extra, len(doc))
}
+ if line, col, ok := kids(kids(tr.Root)[0])[2*20000-1].Position(); !ok || line != 20001 || col != 1 {
+ t.Errorf("last element at line %d col %d (ok=%v), want line 20001 col 1", line, col, ok)
+ }
}
diff --git a/xdm/textgrow_test.go b/xdm/textgrow_test.go
new file mode 100644
index 00000000..c2d1432b
--- /dev/null
+++ b/xdm/textgrow_test.go
@@ -0,0 +1,62 @@
+package xdm
+
+import (
+ "runtime"
+ "strconv"
+ "strings"
+ "sync"
+ "testing"
+)
+
+// Concurrent parses of long text share nothing: each decoder's buffer is its
+// own, and a value handed to a tree is not written again.
+func TestParseLongTextConcurrent(t *testing.T) {
+ var wg sync.WaitGroup
+ for i := 0; i < 8; i++ {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ text := strings.Repeat("QUJD", (1<<20+i*997)/4)
+ tr, err := Parse(strings.NewReader(""+text+""), ParseOptions{})
+ if err != nil {
+ t.Error(err)
+ return
+ }
+ if tr.Root.StringValue() != text {
+ t.Errorf("parse %d: text differs", i)
+ }
+ }()
+ }
+ wg.Wait()
+}
+
+// A start tag of many distinct attributes sizes the structures that grow with
+// them -- the tokenizer's attribute slice, the name table and its index, the
+// duplicate check -- from the tag, not through every step of their growth.
+// 100,000 attributes allocated about 100 bytes of each byte of the tag with
+// the steps; the structures' own floor is about 40.
+func TestParseManyAttributesAllocation(t *testing.T) {
+ var sb strings.Builder
+ sb.WriteString("")
+ doc := sb.String()
+ var a, b runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&a)
+ tr, err := Parse(strings.NewReader(doc), ParseOptions{})
+ runtime.ReadMemStats(&b)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got := tr.Root.ChildElements()[0].NumAttrs(); got != 100000 {
+ t.Fatalf("%d attributes, want 100000", got)
+ }
+ if got := (b.TotalAlloc - a.TotalAlloc) / uint64(len(doc)); got > 60 {
+ t.Errorf("parse allocated %dx the tag's size, want at most 60x", got)
+ }
+}
diff --git a/xdm/wellformed.go b/xdm/wellformed.go
index caaa9ffe..e5ec446a 100644
--- a/xdm/wellformed.go
+++ b/xdm/wellformed.go
@@ -221,7 +221,7 @@ func validateStartElement(t xml.StartElement, ns *nsScope, xml11 bool) error {
case len(seen) < len(small):
seen = append(seen, k)
default:
- many = make(map[expandedName]bool, 2*len(small))
+ many = make(map[expandedName]bool, max(2*len(small), len(t.Attr)))
for _, s := range seen {
many[s] = true
}
@@ -281,7 +281,10 @@ func (s *nsScope) declare(prefix, uri string) {
s.prev = append(s.prev, s.top[prefix]-1)
s.top[prefix] = len(s.prev)
}
- s.b = append(s.b, nsBinding{prefix, uri})
+ // The URI is copied out of the tokeniser's arena: the tree keeps it in
+ // every name and frame it resolves, and one arena string reachable from a
+ // tree keeps its whole block alive.
+ s.b = append(s.b, nsBinding{prefix, strings.Clone(uri)})
s.own++
}
@@ -361,21 +364,25 @@ func requireQName(n xml.Name) error {
// attributes to the raw tag, ahead of validateStartElement. Namespaces in
// XML §3 binds a prefix to the attribute's *normalized* value, so xmlns:b
// declared NMTOKEN and written " urn:x " binds urn:x — and p:a, b:a collide.
-func normalizeAttTokens(t xml.StartElement, types []attDeclaredType) xml.StartElement {
+func normalizeAttTokens(t xml.StartElement, types []attDeclaredType, ix declIndex) xml.StartElement {
var attrs []xml.Attr
- for _, d := range types {
- if !lexicalIs(t.Name.Space, t.Name.Local, d.element) && d.element != t.Name.Local {
- continue
- }
+ for _, i := range ix.at(t.Name.Space, t.Name.Local) {
+ d := types[i]
for i, a := range t.Attr {
if !lexicalIs(a.Name.Space, a.Name.Local, d.attr) && a.Name.Local != d.attr {
continue
}
+ // A value already collapsed is left as it is, so that a default
+ // keeps the storage the text store shares.
+ c := strings.Join(strings.FieldsFunc(a.Value, func(r rune) bool { return r == ' ' }), " ")
+ if c == a.Value {
+ continue
+ }
if attrs == nil {
// Copy: the tokeniser's attribute slice is not ours to write.
attrs = append([]xml.Attr(nil), t.Attr...)
}
- attrs[i].Value = strings.Join(strings.FieldsFunc(a.Value, func(r rune) bool { return r == ' ' }), " ")
+ attrs[i].Value = c
}
}
if attrs != nil {
diff --git a/xdm/wellformed_scope_test.go b/xdm/wellformed_scope_test.go
index 74dddd76..ef82f078 100644
--- a/xdm/wellformed_scope_test.go
+++ b/xdm/wellformed_scope_test.go
@@ -215,8 +215,8 @@ func TestTagScopeDoesNotAllocate(t *testing.T) {
t.Fatal(err)
}
ns.pop(ns.own)
- }); n != 0 {
- t.Errorf("validateStartElement under 16 inherited bindings allocated %.0f times, want 0", n)
+ }); n > 1 { // the one is the declared URI's copy out of the arena (nsScope.declare)
+ t.Errorf("validateStartElement under 16 inherited bindings allocated %.0f times, want 1", n)
}
}
diff --git a/xdm/xinclude.go b/xdm/xinclude.go
index 3107308c..78aa0169 100644
--- a/xdm/xinclude.go
+++ b/xdm/xinclude.go
@@ -113,6 +113,12 @@ func ProcessXInclude(tree *Tree, opts XIncludeOptions) (*Tree, error) {
if base == "" {
base = tree.Root.DocumentURI()
}
+ if tree.frozen && !mentionsXInclude(tree) {
+ // Nothing to replace: the copy processTree would make is the tree.
+ // Only a parsed tree is returned as is; a built one can still be
+ // changed, and the caller is promised a tree separate from it.
+ return tree, nil
+ }
p := &includeProc{opts: opts, budget: &entityBudget{}}
if base != "" {
p.stack = append(p.stack, base)
@@ -161,6 +167,18 @@ type includeProc struct {
done map[*Node]*Node
}
+// mentionsXInclude reports whether t could hold an XInclude-namespace element.
+// Every element name is in the name table, so a table without the namespace
+// proves the tree has no xi:include or xi:fallback to process.
+func mentionsXInclude(t *Tree) bool {
+ for _, q := range t.names {
+ if q.URI == NSXInclude {
+ return true
+ }
+ }
+ return false
+}
+
// processTree builds the included form of t: a new tree with every xi:include
// replaced by what it includes.
func (p *includeProc) processTree(t *Tree, base string, depth int) (*Tree, error) {
@@ -255,18 +273,10 @@ func appendLeafKeep(dst, n *Node) *Node {
// include appends to dst what one xi:include element includes.
//
-// The inclusion is built in a holder first and only appended once it has
-// succeeded: a failure anywhere in it must leave dst as it was, so that the
-// fallback can take its place.
+// An inclusion is appended only once it has succeeded: a failure anywhere in
+// it must leave dst as it was, so that the fallback can take its place.
func (p *includeProc) include(dst, inc *Node, depth int) error {
- holder, err := p.expandInclude(inc, depth)
- if err != nil {
- return err
- }
- for n := range holder.Children() {
- appendSubtreeKeep(dst, n)
- }
- return nil
+ return p.expandInclude(dst, inc, depth)
}
// appendSubtreeKeep appends a deep copy of n, merging text as appendLeafKeep
@@ -287,11 +297,10 @@ func appendSubtreeKeep(dst, n *Node) {
// positions of what it holds survive the move.
func newHolder() *Node { return NewTree().Root }
-// expandInclude computes, in a holder, the replacement for one xi:include
-// element.
-func (p *includeProc) expandInclude(inc *Node, depth int) (*Node, error) {
+// expandInclude appends to dst the replacement for one xi:include element.
+func (p *includeProc) expandInclude(dst, inc *Node, depth int) error {
if depth >= maxIncludeDepth {
- return nil, fatalInclude{fmt.Errorf(
+ return fatalInclude{fmt.Errorf(
"resource limit exceeded: xi:include nesting exceeds %d levels: %w",
maxIncludeDepth, ErrResourceLimit)}
}
@@ -306,20 +315,20 @@ func (p *includeProc) expandInclude(inc *Node, depth int) (*Node, error) {
if c.Name().Local != "fallback" {
// Section 3.1: the content of xi:include is "(fallback?)",
// so any other XInclude-namespace child is a fatal error.
- return nil, fmt.Errorf(
+ return fmt.Errorf(
"xi:%s is not permitted as a child of xi:include", c.Name().Local)
}
seen++
}
}
if seen > 1 {
- return nil, fmt.Errorf("xi:include has more than one xi:fallback")
+ return fmt.Errorf("xi:include has more than one xi:fallback")
}
href := inc.AttrValue("href")
if href != "" {
if err := ValidateXIncludeHref(href); err != nil {
- return nil, err
+ return err
}
}
parse := inc.AttrValue("parse")
@@ -332,7 +341,7 @@ func (p *includeProc) expandInclude(inc *Node, depth int) (*Node, error) {
// something to fall back from: the attribute is the processor's
// instruction, not the resource's condition, so a fallback would be
// answering the wrong question.
- return nil, fmt.Errorf("xi:include parse=%q must be \"xml\" or \"text\"", parse)
+ return fmt.Errorf("xi:include parse=%q must be \"xml\" or \"text\"", parse)
}
xptr := inc.AttrValue("xpointer")
@@ -389,10 +398,17 @@ func (p *includeProc) expandInclude(inc *Node, depth int) (*Node, error) {
// the loop rule does not apply — a text inclusion cannot recurse.
holder, err = p.fetchText(target, base, encoding)
default:
- holder, err = p.fetch(target, base, parse, xptr, encoding, depth)
+ // fetch appends to dst itself, and only once it cannot fail.
+ err = p.fetch(dst, target, base, parse, xptr, encoding, depth)
+ if err == nil {
+ return nil
+ }
}
- if err == nil {
- return holder, nil
+ if holder != nil && err == nil {
+ for n := range holder.Children() {
+ appendSubtreeKeep(dst, n)
+ }
+ return nil
}
// Section 4.3: "If the resource ... cannot be fetched ... the processor
@@ -406,7 +422,7 @@ func (p *includeProc) expandInclude(inc *Node, depth int) (*Node, error) {
// a document could quietly get a different result by being expensive, and
// a fallback chain would become a way to keep asking after being told no.
if fatalIncludeError(err) {
- return nil, err
+ return err
}
if fb := fallbackOf(inc); fb != nil {
// The fallback's own content may itself contain xi:include elements
@@ -415,14 +431,10 @@ func (p *includeProc) expandInclude(inc *Node, depth int) (*Node, error) {
// like any other content. The depth is carried through so that a
// fallback chain cannot be used to sidestep the nesting bound.
fbBase := elementBase(fb)
- holder := newHolder()
- if err := p.copyChildren(holder, fb, fbBase, depth+1); err != nil {
- return nil, err
- }
- return holder, nil
+ return p.copyChildren(dst, fb, fbBase, depth+1)
}
// Section 4.3: "if the fallback element is absent, it is a fatal error."
- return nil, fmt.Errorf("xi:include of %q failed and has no xi:fallback: %w", target, err)
+ return fmt.Errorf("xi:include of %q failed and has no xi:fallback: %w", target, err)
}
// selectLocal resolves an xpointer against the including document, for an
@@ -540,25 +552,32 @@ func copySubtree(dst, n *Node, base string) {
}
}
-// fetch reads one resource and returns, in a holder, the nodes that replace
-// the xi:include element.
-func (p *includeProc) fetch(target, base, parse, xptr, encoding string, depth int) (*Node, error) {
+// fetch reads one resource and appends to dst the nodes that replace the
+// xi:include element. It appends only once nothing can fail any more.
+func (p *includeProc) fetch(dst *Node, target, base, parse, xptr, encoding string, depth int) error {
if parse == "text" {
- return p.fetchText(target, base, encoding)
+ holder, err := p.fetchText(target, base, encoding)
+ if err != nil {
+ return err
+ }
+ for n := range holder.Children() {
+ appendSubtreeKeep(dst, n)
+ }
+ return nil
}
if p.fetches >= maxIncludeFetches {
- return nil, fatalInclude{fmt.Errorf(
+ return fatalInclude{fmt.Errorf(
"resource limit exceeded: document performs more than %d inclusions: %w",
maxIncludeFetches, ErrResourceLimit)}
}
if p.opts.Resolver == nil {
- return nil, fmt.Errorf("no include resolver: inclusions are not permitted")
+ return fmt.Errorf("no include resolver: inclusions are not permitted")
}
p.fetches++
data, uri, err := p.opts.Resolver.ResolveInclude(target, base, encoding)
if err != nil {
- return nil, err
+ return err
}
// A cycle is checked on the URI the resolver reports rather than on the
@@ -567,7 +586,7 @@ func (p *includeProc) fetch(target, base, parse, xptr, encoding string, depth in
// without this the recursion below simply does not terminate.
for _, u := range p.stack {
if u == uri {
- return nil, fatalInclude{fmt.Errorf(
+ return fatalInclude{fmt.Errorf(
"circular xi:include loop: %q includes itself", uri)}
}
}
@@ -595,19 +614,22 @@ func (p *includeProc) fetch(target, base, parse, xptr, encoding string, depth in
// entity bomb expanded 6,291,456 bytes — six times the ceiling — and
// the document was accepted, the refusal swallowed at every level.
if errors.Is(err, ErrResourceLimit) {
- return nil, fatalInclude{err}
+ return fatalInclude{err}
}
- return nil, err
+ return err
}
// Process the included document before selecting, so that an xpointer
// may address content that an inner inclusion brought in. The stack
// grows for the duration.
p.stack = append(p.stack, uri)
- done, err := p.processTree(sub, uri, depth+1)
+ done := sub
+ if mentionsXInclude(sub) {
+ done, err = p.processTree(sub, uri, depth+1)
+ }
p.stack = p.stack[:len(p.stack)-1]
if err != nil {
- return nil, err
+ return err
}
var picked []*Node
@@ -624,22 +646,21 @@ func (p *includeProc) fetch(target, base, parse, xptr, encoding string, depth in
} else {
picked, err = selectXPointer(done.Root, xptr)
if err != nil {
- return nil, err
+ return err
}
if len(picked) == 0 {
// Section 4.4: an xpointer that "does not identify any
// resource" is an error, recoverable through fallback — which
// is what returning an error here achieves, since the caller
// consults xi:fallback on any error from this function.
- return nil, fmt.Errorf("xpointer %q selected nothing in %s", xptr, uri)
+ return fmt.Errorf("xpointer %q selected nothing in %s", xptr, uri)
}
}
- holder := newHolder()
for _, n := range picked {
- copyFixedUp(holder, n, base)
+ copyFixedUp(dst, n, base)
}
- return holder, nil
+ return nil
}
// copyFixedUp appends a copy of n, a top-level included node, with the base
diff --git a/xdm/xinclude_nocopy_test.go b/xdm/xinclude_nocopy_test.go
new file mode 100644
index 00000000..07b12134
--- /dev/null
+++ b/xdm/xinclude_nocopy_test.go
@@ -0,0 +1,49 @@
+package xdm
+
+import (
+ "runtime"
+ "strings"
+ "testing"
+)
+
+// A tree with no XInclude element has nothing to process, so neither the
+// including document nor an included one is copied (-xinclude
+// on a 100 MB document peaked 500 MB higher for no inclusion at all).
+func TestXIncludeSkipsCopyWithoutXIElements(t *testing.T) {
+ tree := parseWithBase(t, "mem:/a.xml", "text")
+ out, err := ProcessXInclude(tree, XIncludeOptions{})
+ if err != nil || out != tree {
+ t.Fatalf("plain tree: got %p (tree %p), err %v; want the tree itself", out, tree, err)
+ }
+ // A tree that is not a frozen parse can still be changed, so it gets its
+ // own copy even with nothing to include.
+ built := &Tree{Root: Copy(tree.Root)}
+ if out, err := ProcessXInclude(built, XIncludeOptions{}); err != nil || out == built {
+ t.Fatalf("built tree: got %p (tree %p), err %v; want a new tree", out, built, err)
+ }
+
+ big := "" + strings.Repeat("text", 20000) + ""
+ parseBytes := allocBytes(func() { parseWithBase(t, "mem:/big.xml", big) })
+ doc := parseWithBase(t, "mem:/a.xml", ``)
+ res := &mapResolver{files: map[string]string{"mem:/big.xml": big}}
+ incBytes := allocBytes(func() {
+ if _, err := ProcessXInclude(doc, XIncludeOptions{Resolver: res}); err != nil {
+ t.Fatal(err)
+ }
+ })
+ // Parse plus the one copy into the result is about 2.2 parses; a copy of
+ // the included document before it, or through a holder, adds 1 each.
+ if incBytes > parseBytes*13/5 {
+ t.Errorf("including a plain document allocated %d bytes; parsing it alone is %d", incBytes, parseBytes)
+ }
+ t.Logf("parse %d include %d", parseBytes, incBytes)
+}
+
+func allocBytes(f func()) uint64 {
+ var a, b runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&a)
+ f()
+ runtime.ReadMemStats(&b)
+ return b.TotalAlloc - a.TotalAlloc
+}
diff --git a/xdmbuild/builder.go b/xdmbuild/builder.go
index b3defda2..3faf4c8e 100644
--- a/xdmbuild/builder.go
+++ b/xdmbuild/builder.go
@@ -777,7 +777,7 @@ func (b *Builder) setDecl(prefix, uri string) {
}
}
if b.declared == nil {
- b.declared = make([]decl, 0, 8)
+ b.declared = make([]decl, 0, 3)
}
b.declared = append(b.declared, decl{Prefix: prefix, URI: uri})
b.declShared = false // a lent list has no spare capacity, so append copied it
diff --git a/xpath/arrayappend_alloc_test.go b/xpath/arrayappend_alloc_test.go
new file mode 100644
index 00000000..b855275c
--- /dev/null
+++ b/xpath/arrayappend_alloc_test.go
@@ -0,0 +1,44 @@
+package xpath
+
+import (
+ "runtime"
+ "testing"
+ "unsafe"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// array:append, array:insert-before and array:join must copy the members once,
+// not once in Members() and again into the result.
+func TestArrayBuildersCopyMembersOnce(t *testing.T) {
+ const n, runs = 10000, 20
+ members := make([]xdm.Sequence, n)
+ for i := range members {
+ members[i] = xdm.One(xdm.NewInteger(int64(i)))
+ }
+ arr := xdm.One(xdm.NewArray(members...))
+ oneCopy := uint64(n * unsafe.Sizeof(xdm.Sequence(nil)))
+ const ns = "Q{http://www.w3.org/2005/xpath-functions/array}"
+ for _, q := range []string{
+ ns + "append($a, 1)", ns + "insert-before($a, 5, 1)", ns + "join(($a))",
+ } {
+ c, err := CompileWith(q, CompileOptions{Version: XPath31})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := NewContext(nil, Builtins()).WithVar(xdm.QName{Local: "a"}, arr)
+ var before, after runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&before)
+ for i := 0; i < runs; i++ {
+ if _, err := c.Eval(ctx); err != nil {
+ t.Fatal(err)
+ }
+ }
+ runtime.ReadMemStats(&after)
+ per := (after.TotalAlloc - before.TotalAlloc) / runs
+ if per > oneCopy*3/2 {
+ t.Errorf("%s allocated %d bytes per call; one copy of the members is %d", q, per, oneCopy)
+ }
+ }
+}
diff --git a/xpath/ast.go b/xpath/ast.go
index 38af2182..f874db81 100644
--- a/xpath/ast.go
+++ b/xpath/ast.go
@@ -315,6 +315,16 @@ type InlineFunctionExpr struct {
// Result is the declared return type, or nil if none was written.
Result *SequenceType
Body Expr
+
+ // needs is what closureNeeds found, computed on first evaluation.
+ needs atomic.Pointer[closureVars]
+}
+
+// closureVars is the variables a closure's body reads, or all when it must
+// capture every one.
+type closureVars struct {
+ names []xdm.QName
+ all bool
}
// InlineParam is one parameter of an inline function.
diff --git a/xpath/builtins.go b/xpath/builtins.go
index 0992399d..a4713186 100644
--- a/xpath/builtins.go
+++ b/xpath/builtins.go
@@ -197,7 +197,7 @@ func registerConstructors(l *Library) {
for name, code := range types {
target := code
- l.register(xdm.NSXS, name, 1, func(_ *Context, args []xdm.Sequence) (xdm.Sequence, error) {
+ l.register(xdm.NSXS, name, 1, func(ctx *Context, args []xdm.Sequence) (xdm.Sequence, error) {
atoms := xdm.Atomize(args[0])
// A constructor applied to the empty sequence yields the empty
// sequence rather than raising, so "xs:date($optional)" is safe.
@@ -208,6 +208,9 @@ func registerConstructors(l *Library) {
if err != nil {
return nil, err
}
+ if err := ctx.checkCastDigits(it.(*xdm.Atomic), target); err != nil {
+ return nil, err
+ }
out, err := CastAtomic(it.(*xdm.Atomic), target)
if err != nil {
return nil, err
diff --git a/xpath/cache.go b/xpath/cache.go
index 588a40f3..639b43aa 100644
--- a/xpath/cache.go
+++ b/xpath/cache.go
@@ -31,6 +31,12 @@ import "sync"
// contended case that would justify an RWMutex is a long read, which this is
// not. Mutex is also the smaller and simpler primitive, which matters for a
// type instantiated three times over three different value shapes.
+// maxCachedKey is the longest key worth caching. The caches bound entries, not
+// bytes, and the key (a pattern, a collation URI) can come from document data:
+// 1024 entries of long keys, each with a compiled program behind it, retained
+// gigabytes. A key this long is simply recompiled on each use.
+const maxCachedKey = 1024
+
type boundedCache[K comparable, V any] struct {
mu sync.Mutex
items map[K]V
diff --git a/xpath/clearhostvars_test.go b/xpath/clearhostvars_test.go
new file mode 100644
index 00000000..39729c54
--- /dev/null
+++ b/xpath/clearhostvars_test.go
@@ -0,0 +1,36 @@
+package xpath
+
+import (
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// TestClearHostVarsOneScope: the host's cleared and marked names are bound in
+// one scope each, not one scope per name, and still resolve as before.
+func TestClearHostVarsOneScope(t *testing.T) {
+ oc, om := ClearedOnDynamicCall, MarkedOnDynamicCall
+ defer func() { ClearedOnDynamicCall, MarkedOnDynamicCall = oc, om }()
+ var cleared []xdm.QName
+ for _, n := range []string{"a", "b", "c", "d", "e", "f"} {
+ cleared = append(cleared, xdm.QName{URI: "urn:h", Local: n})
+ }
+ ClearedOnDynamicCall = cleared
+ MarkedOnDynamicCall = []xdm.QName{{URI: "urn:h", Local: "m"}}
+
+ base := NewContext(nil, Builtins()).WithVar(xdm.QName{URI: "urn:h", Local: "c"}, xdm.One(xdm.NewInteger(7)))
+ got := clearHostVars(base)
+ if v, ok := got.LookupVar(cleared[2]); !ok || len(v) != 0 {
+ t.Fatalf("cleared name resolves to %v, %v; want bound and empty", v, ok)
+ }
+ if v, ok := got.LookupVar(MarkedOnDynamicCall[0]); !ok || len(v) != 1 {
+ t.Fatalf("marked name resolves to %v, %v; want one true", v, ok)
+ }
+ depth := 0
+ for s := got; s.bind != base.bind; s = s.Parent {
+ depth++
+ }
+ if depth != 2 {
+ t.Fatalf("clearHostVars made %d scopes, want 2", depth)
+ }
+}
diff --git a/xpath/closure_capture_test.go b/xpath/closure_capture_test.go
new file mode 100644
index 00000000..127e2f31
--- /dev/null
+++ b/xpath/closure_capture_test.go
@@ -0,0 +1,41 @@
+package xpath
+
+import (
+ "runtime"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// TestInlineFunctionCapturesOnlyWhatItReads: a closure whose body does not
+// mention $big must not keep it alive, and one that does must still see it.
+func TestInlineFunctionCapturesOnlyWhatItReads(t *testing.T) {
+ heldAfterGC := func(src string) (uint64, xdm.Sequence) {
+ c, err := CompileWith(src, CompileOptions{Version: XPath31})
+ if err != nil {
+ t.Fatal(err)
+ }
+ out, err := c.Eval(NewContext(nil, Builtins()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ runtime.GC()
+ var m runtime.MemStats
+ runtime.ReadMemStats(&m)
+ return m.HeapAlloc, out
+ }
+ var before runtime.MemStats
+ runtime.GC()
+ runtime.ReadMemStats(&before)
+ heldIgnoring, f1 := heldAfterGC(`let $big := (1 to 300000) ! string(.) return function() { 1 }`)
+ if grown := int64(heldIgnoring) - int64(before.HeapAlloc); grown > 2<<20 {
+ t.Fatalf("closure ignoring $big retains %d bytes", grown)
+ }
+ runtime.KeepAlive(f1)
+ _, f2 := heldAfterGC(`let $big := (1 to 3) ! string(.), $k := 2 return function($i) { $big[$i] || $k }`)
+ call := f2[0].(*xdm.FunctionItem)
+ got, err := call.Invoke(NewContext(nil, Builtins()), []xdm.Sequence{xdm.One(xdm.NewInteger(2))})
+ if err != nil || renderSeq(got) != "22" {
+ t.Fatalf("closure over $big and $k gave %v, %v", got, err)
+ }
+}
diff --git a/xpath/collation.go b/xpath/collation.go
index 1fcaea97..a2f4e8b4 100644
--- a/xpath/collation.go
+++ b/xpath/collation.go
@@ -820,7 +820,9 @@ func ucaCollationFor(uri string) (Collation, error) {
return e.c, e.err
}
c, err := parseUCACollation(uri)
- ucaCache.Put(uri, ucaCacheEntry{c, err})
+ if len(uri) <= maxCachedKey {
+ ucaCache.Put(uri, ucaCacheEntry{c, err})
+ }
return c, err
}
diff --git a/xpath/context.go b/xpath/context.go
index 38a813d1..38813dc5 100644
--- a/xpath/context.go
+++ b/xpath/context.go
@@ -253,6 +253,13 @@ type Env struct {
// AdoptBudget copies it along with the shared counter, so a nested
// evaluation cannot raise the bound it is charged against.
MaxItems int
+ // MaxDigits bounds the decimal digits of an integer or decimal that a
+ // cast reads from text or an arithmetic operator produces. Zero means
+ // MaxDigits' package default, 10^6; a negative value means no bound. A
+ // cast over it is FOCA0003 and a result over it FOAR0002, both carrying
+ // xdm.ErrResourceLimit: parsing and multiplying are quadratic in the
+ // digit count, so an unbounded value is a time and memory attack.
+ MaxDigits int
// items counts the items materialised into intermediate sequences during
// this evaluation, bounding memory the way Depth bounds stack.
//
@@ -922,6 +929,35 @@ func (c *Context) WithVar(name xdm.QName, val xdm.Sequence) *Context {
return n
}
+// withVars binds every name to val in one scope. The names have non-empty
+// local parts; it is what the host registries of a dynamic call are bound with.
+func (c *Context) withVars(names []xdm.QName, val xdm.Sequence) *Context {
+ if len(names) == 0 {
+ return c
+ }
+ s := &struct {
+ n Context
+ b varBinding
+ }{n: *c}
+ s.n.Vars = nil
+ s.b = varBinding{names: names, val: val}
+ s.n.bind = &s.b
+ s.n.Parent = c
+ return &s.n
+}
+
+// WithVars is WithVar for several variables at once: one scope binds names[i]
+// to vals[i]. The names must be distinct, non-empty and as long as vals, and
+// neither slice may be written while the scope is in use.
+func (c *Context) WithVars(names []xdm.QName, vals []xdm.Sequence) *Context {
+ if len(names) == 0 {
+ return c
+ }
+ n := c.withVars(names, nil)
+ n.bind.vals = vals
+ return n
+}
+
// varBinding is the binding a scope made by WithVar adds. Never written once
// the scope is published. A scope made by WithLazyVars holds lazy instead,
// and binds every name in it; one made by xpathleaf.WithLocals holds locals,
@@ -929,6 +965,8 @@ func (c *Context) WithVar(name xdm.QName, val xdm.Sequence) *Context {
type varBinding struct {
uri, local string
val xdm.Sequence
+ names []xdm.QName // withVars: every name is bound, to vals[i] or else to val
+ vals []xdm.Sequence
lazy map[varKey]*LazyVar
locals *xpathleaf.Locals
}
@@ -954,6 +992,38 @@ func frozenLocals(c *Context) *Context {
return &n
}
+// capturedFor is frozenLocals for a closure that reads only the variables keep
+// accepts: a scope that binds one name and is not accepted is left out of the
+// chain, and a host stack of locals is cut down to the accepted names. Scopes
+// binding several names, lazily or by the Vars map stay whole.
+func capturedFor(c *Context, keep func(uri, local string) bool, leaf bool) *Context {
+ if c == nil {
+ return nil
+ }
+ if b := c.bind; b != nil && b.locals != nil {
+ n := *c
+ n.bind = &varBinding{locals: b.locals.FrozenOnly(keep)}
+ return &n
+ }
+ p := capturedFor(c.Parent, keep, false)
+ if b := c.bind; b != nil && b.names == nil && b.lazy == nil && b.local != "" &&
+ len(c.Vars) == 0 && !keep(b.uri, b.local) {
+ if !leaf {
+ return p
+ }
+ // The innermost scope also carries the environment the body runs in.
+ n := *c
+ n.bind, n.Parent = nil, p
+ return &n
+ }
+ if p == c.Parent {
+ return c
+ }
+ n := *c
+ n.Parent = p
+ return &n
+}
+
// varKey is a variable's expanded name as a map key that needs no Clark
// string built for a lookup.
type varKey struct{ uri, local string }
@@ -1240,6 +1310,15 @@ func (c *Context) lookupVarPlain(name xdm.QName) (xdm.Sequence, bool, error) {
return v, true, err
}
}
+ } else if b.names != nil {
+ for i, q := range b.names {
+ if q.Local == name.Local && q.URI == name.URI {
+ if b.vals != nil {
+ return b.vals[i], true, nil
+ }
+ return b.val, true, nil
+ }
+ }
} else if b.local == name.Local && b.uri == name.URI {
return b.val, true, nil
}
@@ -1322,6 +1401,18 @@ func (c *Context) checkDepth() error {
return nil
}
+// checkValueDepth bounds the nesting of a map or array value that deep-equal
+// or map:find descends into at maxJSONDepth, like parse-json. The depth is
+// passed down by the walkers rather than kept in the Context, whose size is
+// pinned, and so is counted apart from function recursion.
+func checkValueDepth(depth int) error {
+ if depth >= maxJSONDepth {
+ return fmt.Errorf("XPDY0001: value nests more than %d levels: %w",
+ maxJSONDepth, xdm.ErrResourceLimit)
+ }
+ return nil
+}
+
// countItems charges n items against the evaluation budget.
//
// Accumulating constructs call it as they build, so a runaway is stopped while
@@ -1333,13 +1424,86 @@ func (c *Context) countItems(n int) error {
return nil
}
if lim := c.itemLimit(); atomic.AddInt64(c.ev().items, int64(n)) > lim {
- // The code is kept -- the suites and callers read it -- and the
- // sentinel added, because this is the processor declining to
- // allocate rather than anything wrong with the expression.
- return fmt.Errorf(
- "XPDY0130: evaluation materialised more than %d items; "+
- "the expression is building a sequence too large to hold: %w",
- lim, xdm.ErrResourceLimit)
+ return itemsRefusal(lim)
+ }
+ return nil
+}
+
+// itemsRefusal is the XPDY0130 the item budget raises.
+func itemsRefusal(lim int64) error {
+ // The code is kept -- the suites and callers read it -- and the
+ // sentinel added, because this is the processor declining to
+ // allocate rather than anything wrong with the expression.
+ return fmt.Errorf(
+ "XPDY0130: evaluation materialised more than %d items; "+
+ "the expression is building a sequence too large to hold: %w",
+ lim, xdm.ErrResourceLimit)
+}
+
+// ItemsRefused reports the item-budget refusal if total exceeds the item bound
+// in force, and charges nothing.
+//
+// A host that retains items across many expression evaluations -- xsl:key's
+// index, for-each-group's groups -- keeps the running total itself, because
+// Compiled.Eval resets the shared counter for every expression and a charge
+// made through ChargeItems would be cleared by the next node's evaluation.
+func (c *Context) ItemsRefused(total int64) error {
+ if lim := c.itemLimit(); total > lim {
+ return itemsRefusal(lim)
+ }
+ return nil
+}
+
+// MaxDigits is the digit bound in force where Env.MaxDigits is zero.
+const MaxDigits = 1000000
+
+// digitLimit is the digit bound in force, or -1 for none.
+func (c *Context) digitLimit() int {
+ switch {
+ case c == nil || c.ev().MaxDigits == 0:
+ return MaxDigits
+ case c.ev().MaxDigits < 0:
+ return -1
+ }
+ return c.ev().MaxDigits
+}
+
+// checkCastDigits refuses a string cast to xs:integer or xs:decimal whose text
+// is longer than the digit bound, before it is parsed.
+func (c *Context) checkCastDigits(a *xdm.Atomic, target xdm.TypeCode) error {
+ if target != xdm.TypeInteger && target != xdm.TypeDecimal ||
+ a.Type != xdm.TypeString && a.Type != xdm.TypeUntypedAtomic {
+ return nil
+ }
+ if lim := c.digitLimit(); lim >= 0 && len(a.Str()) > lim {
+ return &xdm.Error{
+ Code: "FOCA0003",
+ Message: fmt.Sprintf("a number of more than %d digits: %s",
+ lim, xdm.ErrResourceLimit),
+ Err: xdm.ErrResourceLimit,
+ }
+ }
+ return nil
+}
+
+// checkResultDigits refuses an exact arithmetic result of more than the digit
+// bound. A value held in an int64 has no big.Rat and is always small enough.
+func (c *Context) checkResultDigits(a *xdm.Atomic) error {
+ r := a.Rat()
+ if r == nil {
+ return nil
+ }
+ lim := c.digitLimit()
+ if lim < 0 {
+ return nil
+ }
+ if bits := max(r.Num().BitLen(), r.Denom().BitLen()); bits*30103/100000 > lim {
+ return &xdm.Error{
+ Code: "FOAR0002",
+ Message: fmt.Sprintf("a result of more than %d digits: %s",
+ lim, xdm.ErrResourceLimit),
+ Err: xdm.ErrResourceLimit,
+ }
}
return nil
}
@@ -1499,21 +1663,38 @@ func (c *Context) countNodes(n int) error {
return nil
}
if atomic.AddInt64(c.ev().nodes, int64(n)) > MaxNodes {
- // XPDY0130 again, and the wording says nodes so that the three
- // refusals are not confused with one another. XPath 3.1 §2.3.1
- // sanctions exactly this: "limitations may exist on the maximum
- // numbers or sizes of various objects... An error must be raised if
- // such a limitation is exceeded [err:XPDY0130]". The sentinel is
- // added alongside so a caller can tell a refusal from a fault. See
- // xdm.ErrResourceLimit.
- return fmt.Errorf(
- "XPDY0130: evaluation constructed more than %d result-tree "+
- "nodes; the transformation is building a tree too large to "+
- "hold: %w", MaxNodes, xdm.ErrResourceLimit)
+ return nodesRefusal()
}
return nil
}
+// nodesRefusal is the XPDY0130 the node budget raises.
+func nodesRefusal() error {
+ // XPDY0130 again, and the wording says nodes so that the three
+ // refusals are not confused with one another. XPath 3.1 §2.3.1
+ // sanctions exactly this: "limitations may exist on the maximum
+ // numbers or sizes of various objects... An error must be raised if
+ // such a limitation is exceeded [err:XPDY0130]". The sentinel is
+ // added alongside so a caller can tell a refusal from a fault. See
+ // xdm.ErrResourceLimit.
+ return fmt.Errorf(
+ "XPDY0130: evaluation constructed more than %d result-tree "+
+ "nodes; the transformation is building a tree too large to "+
+ "hold: %w", MaxNodes, xdm.ErrResourceLimit)
+}
+
+// NodesRefused reports the node-budget refusal if any charge has already
+// crossed MaxNodes, and charges nothing. A host calls it where a transform
+// finishes: the builders latch a refusal per builder, so a built-in rule or a
+// temporary tree that crossed the bound would otherwise end as truncated
+// output with no error.
+func (c *Context) NodesRefused() error {
+ if c == nil || c.ev().nodes == nil || atomic.LoadInt64(c.ev().nodes) <= MaxNodes {
+ return nil
+ }
+ return nodesRefusal()
+}
+
// ChargeNodes charges n constructed result-tree nodes against the evaluation
// budget, reporting XPDY0130 when the budget is exhausted.
//
diff --git a/xpath/copycharge_test.go b/xpath/copycharge_test.go
new file mode 100644
index 00000000..85b16142
--- /dev/null
+++ b/xpath/copycharge_test.go
@@ -0,0 +1,32 @@
+package xpath
+
+import (
+ "strings"
+ "testing"
+)
+
+// A sequence constructor or a "!" that copies its operand into a larger one
+// is charged for the copies, so doubling it in a fold hits the item budget.
+// The 23 doublings build 16.7M items against the 5M limit.
+func TestSequenceCopiesAreCharged(t *testing.T) {
+ for _, q := range []string{
+ `fold-left(1 to 23, (1,2), function($s,$i){($s,$s)})`,
+ `fold-left(1 to 23, (1,2), function($s,$i){$s ! (.,.)})`,
+ } {
+ c, err := CompileWith(q, CompileOptions{Version: XPath31})
+ if err != nil {
+ t.Fatal(err)
+ }
+ _, err = c.Eval(NewContext(nil, Builtins()))
+ if err == nil || !strings.Contains(err.Error(), "XPDY0130") {
+ t.Errorf("%s: got %v, want XPDY0130", q, err)
+ }
+ }
+ // A legal modest sequence still evaluates, with and without the floor.
+ for _, q := range []string{`count((1 to 1000, 1 to 1000))`, `count(((1,2),(3,4)))`, `count((1 to 1000) ! (., .))`} {
+ c, _ := CompileWith(q, CompileOptions{Version: XPath31})
+ if _, err := c.Eval(NewContext(nil, Builtins())); err != nil {
+ t.Errorf("%s: %v", q, err)
+ }
+ }
+}
diff --git a/xpath/deepnest_test.go b/xpath/deepnest_test.go
new file mode 100644
index 00000000..6fcc5f59
--- /dev/null
+++ b/xpath/deepnest_test.go
@@ -0,0 +1,53 @@
+package xpath
+
+import (
+ "errors"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// A value nested far past any real document must be refused with the
+// resource-limit error, not recurse until the Go stack overflows (fatal, and
+// not recoverable). The depth is just past the smallest bound, so a reverted
+// fix fails on the assertion rather than crashing the test binary.
+func TestDeepNestedValuesAreRefused(t *testing.T) {
+ const depth = 20000
+ nest := func(wrap func(xdm.Sequence) xdm.Item) xdm.Sequence {
+ v := xdm.One(xdm.NewInteger(1))
+ for i := 0; i < depth; i++ {
+ v = xdm.One(wrap(v))
+ }
+ return v
+ }
+ arrays := nest(func(s xdm.Sequence) xdm.Item { return xdm.NewArray(s) })
+ maps := nest(func(s xdm.Sequence) xdm.Item {
+ m := xdm.NewMap()
+ m, _ = m.Put(xdm.NewString("k"), s)
+ return m
+ })
+ for _, tc := range []struct{ name, query string }{
+ {"json array", `serialize($a, map{"method":"json"})`},
+ {"json map", `serialize($m, map{"method":"json"})`},
+ {"adaptive array", `serialize($a, map{"method":"adaptive"})`},
+ {"adaptive map", `serialize($m, map{"method":"adaptive"})`},
+ {"deep-equal array", `deep-equal($a, $a)`},
+ {"deep-equal map", `deep-equal($m, $m)`},
+ {"map:find", `Q{http://www.w3.org/2005/xpath-functions/map}find($m, "nope")`},
+ } {
+ c, err := CompileWith(tc.query, CompileOptions{Version: XPath31})
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx := NewContext(nil, Builtins())
+ ctx = ctx.WithVar(xdm.QName{Local: "a"}, arrays).WithVar(xdm.QName{Local: "m"}, maps)
+ if _, err := c.Eval(ctx); err == nil || !errors.Is(err, xdm.ErrResourceLimit) {
+ t.Errorf("%s: got %v, want an ErrResourceLimit refusal", tc.name, err)
+ }
+ }
+
+ // Flatten is iterative: the same value flattens to its one leaf.
+ if got := xdm.Flatten(arrays); len(got) != 1 {
+ t.Errorf("Flatten of %d nested arrays: %d items, want 1", depth, len(got))
+ }
+}
diff --git a/xpath/eval.go b/xpath/eval.go
index 1997eaf4..85c9d797 100644
--- a/xpath/eval.go
+++ b/xpath/eval.go
@@ -42,16 +42,36 @@ func (e *ContextItem) Eval(ctx *Context) (xdm.Sequence, error) {
// Eval implements Expr for a sequence constructor.
func (e *SequenceExpr) Eval(ctx *Context) (xdm.Sequence, error) {
var out xdm.Sequence
+ widest := 0
for _, it := range e.Items {
v, err := it.Eval(ctx)
if err != nil {
return nil, err
}
+ widest = max(widest, len(v))
out = append(out, v...)
}
+ // The widest operand was charged by whatever built it; the rest is
+ // copies, which ($s, $s) in a loop doubles without any other charge.
+ if err := ctx.chargeCopies(len(out) - widest); err != nil {
+ return nil, err
+ }
return out, nil
}
+// chargeCopyFloor is the size below which chargeCopies charges nothing, so
+// that ordinary small sequences cost no atomic add.
+const chargeCopyFloor = 64
+
+// chargeCopies charges n items a constructor copied beyond those the
+// expressions that built them already charged.
+func (c *Context) chargeCopies(n int) error {
+ if n < chargeCopyFloor {
+ return nil
+ }
+ return c.countItems(n)
+}
+
// Eval implements Expr for a single axis step.
//
// A step is evaluated against the context item alone; iterating a step over
@@ -74,22 +94,7 @@ func (e *Step) Eval(ctx *Context) (xdm.Sequence, error) {
// nothing would read.
func (e *Step) evalFrom(ctx *Context, node *xdm.Node) (xdm.Sequence, error) {
var err error
- principal := e.Axis.PrincipalKind()
- var selected xdm.Sequence
- if nt, ok := e.Test.(*NameTest); ok && (e.Axis == AxisDescendant || e.Axis == AxisDescendantOrSelf) {
- // The direct walk selects what walkAxis would, in the same order.
- if e.Axis == AxisDescendantOrSelf && nt.Matches(node, principal) {
- selected = append(selected, node)
- }
- selected = appendNamedDescendants(selected, node, nt)
- } else {
- walkAxis(node, e.Axis, func(n *xdm.Node) bool {
- if e.Test.Matches(n, principal) {
- selected = append(selected, n)
- }
- return true
- })
- }
+ selected := e.appendFrom(nil, node)
// Predicates apply in axis order, so a reverse axis numbers positions
// from the context node outwards.
@@ -105,6 +110,32 @@ func (e *Step) evalFrom(ctx *Context, node *xdm.Node) (xdm.Sequence, error) {
return xdm.SortDocumentOrder(selected), nil
}
+// appendFrom appends to selected the nodes e's axis and node test select
+// from node, in axis order, before any predicate.
+func (e *Step) appendFrom(selected xdm.Sequence, node *xdm.Node) xdm.Sequence {
+ principal := e.Axis.PrincipalKind()
+ if nt, ok := e.Test.(*NameTest); ok && (e.Axis == AxisDescendant || e.Axis == AxisDescendantOrSelf) {
+ // The direct walk selects what walkAxis would, in the same order.
+ if e.Axis == AxisDescendantOrSelf && nt.Matches(node, principal) {
+ selected = append(selected, node)
+ }
+ return appendNamedDescendants(selected, node, nt)
+ }
+ // Ancestors come in a chain, so a first match usually has more behind it:
+ // one block of four in place of the 1, 2, 4 growth.
+ chain := e.Axis == AxisAncestor || e.Axis == AxisAncestorOrSelf
+ walkAxis(node, e.Axis, func(n *xdm.Node) bool {
+ if e.Test.Matches(n, principal) {
+ if chain && selected == nil {
+ selected = make(xdm.Sequence, 0, 4)
+ }
+ selected = append(selected, n)
+ }
+ return true
+ })
+ return selected
+}
+
// Eval implements Expr for a path expression.
func (e *PathExpr) Eval(ctx *Context) (xdm.Sequence, error) {
var cur xdm.Sequence
@@ -234,6 +265,13 @@ func evalStepOver(ctx *Context, input xdm.Sequence, step Expr, last bool) (xdm.S
var v xdm.Sequence
var err error
st, fresh := step.(*Step)
+ if fresh && len(st.Predicates) == 0 && !st.Axis.IsReverse() {
+ // Nothing numbers this node's selection apart from the others,
+ // so it goes straight onto out; the sort below orders and
+ // deduplicates the whole, as it would the per-node results.
+ out = st.appendFrom(out, it.(*xdm.Node))
+ continue
+ }
if fresh {
v, err = st.evalFrom(ctx, it.(*xdm.Node))
} else if da, ok := step.(*descendantAttrs); ok {
@@ -328,8 +366,9 @@ func applyPredicate(ctx *Context, seq xdm.Sequence, pred Expr) (xdm.Sequence, er
var out xdm.Sequence
size := len(seq)
var h *comparisonHoist
- if size > 1 {
- h = newComparisonHoist(ctx, pred)
+ var hoist comparisonHoist // on the stack: one allocation less per predicate
+ if size > 1 && hoist.init(ctx, pred) {
+ h = &hoist
}
// One focus context serves every item when the predicate cannot keep
@@ -470,34 +509,36 @@ type comparisonHoist struct {
ready bool
}
-// newComparisonHoist returns nil when pred is not such a comparison, under
+// init sets up h for pred and reports true, or reports false when pred is not
+// such a comparison, under
// XPath 1.0 compatibility (whose conversions need the raw operands), and for
// "A = (M to N)", which evalGeneralComparison answers from the bounds.
-func newComparisonHoist(ctx *Context, pred Expr) *comparisonHoist {
+func (h *comparisonHoist) init(ctx *Context, pred Expr) bool {
b, ok := pred.(*BinaryOp)
if !ok {
- return nil
+ return false
}
switch b.Op {
case "eq", "ne", "lt", "le", "gt", "ge":
case "=", "!=", "<", "<=", ">", ">=":
if r, isRange := b.Right.(*BinaryOp); ctx.Compat() || (isRange && r.Op == "to") {
- return nil
+ return false
}
default:
- return nil
+ return false
}
side, left := b.Right, false
if !hoistable(side, false) {
side, left = b.Left, true
if !hoistable(side, false) {
- return nil
+ return false
}
}
if !callsBuiltins(side, ctx) {
- return nil
+ return false
}
- return &comparisonHoist{cmp: b, left: left}
+ *h = comparisonHoist{cmp: b, left: left}
+ return true
}
// holds reports whether the predicate selects sub's context item. The hoisted
diff --git a/xpath/fn_31.go b/xpath/fn_31.go
index 2b18e2e9..5122e73b 100644
--- a/xpath/fn_31.go
+++ b/xpath/fn_31.go
@@ -1179,7 +1179,7 @@ func permuteWith(state uint64, seq xdm.Sequence) xdm.Sequence {
// A map or an array compared against anything else is simply unequal rather
// than an error, which is the same rule the atomic branch follows for values
// of incomparable types.
-func deepEqualMapArray(ctx *Context, x, y xdm.Item) (eq, handled bool, err error) {
+func deepEqualMapArray(ctx *Context, x, y xdm.Item, depth int) (eq, handled bool, err error) {
xm, xIsMap := x.(*xdm.MapItem)
ym, yIsMap := y.(*xdm.MapItem)
xa, xIsArr := x.(*xdm.ArrayItem)
@@ -1190,10 +1190,10 @@ func deepEqualMapArray(ctx *Context, x, y xdm.Item) (eq, handled bool, err error
}
switch {
case xIsMap && yIsMap:
- e, err := deepEqualMaps(ctx, xm, ym)
+ e, err := deepEqualMaps(ctx, xm, ym, depth)
return e, true, err
case xIsArr && yIsArr:
- e, err := deepEqualArrays(ctx, xa, ya)
+ e, err := deepEqualArrays(ctx, xa, ya, depth)
return e, true, err
}
// One of the two is a map or an array and the other is not, so they
@@ -1202,7 +1202,11 @@ func deepEqualMapArray(ctx *Context, x, y xdm.Item) (eq, handled bool, err error
return false, true, nil
}
-func deepEqualArrays(ctx *Context, a, b *xdm.ArrayItem) (bool, error) {
+func deepEqualArrays(ctx *Context, a, b *xdm.ArrayItem, depth int) (bool, error) {
+ // Nested values recurse here; bound them like any other recursion.
+ if err := checkValueDepth(depth); err != nil {
+ return false, err
+ }
if a.Len() != b.Len() {
return false, nil
}
@@ -1211,7 +1215,7 @@ func deepEqualArrays(ctx *Context, a, b *xdm.ArrayItem) (bool, error) {
// Members are sequences, so this is the sequence comparison rather
// than the item one: an array of one two-item member is not equal to
// an array of two one-item members.
- eq, err := deepEqual(ctx, am[i], bm[i])
+ eq, err := deepEqualAt(ctx, am[i], bm[i], depth+1)
if err != nil || !eq {
return false, err
}
@@ -1219,7 +1223,11 @@ func deepEqualArrays(ctx *Context, a, b *xdm.ArrayItem) (bool, error) {
return true, nil
}
-func deepEqualMaps(ctx *Context, a, b *xdm.MapItem) (bool, error) {
+func deepEqualMaps(ctx *Context, a, b *xdm.MapItem, depth int) (bool, error) {
+ // Nested values recurse here; bound them like any other recursion.
+ if err := checkValueDepth(depth); err != nil {
+ return false, err
+ }
if a.Len() != b.Len() {
return false, nil
}
@@ -1242,7 +1250,7 @@ func deepEqualMaps(ctx *Context, a, b *xdm.MapItem) (bool, error) {
equal = false
return nil
}
- eq, err := deepEqual(ctx, value, other)
+ eq, err := deepEqualAt(ctx, value, other, depth+1)
if err != nil {
return err
}
diff --git a/xpath/fn_array.go b/xpath/fn_array.go
index 4586a541..22b76c7f 100644
--- a/xpath/fn_array.go
+++ b/xpath/fn_array.go
@@ -70,7 +70,8 @@ func registerArrayFuncs(l *Library) {
if err != nil {
return nil, err
}
- return xdm.One(xdm.NewArray(append(a.Members(), seqArg(args, 1))...)), nil
+ members := a.AppendMembers(make([]xdm.Sequence, 0, a.Len()+1))
+ return xdm.One(xdm.NewArray(append(members, seqArg(args, 1))...)), nil
})
// array:subarray($array, $start as xs:integer[, $length as xs:integer])
@@ -161,11 +162,11 @@ func registerArrayFuncs(l *Library) {
"array:insert-before: position %d is outside the array's bounds (1 to %d)",
i, a.Len()+1)
}
- old := a.Members()
- members := make([]xdm.Sequence, 0, len(old)+1)
- members = append(members, old[:i-1]...)
- members = append(members, seqArg(args, 2))
- members = append(members, old[i-1:]...)
+ members := make([]xdm.Sequence, 0, a.Len()+1)
+ members = a.AppendMembers(members)
+ members = append(members, nil)
+ copy(members[i:], members[i-1:])
+ members[i-1] = seqArg(args, 2)
return xdm.One(xdm.NewArray(members...)), nil
})
@@ -216,7 +217,7 @@ func registerArrayFuncs(l *Library) {
return nil, xdm.ErrType(
"array:join: expected an array, got %s", it.TypeName())
}
- members = append(members, a.Members()...)
+ members = a.AppendMembers(members)
}
return xdm.One(xdm.NewArray(members...)), nil
})
diff --git a/xpath/fn_json.go b/xpath/fn_json.go
index 319f3c54..a39ca1fb 100644
--- a/xpath/fn_json.go
+++ b/xpath/fn_json.go
@@ -882,6 +882,8 @@ type jsonBuilder struct {
// to; finishing one pops it and hands the finished item to its parent.
stack []*jsonFrame
opts jsonOptions
+ // ctx is charged one item for every value the parse produces.
+ ctx *Context
// result is the top-level value, once one has been produced.
result xdm.Sequence
done bool
@@ -901,6 +903,9 @@ type jsonFrame struct {
}
func (b *jsonBuilder) emit(v xdm.Sequence) error {
+ if err := b.ctx.countItems(1); err != nil {
+ return err
+ }
if len(b.stack) == 0 {
if b.done {
return errFOJS0001("more than one value at the top level")
@@ -943,15 +948,13 @@ func (b *jsonBuilder) key(k string) error {
func (b *jsonBuilder) endMap() error {
f := b.stack[len(b.stack)-1]
b.stack = b.stack[:len(b.stack)-1]
- m := xdm.NewMap()
+ mb := xdm.NewMapBuilder()
for i, k := range f.keys {
- next, err := m.Put(xdm.NewString(k), f.values[i])
- if err != nil {
+ if err := mb.Set(xdm.NewString(k), f.values[i]); err != nil {
return err
}
- m = next
}
- return b.emit(xdm.One(m))
+ return b.emit(xdm.One(mb.Build()))
}
func (b *jsonBuilder) startArray() error {
@@ -1023,7 +1026,7 @@ func jsonDocReadError(err error) error {
// parseJSONText is the shared body of fn:parse-json and fn:json-doc.
func parseJSONText(ctx *Context, text string, opts jsonOptions) (xdm.Sequence, error) {
- b := &jsonBuilder{opts: opts}
+ b := &jsonBuilder{opts: opts, ctx: ctx}
fb := jsonFallback(ctx, opts)
if err := scanJSON(text, opts, b, fb); err != nil {
return nil, err
diff --git a/xpath/fn_map.go b/xpath/fn_map.go
index 1c9ba972..72df80f4 100644
--- a/xpath/fn_map.go
+++ b/xpath/fn_map.go
@@ -182,14 +182,18 @@ func registerMapFuncs(l *Library) {
// one array. Anything that is neither a map nor an array is passed over
// rather than being a type error, which is what makes it usable on a
// json-doc whose shape is not known in advance.
- l.registerMap("find", []int{2}, func(_ *Context, args []xdm.Sequence) (xdm.Sequence, error) {
+ l.registerMap("find", []int{2}, func(ctx *Context, args []xdm.Sequence) (xdm.Sequence, error) {
key, err := argMapKey(args, 1)
if err != nil {
return nil, err
}
var found []xdm.Sequence
- var walk func(seq xdm.Sequence) error
- walk = func(seq xdm.Sequence) error {
+ var walk func(seq xdm.Sequence, depth int) error
+ walk = func(seq xdm.Sequence, depth int) error {
+ // Recursion on the value's own nesting, so bounded like any other.
+ if err := checkValueDepth(depth); err != nil {
+ return err
+ }
for _, it := range seq {
switch v := it.(type) {
case *xdm.MapItem:
@@ -203,13 +207,13 @@ func registerMapFuncs(l *Library) {
found = append(found, val)
}
if err := v.Entries(func(_ *xdm.Atomic, val xdm.Sequence) error {
- return walk(val)
+ return walk(val, depth+1)
}); err != nil {
return err
}
case *xdm.ArrayItem:
for _, mem := range v.Members() {
- if err := walk(mem); err != nil {
+ if err := walk(mem, depth+1); err != nil {
return err
}
}
@@ -217,7 +221,7 @@ func registerMapFuncs(l *Library) {
}
return nil
}
- if err := walk(args[0]); err != nil {
+ if err := walk(args[0], 0); err != nil {
return nil, err
}
return xdm.One(xdm.NewArray(found...)), nil
diff --git a/xpath/fn_misc.go b/xpath/fn_misc.go
index c7ba5b70..0ea074d8 100644
--- a/xpath/fn_misc.go
+++ b/xpath/fn_misc.go
@@ -198,7 +198,7 @@ func registerUnparsedText(l *Library, since Version) {
if err != nil {
return nil, err
}
- return xdm.Sequence(splitTextLines(text)), nil
+ return splitTextLines(ctx, text)
})
}
@@ -298,21 +298,26 @@ func readText(ctx *Context, s, enc string) (string, error) {
// The spec defines the result as the sequence obtained by splitting on \n,
// \r\n or \r, with a trailing line ending producing no final empty string —
// "a\n" is one line, not two.
-func splitTextLines(text string) []xdm.Item {
+func splitTextLines(ctx *Context, text string) (xdm.Sequence, error) {
if text == "" {
- return nil
+ return nil, nil
}
// Normalise the two-character ending first so the split is over single
// characters, then treat a lone \r as an ending too.
text = strings.ReplaceAll(text, "\r\n", "\n")
text = strings.ReplaceAll(text, "\r", "\n")
text = strings.TrimSuffix(text, "\n")
+ // Charge the lines before the strings.Split slice and the items are built:
+ // a 67 MB file of newlines is 67M items.
+ out, err := makeSequence(ctx, strings.Count(text, "\n")+1)
+ if err != nil {
+ return nil, err
+ }
parts := strings.Split(text, "\n")
- out := make([]xdm.Item, 0, len(parts))
for _, p := range parts {
out = append(out, xdm.NewString(p))
}
- return out
+ return out, nil
}
// unparsedTextArgs pulls the href and the optional encoding out of a call on
@@ -493,11 +498,16 @@ func lookupByID(ctx *Context, args []xdm.Sequence, wantID bool) (xdm.Sequence, e
// instructions are ignored when comparing element content, which is why this
// cannot simply compare serialised forms.
func deepEqual(ctx *Context, a, b xdm.Sequence) (bool, error) {
+ return deepEqualAt(ctx, a, b, 0)
+}
+
+// deepEqualAt is deepEqual for values nested depth maps or arrays down.
+func deepEqualAt(ctx *Context, a, b xdm.Sequence, depth int) (bool, error) {
if len(a) != len(b) {
return false, nil
}
for i := range a {
- eq, err := deepEqualItem(ctx, a[i], b[i])
+ eq, err := deepEqualItem(ctx, a[i], b[i], depth)
if err != nil || !eq {
return false, err
}
@@ -505,7 +515,7 @@ func deepEqual(ctx *Context, a, b xdm.Sequence) (bool, error) {
return true, nil
}
-func deepEqualItem(ctx *Context, x, y xdm.Item) (bool, error) {
+func deepEqualItem(ctx *Context, x, y xdm.Item, depth int) (bool, error) {
// An Opaque is engine-internal state, not a value the data model defines
// equality over. A stylesheet naming the internal namespace can put one
// here, so it is compared by identity rather than asserted to be atomic.
@@ -531,7 +541,7 @@ func deepEqualItem(ctx *Context, x, y xdm.Item) (bool, error) {
// before the node and atomic branches because neither is either one: the
// fallback below compares unrecognised items by identity, so two equal
// arrays built separately were answering false.
- if eq, handled, err := deepEqualMapArray(ctx, x, y); handled {
+ if eq, handled, err := deepEqualMapArray(ctx, x, y, depth); handled {
return eq, err
}
@@ -1313,6 +1323,10 @@ func defaultPresentation(comp byte) string {
return "1"
}
+// maxPictureWidth bounds a width modifier's number. The padding it asks for is
+// built before any budget sees it: [D01,1000000000] allocated 2.8 GB.
+const maxPictureWidth = 1 << 16
+
// checkWidthModifier rejects a width modifier whose syntax is wrong, which is
// XTDE1340. Both bounds must be "*" or an integer greater than zero.
func checkWidthModifier(width string) error {
@@ -1333,6 +1347,10 @@ func checkWidthModifier(width string) error {
return fmt.Errorf("FOFD1340: malformed width modifier %q", width)
}
n = n*10 + int(c-'0')
+ if n > maxPictureWidth {
+ return fmt.Errorf("FOFD1340: width modifier %q asks for more than %d "+
+ "characters: %w", width, maxPictureWidth, xdm.ErrResourceLimit)
+ }
}
if n == 0 {
return fmt.Errorf("FOFD1340: width modifier %q must be greater than zero", width)
diff --git a/xpath/fn_misc_test.go b/xpath/fn_misc_test.go
index 518208bc..8cc5cf9a 100644
--- a/xpath/fn_misc_test.go
+++ b/xpath/fn_misc_test.go
@@ -867,6 +867,10 @@ func TestDistinctValuesNumericPromotion(t *testing.T) {
{`count(distinct-values((1.2, xs:double(1.2))))`, "1"},
{`count(distinct-values((1, xs:double(1))))`, "1"},
{`count(distinct-values((1, 1.0, xs:double(1), xs:float(1))))`, "1"},
+ // The promoted keys are recorded from the first double on, for the
+ // exact values kept before it and after it alike.
+ {`count(distinct-values((1.2, xs:double(5), 1.3, xs:double(1.2), xs:double(1.3))))`, "3"},
+ {`count(distinct-values((xs:double(1.2), 1.2)))`, "1"},
// Genuinely different values are still distinguished.
{`count(distinct-values((1, 2, 3)))`, "3"},
{`count(distinct-values((1.2, 1.3)))`, "2"},
@@ -1012,6 +1016,32 @@ func TestDistinctValuesLargeHomogeneous(t *testing.T) {
if got := evalStrXSLT(t, testDoc, `count(distinct-values((1 to 1000, xs:float(1.0))))`); got != "1000" {
t.Errorf("range plus equal float = %q, want 1000", got)
}
+
+ // With no float or double among them, exact values are keyed once, not
+ // also under the double and float they promote to. 2000 integers
+ // measured 14,061 allocations with the promoted keys and 6,054 without.
+ ctx := NewContext(nil, Builtins())
+ v, err := Eval(`1000 to 2999`, ctx, nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ ctx.Vars["v"] = v
+ dv := MustCompile(`distinct-values($v)`, nil)
+ var n int
+ allocs := testing.AllocsPerRun(5, func() {
+ got, err := dv.Eval(ctx)
+ if err != nil {
+ t.Fatal(err)
+ }
+ n = len(got)
+ })
+ if n != 2000 {
+ t.Fatalf("distinct-values($v): %d items, want 2000", n)
+ }
+ t.Logf("%.0f allocations", allocs)
+ if allocs > 10000 {
+ t.Errorf("distinct-values($v) allocated %.0f times, want at most 10,000", allocs)
+ }
}
// benchEvalXSLT is evalStrXSLT for a benchmark, which cannot use the *testing.T
diff --git a/xpath/fn_regex.go b/xpath/fn_regex.go
index a231d5c7..5c647b25 100644
--- a/xpath/fn_regex.go
+++ b/xpath/fn_regex.go
@@ -133,10 +133,32 @@ func registerRegexFuncs(l *Library) {
if err != nil {
return nil, err
}
- out := re.ReplaceAllString(s, goRepl)
- if err := ctx.countBytes(len(out)); err != nil {
+ // No match leaves the input as it is: nothing is built and nothing is
+ // charged.
+ if !re.MatchString(s) {
+ return strSeq(s), nil
+ }
+ // The result is charged before it is built: 500 KB of input with a 4 KB
+ // replacement allocated 12.8 GB before the old after-the-fact charge
+ // refused it. The estimate is an upper bound -- the unmatched text, one
+ // replacement per match, and for each "$" in the replacement at most
+ // the matched text again, since a group lies inside its match. What
+ // the built string comes to is then settled against it, so a bound
+ // that was generous is handed back rather than kept.
+ n, matched := 0, 0
+ re.ReplaceAllStringFunc(s, func(m string) string { n++; matched += len(m); return "" })
+ est := len(s) - matched + n*len(goRepl) + strings.Count(goRepl, "$")*matched
+ if err := ctx.countBytes(est); err != nil {
return nil, err
}
+ out := re.ReplaceAllString(s, goRepl)
+ if len(out) > est {
+ if err := ctx.countBytes(len(out) - est); err != nil {
+ return nil, err
+ }
+ } else {
+ ctx.refundBytes(est - len(out))
+ }
return strSeq(out), nil
})
@@ -299,6 +321,16 @@ func compileXPathRegexp(pattern, flags string, v Version) (*regexp.Regexp, error
// the check and the insert under one lock: doing them separately let concurrent
// callers interleave between the two and carry the table past its limit.
func storeRegex(key string, v any) {
+ if len(key) > maxCachedKey {
+ return
+ }
+ // A pattern under the key limit can still compile to megabytes (85 copies
+ // of \p{L}{1000} is about 1 KB and retains 3.95 MB), so a large program is
+ // not kept either. The largest real pattern in the corpora is about 2,593
+ // instructions.
+ if re, ok := v.(*regexp.Regexp); ok && cachedProgramTooBig(re) {
+ return
+ }
regexCache.Put(key, v)
}
@@ -391,7 +423,7 @@ func buildRegexp(pattern, flags string, v Version) (*regexp.Regexp, error) {
// XPath's fn:matches is a *containment* test, unlike XML Schema's pattern
// facet which is anchored. Go's MatchString is also containment, so no
// anchoring is added here.
- re, err := regexp.Compile(translated)
+ re, err := CompileBounded(translated)
if err != nil {
// A repeat count RE2 will not accept is still a valid XML Schema
// pattern — "a{2147483647}" simply matches nothing any input can
@@ -399,7 +431,7 @@ func buildRegexp(pattern, flags string, v Version) (*regexp.Regexp, error) {
// when it was merely enormous, so the count is clamped to one past
// RE2's limit, which matches exactly what the original would.
if clamped, ok := clampRepeatCounts(translated); ok {
- if re2, err2 := regexp.Compile(clamped); err2 == nil {
+ if re2, err2 := CompileBounded(clamped); err2 == nil {
return re2, nil
}
}
diff --git a/xpath/fn_seq.go b/xpath/fn_seq.go
index 6763672a..d014e224 100644
--- a/xpath/fn_seq.go
+++ b/xpath/fn_seq.go
@@ -352,6 +352,21 @@ func fnDistinctValues(ctx *Context, args []xdm.Sequence) (xdm.Sequence, error) {
// the pairwise scan walks neither the non-numeric results nor the exact
// numerics, which the hash key already settles.
var inexact []*xdm.Atomic
+ // A later double is compared against an exact value by promoting it to
+ // double, so each exact value kept records which double (and float) it
+ // promotes to as well. That is the key the double will look itself up
+ // under, and it is what makes xs:decimal("1.2") and xs:double(1.2) one
+ // value whichever order they arrive in. Only a float or a double looks
+ // those keys up, so they are recorded once the first one arrives, and
+ // the exact values kept until then wait in pending.
+ var pending []*big.Rat
+ promoting := false
+ promote := func(r *big.Rat) {
+ f, _ := r.Float64()
+ seen["xd\x00"+strconv.FormatFloat(f, 'g', 17, 64)] = true
+ seen["xf\x00"+strconv.FormatFloat(
+ float64(float32(f)), 'g', 17, 32)] = true
+ }
for _, it := range atoms {
a := it.(*xdm.Atomic)
@@ -397,15 +412,11 @@ func fnDistinctValues(ctx *Context, args []xdm.Sequence) (xdm.Sequence, error) {
continue
}
seen[key] = true
- // A later double is compared against this value by promoting
- // it to double, so record which double it promotes to as
- // well. That is the key the double will look itself up under,
- // and it is what makes xs:decimal("1.2") and xs:double(1.2)
- // one value whichever order they arrive in.
- f, _ := r.Float64()
- seen["xd\x00"+strconv.FormatFloat(f, 'g', 17, 64)] = true
- seen["xf\x00"+strconv.FormatFloat(
- float64(float32(f)), 'g', 17, 32)] = true
+ if promoting {
+ promote(r)
+ } else {
+ pending = append(pending, r)
+ }
out = append(out, a)
continue
}
@@ -414,7 +425,14 @@ func fnDistinctValues(ctx *Context, args []xdm.Sequence) (xdm.Sequence, error) {
// the scan. An exact value e is equal to this value under eq
// exactly when e promoted to this type equals it -- so the double
// (or float) that e promotes to is the key to look up, and each
- // exact value recorded both when it was kept.
+ // exact value records both from here on.
+ if !promoting {
+ promoting = true
+ for _, r := range pending {
+ promote(r)
+ }
+ pending = nil
+ }
var key string
if a.Type == xdm.TypeFloat {
key = "xf\x00" + strconv.FormatFloat(
diff --git a/xpath/fn_serialize.go b/xpath/fn_serialize.go
index fe9f5fae..bde6d6a9 100644
--- a/xpath/fn_serialize.go
+++ b/xpath/fn_serialize.go
@@ -1,7 +1,9 @@
package xpath
import (
+ "bufio"
"fmt"
+ "io"
"math"
"sort"
"strings"
@@ -113,6 +115,12 @@ func registerSerialize(l *Library) {
if err := serializeItem(sb, it, opts); err != nil {
return nil, err
}
+ // A refused sink drops every later write, but the loop would
+ // still walk the rest of the items and their separators. The
+ // latched field is read directly: err() settles the reserve.
+ if sb.e != nil {
+ return nil, sb.err()
+ }
prevAtomic = atomic
}
if err := sb.err(); err != nil {
@@ -902,13 +910,13 @@ func serializeNode(sb *serializeSink, n *xdm.Node, opts serializeOptions, depth
}
case xdm.KindElement:
sb.WriteString("<")
- sb.WriteString(elementName(n))
+ writeName(sb, n)
writeNamespaceDecls(sb, n, opts)
// Attributes are written in the order the document had them, which is
// what a round-trip test compares against.
for a := range n.Attrs() {
sb.WriteString(" ")
- sb.WriteString(elementName(a))
+ writeName(sb, a)
sb.WriteString(`="`)
// A URI-valued attribute under the html or xhtml method has its
// non-ASCII characters percent-escaped. XSLT 2.0
@@ -917,9 +925,9 @@ func serializeNode(sb *serializeSink, n *xdm.Node, opts serializeOptions, depth
// so the two serialisers disagreed on identical input until now.
if opts.escapesURIAttrs() && (opts.method == "html" || opts.method == "xhtml") &&
isURIAttribute(n.Name().Local, a) {
- sb.WriteString(escapeAttr(escapeURIAttribute(a.Value())))
+ writeEscaped(sb, escapeURIAttribute(a.Value()), true)
} else {
- sb.WriteString(escapeAttr(a.Value()))
+ writeEscaped(sb, a.Value(), true)
}
sb.WriteString(`"`)
}
@@ -952,7 +960,7 @@ func serializeNode(sb *serializeSink, n *xdm.Node, opts serializeOptions, depth
sb.WriteString(">")
} else {
sb.WriteString(">")
- sb.WriteString(elementName(n))
+ writeName(sb, n)
sb.WriteString(">")
}
return
@@ -1046,10 +1054,10 @@ func serializeNode(sb *serializeSink, n *xdm.Node, opts serializeOptions, depth
writeIndent(sb, depth)
}
sb.WriteString("")
- sb.WriteString(elementName(n))
+ writeName(sb, n)
sb.WriteString(">")
case xdm.KindText:
- sb.WriteString(escapeText(opts.normalized(n.Value())))
+ writeEscaped(sb, opts.normalized(n.Value()), false)
case xdm.KindComment:
sb.WriteString("")
+ s.writeString("")
case xdm.KindPI:
if err := s.validatePI(n.Name().Local, n.Value()); err != nil {
@@ -730,9 +772,11 @@ func (s *serializer) node(n *xdm.Node, depth int) {
return
}
s.indent(depth)
- s.writeString("" + n.Name().Local)
+ s.writeString("")
+ s.writeString(n.Name().Local)
if n.Value() != "" {
- s.writeString(" " + n.Value())
+ s.writeString(" ")
+ s.writeString(n.Value())
}
// The html method closes a processing instruction with ">" alone,
// which is the same rule the SERE0015 check above is written for: if
@@ -1154,15 +1198,12 @@ func (s *serializer) ownNamespaceDecl(n *xdm.Node, base int, prefix, uri string)
// otherwise the union is sorted by prefix, which is the order a literal
// result element copies its bindings in.
func rootNamespaces(n *xdm.Node) []nsPair {
- scope := n.InScopeNamespaces()
- own := make(map[string]bool, n.NumNamespaceDecls())
- for prefix := range n.DeclaredNamespaces() {
- own[prefix] = true
- }
- var out []nsPair
- for p, uri := range scope {
- if p != "xml" && !own[p] {
- out = append(out, nsPair{p, uri})
+ var arr [32]nsBinding
+ scope := scopeBindings(n, arr[:0])
+ out := make([]nsPair, 0, len(scope)+n.NumNamespaceDecls())
+ for _, b := range scope {
+ if !declares(n, b.prefix) {
+ out = append(out, nsPair{b.prefix, b.uri})
}
}
inherited := len(out)
@@ -1170,8 +1211,8 @@ func rootNamespaces(n *xdm.Node) []nsPair {
out = append(out, nsPair{prefix, uri})
}
if inherited > 0 {
- sort.SliceStable(out, func(i, j int) bool {
- return out[i].prefix < out[j].prefix
+ slices.SortStableFunc(out, func(a, b nsPair) int {
+ return strings.Compare(a.prefix, b.prefix)
})
}
return out
@@ -1668,30 +1709,36 @@ func (s *serializer) writeCData(text string) {
// character the encoding cannot hold — NFD splits "\u00e7" into "c" and a
// combining cedilla, and US-ASCII output must then break the section
// around the cedilla rather than write it raw.
+ //
+ // The section is written to w in runs, as escapeText writes text: the
+ // function has no error path, so nothing is gained by building it first.
text = s.normalized(text)
- var sb strings.Builder
- sb.WriteString("") {
// The section ends at the first "]]>", which the text may itself
// contain — so it is split there and the ">" begins a new one.
- sb.WriteString("]]]]>")
+ s.writeString(text[run:i])
+ s.writeString("]]]]>")
i += 3
+ run = i
continue
}
r, size := utf8.DecodeRuneInString(text[i:])
if !s.representable(r) {
- sb.WriteString("]]>")
- fmt.Fprintf(&sb, "%d;", r)
- sb.WriteString("")
+ s.writeCharRef(r)
+ s.writeString("")
- s.writeString(sb.String())
+ s.writeString(text[run:])
+ s.writeString("]]>")
}
// representable reports whether the declared encoding can hold this
@@ -1785,7 +1832,10 @@ func (s *serializer) mapSegments(text string) []mapSegment {
s.oneSeg[0] = mapSegment{text: s.normalized(text)}
return s.oneSeg[:]
}
- var segs []mapSegment
+ // The slice is the serializer's own, reused for the same reason as
+ // oneSeg: a fresh one per text node and attribute value was the whole
+ // allocation cost of serialising with a character map.
+ segs := s.segBuf[:0]
run := 0
for i, r := range text {
repl, ok := s.charMap[r]
@@ -1799,7 +1849,9 @@ func (s *serializer) mapSegments(text string) []mapSegment {
})
run = i + utf8.RuneLen(r)
}
- return append(segs, mapSegment{text: s.normalized(text[run:])})
+ segs = append(segs, mapSegment{text: s.normalized(text[run:])})
+ s.segBuf = segs
+ return segs
}
// mapSegment is one unmapped run of text and, where the run ended at a
@@ -2758,15 +2810,5 @@ func flattenArrays(seq xdm.Sequence) xdm.Sequence {
if !has {
return seq
}
- out := make(xdm.Sequence, 0, len(seq))
- for _, it := range seq {
- if a, ok := it.(*xdm.ArrayItem); ok {
- for _, m := range a.Members() {
- out = append(out, flattenArrays(m)...)
- }
- continue
- }
- out = append(out, it)
- }
- return out
+ return xdm.Flatten(seq)
}
diff --git a/xslt/serialize_streamalloc_test.go b/xslt/serialize_streamalloc_test.go
new file mode 100644
index 00000000..9faf39ca
--- /dev/null
+++ b/xslt/serialize_streamalloc_test.go
@@ -0,0 +1,169 @@
+package xslt
+
+import (
+ "bytes"
+ "io"
+ goruntime "runtime"
+ "strings"
+ "testing"
+
+ "github.com/knroy/go-xml/v2/xdm"
+)
+
+// streamDoc is n copies of an element holding text, a comment and a PI, so
+// that every path below has a node per copy to allocate for.
+func streamDoc(t *testing.T, n int) xdm.Sequence {
+ t.Helper()
+ var b strings.Builder
+ b.WriteString(``)
+ for range n {
+ b.WriteString(`x ]]> y z`)
+ }
+ b.WriteString(``)
+ tree, err := xdm.ParseString(b.String(), xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ return xdm.One(tree.Root)
+}
+
+// The cdata-section-elements path built each section in a builder, a
+// character map allocated a segment slice per text node, and comments and PIs
+// were concatenated before being written. Each cost an allocation per node,
+// so the count must not grow with the number of nodes.
+func TestSerializeStreamAllocsFlat(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation counts are not stable under -race")
+ }
+ for _, tc := range []struct {
+ name string
+ opts OutputSettings
+ cmap map[rune]string
+ }{
+ {"cdata", OutputSettings{Method: "xml", CDataElements: []xdm.QName{{Local: "a"}}}, nil},
+ {"charmap", OutputSettings{Method: "xml"}, map[rune]string{' ': " "}},
+ {"comment-pi", OutputSettings{Method: "xml"}, nil},
+ } {
+ count := func(seq xdm.Sequence) float64 {
+ return testing.AllocsPerRun(10, func() {
+ if err := Serialize(io.Discard, seq, tc.opts, tc.cmap); err != nil {
+ t.Fatal(err)
+ }
+ })
+ }
+ few, many := count(streamDoc(t, 5)), count(streamDoc(t, 500))
+ if many > few+5 {
+ t.Errorf("%s: 500 nodes %.0f allocations, 5 nodes %.0f; want the same", tc.name, many, few)
+ }
+ }
+}
+
+// The text method concatenated the whole string value before writing it, a
+// copy of the output and its builder's growth on top. It now writes each text
+// node as it comes, so what it allocates must not grow with the output.
+func TestSerializeTextMethodStreams(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation counts are not stable under -race")
+ }
+ seq := streamDoc(t, 20000)
+ var out bytes.Buffer
+ if err := Serialize(&out, seq, OutputSettings{Method: "text"}, nil); err != nil {
+ t.Fatal(err)
+ }
+ if got := allocBytes(func() {
+ if err := Serialize(io.Discard, seq, OutputSettings{Method: "text"}, nil); err != nil {
+ t.Fatal(err)
+ }
+ }); got > uint64(out.Len())/4 {
+ t.Errorf("text method allocated %d bytes for %d bytes of output", got, out.Len())
+ }
+}
+
+// allocBytes is the heap f allocates, the least of three runs so that a
+// collection landing inside one does not count against it.
+func allocBytes(f func()) uint64 {
+ best := ^uint64(0)
+ for range 3 {
+ var a, b goruntime.MemStats
+ goruntime.ReadMemStats(&a)
+ f()
+ goruntime.ReadMemStats(&b)
+ best = min(best, b.TotalAlloc-a.TotalAlloc)
+ }
+ return best
+}
+
+// The json and adaptive methods built the whole document as a string before
+// writing it, 7 to 12 times the output in all. They now write as they render,
+// so what they allocate must not grow with the output.
+func TestSerializeJSONAdaptiveStream(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation counts are not stable under -race")
+ }
+ seq := streamDoc(t, 20000)
+ for _, tc := range []struct {
+ name string
+ opts OutputSettings
+ cmap map[rune]string
+ }{
+ {"json", OutputSettings{Method: "json"}, nil},
+ {"adaptive", OutputSettings{Method: "adaptive"}, nil},
+ {"adaptive-charmap", OutputSettings{Method: "adaptive"}, map[rune]string{' ': " "}},
+ } {
+ var out bytes.Buffer
+ if err := Serialize(&out, seq, tc.opts, tc.cmap); err != nil {
+ t.Fatal(err)
+ }
+ if got := allocBytes(func() {
+ if err := Serialize(io.Discard, seq, tc.opts, tc.cmap); err != nil {
+ t.Fatal(err)
+ }
+ }); got > uint64(out.Len())/4 {
+ t.Errorf("%s allocated %d bytes for %d bytes of output", tc.name, got, out.Len())
+ }
+ }
+}
+
+// Result.String, SecondaryResult.String and SerializeAsXML grew a
+// strings.Builder by doubling from empty, about five times the output. They now keep the serializer's flushed
+// chunks and join them once.
+func TestResultStringAllocation(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation counts are not stable under -race")
+ }
+ for _, n := range []int{2000, 13000} {
+ r := &Result{Nodes: streamDoc(t, n), output: OutputSettings{Method: "xml"}}
+ sr := &SecondaryResult{Nodes: r.Nodes, Output: r.output}
+ out := r.String()
+ for name, f := range map[string]func(){
+ "String": func() { _ = r.String() },
+ "SerializeAsXML": func() { _ = SerializeAsXML(r) },
+ "SecondaryResult.String": func() { _ = sr.String() },
+ } {
+ got := allocBytes(f)
+ t.Logf("%s, %d bytes: %d allocated (%.2fx)", name, len(out), got, float64(got)/float64(len(out)))
+ // The 32 KiB write buffer is a fixed cost on top.
+ if got > uint64(len(out))*5/2+64<<10 {
+ t.Errorf("%s allocated %d bytes for %d bytes of output, want under 2.5x and the buffer", name, got, len(out))
+ }
+ }
+ }
+}
+
+// Each Serialize to an unbuffered writer allocated a 32 KiB buffer, most of
+// what a small result cost. The buffers are pooled now.
+func TestSerializeBufferPooled(t *testing.T) {
+ if raceEnabled {
+ t.Skip("allocation counts are not stable under -race")
+ }
+ seq := streamDoc(t, 10)
+ f := func() {
+ if err := Serialize(io.Discard, seq, OutputSettings{Method: "xml"}, nil); err != nil {
+ t.Fatal(err)
+ }
+ }
+ f()
+ if got := allocBytes(f); got > 16<<10 {
+ t.Errorf("a small result allocated %d bytes, want under 16 KiB", got)
+ }
+}
diff --git a/xslt/sharedns_test.go b/xslt/sharedns_test.go
index 784346ee..b70509c4 100644
--- a/xslt/sharedns_test.go
+++ b/xslt/sharedns_test.go
@@ -54,3 +54,49 @@ func TestInScopeNamespacesShared(t *testing.T) {
t.Error("maps shared outside a compilation")
}
}
+
+// SchXslt redeclares one prefix on each of the CEN rules' svrl:failed-assert
+// elements. Elements that each declare the same bindings share one map, one
+// resolver and one binding list in the compiled stylesheet, rather than a copy
+// per element.
+func TestNamespacesInternedByContent(t *testing.T) {
+ const sheet = `
+
+
+
+
+`
+ doc, err := xdm.ParseString(sheet, xdm.ParseOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var outs []*xdm.Node
+ for n := range doc.Root.Descendants() {
+ if n.Kind() == xdm.KindElement && n.Name().Local == "out" {
+ outs = append(outs, n)
+ }
+ }
+ setSharedNS(map[*xdm.Node]map[string]string{})
+ ptr := func(m map[string]string) uintptr { return reflect.ValueOf(m).Pointer() }
+ if ptr(inScopeNamespacesShared(outs[0])) != ptr(inScopeNamespacesShared(outs[1])) {
+ t.Error("two elements declaring the same bindings do not share a map")
+ }
+ if newNSResolver(outs[0], "") != newNSResolver(outs[1], "") {
+ t.Error("two elements with the same static context do not share a resolver")
+ }
+ setSharedNS(nil)
+
+ st, err := Compile(doc.Root, CompileOptions{})
+ if err != nil {
+ t.Fatal(err)
+ }
+ var lits []*literalElemInstr
+ for _, in := range st.named["t"].Body {
+ if l, ok := in.(*literalElemInstr); ok {
+ lits = append(lits, l)
+ }
+ }
+ if len(lits) != 2 || len(lits[0].namespaces) == 0 || &lits[0].namespaces[0] != &lits[1].namespaces[0] {
+ t.Error("two literal elements with the same bindings do not share one list")
+ }
+}
diff --git a/xslt/sortvalue_test.go b/xslt/sortvalue_test.go
new file mode 100644
index 00000000..74ef6065
--- /dev/null
+++ b/xslt/sortvalue_test.go
@@ -0,0 +1,15 @@
+package xslt
+
+import (
+ "testing"
+ "unsafe"
+)
+
+// applySorts holds one sortValue per item per key. It was 152 bytes, with the
+// collation and case-fold material inline; that material now hangs off a
+// pointer, which only the keys needing it pay for.
+func TestSortValueStaysSmall(t *testing.T) {
+ if sz := unsafe.Sizeof(sortValue{}); sz > 96 {
+ t.Fatalf("sortValue is %d bytes, want at most 96", sz)
+ }
+}
diff --git a/xslt/stylesheet.go b/xslt/stylesheet.go
index 1f23b2d6..dfb10eab 100644
--- a/xslt/stylesheet.go
+++ b/xslt/stylesheet.go
@@ -2,9 +2,11 @@ package xslt
import (
"fmt"
+ "reflect"
"sort"
"strings"
"sync"
+ "unsafe"
"github.com/knroy/go-xml/v2/xdm"
"github.com/knroy/go-xml/v2/xpath"
@@ -245,6 +247,10 @@ type Stylesheet struct {
source *xdm.Node
// nested caches the stylesheets fn:transform compiles; see nestedCache.
nested nestedCache
+ // globalsOnce builds globalsByName, the first declaration of each global
+ // by Clark name, for evalGlobals; see globalByName.
+ globalsOnce sync.Once
+ globalsByName map[string]*Variable
}
// Template is a compiled xsl:template.
@@ -700,17 +706,61 @@ func Compile(doc *xdm.Node, opts CompileOptions) (*Stylesheet, error) {
// Outside a compilation m is nil and nothing is cached. It has its own mutex
// rather than relying on compileMu because a running transform, which does
// not hold compileMu, can reach inScopeNamespacesShared too.
+//
+// byContent and lists intern the maps and the literal elements' binding lists
+// by content as well: SchXslt redeclares one prefix on each of the CEN
+// rules' ~980 svrl:failed-assert elements, so keying by the declaring
+// element alone kept that many identical copies in the compiled stylesheet.
var sharedNS struct {
sync.Mutex
- m map[*xdm.Node]map[string]string
+ m map[*xdm.Node]map[string]string
+ byContent map[string]map[string]string
+ lists map[string][]nsBinding
+ resolvers map[nsResolverKey]*nsResolver
+ keyBuf []byte // scratch for internBindings, under the lock
}
func setSharedNS(m map[*xdm.Node]map[string]string) {
sharedNS.Lock()
sharedNS.m = m
+ sharedNS.byContent, sharedNS.lists, sharedNS.resolvers = nil, nil, nil
+ if m != nil {
+ sharedNS.byContent = map[string]map[string]string{}
+ sharedNS.lists = map[string][]nsBinding{}
+ sharedNS.resolvers = map[nsResolverKey]*nsResolver{}
+ }
sharedNS.Unlock()
}
+// internBindings returns a shared copy of s for the rest of the compilation,
+// or s itself outside one. The result must not be modified.
+func internBindings(s []nsBinding) []nsBinding {
+ if len(s) == 0 {
+ return s
+ }
+ sharedNS.Lock()
+ defer sharedNS.Unlock()
+ if sharedNS.lists == nil {
+ return s
+ }
+ // Built in a buffer reused across calls and looked up as m[string(b)],
+ // which does not allocate: a strings.Builder allocated a key per call
+ // and raised CEN's compile allocation by 12-14%. Only a miss copies it.
+ b := sharedNS.keyBuf[:0]
+ for _, ns := range s {
+ b = append(b, ns.prefix...)
+ b = append(b, 0)
+ b = append(b, ns.uri...)
+ b = append(b, 0)
+ }
+ sharedNS.keyBuf = b
+ if v, ok := sharedNS.lists[string(b)]; ok {
+ return v
+ }
+ sharedNS.lists[string(b)] = s
+ return s
+}
+
// forgetSharedNS drops the cached maps, and the version memo, for a caller
// that has just changed a stylesheet tree's namespaces or parent links
// during a compilation.
@@ -741,11 +791,43 @@ func inScopeNamespacesShared(el *xdm.Node) map[string]string {
m, ok := sharedNS.m[d]
if !ok {
m = d.InScopeNamespaces()
+ // NUL cannot occur in a prefix or a URI, so it separates them.
+ prefixes := make([]string, 0, len(m))
+ for p := range m {
+ prefixes = append(prefixes, p)
+ }
+ sort.Strings(prefixes)
+ var b strings.Builder
+ for _, p := range prefixes {
+ b.WriteString(p)
+ b.WriteByte(0)
+ b.WriteString(m[p])
+ b.WriteByte(0)
+ }
+ if v, ok := sharedNS.byContent[b.String()]; ok {
+ m = v
+ } else {
+ sharedNS.byContent[b.String()] = m
+ }
sharedNS.m[d] = m
}
return m
}
+// globalByName is the first declaration of each global by Clark name. It
+// depends only on the compiled globals, so every transform shares one.
+func (s *Stylesheet) globalByName() map[string]*Variable {
+ s.globalsOnce.Do(func() {
+ s.globalsByName = make(map[string]*Variable, len(s.globals))
+ for _, g := range s.globals {
+ if _, dup := s.globalsByName[g.Name.Clark()]; !dup {
+ s.globalsByName[g.Name.Clark()] = g
+ }
+ }
+ })
+ return s.globalsByName
+}
+
// compileNestedLocked compiles a stylesheet from inside a compilation that is
// already running on this goroutine.
//
@@ -1233,7 +1315,7 @@ func newNSResolver(el *xdm.Node, defaultElementNS string) *nsResolver {
if defaultElementNS == "" {
defaultElementNS = xpathDefaultNamespaceAt(el)
}
- return &nsResolver{
+ r := &nsResolver{
bindings: inScopeNamespacesShared(el),
defaultNS: defaultElementNS,
baseURI: el.BaseURI(),
@@ -1245,6 +1327,38 @@ func newNSResolver(el *xdm.Node, defaultElementNS string) *nsResolver {
xpathVersion: xpathVersionAt(el),
xsltVersion: declaredXSLTVersion(el),
}
+ return internResolver(r)
+}
+
+// nsResolverKey is an nsResolver as a map key. The bindings map is keyed by
+// identity, which inScopeNamespacesShared has already made content identity
+// for the compilation; every other field must be listed here.
+type nsResolverKey struct {
+ bindings unsafe.Pointer
+ defaultNS, baseURI, collate string
+ compat bool
+ schema *xsd.Schema
+ pkg int
+ xpathVersion xpath.Version
+ xsltVersion float64
+}
+
+// internResolver returns the compilation's shared resolver equal to r, or r
+// itself outside a compilation. A resolver is never modified once built
+// (xsl:evaluate copies one to change it), so equal ones can be one.
+func internResolver(r *nsResolver) *nsResolver {
+ sharedNS.Lock()
+ defer sharedNS.Unlock()
+ if sharedNS.resolvers == nil {
+ return r
+ }
+ k := nsResolverKey{reflect.ValueOf(r.bindings).UnsafePointer(), r.defaultNS, r.baseURI,
+ r.collation, r.compat, r.schema, r.pkg, r.xpathVersion, r.xsltVersion}
+ if v, ok := sharedNS.resolvers[k]; ok {
+ return v
+ }
+ sharedNS.resolvers[k] = r
+ return r
}
// LookupSchemaDeclaration implements xpath.SchemaTypes.
@@ -1666,13 +1780,28 @@ func (r *nsResolver) ResolvePrefix(p string) (string, bool) {
func (r *nsResolver) DefaultElementNamespace() string { return r.defaultNS }
func (r *nsResolver) DefaultFunctionNamespace() string { return xdm.NSFN }
+// prefixLookup is an element, or the in-scope namespaces a compiled
+// instruction keeps in its place so as not to keep the stylesheet tree alive.
+type prefixLookup interface {
+ LookupPrefix(prefix string) (string, bool)
+}
+
+// nsMap is an element's in-scope namespaces, as InScopeNamespaces returns
+// them (xml included), answering LookupPrefix exactly as the element would.
+type nsMap map[string]string
+
+func (m nsMap) LookupPrefix(prefix string) (string, bool) {
+ uri, ok := m[prefix]
+ return uri, ok || prefix == ""
+}
+
// resolveQNameAttr resolves a lexical QName found in a stylesheet attribute
// against the element's namespace context.
//
// An unprefixed QName in an attribute value is in *no* namespace, unlike an
// unprefixed element name test. This is the rule that makes
// name="foo" and mean different things inside a default namespace.
-func resolveQNameAttr(el *xdm.Node, lex string) (xdm.QName, error) {
+func resolveQNameAttr(el prefixLookup, lex string) (xdm.QName, error) {
lex = strings.TrimSpace(lex)
// The EQName form Q{uri}local carries its own namespace, so no prefix has
// to be in scope for it. The suite writes it wherever a QName is
diff --git a/xslt/transform.go b/xslt/transform.go
index 1033e75e..994d45f2 100644
--- a/xslt/transform.go
+++ b/xslt/transform.go
@@ -532,7 +532,7 @@ func (s *Stylesheet) Transform(ctx context.Context, source *xdm.Node, opts Trans
}
}
if err := runTemplate(rt, t,
- opts.InitialTemplateParams, opts.InitialTemplateTunnelParams,
+ paramsOf(opts.InitialTemplateParams), paramsOf(opts.InitialTemplateTunnelParams),
out); err != nil {
return nil, err
}
@@ -620,7 +620,7 @@ func (s *Stylesheet) Transform(ctx context.Context, source *xdm.Node, opts Trans
"parameter $%s", code, opts.InitialTemplate, p.Name.Lexical())
}
if err := runTemplate(rt, t,
- opts.InitialTemplateParams, opts.InitialTemplateTunnelParams,
+ paramsOf(opts.InitialTemplateParams), paramsOf(opts.InitialTemplateTunnelParams),
out); err != nil {
return nil, err
}
@@ -699,7 +699,7 @@ func (s *Stylesheet) Transform(ctx context.Context, source *xdm.Node, opts Trans
initialMode = want
}
if err := applyInitialSelection(rt, source, opts.InitialMatchSelection,
- initialMode, opts.InitialModeParams, opts.InitialModeTunnelParams,
+ initialMode, paramsOf(opts.InitialModeParams), paramsOf(opts.InitialModeTunnelParams),
out); err != nil {
return nil, err
}
@@ -748,6 +748,14 @@ func (s *Stylesheet) Transform(ctx context.Context, source *xdm.Node, opts Trans
}
}
+ // A budget refusal is latched by the builder that met it and is only
+ // reported by execSequenceBody's loop. The built-in rules, and any
+ // temporary tree, write through a builder nobody polls, so output cut
+ // short at the bound would otherwise be returned as a success. The budget
+ // counter is the transform's, so it sees the overrun wherever it happened.
+ if err := rt.ctx.NodesRefused(); err != nil {
+ return nil, err
+ }
nodes := out.Sequence()
if haveRaw {
nodes = rawResult
@@ -1265,9 +1273,24 @@ func (s *Stylesheet) spaceDeclsFor(pkg int) (strip, preserve []xdm.QName) {
// Transform returns nil for a stylesheet whose output settings are invalid.
// Use Serialize for anything whose failure you need to see.
func (r *Result) String() string {
- var sb strings.Builder
- _ = r.Serialize(&sb)
- return sb.String()
+ return serializeString(r.Nodes, r.output, r.charMap)
+}
+
+// serializeString is serialize to a string. The output is kept in the chunks
+// the serializer's buffer flushes and joined once, about twice its size in
+// all, where a strings.Builder grown by doubling cost up to four times.
+func serializeString(seq xdm.Sequence, opts OutputSettings, charMap map[rune]string) string {
+ var c chunkWriter
+ _ = serialize(&c, seq, opts, charMap)
+ return strings.Join(c, "")
+}
+
+// chunkWriter keeps a copy of each write.
+type chunkWriter []string
+
+func (c *chunkWriter) Write(p []byte) (int, error) {
+ *c = append(*c, string(p))
+ return len(p), nil
}
// Serialize writes the result using the stylesheet's xsl:output settings.
@@ -1470,10 +1493,8 @@ func appendLeafCopy(parent, n *xdm.Node) *xdm.Node {
// and -020 stopped at ""), reporting a defect in the transform where the
// only defect was in how the comparison asked for the text.
func SerializeAsXML(r *Result) string {
- var sb strings.Builder
- _ = serialize(&sb, r.Nodes,
+ return serializeString(r.Nodes,
OutputSettings{Method: "xml", Version: r.output.Version}, r.charMap)
- return sb.String()
}
// applyInitialSelection runs the initial apply-templates over the initial
@@ -1485,7 +1506,7 @@ func SerializeAsXML(r *Result) string {
// position and size of each item have to be set the way xsl:apply-templates
// sets them, since a template rule reached this way may call fn:position().
func applyInitialSelection(rt *runtime, source *xdm.Node, sel xdm.Sequence,
- mode string, params, tunnels map[string]xdm.Sequence,
+ mode string, params, tunnels paramValues,
out *outputBuilder) error {
if sel == nil {