Files
mcp-nextcloud/tests/unit/test_pdf_parse_isolation.py
T
Chris CoutinhoandClaude Opus 4.8 62274069de refactor(documents): fully decouple document stack from server startup; Windows-safe tests
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>
2026-06-08 15:11:18 +02:00

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"