Files
LangBot/plugins/misc/GeneralParsers/tests/test_pdf_boundary.py

503 lines
20 KiB
Python

"""F-01 evidence: PDF parsing must run inside a dedicated process boundary.
PyMuPDF is documented as not thread safe and its table finder keeps module-level
page state (``EDGES`` / ``CHARS``), so driving it from the shared worker's thread
pool made concurrent parses from different installations re-enter the dependency
and let a cancelled parse keep working after its caller was gone. These tests
pin the replacement contract:
* a parse runs in its own short-lived process, never in the shared one,
* PyMuPDF's module state only ever exists inside that process,
* the worker is reaped before the caller resumes on success, timeout and
cancellation, so a later request can never overlap the previous one's work.
The tests fail against the pre-fix code: there the parse ran through
``asyncio.to_thread`` inside the shared process, so PyMuPDF modules (including
``pymupdf.table`` and its page state) appeared in the shared interpreter and no
process boundary existed to observe or tear down.
"""
from __future__ import annotations
import asyncio
import json
import os
import subprocess
import sys
import tempfile
import time
import unittest
from importlib import import_module, util
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
BOUNDARY_MODULE = 'components.general_parsers.isolated_process'
PDF_MODULE = 'components.general_parsers.parsers.pdf'
PYMUPDF_AVAILABLE = util.find_spec('pymupdf') is not None or util.find_spec('fitz') is not None
def _pymupdf_modules() -> set[str]:
"""PyMuPDF modules currently imported in *this* process."""
return {
name for name in list(sys.modules)
if name == 'fitz' or name.startswith(('fitz.', 'pymupdf'))
}
def _worker_dependency_state() -> dict:
"""Runs inside the worker: does the dependency keep module-level page state?"""
table = None
for module_name in ('pymupdf.table', 'fitz.table'):
try:
table = import_module(module_name)
break
except ImportError:
continue
if table is None:
return {'modules': [], 'page_state': False}
return {
'modules': sorted(_pymupdf_modules()),
'page_state': hasattr(table, 'CHARS') or hasattr(table, 'EDGES'),
}
def _probe_worker(payload: dict) -> dict:
"""Fake worker target: reports its process and holds the boundary open."""
marker_dir = Path(payload['marker_dir'])
(marker_dir / 'started').write_text(str(os.getpid()), encoding='utf-8')
dependency = _worker_dependency_state()
deadline = time.monotonic() + float(payload.get('hold_seconds', 0.0))
while time.monotonic() < deadline:
time.sleep(0.02)
(marker_dir / 'finished').write_text('done', encoding='utf-8')
return {'pid': os.getpid(), 'dependency': dependency}
def _crashing_worker(payload: dict) -> dict:
"""Fake worker target: dies like a native PyMuPDF fault would."""
os._exit(int(payload.get('exit_code', 7)))
def _pid_is_alive(pid: int) -> bool:
if os.name == 'nt':
import ctypes
kernel32 = ctypes.WinDLL('kernel32', use_last_error=True)
kernel32.OpenProcess.restype = ctypes.c_void_p
kernel32.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
kernel32.GetExitCodeProcess.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_ulong)]
kernel32.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel32.OpenProcess(0x1000, False, pid) # PROCESS_QUERY_LIMITED_INFORMATION
if not handle:
return False
try:
exit_code = ctypes.c_ulong()
if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)):
return False
return exit_code.value == 259 # STILL_ACTIVE
finally:
kernel32.CloseHandle(handle)
try:
os.kill(pid, 0)
except OSError:
return False
return True
def _build_pdf(page_texts: list[str]) -> bytes:
"""Build a minimal valid multi-page PDF without third-party helpers."""
first_page_object = 3
font_object = first_page_object + 2 * len(page_texts)
page_objects = [first_page_object + 2 * index for index in range(len(page_texts))]
objects: list[bytes] = [
b'<< /Type /Catalog /Pages 2 0 R >>',
(
'<< /Type /Pages /Kids ['
+ ' '.join(f'{number} 0 R' for number in page_objects)
+ f'] /Count {len(page_texts)} >>'
).encode(),
]
for index, page_text in enumerate(page_texts):
content_object = first_page_object + 2 * index + 1
objects.append(
(
f'<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] '
f'/Resources << /Font << /F1 {font_object} 0 R >> >> '
f'/Contents {content_object} 0 R >>'
).encode()
)
escaped = page_text.replace('\\', r'\\').replace('(', r'\(').replace(')', r'\)')
stream = f'BT /F1 24 Tf 72 700 Td ({escaped}) Tj ET'.encode()
objects.append(
b'<< /Length ' + str(len(stream)).encode() + b' >>\nstream\n' + stream + b'\nendstream'
)
objects.append(b'<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>')
document = bytearray(b'%PDF-1.4\n')
offsets = []
for number, body in enumerate(objects, start=1):
offsets.append(len(document))
document += f'{number} 0 obj\n'.encode() + body + b'\nendobj\n'
xref_offset = len(document)
document += f'xref\n0 {len(objects) + 1}\n'.encode()
document += b'0000000000 65535 f \n'
for offset in offsets:
document += f'{offset:010d} 00000 n \n'.encode()
document += (
f'trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n'
f'startxref\n{xref_offset}\n%%EOF\n'
).encode()
return bytes(document)
class BoundaryProcessTests(unittest.IsolatedAsyncioTestCase):
"""The process boundary itself: identity, reaping, timeout, cancellation."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.marker_root = Path(self._tmp.name)
def _marker_dir(self, name: str) -> Path:
path = self.marker_root / name
path.mkdir()
return path
def _boundary(self):
return import_module(BOUNDARY_MODULE)
async def _wait_for_pid(self, marker: Path, timeout: float = 30.0) -> int:
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if marker.exists():
content = marker.read_text(encoding='utf-8').strip()
if content:
return int(content)
await asyncio.sleep(0.02)
raise AssertionError(f'the worker never reported itself through {marker}')
async def test_target_runs_in_its_own_process_and_is_reaped_before_returning(self) -> None:
boundary = self._boundary()
markers = self._marker_dir('single')
modules_before = _pymupdf_modules()
result = await boundary.run_isolated(
f'{__name__}:_probe_worker',
{'marker_dir': str(markers), 'hold_seconds': 0.0},
timeout=60,
name='probe',
)
self.assertNotEqual(result['pid'], os.getpid(), 'the parse ran inside the shared process')
self.assertFalse(_pid_is_alive(result['pid']), 'the worker outlived the boundary')
self.assertEqual(boundary.active_worker_count(), 0)
self.assertTrue((markers / 'finished').exists())
self.assertEqual(
_pymupdf_modules(),
modules_before,
'the shared process gained PyMuPDF module state',
)
if PYMUPDF_AVAILABLE:
self.assertTrue(
result['dependency']['modules'],
'the worker did not exercise the dependency',
)
self.assertTrue(
result['dependency']['page_state'],
'the dependency no longer keeps module-level page state',
)
async def test_worker_crash_does_not_take_down_the_shared_process(self) -> None:
boundary = self._boundary()
with self.assertRaises(boundary.IsolatedWorkerError) as caught:
await boundary.run_isolated(
f'{__name__}:_crashing_worker',
{'exit_code': 7},
timeout=60,
name='crash probe',
)
self.assertNotIsInstance(caught.exception, boundary.IsolatedWorkerTimeout)
self.assertIn('7', str(caught.exception))
self.assertEqual(boundary.active_worker_count(), 0)
async def test_hard_timeout_terminates_the_worker(self) -> None:
boundary = self._boundary()
markers = self._marker_dir('timeout')
started = markers / 'started'
started_at = time.monotonic()
task = asyncio.create_task(
boundary.run_isolated(
f'{__name__}:_probe_worker',
{'marker_dir': str(markers), 'hold_seconds': 30.0},
timeout=1.5,
name='timeout probe',
)
)
pid = await self._wait_for_pid(started)
with self.assertRaises(boundary.IsolatedWorkerTimeout):
await task
elapsed = time.monotonic() - started_at
self.assertLess(elapsed, 15.0, 'the worker was not stopped by its hard timeout')
self.assertFalse(_pid_is_alive(pid), 'the timed out worker is still running')
self.assertEqual(boundary.active_worker_count(), 0)
self.assertFalse((markers / 'finished').exists(), 'timed out work continued')
async def test_cancellation_tears_the_worker_down_before_the_caller_resumes(self) -> None:
boundary = self._boundary()
markers = self._marker_dir('cancelled')
started = markers / 'started'
task = asyncio.create_task(
boundary.run_isolated(
f'{__name__}:_probe_worker',
{'marker_dir': str(markers), 'hold_seconds': 30.0},
timeout=120,
name='cancel probe',
)
)
cancelled_pid = await self._wait_for_pid(started)
self.assertEqual(boundary.active_worker_count(), 1)
self.assertTrue(_pid_is_alive(cancelled_pid), 'the worker was not running before the cancel')
task.cancel()
with self.assertRaises(asyncio.CancelledError):
await task
# The caller only regains control once the worker is gone.
self.assertEqual(boundary.active_worker_count(), 0)
self.assertFalse(_pid_is_alive(cancelled_pid), 'the cancelled worker is still running')
self.assertFalse((markers / 'finished').exists(), 'cancelled work continued')
# Another installation can start immediately, in a fresh process.
next_markers = self._marker_dir('after-cancel')
follow_up = await boundary.run_isolated(
f'{__name__}:_probe_worker',
{'marker_dir': str(next_markers), 'hold_seconds': 0.0},
timeout=60,
name='follow up',
)
self.assertNotEqual(follow_up['pid'], cancelled_pid)
self.assertFalse(_pid_is_alive(follow_up['pid']))
async def test_concurrent_parses_never_share_a_process(self) -> None:
boundary = self._boundary()
first_markers = self._marker_dir('first')
second_markers = self._marker_dir('second')
first = asyncio.create_task(
boundary.run_isolated(
f'{__name__}:_probe_worker',
{'marker_dir': str(first_markers), 'hold_seconds': 3.0},
timeout=60,
name='first probe',
)
)
second = asyncio.create_task(
boundary.run_isolated(
f'{__name__}:_probe_worker',
{'marker_dir': str(second_markers), 'hold_seconds': 3.0},
timeout=60,
name='second probe',
)
)
first_pid = await self._wait_for_pid(first_markers / 'started')
second_pid = await self._wait_for_pid(second_markers / 'started')
self.assertNotEqual(first_pid, second_pid, 'two parses shared one worker process')
self.assertEqual(boundary.active_worker_count(), 2)
first_result, second_result = await asyncio.gather(first, second)
self.assertEqual(first_result['pid'], first_pid)
self.assertEqual(second_result['pid'], second_pid)
self.assertEqual(boundary.active_worker_count(), 0)
self.assertFalse(_pid_is_alive(first_pid))
self.assertFalse(_pid_is_alive(second_pid))
class PdfParsingIsolationTests(unittest.IsolatedAsyncioTestCase):
"""The real PDF path: out-of-process parsing, no PyMuPDF in the shared process."""
def setUp(self) -> None:
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
def _tmp_pdf(self) -> str:
path = Path(self._tmp.name) / 'fresh-process.pdf'
path.write_bytes(_build_pdf(['Boundary page one', 'Boundary page two', 'Boundary page three']))
return str(path)
def _tmp_probe(self) -> str:
path = Path(self._tmp.name) / 'fresh_process_probe.py'
path.write_text(
'import asyncio\n'
'import json\n'
'import os\n'
'import sys\n'
'\n'
'plugin_root, pdf_path = sys.argv[1], sys.argv[2]\n'
'sys.path.insert(0, plugin_root)\n'
'\n'
'from components.general_parsers.parsers.pdf import parse_pdf\n'
'\n'
'with open(pdf_path, "rb") as handle:\n'
' payload = handle.read()\n'
'\n'
'text, metadata = asyncio.run(parse_pdf(payload, "probe.pdf"))\n'
'modules = sorted(\n'
' name for name in sys.modules\n'
' if name == "fitz" or name.startswith(("fitz.", "pymupdf"))\n'
')\n'
'print(json.dumps({\n'
' "text": text,\n'
' "page_count": metadata.get("page_count"),\n'
' "modules": modules,\n'
' "pid": os.getpid(),\n'
'}))\n',
encoding='utf-8',
)
return str(path)
async def test_pdf_parse_runs_out_of_process_and_reaps_its_worker(self) -> None:
if not PYMUPDF_AVAILABLE:
self.skipTest('PyMuPDF is not installed')
parse_pdf = getattr(import_module(PDF_MODULE), 'parse_pdf')
boundary = import_module(BOUNDARY_MODULE)
pdf_bytes = _build_pdf(['Boundary page one', 'Boundary page two', 'Boundary page three'])
modules_before = _pymupdf_modules()
task = asyncio.create_task(parse_pdf(pdf_bytes, 'boundary.pdf'))
observed: set[int] = set()
for _ in range(2000):
observed |= set(boundary.active_worker_pids())
if task.done():
break
await asyncio.sleep(0.005)
text, metadata = await task
self.assertIn('Boundary page one', text)
self.assertEqual(metadata['page_count'], 3)
self.assertTrue(observed, 'the parse never appeared in a worker process')
self.assertNotIn(os.getpid(), observed, 'the parse ran inside the shared process')
self.assertEqual(boundary.active_worker_count(), 0)
self.assertEqual(
_pymupdf_modules(),
modules_before,
'the shared process gained PyMuPDF module state',
)
@unittest.skipUnless(PYMUPDF_AVAILABLE, 'PyMuPDF is not installed')
async def test_fresh_process_ends_a_pdf_parse_without_pymupdf_state(self) -> None:
pdf_path = self._tmp_pdf()
probe_path = self._tmp_probe()
modules_before = _pymupdf_modules()
completed = await asyncio.to_thread(
subprocess.run,
[sys.executable, probe_path, str(REPO_ROOT), pdf_path],
capture_output=True,
text=True,
timeout=180,
)
self.assertEqual(completed.returncode, 0, completed.stderr)
report = json.loads(completed.stdout.strip().splitlines()[-1])
self.assertEqual(
report['modules'],
[],
'PyMuPDF state is reachable in the process that ran the parse',
)
self.assertIn('Boundary page one', report['text'])
self.assertEqual(report['page_count'], 3)
self.assertEqual(_pymupdf_modules(), modules_before)
class TwoInstallationPdfTests(unittest.IsolatedAsyncioTestCase):
"""Two installations parsing at once must not see each other's document."""
def setUp(self) -> None:
if not PYMUPDF_AVAILABLE:
self.skipTest('PyMuPDF is not installed')
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
def _binding(self, installation: str):
from langbot_plugin.entities.io.context import InstallationBinding
return InstallationBinding(
instance_uuid='instance-1',
workspace_uuid='workspace-1',
installation_uuid=installation,
runtime_revision=1,
artifact_digest='a' * 64,
)
async def test_concurrent_parses_keep_their_own_document_text(self) -> None:
from langbot_plugin.api.entities.builtin.rag.models import ParseContext
from langbot_plugin.api.proxies.invocation import bind_invocation
from components.observability.telemetry import get_telemetry, release_telemetry
parser_class = getattr(import_module('components.general_parsers.general_parsers'), 'GeneralParsers')
handler = object()
class Plugin:
plugin_runtime_handler = handler
def get_config(self) -> dict:
return {'enable_vision': False}
parser = parser_class()
parser.plugin = Plugin()
first_binding = self._binding('installation-a')
second_binding = self._binding('installation-b')
first_pdf = _build_pdf(['Tenant A page one', 'Tenant A page two'])
second_pdf = _build_pdf(['Tenant B page one', 'Tenant B page two'])
async def _parse_for(binding, pdf_bytes: bytes, filename: str):
# Each task carries its own context, so its invocation binding stays
# active for the whole parse and never overlaps the other one.
with bind_invocation(handler, config={'enable_vision': False}, binding=binding):
return await parser.parse(ParseContext(
file_content=pdf_bytes,
filename=filename,
mime_type='application/pdf',
))
for _ in range(2):
first_task = asyncio.create_task(_parse_for(first_binding, first_pdf, 'tenant-a.pdf'))
second_task = asyncio.create_task(_parse_for(second_binding, second_pdf, 'tenant-b.pdf'))
first_result, second_result = await asyncio.gather(first_task, second_task)
self.assertIn('Tenant A page one', first_result.text)
self.assertNotIn('Tenant B', first_result.text)
self.assertEqual(first_result.metadata['page_count'], 2)
self.assertIn('Tenant B page one', second_result.text)
self.assertNotIn('Tenant A', second_result.text)
self.assertEqual(second_result.metadata['page_count'], 2)
first_snapshot = get_telemetry(first_binding).snapshot()
second_snapshot = get_telemetry(second_binding).snapshot()
self.assertEqual(
{entry['filename'] for entry in first_snapshot['recent_parses']},
{'tenant-a.pdf'},
)
self.assertEqual(
{entry['filename'] for entry in second_snapshot['recent_parses']},
{'tenant-b.pdf'},
)
release_telemetry(first_binding)
release_telemetry(second_binding)
if __name__ == '__main__':
unittest.main()