mirror of
https://github.com/hanzoui/pyisolate.git
synced 2026-10-30 20:37:17 +00:00
Architecture changes: - Add RPCTransport protocol with QueueTransport, ConnectionTransport, and JSONSocketTransport implementations - New uds_client.py entry point for child processes (replaces client.py) - Host launches child via subprocess.Popen + UDS listener instead of multiprocessing.Process + Queue Tensor serialization (new tensor_serializer.py): - CPU tensors: file_system shared memory strategy with rebuild_storage_filename - CUDA tensors: IPC handles via reduce_tensor/rebuild_cuda_tensor - Handles re-sharing IPC tensors by cloning with size-based warning (>100MB) - TensorRef deserialized early in _json_object_hook for proper nesting JSON object serialization enhancements: - Generic __dict__ serialization with class attribute inheritance - Enum, bytes, UUID, exception serialization/reconstruction - Class reconstruction via no-arg constructor or __new__ + setattr - PyTorch tensor priority check before __dict__ fallback - Callable/method filtering to avoid serializing bound methods Event loop handling: - Added _get_valid_loop() for cross-workflow loop management - Graceful handling of closed loops in RPC response delivery - Thread-safe default_loop updates via _loop_lock Bwrap sandbox support (not yet active): - _build_bwrap_command() with deny-by-default filesystem - GPU passthrough patterns for CUDA IPC - System path allowlist for minimal attack surface Test updates: - Mock subprocess.Popen, socket, JSONSocketTransport for unit tests - FakeExtension gains _process_initialized tracking
117 lines
3.2 KiB
Python
117 lines
3.2 KiB
Python
import types
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from pyisolate.host import ExtensionManager
|
|
|
|
|
|
class FakeExtension:
|
|
@classmethod
|
|
def __class_getitem__(cls, item):
|
|
return cls
|
|
|
|
def __init__(
|
|
self, module_path: str, extension_type: Any, config: dict[str, Any], venv_root_path: str
|
|
) -> None:
|
|
self.module_path = module_path
|
|
self.extension_type = extension_type
|
|
self.config = config
|
|
self.venv_root_path = venv_root_path
|
|
self.started = 0
|
|
self.proxy_obj = types.SimpleNamespace(run=lambda: "ok")
|
|
self.rpc = object()
|
|
self.stopped = 0
|
|
self._process_initialized = False
|
|
|
|
def ensure_process_started(self) -> None:
|
|
self.started += 1
|
|
self._process_initialized = True
|
|
|
|
def get_proxy(self):
|
|
return self.proxy_obj
|
|
|
|
def stop(self):
|
|
self.stopped += 1
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def patch_extension(monkeypatch):
|
|
monkeypatch.setattr("pyisolate.host.Extension", FakeExtension)
|
|
|
|
|
|
def make_manager(tmp_path):
|
|
return ExtensionManager(types.SimpleNamespace, {"venv_root_path": str(tmp_path)})
|
|
|
|
|
|
def base_config(tmp_path):
|
|
return {
|
|
"name": "demo",
|
|
"module_path": "/tmp/mod.py",
|
|
"dependencies": [],
|
|
"share_torch": True,
|
|
"share_cuda_ipc": False,
|
|
"apis": [],
|
|
"venv_root_path": str(tmp_path),
|
|
}
|
|
|
|
|
|
def test_load_extension_returns_host_extension(monkeypatch, tmp_path):
|
|
mgr = make_manager(tmp_path)
|
|
proxy = mgr.load_extension(base_config(tmp_path))
|
|
# First access triggers start + rpc init + proxy creation
|
|
assert proxy.proxy.run() == "ok"
|
|
assert getattr(proxy, "_rpc", None) is mgr.extensions["demo"].rpc
|
|
# Subsequent access uses cached proxy, no extra starts
|
|
_ = proxy.proxy # Access to verify caching works
|
|
ext = mgr.extensions["demo"]
|
|
assert isinstance(ext, FakeExtension)
|
|
assert ext.started == 1
|
|
|
|
|
|
def test_duplicate_extension_name_raises(tmp_path):
|
|
mgr = make_manager(tmp_path)
|
|
cfg = base_config(tmp_path)
|
|
mgr.load_extension(cfg)
|
|
with pytest.raises(ValueError):
|
|
mgr.load_extension(cfg)
|
|
|
|
|
|
def test_host_extension_getattr_delegates(monkeypatch, tmp_path):
|
|
mgr = make_manager(tmp_path)
|
|
cfg = base_config(tmp_path)
|
|
proxy = mgr.load_extension(cfg)
|
|
# Add attr to underlying extension
|
|
ext = mgr.extensions["demo"]
|
|
ext.special = "hello"
|
|
assert proxy.special == "hello"
|
|
# Attribute missing on extension should delegate to proxy
|
|
assert proxy.run() == "ok"
|
|
|
|
|
|
def test_stop_all_extensions_calls_stop(tmp_path):
|
|
mgr = make_manager(tmp_path)
|
|
cfg = base_config(tmp_path)
|
|
mgr.load_extension(cfg)
|
|
mgr.load_extension({**cfg, "name": "demo2"})
|
|
mgr.stop_all_extensions()
|
|
assert mgr.extensions == {}
|
|
|
|
|
|
def test_stop_all_extensions_logs_error(caplog, tmp_path):
|
|
mgr = make_manager(tmp_path)
|
|
cfg = base_config(tmp_path)
|
|
_proxy = mgr.load_extension(cfg) # noqa: F841 - load to register extension
|
|
ext = mgr.extensions["demo"]
|
|
|
|
def boom():
|
|
raise RuntimeError("boom")
|
|
|
|
ext.stop = boom # type: ignore[assignment]
|
|
|
|
with caplog.at_level("ERROR"):
|
|
mgr.stop_all_extensions()
|
|
|
|
assert "Error stopping extension 'demo'" in caplog.text
|
|
assert mgr.extensions == {}
|