Coverage for src/local_deep_research/utilities/lxml_thread_safety.py: 94%
44 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-09-06 15:42 +0000
1"""Per-thread lxml parsers, so concurrent HTML extraction cannot corrupt the heap.
3Why this exists
4---------------
5A research run reads several pages at once — one worker thread per page — and
6each thread runs the extraction pipeline, which parses HTML through
7trafilatura, newspaper4k, readabilipy and justext. All four reach lxml, and
8therefore libxml2.
10lxml keeps a *per-thread* string dictionary (libxml2's ``xmlDict``). On entry
11to a parse it rebinds the parser context's dict to the calling thread's dict
12(``_ParserDictionaryContext.initThreadDictRef``, ``lxml/parser.pxi``), and a
13thread that has no dict yet **adopts whatever dict the parser is currently
14carrying**. Because trafilatura parses through a module-level parser
15(``trafilatura.utils.HTML_PARSER``) and newspaper4k/readabilipy parse through
16lxml's own module-level ``lxml.html.html_parser``, worker threads end up
17adopting *each other's* dict and then interning strings into one ``xmlDict``
18concurrently. ``xmlDict`` is not thread-safe, so its hash table gets corrupted
19and the interpreter dies with SIGABRT — ``double free or corruption (out)``
20raised from inside ``htmlParseDocument``, taking every user's session and all
21in-flight research down with it.
23lxml's own per-parser lock does not prevent this: the racing parses run on
24*different* parser objects that merely share a dict, so the lock is never
25contended by them.
27What this does
28--------------
29lxml's FAQ prescribes "create a parser for each thread yourself". Each library
30needs a different route to get there:
32* ``lxml.html.fromstring`` is wrapped so that a call passing no explicit
33 ``parser=`` receives *this thread's* parser rather than the process-global
34 one. newspaper4k (``newspaper/parsers.py``) and readabilipy
35 (``readabilipy/extractors/extract_element.py``) both call it as a module
36 attribute, so the wrapper reaches them. Anything else in the process that
37 calls ``lxml.html.fromstring`` is covered for free.
38* trafilatura cannot be reached that way — it ``from``-imports ``fromstring``
39 and always passes its own ``HTML_PARSER`` explicitly. Instead callers hand
40 it an already-parsed tree via :func:`parse_for_trafilatura`; its
41 ``load_html`` accepts an ``HtmlElement`` directly.
42* justext already builds a fresh parser per call and needs nothing.
44Extraction output was verified byte-identical before and after this change on
45the three real pages from the crash (3237 / 21578 / 26882 characters).
47Note the parsers below are built from ``lxml.html.HTMLParser``, never
48``lxml.etree.HTMLParser``. The etree variant yields plain ``_Element`` objects,
49which fail trafilatura's ``isinstance(..., HtmlElement)`` check in
50``load_html`` — it then returns None and extraction silently yields empty text
51rather than raising.
52"""
54import threading
55from typing import Any, Optional
57import lxml.html
58from loguru import logger
60# One parser per thread, per configuration. Parsers are cheap; a worker thread
61# builds its two parsers once and reuses them for its lifetime.
62_local = threading.local()
64_install_lock = threading.Lock()
65_installed = False
68def get_thread_parser() -> lxml.html.HTMLParser:
69 """This thread's general-purpose HTML parser.
71 Deliberately constructed with lxml's defaults so that replacing the
72 process-global ``lxml.html.html_parser`` changes *which* parser object is
73 used and nothing else about how the document is parsed.
74 """
75 parser = getattr(_local, "html_parser", None)
76 if parser is None:
77 parser = lxml.html.HTMLParser()
78 _local.html_parser = parser
79 return parser
82def get_thread_trafilatura_parser() -> lxml.html.HTMLParser:
83 """This thread's parser configured exactly as trafilatura configures its own.
85 Mirrors ``trafilatura.utils.HTML_PARSER``. The options must match, or the
86 tree we hand trafilatura would not be the tree it would have built for
87 itself, and extraction output could drift.
88 """
89 parser = getattr(_local, "trafilatura_parser", None)
90 if parser is None:
91 parser = lxml.html.HTMLParser(
92 collect_ids=False,
93 default_doctype=False,
94 encoding="utf-8",
95 remove_comments=True,
96 remove_pis=True,
97 )
98 _local.trafilatura_parser = parser
99 return parser
102def parse_for_trafilatura(html: str) -> Optional[Any]:
103 """Parse ``html`` with this thread's parser, for handing to trafilatura.
105 Returns an ``HtmlElement``, or None if the document could not be parsed —
106 in which case the caller should fall back to passing the raw string, since
107 trafilatura's own error handling is more forgiving than ours needs to be.
108 """
109 if not html or not html.strip():
110 return None
111 try:
112 return lxml.html.fromstring(
113 html, parser=get_thread_trafilatura_parser()
114 )
115 except Exception:
116 logger.debug(
117 "Per-thread parse failed; caller should fall back to the raw string"
118 )
119 return None
122def install_per_thread_html_parsers() -> bool:
123 """Route parser-less ``lxml.html.fromstring`` calls onto per-thread parsers.
125 Idempotent and safe to call from any thread. Returns True if this call
126 installed the wrapper, False if it was already in place.
128 Must run before worker threads start parsing — importing it at extraction
129 module import time is early enough, since nothing parses before that.
130 """
131 global _installed
132 with _install_lock:
133 if _installed:
134 return False
136 original = lxml.html.fromstring
138 def fromstring(html, base_url=None, parser=None, **kwargs):
139 if parser is None:
140 parser = get_thread_parser()
141 return original(html, base_url=base_url, parser=parser, **kwargs)
143 # Keep a handle on the original so the wrapper is inspectable and a
144 # second install() can detect it rather than double-wrapping.
145 fromstring.__wrapped__ = original
146 fromstring.__doc__ = (
147 "lxml.html.fromstring, defaulting to a per-thread parser. "
148 "See local_deep_research.utilities.lxml_thread_safety."
149 )
151 lxml.html.fromstring = fromstring
152 _installed = True
153 logger.debug(
154 "Installed per-thread lxml.html parsers "
155 "(guards against concurrent xmlDict corruption)"
156 )
157 return True
160def is_installed() -> bool:
161 """Whether the per-thread parser wrapper is currently active."""
162 return getattr(lxml.html.fromstring, "__wrapped__", None) is not None