Addresses round-1 review on #878: - Move the eager `document_processors` imports out of the API startup graph: `app.py` (get_registry now imported inside initialize_document_processors, after the disabled early-return) and `vector/processor.py` (get_registry now imported at its single use site). Importing `app` + `cli` no longer loads `document_processors` / `_isolation` at all -- the #877 stack is fully out of startup (pymupdf still loads via search/pdf_highlighter, a Windows-compatible and separately-tracked concern). - Make `tests/unit/test_pdf_parse_isolation.py` importable on Windows: guard the top-level `import resource` with try/except and skip the three rlimit computation tests via a `requires_resource` marker when the module is absent. The Windows no-op / import-guard tests don't use the real module and still run. - Fix the `# pragma: no cover` comment on the win32 branch to be accurate. - Add `enable-cache: true` to the package-smoke setup-uv step. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
254 lines
8.4 KiB
Python
254 lines
8.4 KiB
Python
"""Unit tests for the isolated PDF parse (OOM hotfix).
|
|
|
|
The parse runs in a worker subprocess (``anyio.to_process``) with a memory
|
|
rlimit and a wall-clock timeout so a pathological PDF fails *that document*
|
|
instead of OOM-killing the pod. These tests pin:
|
|
* the failure classification (oom / timeout / error) of the async wrapper;
|
|
* ``_apply_mem_limit`` rlimit computation (mocked, never applied in-process);
|
|
* the PyMuPDF processor wiring: settings forwarded, success path, and a
|
|
graceful ``success=False`` result on a permanent parse failure.
|
|
|
|
The real subprocess + rlimit enforcement is exercised by the local end-to-end
|
|
check on the sample PDFs, not here (unit tests must not spawn the heavy worker
|
|
or depend on the sample files).
|
|
"""
|
|
|
|
import sys
|
|
|
|
import anyio
|
|
import anyio.to_process
|
|
import pymupdf
|
|
import pytest
|
|
from anyio import BrokenWorkerProcess
|
|
|
|
from nextcloud_mcp_server.document_processors import _isolation
|
|
from nextcloud_mcp_server.document_processors._isolation import (
|
|
PdfParseFailed,
|
|
run_isolated_pdf_parse,
|
|
)
|
|
|
|
# ``resource`` is a Unix-only stdlib module (absent on Windows, #877). Guard the
|
|
# import so this test module stays importable on Windows; the rlimit-computation
|
|
# tests below are skipped there via the ``requires_resource`` marker.
|
|
try:
|
|
import resource
|
|
except ImportError: # pragma: no cover - only reached on Windows
|
|
resource = None # type: ignore[assignment]
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
requires_resource = pytest.mark.skipif(
|
|
resource is None, reason="resource module is Unix-only (absent on Windows)"
|
|
)
|
|
|
|
|
|
def _tiny_pdf() -> bytes:
|
|
doc = pymupdf.open()
|
|
page = doc.new_page(width=595, height=842)
|
|
page.insert_text((50, 50), "Hello world")
|
|
data: bytes = doc.tobytes()
|
|
doc.close()
|
|
return data
|
|
|
|
|
|
async def _run(monkeypatch, fake_run_sync) -> list:
|
|
monkeypatch.setattr(anyio.to_process, "run_sync", fake_run_sync)
|
|
return await run_isolated_pdf_parse(
|
|
b"%PDF-1.7",
|
|
write_images=False,
|
|
image_path=None,
|
|
graphics_limit=5000,
|
|
timeout_seconds=5,
|
|
mem_limit_mb=1536,
|
|
)
|
|
|
|
|
|
# --- failure classification of the async wrapper ----------------------------
|
|
|
|
|
|
async def test_success_returns_worker_value(monkeypatch):
|
|
page_chunks = [{"text": "ok", "metadata": {"page": 1}}]
|
|
|
|
async def fake(*args, **kwargs):
|
|
return page_chunks
|
|
|
|
assert await _run(monkeypatch, fake) == page_chunks
|
|
|
|
|
|
async def test_memory_error_classified_as_oom(monkeypatch):
|
|
async def fake(*args, **kwargs):
|
|
raise MemoryError("rlimit hit")
|
|
|
|
with pytest.raises(PdfParseFailed) as exc:
|
|
await _run(monkeypatch, fake)
|
|
assert exc.value.reason == "oom"
|
|
|
|
|
|
async def test_broken_worker_classified_as_oom(monkeypatch):
|
|
async def fake(*args, **kwargs):
|
|
raise BrokenWorkerProcess("worker died")
|
|
|
|
with pytest.raises(PdfParseFailed) as exc:
|
|
await _run(monkeypatch, fake)
|
|
assert exc.value.reason == "oom"
|
|
|
|
|
|
async def test_other_exception_classified_as_error(monkeypatch):
|
|
async def fake(*args, **kwargs):
|
|
raise ValueError("not a pdf")
|
|
|
|
with pytest.raises(PdfParseFailed) as exc:
|
|
await _run(monkeypatch, fake)
|
|
assert exc.value.reason == "error"
|
|
|
|
|
|
async def test_timeout_kills_and_classifies_as_timeout(monkeypatch):
|
|
async def fake(*args, **kwargs):
|
|
# Simulate a hung worker; the move_on_after timeout must win.
|
|
await anyio.sleep(30)
|
|
|
|
monkeypatch.setattr(anyio.to_process, "run_sync", fake)
|
|
with pytest.raises(PdfParseFailed) as exc:
|
|
await run_isolated_pdf_parse(
|
|
b"%PDF-1.7",
|
|
write_images=False,
|
|
image_path=None,
|
|
graphics_limit=5000,
|
|
timeout_seconds=0.2,
|
|
mem_limit_mb=1536,
|
|
)
|
|
assert exc.value.reason == "timeout"
|
|
|
|
|
|
# --- _apply_mem_limit computation (mocked; never applied to the test proc) ---
|
|
|
|
|
|
@requires_resource
|
|
def test_apply_mem_limit_caps_soft_below_finite_hard(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(_isolation, "_MEM_LIMIT_APPLIED", False)
|
|
monkeypatch.setattr(
|
|
_isolation.resource,
|
|
"getrlimit",
|
|
lambda _w: (resource.RLIM_INFINITY, 4 * 1024**3),
|
|
)
|
|
monkeypatch.setattr(
|
|
_isolation.resource, "setrlimit", lambda _w, pair: captured.update(pair=pair)
|
|
)
|
|
# target = 1536 MiB < hard (4 GiB) -> soft becomes the target, hard untouched
|
|
_isolation._apply_mem_limit(1536)
|
|
soft, hard = captured["pair"]
|
|
assert soft == 1536 * 1024 * 1024
|
|
assert hard == 4 * 1024**3
|
|
|
|
|
|
@requires_resource
|
|
def test_apply_mem_limit_uses_target_when_hard_unlimited(monkeypatch):
|
|
captured = {}
|
|
monkeypatch.setattr(_isolation, "_MEM_LIMIT_APPLIED", False)
|
|
monkeypatch.setattr(
|
|
_isolation.resource,
|
|
"getrlimit",
|
|
lambda _w: (resource.RLIM_INFINITY, resource.RLIM_INFINITY),
|
|
)
|
|
monkeypatch.setattr(
|
|
_isolation.resource, "setrlimit", lambda _w, pair: captured.update(pair=pair)
|
|
)
|
|
# hard is unbounded -> soft is exactly the target, hard stays RLIM_INFINITY
|
|
_isolation._apply_mem_limit(1536)
|
|
soft, hard = captured["pair"]
|
|
assert soft == 1536 * 1024 * 1024
|
|
assert hard == resource.RLIM_INFINITY
|
|
|
|
|
|
@requires_resource
|
|
def test_apply_mem_limit_is_applied_once(monkeypatch):
|
|
calls = []
|
|
monkeypatch.setattr(_isolation, "_MEM_LIMIT_APPLIED", False)
|
|
monkeypatch.setattr(
|
|
_isolation.resource,
|
|
"getrlimit",
|
|
lambda _w: (resource.RLIM_INFINITY, resource.RLIM_INFINITY),
|
|
)
|
|
monkeypatch.setattr(_isolation.resource, "setrlimit", lambda *a: calls.append(a))
|
|
_isolation._apply_mem_limit(1536)
|
|
_isolation._apply_mem_limit(1536)
|
|
assert len(calls) == 1 # second call is a no-op
|
|
|
|
|
|
# --- Windows / no-``resource`` platform compatibility (#877) -----------------
|
|
|
|
|
|
def test_apply_mem_limit_noop_when_resource_unavailable(monkeypatch):
|
|
"""On a platform without ``resource`` (e.g. Windows) the cap is skipped.
|
|
|
|
Regression for #877: ``resource`` is Unix-only, so ``_apply_mem_limit`` must
|
|
degrade to a no-op (rather than crash) when the module is unavailable.
|
|
"""
|
|
monkeypatch.setattr(_isolation, "_MEM_LIMIT_APPLIED", False)
|
|
monkeypatch.setattr(_isolation, "resource", None)
|
|
_isolation._apply_mem_limit(1536) # must not raise
|
|
assert _isolation._MEM_LIMIT_APPLIED is True
|
|
|
|
|
|
def test_isolation_imports_on_windows_without_resource(monkeypatch):
|
|
"""Importing ``_isolation`` on Windows must not crash on ``import resource``.
|
|
|
|
Regression for #877: a module-scope ``import resource`` raised
|
|
``ModuleNotFoundError`` on Windows and took down server startup. With
|
|
``sys.platform == 'win32'`` the module must import cleanly and bind
|
|
``resource`` to ``None``.
|
|
"""
|
|
import importlib
|
|
|
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
monkeypatch.delitem(
|
|
sys.modules,
|
|
"nextcloud_mcp_server.document_processors._isolation",
|
|
raising=False,
|
|
)
|
|
mod = importlib.import_module("nextcloud_mcp_server.document_processors._isolation")
|
|
assert mod.resource is None
|
|
|
|
|
|
# --- PyMuPDF processor wiring ------------------------------------------------
|
|
|
|
|
|
async def test_processor_success_builds_page_boundaries(monkeypatch):
|
|
from nextcloud_mcp_server.document_processors import pymupdf as pymupdf_proc
|
|
|
|
seen = {}
|
|
|
|
async def fake_parse(content, **kwargs):
|
|
seen.update(kwargs)
|
|
return [{"text": "Hello world", "metadata": {"page": 1}}]
|
|
|
|
monkeypatch.setattr(pymupdf_proc, "run_isolated_pdf_parse", fake_parse)
|
|
|
|
proc = pymupdf_proc.PyMuPDFProcessor(extract_images=False)
|
|
result = await proc.process(_tiny_pdf(), "application/pdf", filename="t.pdf")
|
|
|
|
assert result.success is True
|
|
assert "Hello world" in result.text
|
|
assert result.metadata["page_boundaries"][0]["page"] == 1
|
|
# settings forwarded to the isolated parse
|
|
assert seen["graphics_limit"] == 1000
|
|
assert seen["timeout_seconds"] == 120
|
|
assert seen["mem_limit_mb"] == 1536
|
|
|
|
|
|
async def test_processor_parse_failure_returns_success_false(monkeypatch):
|
|
from nextcloud_mcp_server.document_processors import pymupdf as pymupdf_proc
|
|
|
|
async def fake_parse(content, **kwargs):
|
|
raise PdfParseFailed("oom", "killed")
|
|
|
|
monkeypatch.setattr(pymupdf_proc, "run_isolated_pdf_parse", fake_parse)
|
|
|
|
proc = pymupdf_proc.PyMuPDFProcessor(extract_images=False)
|
|
result = await proc.process(_tiny_pdf(), "application/pdf", filename="bomb.pdf")
|
|
|
|
assert result.success is False
|
|
assert result.text == ""
|
|
assert result.metadata["parse_failed_reason"] == "oom"
|