makiri 0.10.0.rc2 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +37 -0
- data/NOKOGIRI_DIFFERENCES.md +7 -0
- data/Rakefile +14 -0
- data/ext/makiri/rust/clippy.toml +18 -10
- data/ext/makiri/rust/fuzz/fuzz_targets/common.rs +1 -1
- data/ext/makiri/rust/src/bridge/html.rs +23 -2
- data/ext/makiri/rust/src/bridge/node_set.rs +2 -2
- data/ext/makiri/rust/src/bridge/typed.rs +47 -2
- data/ext/makiri/rust/src/bridge/wrapper.rs +147 -4
- data/ext/makiri/rust/src/bridge/xml.rs +113 -25
- data/ext/makiri/rust/src/bridge/xml_decode.rs +21 -7
- data/ext/makiri/rust/src/bridge/xpath/context_object.rs +2 -2
- data/ext/makiri/rust/src/css/build.rs +1 -1
- data/ext/makiri/rust/src/falloc/mod.rs +25 -25
- data/ext/makiri/rust/src/glue/xml_node/mutate.rs +53 -52
- data/ext/makiri/rust/src/glue/xml_node/ns.rs +1 -1
- data/ext/makiri/rust/src/glue/xml_node/read.rs +1 -1
- data/ext/makiri/rust/src/glue/xml_node/serialize.rs +3 -0
- data/ext/makiri/rust/src/glue/xml_node/strings.rs +1 -1
- data/ext/makiri/rust/src/lexbor/adapter/cross_import.rs +2 -7
- data/ext/makiri/rust/src/lexbor/adapter/source_loc.rs +5 -1
- data/ext/makiri/rust/src/lexbor/adapter/text_index.rs +3 -3
- data/ext/makiri/rust/src/lexbor/contains_guard.rs +299 -0
- data/ext/makiri/rust/src/lexbor/css_engine.rs +28 -2
- data/ext/makiri/rust/src/lexbor/fragment.rs +2 -2
- data/ext/makiri/rust/src/lexbor/mod.rs +6 -0
- data/ext/makiri/rust/src/lexbor/selectors.rs +11 -4
- data/ext/makiri/rust/src/lexbor/stylesheet.rs +48 -7
- data/ext/makiri/rust/src/lexbor/tests.rs +233 -0
- data/ext/makiri/rust/src/lib.rs +1 -1
- data/ext/makiri/rust/src/rust_tests.rs +5 -45
- data/ext/makiri/rust/src/xml/arena.rs +183 -99
- data/ext/makiri/rust/src/xml/chars/expand.rs +159 -0
- data/ext/makiri/rust/src/xml/chars/mod.rs +181 -0
- data/ext/makiri/rust/src/xml/dom_name.rs +84 -0
- data/ext/makiri/rust/src/xml/encoding_sniff.rs +191 -114
- data/ext/makiri/rust/src/xml/index.rs +90 -67
- data/ext/makiri/rust/src/xml/mod.rs +1 -2
- data/ext/makiri/rust/src/xml/model.rs +10 -13
- data/ext/makiri/rust/src/xml/mutate/attr.rs +154 -0
- data/ext/makiri/rust/src/xml/mutate/copy.rs +206 -0
- data/ext/makiri/rust/src/xml/mutate/edit.rs +100 -0
- data/ext/makiri/rust/src/xml/mutate/factory.rs +138 -0
- data/ext/makiri/rust/src/xml/mutate/insert.rs +488 -0
- data/ext/makiri/rust/src/xml/mutate/mod.rs +66 -0
- data/ext/makiri/rust/src/xml/mutate/ns.rs +174 -0
- data/ext/makiri/rust/src/xml/qname.rs +50 -121
- data/ext/makiri/rust/src/xml/selftest.rs +881 -898
- data/ext/makiri/rust/src/xml/serialize/c14n.rs +243 -0
- data/ext/makiri/rust/src/xml/serialize/mod.rs +110 -0
- data/ext/makiri/rust/src/xml/serialize/out.rs +89 -0
- data/ext/makiri/rust/src/xml/serialize/xml.rs +511 -0
- data/ext/makiri/rust/src/xml/tree/cursor.rs +367 -0
- data/ext/makiri/rust/src/xml/tree/decl.rs +102 -0
- data/ext/makiri/rust/src/xml/tree/dtd.rs +442 -0
- data/ext/makiri/rust/src/xml/tree/mod.rs +710 -0
- data/ext/makiri/rust/src/xml/tree/scope.rs +93 -0
- data/ext/makiri/rust/src/xml/xpath.rs +5 -3
- data/ext/makiri/rust/src/xpath/ctx.rs +2 -2
- data/ext/makiri/rust/src/xpath/eval.rs +2 -2
- data/ext/makiri/rust/src/xpath/funcs/mod.rs +1 -1
- data/ext/makiri/rust/src/xpath/parse.rs +3 -3
- data/ext/makiri/rust/src/xpath/str_cache.rs +1 -1
- data/ext/makiri/rust/src/xpath/tests.rs +1 -1
- data/ext/makiri/rust/src/xpath/value.rs +1 -1
- data/lib/makiri/version.rb +1 -1
- data/script/check_unsafe_boundaries.rb +40 -7
- metadata +22 -7
- data/ext/makiri/rust/src/xml/api.rs +0 -18
- data/ext/makiri/rust/src/xml/chars.rs +0 -287
- data/ext/makiri/rust/src/xml/mutate.rs +0 -1159
- data/ext/makiri/rust/src/xml/parse.rs +0 -46
- data/ext/makiri/rust/src/xml/serialize.rs +0 -729
- data/ext/makiri/rust/src/xml/tree.rs +0 -1467
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b6163794fb07f39d4476e91a5d9db367d82b02888e167bc41435bb12de32de49
|
|
4
|
+
data.tar.gz: 4c075f42cbdf69b39b9783e3ad66bd09acb48f380447e8e80a47402dcdd427e5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: d6f1d154116c49d5b42e57e08d584781af42c2d0a02a6d3a1d6073659437cd7b0920a30a42451fedc70b173de242c9738a34ea4fd8d582dd3d199094bbfe10ab
|
|
7
|
+
data.tar.gz: 7278778cf2e98166e03ef79364ef9a4b0feec3b410949c2e4889f82f899594da8ba492ac00a6d75de2ee7a553430e5dc69a9af52a41292ab86249af830dea7fe
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.10.0] - 2026-09-22
|
|
4
|
+
|
|
5
|
+
### Fixed
|
|
6
|
+
|
|
7
|
+
* A frozen node now raises `FrozenError` when it is the ARGUMENT of a tree
|
|
8
|
+
mutation, not only the receiver: `b.add_child(a)` relinks `a` exactly as
|
|
9
|
+
`a.remove` does. Both `Makiri::XML` and `Makiri::HTML`. A fragment argument
|
|
10
|
+
splices its children, which have no wrapper of their own to check.
|
|
11
|
+
* A `Makiri::XML` mutation that exceeds the document's own `max_bytes` /
|
|
12
|
+
`max_nodes` now raises `Makiri::XML::LimitExceeded` instead of reporting the
|
|
13
|
+
refusal as out of memory.
|
|
14
|
+
* Inserting a `DocumentFragment` is all or nothing on `add_child`, `before` and
|
|
15
|
+
`after`, as it already was on `replace`: a child the rules refuse no longer
|
|
16
|
+
leaves the earlier ones linked in a document the caller was told had not
|
|
17
|
+
changed.
|
|
18
|
+
* A rejected `Makiri::XML::Document#fragment` no longer charges the document for
|
|
19
|
+
the nodes it discarded. 100k rejected fragments grew a `<r/>` document to
|
|
20
|
+
77 MB; it is now 516 bytes.
|
|
21
|
+
* A failed `Makiri::XML` parse reports the FIRST failure for all four kinds
|
|
22
|
+
(`syntax` was sticky while `limit` and `unsupported` overwrote each other).
|
|
23
|
+
* `Makiri::XML#to_xml`'s serializer allocates fallibly again, so running out of
|
|
24
|
+
memory raises instead of aborting the process.
|
|
25
|
+
* `:lexbor-contains()` now rejects an argument the bundled CSS parser does not
|
|
26
|
+
take, the way any unknown pseudo-class is rejected: `Makiri::CSS::SyntaxError`
|
|
27
|
+
from `#css` / `#at_css` / `#matches?`, and a `:bad_style` rule from
|
|
28
|
+
`Makiri::Lexbor::CSS.parse_stylesheet`. Well-formed uses are unchanged.
|
|
29
|
+
|
|
30
|
+
### Performance
|
|
31
|
+
|
|
32
|
+
* `Makiri::XML#to_xml` plans namespaces from a binding stack instead of
|
|
33
|
+
re-walking each ancestor's attribute list, which cost O(depth^2 x attributes).
|
|
34
|
+
403 KB of nested prefixed attributes took 4.88s and now takes 0.001s. A
|
|
35
|
+
crafted document fails closed with `Makiri::Error` ("namespace planning
|
|
36
|
+
exceeded its step budget") rather than running on.
|
|
37
|
+
* Setting an attribute on a `Makiri::XML` element walks the attribute list once
|
|
38
|
+
instead of twice (4096 attributes: 47ms -> 27ms).
|
|
39
|
+
|
|
3
40
|
## [0.10.0.rc2] - 2026-09-20
|
|
4
41
|
|
|
5
42
|
### Added
|
data/NOKOGIRI_DIFFERENCES.md
CHANGED
|
@@ -61,6 +61,13 @@ what browsers do - rather than libxml2. Detailed, test-backed notes live in
|
|
|
61
61
|
* `create_processing_instruction("a:b", ...)` succeeds, as DOM
|
|
62
62
|
`createProcessingInstruction` does, but `#to_xml` / `#canonicalize` then raise,
|
|
63
63
|
as DOM Parsing's well-formed serializer does. Nokogiri writes `<?a:b ...?>`.
|
|
64
|
+
* `#freeze` on a node is ENFORCED: a frozen node's mutators raise `FrozenError`,
|
|
65
|
+
and so does passing a frozen node as the argument of an insertion, which
|
|
66
|
+
relinks it. Nokogiri reports `frozen?` but every mutator still mutates. The
|
|
67
|
+
check reaches the nodes the caller named; a fragment argument splices its
|
|
68
|
+
children, and those cannot be checked, because frozen-ness is a property of a
|
|
69
|
+
Ruby object and the arena keeps no map from a node back to its wrapper.
|
|
70
|
+
|
|
64
71
|
* A node's namespace URI is its identity, not something re-derived from the
|
|
65
72
|
declarations around it - the WHATWG DOM model, measured against Chrome 152
|
|
66
73
|
(`DOMParser` + `XMLSerializer`).
|
data/Rakefile
CHANGED
|
@@ -74,6 +74,20 @@ namespace :unsafe do
|
|
|
74
74
|
end
|
|
75
75
|
end
|
|
76
76
|
|
|
77
|
+
# The lint gate, with CI's OWN arguments. It exists because a narrower spelling
|
|
78
|
+
# looks like it passes and is not: `cargo clippy --no-default-features --features
|
|
79
|
+
# lexbor` never compiles `bridge/` or `glue/` at all, so five `clippy.toml`
|
|
80
|
+
# violations there went unseen locally and turned CI red. `--all-features` is
|
|
81
|
+
# every configuration the crate has, which is why CI uses it - and why this task
|
|
82
|
+
# does, rather than leaving the arguments to be remembered.
|
|
83
|
+
desc "Clippy and rustfmt exactly as CI runs them (--all-features, warnings are errors)"
|
|
84
|
+
task :lint do
|
|
85
|
+
Dir.chdir("ext/makiri/rust") do
|
|
86
|
+
sh "cargo", "clippy", "--all-features", "--", "-D", "warnings"
|
|
87
|
+
sh "cargo", "fmt", "--all", "--", "--check"
|
|
88
|
+
end
|
|
89
|
+
end
|
|
90
|
+
|
|
77
91
|
# `rake spec:valgrind` - run the spec suite under Valgrind memcheck via
|
|
78
92
|
# ruby_memcheck (Linux CI; see .github/workflows/valgrind.yml). The gem ships
|
|
79
93
|
# Ruby's own Valgrind suppression files (matched by Ruby version) and filters
|
data/ext/makiri/rust/clippy.toml
CHANGED
|
@@ -19,17 +19,25 @@
|
|
|
19
19
|
# call, and review is what catches a site that skipped them.
|
|
20
20
|
disallowed-methods = [
|
|
21
21
|
{ path = "std::boxed::Box::new", reason = "aborts on OOM; use falloc::try_box" },
|
|
22
|
+
# `to_vec` is the same allocation as `Vec::from` under a friendlier name, and
|
|
23
|
+
# it was one of the holes this list had. Its sibling `ToOwned::to_owned` is NOT
|
|
24
|
+
# banned here, because it cannot be: on a `&str` it builds the String of a Ruby
|
|
25
|
+
# exception message, which the glue is right to allocate infallibly on a path
|
|
26
|
+
# that is already raising. The rule is per-LAYER and clippy.toml is crate-wide,
|
|
27
|
+
# so the layer half lives in `script/check_unsafe_boundaries.rb` ("infallible
|
|
28
|
+
# allocation inside an engine layer"), beside the other per-layer gates.
|
|
29
|
+
{ path = "slice::to_vec", reason = "aborts on OOM; use falloc::try_to_vec" },
|
|
22
30
|
{ path = "alloc::vec::Vec::with_capacity", reason = "aborts on OOM; use falloc::try_vec_with_capacity" },
|
|
23
|
-
{ path = "alloc::vec::Vec::reserve", reason = "aborts on OOM; use Reserve::
|
|
24
|
-
{ path = "alloc::vec::Vec::reserve_exact", reason = "aborts on OOM; use Reserve::
|
|
25
|
-
{ path = "alloc::vec::Vec::try_reserve", reason = "not injectable; use Reserve::
|
|
26
|
-
{ path = "alloc::vec::Vec::try_reserve_exact", reason = "not injectable; use Reserve::
|
|
27
|
-
{ path = "std::collections::HashMap::reserve", reason = "aborts on OOM; use Reserve::
|
|
28
|
-
{ path = "std::collections::HashMap::try_reserve", reason = "not injectable; use Reserve::
|
|
29
|
-
{ path = "std::collections::HashMap::with_capacity", reason = "aborts on OOM; use Reserve::
|
|
30
|
-
{ path = "std::collections::HashSet::reserve", reason = "aborts on OOM; use Reserve::
|
|
31
|
-
{ path = "std::collections::HashSet::try_reserve", reason = "not injectable; use Reserve::
|
|
32
|
-
{ path = "std::collections::HashSet::with_capacity", reason = "aborts on OOM; use Reserve::
|
|
31
|
+
{ path = "alloc::vec::Vec::reserve", reason = "aborts on OOM; use Reserve::falloc_reserve" },
|
|
32
|
+
{ path = "alloc::vec::Vec::reserve_exact", reason = "aborts on OOM; use Reserve::falloc_reserve_exact" },
|
|
33
|
+
{ path = "alloc::vec::Vec::try_reserve", reason = "not injectable; use Reserve::falloc_reserve" },
|
|
34
|
+
{ path = "alloc::vec::Vec::try_reserve_exact", reason = "not injectable; use Reserve::falloc_reserve_exact" },
|
|
35
|
+
{ path = "std::collections::HashMap::reserve", reason = "aborts on OOM; use Reserve::falloc_reserve" },
|
|
36
|
+
{ path = "std::collections::HashMap::try_reserve", reason = "not injectable; use Reserve::falloc_reserve" },
|
|
37
|
+
{ path = "std::collections::HashMap::with_capacity", reason = "aborts on OOM; use Reserve::falloc_reserve" },
|
|
38
|
+
{ path = "std::collections::HashSet::reserve", reason = "aborts on OOM; use Reserve::falloc_reserve" },
|
|
39
|
+
{ path = "std::collections::HashSet::try_reserve", reason = "not injectable; use Reserve::falloc_reserve" },
|
|
40
|
+
{ path = "std::collections::HashSet::with_capacity", reason = "aborts on OOM; use Reserve::falloc_reserve" },
|
|
33
41
|
# std's STABLE sorts take a scratch buffer from the global allocator and abort
|
|
34
42
|
# the process when it cannot be had - outside falloc, so `rake oom` never
|
|
35
43
|
# fails them. The unstable sorts sort in place and allocate nothing.
|
|
@@ -12,7 +12,7 @@ use core::ffi::c_void;
|
|
|
12
12
|
|
|
13
13
|
pub use makiri::text::VerifiedText;
|
|
14
14
|
pub use makiri::token::Token;
|
|
15
|
-
pub use makiri::xml::parse
|
|
15
|
+
pub use makiri::xml::tree::parse as xml_parse;
|
|
16
16
|
pub use makiri::xml::Document;
|
|
17
17
|
pub use makiri::xpath::ast::Ast;
|
|
18
18
|
pub use makiri::xpath::ctx::{Context, XPathValue};
|
|
@@ -118,15 +118,28 @@ pub fn wrap_html_node(node: RawNode, document: Value) -> Value {
|
|
|
118
118
|
_ => CLASS_HTML_NODE.raw(),
|
|
119
119
|
};
|
|
120
120
|
|
|
121
|
+
/* One wrapper per node: navigating here twice must give the SAME object, or
|
|
122
|
+
* everything that lives on a Ruby object is silently lost - `equal?`, an
|
|
123
|
+
* instance variable, a singleton method, `freeze`. The Document above is
|
|
124
|
+
* already its own wrapper, which is why it needs no entry. */
|
|
125
|
+
let token = node.as_ptr() as usize;
|
|
126
|
+
if let Some(cached) = crate::bridge::wrapper::cached_node(document, token) {
|
|
127
|
+
return cached;
|
|
128
|
+
}
|
|
129
|
+
|
|
121
130
|
/* The Document is stored after the wrap: see `TypedType::wrap`. */
|
|
122
131
|
// SAFETY: a fresh wrapper; the store closure only moves a live VALUE in.
|
|
123
|
-
unsafe {
|
|
132
|
+
let fresh = unsafe {
|
|
124
133
|
value(HTML_NODE_TYPE.wrap(
|
|
125
134
|
klass,
|
|
126
135
|
|nd| nd.node = node.as_ptr(),
|
|
127
136
|
|nd| nd.document = document.as_raw(),
|
|
128
137
|
))
|
|
129
|
-
}
|
|
138
|
+
};
|
|
139
|
+
/* After the wrap, so the VALUE exists; `fresh` is on the stack, where the
|
|
140
|
+
* conservative scan pins it across the cache's own allocation. */
|
|
141
|
+
crate::bridge::wrapper::cache_node(document, token, fresh);
|
|
142
|
+
fresh
|
|
130
143
|
}
|
|
131
144
|
|
|
132
145
|
/// The HTML node handle behind an HTML node or HTML Document.
|
|
@@ -297,6 +310,14 @@ fn release_from_tree(node: HtmlNodeMut<'_>) {
|
|
|
297
310
|
pub fn insert(this: &HtmlSelf, rb_incoming: Value, place: Place) -> Result<Value, Error> {
|
|
298
311
|
let target = edit(this)?;
|
|
299
312
|
let incoming = arg_node(&rb_incoming)?;
|
|
313
|
+
/* The argument is relinked too - `place` changes its parent and siblings, and
|
|
314
|
+
* an adoption removes it from its own document - so a frozen argument is a
|
|
315
|
+
* frozen node being modified. The receiver check alone let it through, which
|
|
316
|
+
* made `a.remove` raise and `span.add_child(a)` not, for the same effect on
|
|
317
|
+
* `a`. Reaches the nodes the caller NAMED; a fragment's children cannot be
|
|
318
|
+
* checked, because frozenness lives on the Ruby object and there is no map
|
|
319
|
+
* from a node back to its wrapper. */
|
|
320
|
+
crate::bridge::ruby::check_frozen(rb_incoming)?;
|
|
300
321
|
Insertion::new(target.node(), place, incoming)
|
|
301
322
|
.and_then(|i| i.check())
|
|
302
323
|
.map_err(|e| refused(e, place))?;
|
|
@@ -613,7 +613,7 @@ impl Index {
|
|
|
613
613
|
return Index::Linear;
|
|
614
614
|
}
|
|
615
615
|
let mut set = PtrSet::default();
|
|
616
|
-
if set.
|
|
616
|
+
if set.falloc_reserve(nodes.len()).is_err() {
|
|
617
617
|
return Index::Linear;
|
|
618
618
|
}
|
|
619
619
|
set.extend(nodes.iter().copied());
|
|
@@ -626,7 +626,7 @@ impl Index {
|
|
|
626
626
|
return Index::Linear;
|
|
627
627
|
}
|
|
628
628
|
let mut set = PtrSet::default();
|
|
629
|
-
if set.
|
|
629
|
+
if set.falloc_reserve(expected).is_err() {
|
|
630
630
|
return Index::Linear;
|
|
631
631
|
}
|
|
632
632
|
Index::Hashed(set)
|
|
@@ -26,12 +26,40 @@ use super::ruby::protect;
|
|
|
26
26
|
pub struct Marker(());
|
|
27
27
|
|
|
28
28
|
impl Marker {
|
|
29
|
-
/// Mark a stored `VALUE` as reachable.
|
|
29
|
+
/// Mark a stored `VALUE` as reachable, PINNING it where it is.
|
|
30
|
+
///
|
|
31
|
+
/// For a field the object holds once. A table of many - a node cache - uses
|
|
32
|
+
/// [`Marker::mark_movable`] instead, so 50,000 pinned objects do not stop
|
|
33
|
+
/// compaction from doing its job.
|
|
30
34
|
#[inline]
|
|
31
35
|
pub fn mark(&self, v: VALUE) {
|
|
32
36
|
// SAFETY: called from Ruby's mark phase, with the GVL held.
|
|
33
37
|
unsafe { rb_sys::rb_gc_mark(v) };
|
|
34
38
|
}
|
|
39
|
+
|
|
40
|
+
/// Mark a stored `VALUE` that compaction MAY move.
|
|
41
|
+
///
|
|
42
|
+
/// Paired with [`Relocator::location`] in [`Hooks::compact`]: whatever is
|
|
43
|
+
/// marked movable must be re-read there, or the stored copy is left pointing
|
|
44
|
+
/// at where the object used to be.
|
|
45
|
+
#[inline]
|
|
46
|
+
pub fn mark_movable(&self, v: VALUE) {
|
|
47
|
+
// SAFETY: as `mark`; `Hooks::compact` updates every VALUE marked here.
|
|
48
|
+
unsafe { rb_sys::rb_gc_mark_movable(v) };
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/// The compaction phase's handle, for [`Hooks::compact`].
|
|
53
|
+
pub struct Relocator(());
|
|
54
|
+
|
|
55
|
+
impl Relocator {
|
|
56
|
+
/// Where `v` is now, after compaction may have moved it.
|
|
57
|
+
#[inline]
|
|
58
|
+
pub fn location(&self, v: VALUE) -> VALUE {
|
|
59
|
+
// SAFETY: called from Ruby's compaction phase, with the GVL held, on a
|
|
60
|
+
// VALUE this object marked movable.
|
|
61
|
+
unsafe { rb_sys::rb_gc_location(v) }
|
|
62
|
+
}
|
|
35
63
|
}
|
|
36
64
|
|
|
37
65
|
/// A Rust value owned by a Ruby object.
|
|
@@ -44,6 +72,15 @@ pub trait Hooks: Sized {
|
|
|
44
72
|
/// Mark every `VALUE` this object holds.
|
|
45
73
|
fn mark(&self, marker: &Marker);
|
|
46
74
|
|
|
75
|
+
/// Re-read every `VALUE` marked with [`Marker::mark_movable`].
|
|
76
|
+
///
|
|
77
|
+
/// Empty by default, which is correct for an object that only ever marks
|
|
78
|
+
/// with [`Marker::mark`] - those are pinned and cannot move. An object that
|
|
79
|
+
/// marks movable MUST implement this; Ruby's `GC.auto_compact` (which
|
|
80
|
+
/// `GC_COMPACT_STRESS=1` turns on for the whole suite) is what catches one
|
|
81
|
+
/// that does not.
|
|
82
|
+
fn compact(&mut self, _relocator: &Relocator) {}
|
|
83
|
+
|
|
47
84
|
/// The bytes this object owns, reported to Ruby's GC.
|
|
48
85
|
fn memsize(&self) -> usize {
|
|
49
86
|
core::mem::size_of::<Self>()
|
|
@@ -82,6 +119,12 @@ unsafe extern "C" fn memsize_cb<T: Hooks>(ptr: *const c_void) -> rb_sys::size_t
|
|
|
82
119
|
unsafe { (*(ptr as *const T)).memsize() as rb_sys::size_t }
|
|
83
120
|
}
|
|
84
121
|
|
|
122
|
+
unsafe extern "C" fn compact_cb<T: Hooks>(ptr: *mut c_void) {
|
|
123
|
+
// SAFETY: as `mark_cb`; compaction has finished moving and is now fixing up
|
|
124
|
+
// the references to what it moved.
|
|
125
|
+
unsafe { (*(ptr as *mut T)).compact(&Relocator(())) };
|
|
126
|
+
}
|
|
127
|
+
|
|
85
128
|
/// A `rb_data_type_t` for `T`: its GC callbacks drive [`Hooks`] for `T`, and
|
|
86
129
|
/// every way to make or read an object of this type goes through it.
|
|
87
130
|
///
|
|
@@ -105,6 +148,7 @@ impl<T: Hooks> TypedType<T> {
|
|
|
105
148
|
Some(mark_cb::<T>),
|
|
106
149
|
Some(free_cb::<T>),
|
|
107
150
|
Some(memsize_cb::<T>),
|
|
151
|
+
Some(compact_cb::<T>),
|
|
108
152
|
),
|
|
109
153
|
_t: PhantomData,
|
|
110
154
|
}
|
|
@@ -190,6 +234,7 @@ impl DataType {
|
|
|
190
234
|
dmark: rb_sys::RUBY_DATA_FUNC,
|
|
191
235
|
dfree: rb_sys::RUBY_DATA_FUNC,
|
|
192
236
|
dsize: Option<unsafe extern "C" fn(*const core::ffi::c_void) -> rb_sys::size_t>,
|
|
237
|
+
dcompact: rb_sys::RUBY_DATA_FUNC,
|
|
193
238
|
) -> DataType {
|
|
194
239
|
DataType(rb_sys::rb_data_type_t {
|
|
195
240
|
wrap_struct_name: name,
|
|
@@ -197,7 +242,7 @@ impl DataType {
|
|
|
197
242
|
dmark,
|
|
198
243
|
dfree,
|
|
199
244
|
dsize,
|
|
200
|
-
dcompact
|
|
245
|
+
dcompact,
|
|
201
246
|
reserved: [core::ptr::null_mut(); 1],
|
|
202
247
|
},
|
|
203
248
|
parent,
|
|
@@ -18,11 +18,14 @@ use crate::bridge::ruby::makiri_error;
|
|
|
18
18
|
use magnus::{prelude::*, Error, Value};
|
|
19
19
|
|
|
20
20
|
use crate::bridge::ruby::{value, VALUE};
|
|
21
|
-
use crate::bridge::typed::{Hooks, Marker, TypedType};
|
|
21
|
+
use crate::bridge::typed::{Hooks, Marker, Relocator, TypedType};
|
|
22
|
+
use crate::falloc::MapInsert;
|
|
22
23
|
use crate::init::CLASS_DOCUMENT;
|
|
23
24
|
use crate::lexbor::adapter::html::{HtmlDoc, RawDoc};
|
|
24
25
|
use crate::lexbor::adapter::post_parse::HtmlParsed;
|
|
25
|
-
use crate::xml::model::
|
|
26
|
+
use crate::xml::model::Document as XmlDoc;
|
|
27
|
+
use core::hash::BuildHasherDefault;
|
|
28
|
+
use std::collections::HashMap;
|
|
26
29
|
|
|
27
30
|
/* ------------------------------------------------------------------ *
|
|
28
31
|
* the node wrapper *
|
|
@@ -97,6 +100,86 @@ impl Content {
|
|
|
97
100
|
}
|
|
98
101
|
}
|
|
99
102
|
|
|
103
|
+
/// One Ruby wrapper per node, so navigating to the same node twice gives the
|
|
104
|
+
/// SAME object.
|
|
105
|
+
///
|
|
106
|
+
/// Without it every navigation allocated a fresh wrapper, and everything that
|
|
107
|
+
/// lives on a Ruby object was silently lost: `equal?` was false for one node,
|
|
108
|
+
/// an instance variable set through one wrapper was gone through the next, a
|
|
109
|
+
/// singleton method vanished, and `freeze` protected only the object you
|
|
110
|
+
/// happened to be holding. `==`/`eql?`/`hash` were unaffected, because those
|
|
111
|
+
/// are node identity, which is why it went unnoticed.
|
|
112
|
+
///
|
|
113
|
+
/// The cost is Nokogiri's, and it is the same cost for the same reason: a
|
|
114
|
+
/// wrapper stays alive while its document does. Measured on a 50,000-node
|
|
115
|
+
/// document, minor GC after wrapping N nodes and dropping every reference:
|
|
116
|
+
/// N=1,000 costs nothing (0.30 ms, the same as N=0), N=50,000 costs +2.2 ms.
|
|
117
|
+
/// Nokogiri's figures for the same experiment are 0.31 ms and +2.24 ms.
|
|
118
|
+
///
|
|
119
|
+
/// Keyed by the node TOKEN - a `NodeId` for XML, a node pointer for HTML - which
|
|
120
|
+
/// is stable for the document's life in both: XML never recycles an arena slot,
|
|
121
|
+
/// and HTML detaches without destroying, so a node is never freed.
|
|
122
|
+
struct NodeCache {
|
|
123
|
+
/// Empty until the first navigation, so a document nobody walks pays
|
|
124
|
+
/// nothing.
|
|
125
|
+
map: HashMap<usize, VALUE, BuildHasherDefault<TokenHasher>>,
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/// The token is already a well-distributed integer - a slot index or an aligned
|
|
129
|
+
/// pointer - so it only needs mixing, not hashing. `ptr_table::mix64` is the
|
|
130
|
+
/// crate's one place for that.
|
|
131
|
+
#[derive(Default)]
|
|
132
|
+
struct TokenHasher(u64);
|
|
133
|
+
|
|
134
|
+
impl core::hash::Hasher for TokenHasher {
|
|
135
|
+
fn finish(&self) -> u64 {
|
|
136
|
+
self.0
|
|
137
|
+
}
|
|
138
|
+
fn write(&mut self, bytes: &[u8]) {
|
|
139
|
+
for &b in bytes {
|
|
140
|
+
self.0 = crate::ptr_table::mix64(self.0 ^ b as u64);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
fn write_usize(&mut self, n: usize) {
|
|
144
|
+
self.0 = crate::ptr_table::mix64(n as u64);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
impl NodeCache {
|
|
149
|
+
fn get(&self, token: usize) -> Option<VALUE> {
|
|
150
|
+
self.map.get(&token).copied()
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/// Remember `wrapper` as the one wrapper for `token`.
|
|
154
|
+
///
|
|
155
|
+
/// A failed insert leaves the node uncached, so the next navigation builds
|
|
156
|
+
/// another wrapper and identity is lost for it. That only happens when the
|
|
157
|
+
/// allocator is refusing, where the process is already failing; the
|
|
158
|
+
/// alternative is raising out of a wrap that has no error path.
|
|
159
|
+
fn insert(&mut self, token: usize, wrapper: VALUE) {
|
|
160
|
+
let _ = self.map.falloc_insert(token, wrapper);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/// MOVABLE, not pinned: a document walked end to end holds one entry per
|
|
164
|
+
/// node, and pinning them all would stop compaction doing its job. Paired
|
|
165
|
+
/// with [`NodeCache::compact`].
|
|
166
|
+
fn mark(&self, marker: &Marker) {
|
|
167
|
+
for v in self.map.values() {
|
|
168
|
+
marker.mark_movable(*v);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
fn compact(&mut self, relocator: &Relocator) {
|
|
173
|
+
for v in self.map.values_mut() {
|
|
174
|
+
*v = relocator.location(*v);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
fn memsize(&self) -> usize {
|
|
179
|
+
self.map.capacity() * (core::mem::size_of::<usize>() + core::mem::size_of::<VALUE>())
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
100
183
|
/// A Document wrapper's data: the parsed content (owned - GC frees it), the
|
|
101
184
|
/// mutation gate's count, and the reserved errors Array.
|
|
102
185
|
pub struct DocData {
|
|
@@ -110,9 +193,38 @@ pub struct DocData {
|
|
|
110
193
|
/// The external bytes this wrapper has told the GC about, so `release`
|
|
111
194
|
/// takes back exactly what [`account_document`] reported.
|
|
112
195
|
reported: usize,
|
|
196
|
+
/// One wrapper per node; see [`NodeCache`].
|
|
197
|
+
///
|
|
198
|
+
/// Boxed and optional because a `DocData` is born from `ruby_xcalloc` - all
|
|
199
|
+
/// zero bytes - and a zeroed `HashMap` is not an empty one: hashbrown's empty
|
|
200
|
+
/// table points at a static, not at null. `None` IS all-zero (Box is
|
|
201
|
+
/// non-null, so the niche is the null pointer), which makes the zeroed state
|
|
202
|
+
/// both valid and the right one: a document nobody navigates never allocates
|
|
203
|
+
/// a cache.
|
|
204
|
+
nodes: Option<Box<NodeCache>>,
|
|
113
205
|
}
|
|
114
206
|
|
|
115
207
|
impl DocData {
|
|
208
|
+
/// The one wrapper for `token`, or None until something navigates to it.
|
|
209
|
+
fn cached(&self, token: usize) -> Option<VALUE> {
|
|
210
|
+
self.nodes.as_ref()?.get(token)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/// Remember `wrapper` for `token`, allocating the cache on first use.
|
|
214
|
+
fn cache(&mut self, token: usize, wrapper: VALUE) {
|
|
215
|
+
if self.nodes.is_none() {
|
|
216
|
+
let Ok(fresh) = crate::falloc::try_box(NodeCache {
|
|
217
|
+
map: HashMap::with_hasher(BuildHasherDefault::default()),
|
|
218
|
+
}) else {
|
|
219
|
+
return; /* see NodeCache::insert on a refusing allocator */
|
|
220
|
+
};
|
|
221
|
+
self.nodes = Some(fresh);
|
|
222
|
+
}
|
|
223
|
+
if let Some(cache) = self.nodes.as_mut() {
|
|
224
|
+
cache.insert(token, wrapper);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
116
228
|
/// The Document's parse-warning Array.
|
|
117
229
|
pub fn errors(&self) -> Value {
|
|
118
230
|
// SAFETY: the live Array this wrapper marks.
|
|
@@ -126,7 +238,7 @@ impl DocData {
|
|
|
126
238
|
match self.content {
|
|
127
239
|
Content::Empty => 0,
|
|
128
240
|
Content::Html(p) => p.as_ref().external_bytes(),
|
|
129
|
-
Content::Xml(d) =>
|
|
241
|
+
Content::Xml(d) => d.as_ref().memsize(),
|
|
130
242
|
}
|
|
131
243
|
}
|
|
132
244
|
}
|
|
@@ -135,10 +247,21 @@ impl DocData {
|
|
|
135
247
|
impl Hooks for DocData {
|
|
136
248
|
fn mark(&self, marker: &Marker) {
|
|
137
249
|
marker.mark(self.errors);
|
|
250
|
+
if let Some(cache) = self.nodes.as_ref() {
|
|
251
|
+
cache.mark(marker);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
fn compact(&mut self, relocator: &Relocator) {
|
|
256
|
+
if let Some(cache) = self.nodes.as_mut() {
|
|
257
|
+
cache.compact(relocator);
|
|
258
|
+
}
|
|
138
259
|
}
|
|
139
260
|
|
|
140
261
|
fn memsize(&self) -> usize {
|
|
141
|
-
core::mem::size_of::<DocData>()
|
|
262
|
+
core::mem::size_of::<DocData>()
|
|
263
|
+
.saturating_add(self.external_bytes())
|
|
264
|
+
.saturating_add(self.nodes.as_ref().map_or(0, |c| c.memsize()))
|
|
142
265
|
}
|
|
143
266
|
|
|
144
267
|
fn release(&mut self) {
|
|
@@ -151,6 +274,10 @@ impl Hooks for DocData {
|
|
|
151
274
|
}
|
|
152
275
|
}
|
|
153
276
|
self.content = Content::Empty;
|
|
277
|
+
/* The cache is Rust-owned heap in a struct Ruby frees with `xfree`, which
|
|
278
|
+
* does NOT run Drop - so it has to be dropped here, like the content
|
|
279
|
+
* above. `rake leaks` is what caught this one when it was missing. */
|
|
280
|
+
drop(self.nodes.take());
|
|
154
281
|
/* Balance the report, or the GC keeps counting freed arenas as live
|
|
155
282
|
* and collects ever more eagerly. A plain C call, as this hook has to
|
|
156
283
|
* be: it only subtracts, and Ruby's own `xfree` does the same from
|
|
@@ -356,6 +483,22 @@ pub(in crate::bridge) fn with_html_parsed_known<R>(
|
|
|
356
483
|
unsafe { f(p.as_mut()) }
|
|
357
484
|
}
|
|
358
485
|
|
|
486
|
+
/// The one wrapper for `token` under `rb_doc`, or None the first time.
|
|
487
|
+
///
|
|
488
|
+
/// `rb_doc` must be a live Document; the two `wrap_*_node` functions are the
|
|
489
|
+
/// only callers and both already hold one.
|
|
490
|
+
pub fn cached_node(rb_doc: Value, token: usize) -> Option<Value> {
|
|
491
|
+
// SAFETY: as `with_doc_data_known`.
|
|
492
|
+
let v = with_doc_data_known(rb_doc, |d| d.cached(token));
|
|
493
|
+
// SAFETY: a VALUE this document marks, so it is live.
|
|
494
|
+
v.map(|v| unsafe { value(v) })
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/// Remember `wrapper` as the one wrapper for `token` under `rb_doc`.
|
|
498
|
+
pub fn cache_node(rb_doc: Value, token: usize, wrapper: Value) {
|
|
499
|
+
with_doc_data_known(rb_doc, |d| d.cache(token, wrapper.as_raw()));
|
|
500
|
+
}
|
|
501
|
+
|
|
359
502
|
/// Run `f` over a Document's wrapper data, for the fields that are the
|
|
360
503
|
/// wrapper's own rather than the content's (the evaluation count).
|
|
361
504
|
fn with_doc_data_known<R>(rb_doc: Value, f: impl FnOnce(&mut DocData) -> R) -> R {
|