Skip to content

Thread safety and memory safety

James Conway edited this page Nov 4, 2026 · 2 revisions

Two questions come up first from anyone moving off a C parser: can I share a document between threads, and can a bad page make it touch memory it shouldn't? Here's what's true today, what isn't promised yet, and what's planned.

On this page:

Threads

What's true today

Document is plain owned data. Inside it are vectors, a boxed string, a String, and Copy enums and integers: no Rc, no RefCell or Cell, no raw pointers, no interior mutability of any kind. Rust therefore makes it Send and Sync automatically, and the same goes for SelectorList. You can check it yourself:

fn assert_send_sync<T: Send + Sync>() {}

assert_send_sync::<parsec::Document>();
assert_send_sync::<parsec::css::SelectorList>();
assert_send_sync::<parsec::css::SelectorError>();
assert_send_sync::<parsec::ParseError>();

That compiles on main. What it means in practice:

  • Parse on as many threads as you like. Each parse builds its own document and shares nothing. There's no global parser state to lock.
  • Read one document from many threads. All the read methods (kind, attrs, children, text, inner_html, selector matching...) take &self, so you can share a &Document through scoped threads or an Arc<Document>.
  • Write from one place at a time. Every edit takes &mut self, so the compiler stops you editing while anyone else holds a reference. To mix readers and writers across threads, put the document in a RwLock or Mutex.
  • Move a document to another thread once it's parsed, or send it down a channel.
use parsec::css::SelectorList;
use std::sync::Arc;

fn main() {
    let pages = ["<p>a</p>", "<p>b</p><p>c</p>", "<div><p>d</p></div>"];

    // Parse on several threads at once.
    let docs: Vec<parsec::Document> = std::thread::scope(|s| {
        let handles: Vec<_> = pages
            .iter()
            .map(|html| s.spawn(move || parsec::parse(html, false)))
            .collect();
        handles.into_iter().map(|h| h.join().unwrap()).collect()
    });

    // Query one shared document from several threads at once.
    let doc = Arc::new(parsec::parse(&"<p>x</p>".repeat(1000), false));
    let list = Arc::new(SelectorList::parse("p").unwrap());
    let counts: Vec<usize> = (0..4)
        .map(|_| {
            let (doc, list) = (Arc::clone(&doc), Arc::clone(&list));
            std::thread::spawn(move || list.select(&doc, doc.root()).len())
        })
        .map(|h| h.join().unwrap())
        .collect();

    assert_eq!(docs.len(), 3);
    assert_eq!(counts, [1000, 1000, 1000, 1000]);
}

There's one piece of state shared by all documents: the table the parser uses to look up tag names, built on first use inside a std::sync::OnceLock (tags.rs). OnceLock makes that first use safe from any number of threads.

What isn't promised yet

Warning

The auto-traits above hold because of how Document happens to be written, not because the crate promises them. No test in the crate checks them yet, so a future change could, in principle, take them away without anyone noticing.

Making Send + Sync a guarantee, with a compile-time test, a write-up of the model (many readers, one writer), and a benchmark that parses on every core at once, is #11.

For comparison, Lexbor has no thread-safety story: an old answer that it was thread-safe was followed by a user pointing out that mutations aren't atomic (lexbor#105), and selectolax documents its Lexbor parsers as not thread-safe (selectolax docs).

Memory

Node handles can't dangle

In Lexbor a node is a pointer, and a pointer to a node that's been destroyed is a use-after-free; there's no reference counting, which has been an open issue since 2021 (lexbor#132). In Parsec a node is a NodeId, an index into the document's vector of nodes. Removing a node only unlinks it; it stays in the vector until the document is dropped, and ids are never reused. So a stale id still names the node you removed, and using it is safe.

What an id can't tell you on its own is whether its node is still in the tree; check doc.parent(id). And an id is only meaningful with the document it came from: an id from another document won't crash anything, but it names a different node. Ids that detect removal are planned in #12.

The one unsafe block

All of Parsec's own code is safe Rust except one block, in crates/parsec/src/tree.rs, in the function source:

/// Gives the document its preprocessed source, and returns it for the
/// tokenizer to read.
fn source<'a>(builder: &mut TreeBuilder, html: &str) -> &'a str {
    builder.doc.src = match tokenizer::preprocess(html) {
        std::borrow::Cow::Borrowed(s) => s.into(),
        std::borrow::Cow::Owned(s) => s.into_boxed_str(),
    };
    // SAFETY: the source is a boxed str the document never changes or
    // drops while it's parsed, so its bytes stay where they are while the
    // tokenizer reads them, even though the builder holding them moves.
    // The returned reference doesn't outlive the parse functions above.
    unsafe { &*(builder.doc.src.as_ref() as *const str) }
}

Why it's there: the tokenizer reads the source, and the tree builder, which owns the document that owns the source, is lent to the tokenizer mutably as its Sink. The borrow checker can't express "this one field won't change while the rest is mutated", so the reference is made by hand.

Why it's sound:

  • src is a Box<str>. Its bytes live on the heap, and moving the builder or the document moves the box's pointer, not the bytes.
  • Nothing writes to src or replaces it after this function, for the rest of the parse. The tree builder only reads it, to check whether a string is a slice of it.
  • The reference is used only by the tokenizer inside run in tree.rs, and the tokenizer doesn't outlive that function. The document is handed back to you after the tokenizer's last use.

Everything else (the arena, the spans, the tokenizer's slices of the input) is index arithmetic on vectors and strings, checked by the compiler and, where indexes are computed, bounds-checked at run time.

Dependencies

Parsec has one dependency, memchr, for fast byte searches. memchr uses unsafe internally for its SIMD code, so "one unsafe block" describes Parsec's own code, not everything it links. The test-only dependency, serde_json, reads the html5lib test files and isn't part of the library. CI runs cargo deny on licences, bans, sources and advisories.

Panics

The parser isn't meant to panic on any input under the size cap, and the fuzz target checks that nightly on inputs of up to 4,096 bytes (see Limits and hostile input). The panics you can cause are documented on the functions:

  • parse and parse_fragment panic on input over MAX_INPUT bytes. parse_with returns an error instead.
  • Every method that takes a NodeId panics on an id that's doc.len() or more.

Serialization, text and select don't recurse on the depth of the tree, and selector nesting is capped at 32, so neither a deep page nor a deep selector exhausts the stack in those paths.

Memory use

A document's memory is proportional to its input, reserved up front from the input's length and grown as needed. There's no limit apart from MAX_INPUT, and edits never give memory back until the document is dropped (see Editing).

Reporting a problem

If you find input that crashes Parsec, hangs it, or makes it touch memory it shouldn't, please report it privately, as SECURITY.md explains, not in a public issue.

See also

Clone this wiki locally