diff --git a/README.md b/README.md index f051812..eae64ef 100644 --- a/README.md +++ b/README.md @@ -259,3 +259,28 @@ MIT – see `LICENSE`. ## Acknowledgements Inspired by PyO3, Tetragon and libsodium. + +## No-GIL readiness is a release axis + +PyIsolate distinguishes **parallel cells** from **scheduled compartments**. A +host may claim parallel-cell semantics only when the interpreter is a +`--disable-gil` build, the process GIL is not enabled, and loaded native +extensions have explicit no-GIL safety declarations. Otherwise PyIsolate treats +work as scheduled compartments: isolated and policy-controlled, but not a hard +parallel execution guarantee. + +Use the doctor subcommands to make this visible in CI and fleet diagnostics: + +```bash +pyisolate doctor gil +pyisolate doctor gil --json +pyisolate doctor extensions +pyisolate doctor extensions --json +``` + +The legacy `pyisolate-doctor` command still prints the full provenance report, +including the `no_gil.axis.mode` field. On free-threaded builds, PyIsolate emits +a `RuntimeWarning` when native extensions are already imported but not declared +safe through `PYISOLATE_NOGIL_SAFE_MODULES`. Only set that environment variable +after auditing upstream support for subinterpreters and CPython no-GIL/free +threading. diff --git a/pyisolate/__init__.py b/pyisolate/__init__.py index 9c314b1..5435838 100644 --- a/pyisolate/__init__.py +++ b/pyisolate/__init__.py @@ -77,6 +77,7 @@ def migrate(*args, **kwargs): # type: ignore[no-redef] from .policy import refresh_remote # noqa: F401 from .sdk import Pipeline, sandbox # noqa: F401 from .subset import OwnershipError, RestrictedExec # noqa: F401 +from .nogil import no_gil_readiness_report, warn_if_unsafe_native_extensions # noqa: F401 from .supervisor import ( Sandbox, Supervisor, @@ -131,5 +132,9 @@ def migrate(*args, **kwargs): # type: ignore[no-redef] "migrate", "refresh_remote", "setup_structured_logging", + "no_gil_readiness_report", + "warn_if_unsafe_native_extensions", "bpf", ] + +warn_if_unsafe_native_extensions() diff --git a/pyisolate/cli.py b/pyisolate/cli.py new file mode 100644 index 0000000..2a0c403 --- /dev/null +++ b/pyisolate/cli.py @@ -0,0 +1,22 @@ +"""Top-level ``pyisolate`` command.""" + +from __future__ import annotations + +import argparse + +from . import doctor + + +def main(argv: list[str] | None = None) -> None: + parser = argparse.ArgumentParser(prog="pyisolate") + subparsers = parser.add_subparsers(dest="command") + subparsers.add_parser("doctor", help="Run installation and no-GIL diagnostics") + args, rest = parser.parse_known_args(argv) + if args.command == "doctor": + doctor.main(rest) + return + parser.print_help() + + +if __name__ == "__main__": + main() diff --git a/pyisolate/doctor.py b/pyisolate/doctor.py index 310749d..b15a64f 100644 --- a/pyisolate/doctor.py +++ b/pyisolate/doctor.py @@ -3,16 +3,78 @@ from __future__ import annotations import argparse +import json +from typing import Any +from .nogil import imported_native_extensions, no_gil_readiness_report from .provenance import installation_report_json +def _print_json(payload: object) -> None: + print(json.dumps(payload, indent=2, sort_keys=True)) + + +def _print_gil_human(report: dict[str, Any]) -> None: + axis = report["axis"] + build = report["build"] + runtime = report["runtime"] + extensions = report["extensions"] + print(f"mode: {axis['mode']}") + print(f"parallel_cells_ready: {axis['parallel_cells_ready']}") + print(f"reason: {axis['reason']}") + print(f"py_gil_disabled: {build['py_gil_disabled']}") + print(f"gil_enabled: {runtime['gil_enabled']}") + print(f"loaded_native_extensions: {extensions['loaded_native_count']}") + print(f"unknown_or_unmarked_extensions: {extensions['unknown_or_unmarked_count']}") + + +def _print_extensions_human(extensions: list[dict[str, object]]) -> None: + if not extensions: + print("No imported native extension modules detected.") + return + for item in extensions: + marker = "OK" if item["no_gil_safe"] else "UNKNOWN" + print(f"{marker}\t{item['name']}\t{item['origin']}\t{item['reason']}") + + def main(argv: list[str] | None = None) -> None: parser = argparse.ArgumentParser( prog="pyisolate-doctor", - description="Print PyIsolate build provenance and kernel feature flags.", + description="Print PyIsolate build provenance and kernel/no-GIL feature flags.", ) - parser.parse_args(argv) + subparsers = parser.add_subparsers(dest="command") + + gil_parser = subparsers.add_parser( + "gil", + help="Report whether this host can run PyIsolate as parallel cells.", + ) + gil_parser.add_argument( + "--json", action="store_true", help="Print machine-readable JSON" + ) + + ext_parser = subparsers.add_parser( + "extensions", + help="List loaded native extensions and their no-GIL audit status.", + ) + ext_parser.add_argument( + "--json", action="store_true", help="Print machine-readable JSON" + ) + + args = parser.parse_args(argv) + if args.command == "gil": + report = no_gil_readiness_report() + if args.json: + _print_json(report) + else: + _print_gil_human(report) + return + if args.command == "extensions": + extensions = imported_native_extensions() + if args.json: + _print_json({"extensions": extensions}) + else: + _print_extensions_human(extensions) + return print(installation_report_json()) diff --git a/pyisolate/nogil.py b/pyisolate/nogil.py new file mode 100644 index 0000000..921218e --- /dev/null +++ b/pyisolate/nogil.py @@ -0,0 +1,165 @@ +"""No-GIL readiness checks for PyIsolate runtime and extensions.""" + +from __future__ import annotations + +import importlib.machinery +import os +import sys +import sysconfig +import warnings +from pathlib import Path +from types import ModuleType +from typing import Any + +_NATIVE_SUFFIXES = tuple(importlib.machinery.EXTENSION_SUFFIXES) +_SAFE_ENV = "PYISOLATE_NOGIL_SAFE_MODULES" +_WARN_ENV = "PYISOLATE_WARN_UNSAFE_NOGIL_EXTENSIONS" + + +def is_no_gil_build() -> bool: + """Return whether this interpreter was built with CPython's free-threaded ABI.""" + + return bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + + +def is_gil_enabled() -> bool | None: + """Return effective process GIL state when CPython exposes it. + + Python 3.13+ free-threaded builds expose ``sys._is_gil_enabled``. Older + interpreters cannot report this distinction, so ``None`` means unknown. + """ + + checker = getattr(sys, "_is_gil_enabled", None) + if checker is None: + return None + return bool(checker()) + + +def _configured_safe_roots() -> set[str]: + raw = os.environ.get(_SAFE_ENV, "") + return {item.strip().split(".", 1)[0] for item in raw.split(",") if item.strip()} + + +def _module_origin(module: ModuleType) -> str | None: + spec = getattr(module, "__spec__", None) + origin = getattr(spec, "origin", None) + if origin: + return origin + filename = getattr(module, "__file__", None) + return str(filename) if filename else None + + +def _is_native_origin(origin: str | None) -> bool: + return bool(origin) and origin.endswith(_NATIVE_SUFFIXES) + + +def imported_native_extensions() -> list[dict[str, Any]]: + """Return imported native extension modules with no-GIL audit status. + + CPython does not expose a portable per-module flag that says whether an + already imported extension declared ``Py_MOD_GIL_NOT_USED``. PyIsolate + therefore treats native modules as unknown unless the deployment marks the + root module in ``PYISOLATE_NOGIL_SAFE_MODULES``. + """ + + safe_roots = _configured_safe_roots() + records: list[dict[str, Any]] = [] + seen: set[tuple[str, str]] = set() + for name, module in sorted(sys.modules.items()): + if not isinstance(module, ModuleType): + continue + origin = _module_origin(module) + if not _is_native_origin(origin): + continue + assert origin is not None + root = name.split(".", 1)[0] + key = (name, origin) + if key in seen: + continue + seen.add(key) + marked_safe = root in safe_roots or name in safe_roots + records.append( + { + "name": name, + "root": root, + "origin": str(Path(origin)), + "suffix": next( + (suffix for suffix in _NATIVE_SUFFIXES if origin.endswith(suffix)), + Path(origin).suffix, + ), + "no_gil_safe": marked_safe, + "status": "declared-safe" if marked_safe else "unknown", + "reason": "declared in PYISOLATE_NOGIL_SAFE_MODULES" + if marked_safe + else "native extension has no PyIsolate no-GIL safety declaration", + } + ) + return records + + +def no_gil_readiness_report() -> dict[str, Any]: + """Return the hard no-GIL axis used to classify runtime behavior.""" + + no_gil_build = is_no_gil_build() + gil_enabled = is_gil_enabled() + extensions = imported_native_extensions() + unknown_extensions = [item for item in extensions if not item["no_gil_safe"]] + + parallel_cells_ready = bool(no_gil_build) and gil_enabled is not True and not unknown_extensions + if parallel_cells_ready: + mode = "parallel_cells" + reason = "free-threaded runtime with no unknown native extensions loaded" + elif not no_gil_build: + mode = "scheduled_compartments" + reason = "Python was not built with --disable-gil" + elif gil_enabled is True: + mode = "scheduled_compartments" + reason = "the process GIL is currently enabled" + else: + mode = "scheduled_compartments" + reason = "native extension no-GIL safety is unknown" + + return { + "build": { + "py_gil_disabled": no_gil_build, + "free_threaded_abi": no_gil_build, + "soabi": sysconfig.get_config_var("SOABI"), + "cache_tag": sys.implementation.cache_tag, + }, + "runtime": { + "gil_enabled": gil_enabled, + "gil_state_known": gil_enabled is not None, + }, + "extensions": { + "loaded_native_count": len(extensions), + "unknown_or_unmarked_count": len(unknown_extensions), + "safe_declaration_env": _SAFE_ENV, + "items": extensions, + }, + "axis": { + "mode": mode, + "parallel_cells_ready": parallel_cells_ready, + "scheduled_compartments": not parallel_cells_ready, + "reason": reason, + }, + } + + +def warn_if_unsafe_native_extensions() -> None: + """Warn on free-threaded builds when native modules block parallel-cell claims.""" + + if os.environ.get(_WARN_ENV, "1").lower() in {"0", "false", "no"}: + return + if not is_no_gil_build(): + return + report = no_gil_readiness_report() + unknown = report["extensions"]["unknown_or_unmarked_count"] + if unknown: + warnings.warn( + f"PyIsolate is running on a no-GIL build, but {unknown} imported native " + "extension module(s) are not declared no-GIL-safe; treating sandboxes as " + "scheduled compartments rather than parallel cells. Set " + f"{_SAFE_ENV}=module1,module2 only after auditing upstream no-GIL support.", + RuntimeWarning, + stacklevel=2, + ) diff --git a/pyisolate/provenance.py b/pyisolate/provenance.py index 7475544..9fe709f 100644 --- a/pyisolate/provenance.py +++ b/pyisolate/provenance.py @@ -11,6 +11,8 @@ from pathlib import Path from typing import Any +from .nogil import no_gil_readiness_report + def _safe_read_text(path: str) -> str | None: try: @@ -117,6 +119,7 @@ def installation_report() -> dict[str, Any]: "features": kernel_feature_flags(), }, "hardening": hardening_feature_flags(), + "no_gil": no_gil_readiness_report(), } diff --git a/pyproject.toml b/pyproject.toml index 4a044f8..68cfd47 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,7 @@ build-backend = "setuptools.build_meta" include = ["pyisolate"] [project.scripts] +pyisolate = "pyisolate.cli:main" pyisolate-doctor = "pyisolate.doctor:main" [project.optional-dependencies] diff --git a/tests/test_provenance.py b/tests/test_provenance.py index b57c937..17e2069 100644 --- a/tests/test_provenance.py +++ b/tests/test_provenance.py @@ -24,3 +24,34 @@ def test_doctor_cli_output(capsys): payload = json.loads(captured.out) assert "kernel" in payload assert "features" in payload["kernel"] + + +def test_installation_report_exposes_no_gil_axis(): + report = installation_report() + assert report["no_gil"]["axis"]["mode"] in { + "parallel_cells", + "scheduled_compartments", + } + assert "parallel_cells_ready" in report["no_gil"]["axis"] + + +def test_doctor_gil_cli_output(capsys): + main(["gil"]) + captured = capsys.readouterr() + assert "mode:" in captured.out + assert "parallel_cells_ready:" in captured.out + + +def test_doctor_extensions_json_output(capsys): + main(["extensions", "--json"]) + captured = capsys.readouterr() + payload = json.loads(captured.out) + assert "extensions" in payload + + +def test_top_level_pyisolate_doctor_gil(capsys): + from pyisolate.cli import main as cli_main + + cli_main(["doctor", "gil"]) + captured = capsys.readouterr() + assert "parallel_cells_ready:" in captured.out