Coverage for src/local_deep_research/utilities/lxml_thread_safety.py: 94%
44 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +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. The
62# short-lived per-call threads used to bound the Readability.js subprocess
63# (see downloaders/extraction/readability_extractor) do not add to that: they
64# run only readabilipy's ``use_readability=True`` branch, which shells out to
65# node and never reaches ``lxml.html.fromstring`` — that call lives in the
66# pure-Python branch, which runs on the caller's own thread.
67_local = threading.local()
69_install_lock = threading.Lock()
70_installed = False
73def get_thread_parser() -> lxml.html.HTMLParser:
74 """This thread's general-purpose HTML parser.
76 Deliberately constructed with lxml's defaults so that replacing the
77 process-global ``lxml.html.html_parser`` changes *which* parser object is
78 used and nothing else about how the document is parsed.
79 """
80 parser = getattr(_local, "html_parser", None)
81 if parser is None:
82 parser = lxml.html.HTMLParser()
83 _local.html_parser = parser
84 return parser
87def get_thread_trafilatura_parser() -> lxml.html.HTMLParser:
88 """This thread's parser configured exactly as trafilatura configures its own.
90 Mirrors ``trafilatura.utils.HTML_PARSER``. The options must match, or the
91 tree we hand trafilatura would not be the tree it would have built for
92 itself, and extraction output could drift.
93 """
94 parser = getattr(_local, "trafilatura_parser", None)
95 if parser is None:
96 parser = lxml.html.HTMLParser(
97 collect_ids=False,
98 default_doctype=False,
99 encoding="utf-8",
100 remove_comments=True,
101 remove_pis=True,
102 )
103 _local.trafilatura_parser = parser
104 return parser
107def parse_for_trafilatura(html: str) -> Optional[Any]:
108 """Parse ``html`` with this thread's parser, for handing to trafilatura.
110 Returns an ``HtmlElement``, or None if the document could not be parsed —
111 in which case the caller should fall back to passing the raw string, since
112 trafilatura's own error handling is more forgiving than ours needs to be.
113 """
114 if not html or not html.strip():
115 return None
116 try:
117 return lxml.html.fromstring(
118 html, parser=get_thread_trafilatura_parser()
119 )
120 except Exception:
121 logger.debug(
122 "Per-thread parse failed; caller should fall back to the raw string"
123 )
124 return None
127def install_per_thread_html_parsers() -> bool:
128 """Route parser-less ``lxml.html.fromstring`` calls onto per-thread parsers.
130 Idempotent and safe to call from any thread. Returns True if this call
131 installed the wrapper, False if it was already in place.
133 Must run before worker threads start parsing — importing it at extraction
134 module import time is early enough, since nothing parses before that.
135 """
136 global _installed
137 with _install_lock:
138 if _installed:
139 return False
141 original = lxml.html.fromstring
143 def fromstring(html, base_url=None, parser=None, **kwargs):
144 if parser is None:
145 parser = get_thread_parser()
146 return original(html, base_url=base_url, parser=parser, **kwargs)
148 # Keep a handle on the original so the wrapper is inspectable and a
149 # second install() can detect it rather than double-wrapping.
150 fromstring.__wrapped__ = original
151 fromstring.__doc__ = (
152 "lxml.html.fromstring, defaulting to a per-thread parser. "
153 "See local_deep_research.utilities.lxml_thread_safety."
154 )
156 lxml.html.fromstring = fromstring
157 _installed = True
158 logger.debug(
159 "Installed per-thread lxml.html parsers "
160 "(guards against concurrent xmlDict corruption)"
161 )
162 return True
165def is_installed() -> bool:
166 """Whether the per-thread parser wrapper is currently active."""
167 return getattr(lxml.html.fromstring, "__wrapped__", None) is not None