Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
5 changes: 5 additions & 0 deletions pyisolate/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
22 changes: 22 additions & 0 deletions pyisolate/cli.py
Original file line number Diff line number Diff line change
@@ -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()
66 changes: 64 additions & 2 deletions pyisolate/doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())


Expand Down
165 changes: 165 additions & 0 deletions pyisolate/nogil.py
Original file line number Diff line number Diff line change
@@ -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,
)
3 changes: 3 additions & 0 deletions pyisolate/provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -117,6 +119,7 @@ def installation_report() -> dict[str, Any]:
"features": kernel_feature_flags(),
},
"hardening": hardening_feature_flags(),
"no_gil": no_gil_readiness_report(),
}


Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
31 changes: 31 additions & 0 deletions tests/test_provenance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading