diff --git a/roar/execution/framework/runtime_imports.py b/roar/execution/framework/runtime_imports.py index 057ad2da..caca8e14 100644 --- a/roar/execution/framework/runtime_imports.py +++ b/roar/execution/framework/runtime_imports.py @@ -22,6 +22,17 @@ class RuntimeImportController: def __init__(self, environ: MutableMapping[str, str]): self._environ = environ self._initialized_backend_names: set[str] = set() + self._backend_dispatch_disabled = False + + def disable_backend_dispatch(self) -> None: + """Suppress backend init / observe / patch for the lifetime of this controller. + + Used when the traced Python's ABI doesn't match roar's bundled compiled + deps — loading a backend plugin would trigger an ``ImportError`` from + wrong-ABI wheels (pydantic_core, etc.). The stdlib tracker hooks + (file opens, environ reads, import names) keep running. + """ + self._backend_dispatch_disabled = True def resolve_selected_backend(self) -> ExecutionBackend | None: backend_name = str(self._environ.get(ROAR_EXECUTION_BACKEND_ENV) or "").strip() @@ -33,11 +44,16 @@ def resolve_selected_backend(self) -> ExecutionBackend | None: return None def initialize_selected_backend(self) -> None: + if self._backend_dispatch_disabled: + return backend = self.resolve_selected_backend() if backend is not None: self._initialize_backend(backend) def handle_import(self, module_name: str, module: Any) -> ExecutionBackend | None: + if self._backend_dispatch_disabled: + return None + matched_backend = match_execution_backend_for_module(module_name) if matched_backend is not None: self._environ.setdefault(ROAR_EXECUTION_BACKEND_ENV, matched_backend.name) diff --git a/roar/execution/runtime/inject/sitecustomize.py b/roar/execution/runtime/inject/sitecustomize.py index 3d443611..2c1f3727 100644 --- a/roar/execution/runtime/inject/sitecustomize.py +++ b/roar/execution/runtime/inject/sitecustomize.py @@ -20,6 +20,7 @@ def _append_roar_runtime_pythonpath() -> None: _append_roar_runtime_pythonpath() from roar.execution.framework.runtime_imports import RuntimeImportController +from roar.execution.runtime.inject.support import abi_minor_version, bundled_abi_tag from roar.execution.runtime.inject.tracker import RuntimeInjectionTracker LOG_FILE = os.environ.get("ROAR_LOG_FILE") @@ -42,7 +43,22 @@ def _append_roar_runtime_pythonpath() -> None: if os.environ.get("ROAR_WRAP") == "1": - _runtime_import_controller.initialize_selected_backend() + _bundled_abi = abi_minor_version(bundled_abi_tag(_ROAR_INJECT_DIR)) + _running_abi = (sys.version_info.major, sys.version_info.minor) + if _bundled_abi is not None and _bundled_abi != _running_abi: + sys.stderr.write( + f"roar: traced Python is {_running_abi[0]}.{_running_abi[1]} but " + f"roar-cli was installed under Python " + f"{_bundled_abi[0]}.{_bundled_abi[1]}.\n" + f" Backend integrations (Ray, OSMO) are disabled for this run.\n" + f" File I/O is still captured.\n" + f" To re-enable backends, reinstall under the matching Python:\n" + f" uv tool install --python python{_running_abi[0]}.{_running_abi[1]} " + f"roar-cli --force\n" + ) + _runtime_import_controller.disable_backend_dispatch() + else: + _runtime_import_controller.initialize_selected_backend() atexit.register(_runtime_tracker.write_log) diff --git a/roar/execution/runtime/inject/support.py b/roar/execution/runtime/inject/support.py index 91c73be2..e86b95b9 100644 --- a/roar/execution/runtime/inject/support.py +++ b/roar/execution/runtime/inject/support.py @@ -2,6 +2,7 @@ import contextlib import os +import re import shutil import sys import threading @@ -9,6 +10,52 @@ _roar_suppress = threading.local() +def bundled_abi_tag(inject_dir: str) -> str | None: + """Return the cpython ABI tag of roar's bundled compiled deps, or None. + + Walks up from ``inject_dir`` to find the enclosing ``site-packages`` and + parses the CPython ABI tag from a known compiled dependency's ``.so`` + filename (e.g. ``_pydantic_core.cpython-313-x86_64-linux-gnu.so`` → + ``"cpython-313"``). Returns ``None`` if the layout doesn't match — callers + should treat ``None`` as "don't gate" rather than assuming mismatch. + """ + site_pkg = inject_dir + while os.path.basename(site_pkg) != "site-packages": + parent = os.path.dirname(site_pkg) + if parent == site_pkg: + return None + site_pkg = parent + + for known_pkg in ("pydantic_core", "blake3"): + pkg_dir = os.path.join(site_pkg, known_pkg) + if not os.path.isdir(pkg_dir): + continue + try: + entries = os.listdir(pkg_dir) + except OSError: + continue + for filename in entries: + for token in filename.split("."): + if token.startswith("cpython-"): + m = re.match(r"cpython-\d+", token) + if m: + return m.group() + return None + + +def abi_minor_version(tag: str | None) -> tuple[int, int] | None: + """Extract ``(major, minor)`` from an ABI tag like ``cp313`` or ``cpython-313``.""" + if not tag: + return None + match = re.search(r"\d+", tag) + if not match: + return None + digits = match.group() + if len(digits) < 2: + return None + return (int(digits[0]), int(digits[1:])) + + def is_suppressed() -> bool: return bool(getattr(_roar_suppress, "active", False)) diff --git a/tests/execution/runtime/test_inject_support.py b/tests/execution/runtime/test_inject_support.py new file mode 100644 index 00000000..433859ed --- /dev/null +++ b/tests/execution/runtime/test_inject_support.py @@ -0,0 +1,62 @@ +from __future__ import annotations + +from pathlib import Path + +from roar.execution.runtime.inject.support import abi_minor_version, bundled_abi_tag + + +def _make_inject_layout(root: Path, compiled_pkg: str, so_filename: str) -> Path: + """Create a fake site-packages tree and return the simulated inject dir.""" + site_pkg = root / "site-packages" + pkg_dir = site_pkg / compiled_pkg + pkg_dir.mkdir(parents=True) + (pkg_dir / so_filename).touch() + inject_dir = site_pkg / "roar" / "execution" / "runtime" / "inject" + inject_dir.mkdir(parents=True) + return inject_dir + + +def test_bundled_abi_tag_finds_pydantic_core_tag(tmp_path: Path) -> None: + inject_dir = _make_inject_layout( + tmp_path, + "pydantic_core", + "_pydantic_core.cpython-313-x86_64-linux-gnu.so", + ) + assert bundled_abi_tag(str(inject_dir)) == "cpython-313" + + +def test_bundled_abi_tag_finds_blake3_tag_when_pydantic_absent(tmp_path: Path) -> None: + inject_dir = _make_inject_layout( + tmp_path, + "blake3", + "_blake3.cpython-312-x86_64-linux-gnu.so", + ) + assert bundled_abi_tag(str(inject_dir)) == "cpython-312" + + +def test_bundled_abi_tag_returns_none_with_no_compiled_deps(tmp_path: Path) -> None: + site_pkg = tmp_path / "site-packages" + inject_dir = site_pkg / "roar" / "execution" / "runtime" / "inject" + inject_dir.mkdir(parents=True) + assert bundled_abi_tag(str(inject_dir)) is None + + +def test_bundled_abi_tag_returns_none_when_site_packages_absent(tmp_path: Path) -> None: + inject_dir = tmp_path / "some" / "other" / "layout" + inject_dir.mkdir(parents=True) + assert bundled_abi_tag(str(inject_dir)) is None + + +def test_abi_minor_version_parses_short_form() -> None: + assert abi_minor_version("cp313") == (3, 13) + + +def test_abi_minor_version_parses_long_form() -> None: + assert abi_minor_version("cpython-312") == (3, 12) + + +def test_abi_minor_version_handles_missing_or_unparseable() -> None: + assert abi_minor_version(None) is None + assert abi_minor_version("") is None + assert abi_minor_version("nothing") is None + assert abi_minor_version("cp3") is None # too few digits to split diff --git a/tests/execution/runtime/test_runtime_imports.py b/tests/execution/runtime/test_runtime_imports.py index 9f4bf898..b22f8646 100644 --- a/tests/execution/runtime/test_runtime_imports.py +++ b/tests/execution/runtime/test_runtime_imports.py @@ -118,3 +118,52 @@ def test_initialize_selected_backend_initializes_only_configured_backend( controller.initialize_selected_backend() assert calls == ["initialize"] + + +def test_disable_backend_dispatch_short_circuits_initialize_selected_backend( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import roar.execution.framework.runtime_imports as runtime_imports + + calls: list[str] = [] + fake_backend = _fake_backend(calls) + controller = RuntimeImportController({ROAR_EXECUTION_BACKEND_ENV: "fake"}) + + monkeypatch.setattr( + runtime_imports, + "get_execution_backend", + lambda backend_name: fake_backend if backend_name == "fake" else None, + ) + + controller.disable_backend_dispatch() + controller.initialize_selected_backend() + + assert calls == [] + + +def test_disable_backend_dispatch_short_circuits_handle_import( + monkeypatch: pytest.MonkeyPatch, +) -> None: + import roar.execution.framework.runtime_imports as runtime_imports + + calls: list[str] = [] + fake_backend = _fake_backend(calls) + controller = RuntimeImportController({}) + + monkeypatch.setattr( + runtime_imports, + "match_execution_backend_for_module", + lambda module_name: fake_backend if module_name == "json" else None, + ) + monkeypatch.setattr( + runtime_imports, + "get_execution_backend", + lambda backend_name: fake_backend if backend_name == "fake" else None, + ) + + controller.disable_backend_dispatch() + result = controller.handle_import("json", __import__("json")) + + assert result is None + assert calls == [] + assert ROAR_EXECUTION_BACKEND_ENV not in controller._environ