Files
ComfyUI/tests/isolation/test_cuda_wheels_and_env_flags.py
John Pollock c5e7b9cdaf feat(isolation): process isolation for custom nodes via pyisolate
Adds opt-in process isolation for custom nodes using pyisolate's
bwrap sandbox and JSON-RPC bridge. Each isolated node pack runs in
its own child process with zero-copy tensor transfer via shared memory.

Core infrastructure:
- CLI flag --use-process-isolation to enable isolation
- Host/child startup fencing via PYISOLATE_CHILD env var
- Manifest-driven node discovery and extension loading
- JSON-RPC bridge between host and child processes
- Shared memory forensics for leak detection

Proxy layer:
- ModelPatcher, CLIP, VAE, and ModelSampling proxies
- Host service proxies (folder_paths, model_management, progress, etc.)
- Proxy base with automatic method forwarding

Execution integration:
- Extension wrapper with V3 hidden param mapping
- Runtime helpers for isolated node execution
- Host policy for node isolation decisions
- Fenced sampler device handling and model ejection parity

Serializers for cross-process data transfer:
- File3D (GLB), PLY (structured + gaussian), NPZ (streaming frames),
  VIDEO (VideoFromFile + VideoFromComponents) serializers
- data_type flag in SerializerRegistry for type-aware dispatch
- Isolated get_temp_directory() fence

New core save nodes:
- SavePLY and SaveNPZ with comfytype registrations (Ply, Npz)

DynamicVRAM compatibility:
- comfy-aimdo early init gated by isolation fence

Tests:
- Integration and policy tests for isolation lifecycle
- Manifest loader, host policy, proxy, and adapter unit tests

Depends on: pyisolate >= 0.9.2
2026-03-12 01:13:43 -05:00

303 lines
8.7 KiB
Python

"""Synthetic integration coverage for manifest plumbing and env flags.
These tests do not perform a real wheel install or a real ComfyUI E2E run.
"""
import asyncio
import logging
import os
import sys
from types import SimpleNamespace
import pytest
from comfy.isolation import runtime_helpers
from comfy.isolation.extension_loader import ExtensionLoadError, load_isolated_node
from comfy.isolation.extension_wrapper import ComfyNodeExtension
from comfy.isolation.model_patcher_proxy_utils import maybe_wrap_model_for_isolation
class _DummyExtension:
def __init__(self) -> None:
self.name = "demo-extension"
async def stop(self) -> None:
return None
def _write_manifest(node_dir, manifest_text: str) -> None:
(node_dir / "pyproject.toml").write_text(manifest_text, encoding="utf-8")
def test_load_isolated_node_passes_normalized_cuda_wheels_config(tmp_path, monkeypatch):
node_dir = tmp_path / "node"
node_dir.mkdir()
manifest_path = node_dir / "pyproject.toml"
_write_manifest(
node_dir,
"""
[project]
name = "demo-node"
dependencies = ["flash-attn>=1.0", "sageattention==0.1"]
[tool.comfy.isolation]
can_isolate = true
share_torch = true
[tool.comfy.isolation.cuda_wheels]
index_url = "https://example.invalid/cuda-wheels"
packages = ["flash_attn", "sageattention"]
[tool.comfy.isolation.cuda_wheels.package_map]
flash_attn = "flash-attn-special"
""".strip(),
)
captured: dict[str, object] = {}
class DummyManager:
def __init__(self, *args, **kwargs) -> None:
return None
def load_extension(self, config):
captured.update(config)
return _DummyExtension()
monkeypatch.setattr(
"comfy.isolation.extension_loader.pyisolate.ExtensionManager", DummyManager
)
monkeypatch.setattr(
"comfy.isolation.extension_loader.load_host_policy",
lambda base_path: {
"allow_network": False,
"writable_paths": [],
"readonly_paths": [],
},
)
monkeypatch.setattr(
"comfy.isolation.extension_loader.is_cache_valid", lambda *args, **kwargs: True
)
monkeypatch.setattr(
"comfy.isolation.extension_loader.load_from_cache",
lambda *args, **kwargs: {"Node": {"display_name": "Node", "schema_v1": {}}},
)
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(base_path=str(tmp_path)))
specs = asyncio.run(
load_isolated_node(
node_dir,
manifest_path,
logging.getLogger("test"),
lambda *args, **kwargs: object,
tmp_path / "venvs",
[],
)
)
assert len(specs) == 1
assert captured["cuda_wheels"] == {
"index_url": "https://example.invalid/cuda-wheels/",
"packages": ["flash-attn", "sageattention"],
"package_map": {"flash-attn": "flash-attn-special"},
}
def test_load_isolated_node_rejects_undeclared_cuda_wheel_dependency(
tmp_path, monkeypatch
):
node_dir = tmp_path / "node"
node_dir.mkdir()
manifest_path = node_dir / "pyproject.toml"
_write_manifest(
node_dir,
"""
[project]
name = "demo-node"
dependencies = ["numpy>=1.0"]
[tool.comfy.isolation]
can_isolate = true
[tool.comfy.isolation.cuda_wheels]
index_url = "https://example.invalid/cuda-wheels"
packages = ["flash-attn"]
""".strip(),
)
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(base_path=str(tmp_path)))
with pytest.raises(ExtensionLoadError, match="undeclared dependencies"):
asyncio.run(
load_isolated_node(
node_dir,
manifest_path,
logging.getLogger("test"),
lambda *args, **kwargs: object,
tmp_path / "venvs",
[],
)
)
def test_load_isolated_node_omits_cuda_wheels_when_not_configured(tmp_path, monkeypatch):
node_dir = tmp_path / "node"
node_dir.mkdir()
manifest_path = node_dir / "pyproject.toml"
_write_manifest(
node_dir,
"""
[project]
name = "demo-node"
dependencies = ["numpy>=1.0"]
[tool.comfy.isolation]
can_isolate = true
""".strip(),
)
captured: dict[str, object] = {}
class DummyManager:
def __init__(self, *args, **kwargs) -> None:
return None
def load_extension(self, config):
captured.update(config)
return _DummyExtension()
monkeypatch.setattr(
"comfy.isolation.extension_loader.pyisolate.ExtensionManager", DummyManager
)
monkeypatch.setattr(
"comfy.isolation.extension_loader.load_host_policy",
lambda base_path: {
"allow_network": False,
"writable_paths": [],
"readonly_paths": [],
},
)
monkeypatch.setattr(
"comfy.isolation.extension_loader.is_cache_valid", lambda *args, **kwargs: True
)
monkeypatch.setattr(
"comfy.isolation.extension_loader.load_from_cache",
lambda *args, **kwargs: {"Node": {"display_name": "Node", "schema_v1": {}}},
)
monkeypatch.setitem(sys.modules, "folder_paths", SimpleNamespace(base_path=str(tmp_path)))
asyncio.run(
load_isolated_node(
node_dir,
manifest_path,
logging.getLogger("test"),
lambda *args, **kwargs: object,
tmp_path / "venvs",
[],
)
)
assert "cuda_wheels" not in captured
def test_maybe_wrap_model_for_isolation_uses_runtime_flag(monkeypatch):
class DummyRegistry:
def register(self, model):
return "model-123"
class DummyProxy:
def __init__(self, model_id, registry, manage_lifecycle):
self.model_id = model_id
self.registry = registry
self.manage_lifecycle = manage_lifecycle
monkeypatch.setattr("comfy.isolation.model_patcher_proxy_utils.args.use_process_isolation", True)
monkeypatch.delenv("PYISOLATE_ISOLATION_ACTIVE", raising=False)
monkeypatch.delenv("PYISOLATE_CHILD", raising=False)
monkeypatch.setitem(
sys.modules,
"comfy.isolation.model_patcher_proxy_registry",
SimpleNamespace(ModelPatcherRegistry=DummyRegistry),
)
monkeypatch.setitem(
sys.modules,
"comfy.isolation.model_patcher_proxy",
SimpleNamespace(ModelPatcherProxy=DummyProxy),
)
wrapped = maybe_wrap_model_for_isolation(object())
assert isinstance(wrapped, DummyProxy)
assert wrapped.model_id == "model-123"
assert wrapped.manage_lifecycle is True
def test_flush_transport_state_uses_child_env_without_legacy_flag(monkeypatch):
monkeypatch.setenv("PYISOLATE_CHILD", "1")
monkeypatch.delenv("PYISOLATE_ISOLATION_ACTIVE", raising=False)
monkeypatch.setattr(
"comfy.isolation.extension_wrapper._flush_tensor_transport_state",
lambda marker: 3,
)
monkeypatch.setitem(
sys.modules,
"comfy.isolation.model_patcher_proxy_registry",
SimpleNamespace(
ModelPatcherRegistry=lambda: SimpleNamespace(
sweep_pending_cleanup=lambda: 0
)
),
)
flushed = asyncio.run(
ComfyNodeExtension.flush_transport_state(SimpleNamespace(name="demo"))
)
assert flushed == 3
def test_build_stub_class_relieves_host_vram_without_legacy_flag(monkeypatch):
import comfy.isolation as isolation_pkg
relieve_calls: list[str] = []
async def deserialize_from_isolation(result, extension):
return result
monkeypatch.delenv("PYISOLATE_CHILD", raising=False)
monkeypatch.delenv("PYISOLATE_ISOLATION_ACTIVE", raising=False)
monkeypatch.setattr(
runtime_helpers, "_relieve_host_vram_pressure", lambda marker, logger: relieve_calls.append(marker)
)
monkeypatch.setattr(runtime_helpers, "scan_shm_forensics", lambda *args, **kwargs: None)
monkeypatch.setattr(isolation_pkg, "_RUNNING_EXTENSIONS", {}, raising=False)
monkeypatch.setitem(
sys.modules,
"pyisolate._internal.model_serialization",
SimpleNamespace(
serialize_for_isolation=lambda payload: payload,
deserialize_from_isolation=deserialize_from_isolation,
),
)
class DummyExtension:
name = "demo-extension"
module_path = os.getcwd()
async def execute_node(self, node_name, **inputs):
return inputs
stub_cls = runtime_helpers.build_stub_class(
"DemoNode",
{"input_types": {}},
DummyExtension(),
{},
logging.getLogger("test"),
)
result = asyncio.run(
getattr(stub_cls, "_pyisolate_execute")(SimpleNamespace(), value=1)
)
assert relieve_calls == ["RUNTIME:pre_execute"]
assert result == {"value": 1}