`document_processors/_isolation.py` did an unconditional module-level `import resource`, a POSIX-only stdlib module absent on Windows. It was pulled into the API startup path via `server/webdav.py -> utils/document_parser -> document_processors`, so the MCP server failed to start on Windows since 0.101.2 with `ModuleNotFoundError: No module named 'resource'`. - Guard the import behind `sys.platform`; bind `resource = None` on win32. `_apply_mem_limit()` degrades to a logged no-op when the module is unavailable (the RLIMIT_AS cap is a Linux-pod safety measure, not a correctness requirement). - Make the document-parser import in `server/webdav.py` lazy so server startup never loads the ingest document stack (document_processors -> pymupdf -> _isolation) at all -- it is only needed when a file is actually read and parsed. This both fixes #877 and decouples the API layer from ingest-only deps. - Add unit regressions for the no-op path and the win32 import guard. - Add a cross-platform `package-smoke` CI job (ubuntu + windows) that installs the package isolated and runs the CLI, exercising the cli -> server -> webdav import chain that crashed in #877. Fixes #877 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
240 lines
7.9 KiB
Python
240 lines
7.9 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 resource
|
|
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,
|
|
)
|
|
|
|
pytestmark = pytest.mark.unit
|
|
|
|
|
|
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) ---
|
|
|
|
|
|
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
|
|
|
|
|
|
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
|
|
|
|
|
|
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"
|