2025 lines
76 KiB
Python
2025 lines
76 KiB
Python
"""Tests split from the Modal executor integration suite."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from modal_executor_test_support import * # noqa: F401,F403
|
|
|
|
def test_stable_modal_cloud_entry_imports_without_modal_sdk(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The stable Modal cloud module should stay importable when modal is unavailable."""
|
|
assert modal_cloud_module.__name__ == "comfyui_modal_sync_cloud"
|
|
assert hasattr(modal_cloud_module, "RemoteEngine")
|
|
|
|
def test_modal_cloud_exposes_affinity_parameter_and_local_gap_keepalive() -> None:
|
|
"""The deployed class must make affinity part of identity and expose a heartbeat."""
|
|
source = (Path(__file__).resolve().parents[1] / "comfyui_modal_sync_cloud.py").read_text()
|
|
|
|
assert "worker_affinity_key: str = modal.parameter" in source
|
|
assert "def keepalive_for_local_gap" in source
|
|
|
|
def test_modal_cloud_image_environment_preserves_unique_app_name(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Remote workers should receive the same per-ComfyUI app name resolved locally."""
|
|
settings = types.SimpleNamespace(
|
|
app_name="comfy-modal-sync-AAECAwQFBgc",
|
|
modal_gpu="B300",
|
|
modal_secret_name="workflow-credentials",
|
|
stream_event_queue_maxsize=256,
|
|
bridge_inline_max_bytes=1024,
|
|
invocation_result_inline_max_bytes=2048,
|
|
execution_timeout_seconds=3600,
|
|
startup_timeout_seconds=900,
|
|
llm_vllm_execution_mode="throughput",
|
|
)
|
|
|
|
image_environment = modal_cloud_module._modal_image_environment(settings, "fingerprint-1")
|
|
|
|
assert image_environment["COMFY_MODAL_APP_NAME"] == "comfy-modal-sync-AAECAwQFBgc"
|
|
assert image_environment["COMFY_MODAL_GPU"] == "B300"
|
|
assert image_environment["COMFY_MODAL_REMOTE_STORAGE_ROOT"] == "/storage"
|
|
assert image_environment["COMFY_MODAL_REMOTE_WORKER"] == "1"
|
|
assert image_environment["COMFY_MODAL_LLM_VLLM_EXECUTION_MODE"] == "throughput"
|
|
assert image_environment["VLLM_CACHE_ROOT"].startswith(
|
|
"/root/.cache/comfy-modal-llm/"
|
|
)
|
|
assert image_environment["TORCHINDUCTOR_CACHE_DIR"].startswith(
|
|
"/root/.cache/comfy-modal-llm/"
|
|
)
|
|
assert image_environment["VLLM_USE_FLASHINFER_SAMPLER"] == "0"
|
|
assert image_environment["COMFY_MODAL_LLM_MAX_RESIDENT_MODELS"] == "2"
|
|
assert image_environment["COMFY_MODAL_LLM_MEMORY_RECOVERY_TIMEOUT_SECONDS"] == "15.0"
|
|
assert image_environment["COMFY_MODAL_LLM_RESERVE_FREE_GB"] == "24.0"
|
|
assert image_environment["COMFY_MODAL_SECRET_NAME"] == "workflow-credentials"
|
|
assert image_environment["COMFY_MODAL_RUNTIME_FINGERPRINT"] == "fingerprint-1"
|
|
|
|
def test_modal_cloud_observes_distinct_workflows_for_llm_auto_mode(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The container boundary should count prompts while excluding canary traffic."""
|
|
observed_prompt_ids: list[str | None] = []
|
|
forced_prompt_ids: list[str | None] = []
|
|
runtime_module = types.ModuleType("modal_llm_runtime")
|
|
runtime_module.observe_modal_workflow_execution = observed_prompt_ids.append
|
|
runtime_module.force_modal_vllm_throughput_after_memory_recovery = (
|
|
forced_prompt_ids.append
|
|
)
|
|
monkeypatch.setitem(sys.modules, "modal_llm_runtime", runtime_module)
|
|
|
|
modal_cloud_module._observe_remote_workflow_for_llm_mode(
|
|
{"prompt_id": "prompt-1", "payload_kind": "subgraph"}
|
|
)
|
|
modal_cloud_module._observe_remote_workflow_for_llm_mode(
|
|
{"prompt_id": "prompt-1", "payload_kind": "mapped_subgraph"}
|
|
)
|
|
modal_cloud_module._observe_remote_workflow_for_llm_mode(
|
|
{"prompt_id": "health-check", "payload_kind": "canary"}
|
|
)
|
|
modal_cloud_module._observe_remote_workflow_for_llm_mode(
|
|
{"prompt_id": "prompt-2", "payload_kind": "subgraph"}
|
|
)
|
|
modal_cloud_module._observe_remote_workflow_for_llm_mode(
|
|
{
|
|
"prompt_id": "prompt-3",
|
|
"payload_kind": "subgraph",
|
|
"force_vllm_throughput_after_memory_recovery": True,
|
|
}
|
|
)
|
|
|
|
assert observed_prompt_ids == ["prompt-1", "prompt-1", "prompt-2"]
|
|
assert forced_prompt_ids == ["prompt-3"]
|
|
|
|
def test_modal_cloud_resolves_configured_named_secret(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Cloud app construction should reference the configured collection without reading .env."""
|
|
observed_names: list[str] = []
|
|
expected_secret = object()
|
|
fake_modal = types.SimpleNamespace(
|
|
Secret=types.SimpleNamespace(
|
|
from_name=lambda name: observed_names.append(name) or expected_secret,
|
|
)
|
|
)
|
|
|
|
resolved_secret = modal_cloud_module._modal_secret_from_settings(
|
|
types.SimpleNamespace(modal_secret_name="workflow-credentials"),
|
|
fake_modal,
|
|
)
|
|
|
|
assert resolved_secret is expected_secret
|
|
assert observed_names == ["workflow-credentials"]
|
|
|
|
def test_modal_cloud_installs_timestamped_logger_handler(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The cloud runtime should install its own timestamped logger handler."""
|
|
matching_handlers = [
|
|
handler
|
|
for handler in modal_cloud_module.logger.handlers
|
|
if getattr(handler, "name", "") == modal_cloud_module._CLOUD_HANDLER_NAME
|
|
]
|
|
|
|
assert len(matching_handlers) == 1
|
|
assert modal_cloud_module.logger.propagate is False
|
|
assert modal_cloud_module.logger.level == logging.INFO
|
|
assert matching_handlers[0].stream is sys.stdout
|
|
formatter = matching_handlers[0].formatter
|
|
assert isinstance(formatter, logging.Formatter)
|
|
assert "%(asctime)s" in formatter._fmt
|
|
assert "%(relativeCreated)" in formatter._fmt
|
|
|
|
def test_modal_cloud_traces_remote_node_execution_spans(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
capsys: Any,
|
|
) -> None:
|
|
"""The tracing prompt server should emit per-node timing lines."""
|
|
prompt = {
|
|
"7": {"class_type": "UNETLoader", "inputs": {}},
|
|
"2": {"class_type": "KSampler", "inputs": {}},
|
|
}
|
|
monkeypatch.setenv("MODAL_IS_REMOTE", "1")
|
|
server = modal_cloud_module._TracingPromptServer("component-1", prompt)
|
|
|
|
server.send_sync("executing", {"node": "7"}, None)
|
|
server.send_sync("executed", {"node": "7"}, None)
|
|
server.send_sync("executing", {"node": "2"}, None)
|
|
server.send_sync("execution_success", {"prompt_id": "component-1"}, None)
|
|
|
|
captured = capsys.readouterr()
|
|
assert "Remote node 7 class_type=UNETLoader role=model_load started" in captured.out
|
|
assert "Remote node 7 class_type=UNETLoader role=model_load finished in " in captured.out
|
|
assert "Remote node 2 class_type=KSampler role=sampling started" in captured.out
|
|
assert "Remote node 2 class_type=KSampler role=sampling finished in " in captured.out
|
|
|
|
def test_modal_cloud_installs_headless_prompt_server_instance(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Remote custom-node init should get a minimal PromptServer.instance shim."""
|
|
class FakeNodeReplaceManager:
|
|
"""Record replacement registrations made through the headless server."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty replacement registry."""
|
|
self.registrations: list[Any] = []
|
|
|
|
def register(self, replacement: Any) -> None:
|
|
"""Record one replacement registered by a ComfyUI extension."""
|
|
self.registrations.append(replacement)
|
|
|
|
fake_prompt_server_class = type("PromptServer", (), {})
|
|
fake_server_module = types.SimpleNamespace(
|
|
NodeReplaceManager=FakeNodeReplaceManager,
|
|
PromptServer=fake_prompt_server_class,
|
|
)
|
|
|
|
monkeypatch.setitem(sys.modules, "server", fake_server_module)
|
|
modal_cloud_module._ensure_headless_prompt_server_instance()
|
|
|
|
instance = fake_prompt_server_class.instance
|
|
assert instance is not None
|
|
assert hasattr(instance, "routes")
|
|
assert hasattr(instance, "app")
|
|
assert isinstance(instance.node_replace_manager, FakeNodeReplaceManager)
|
|
assert instance.supports == ["custom_nodes_from_web"]
|
|
assert instance.client_id is None
|
|
assert instance.last_node_id is None
|
|
assert instance.number == 0
|
|
assert instance.prompt_queue.currently_running == {}
|
|
assert instance.prompt_queue.get_current_queue() == ([], [])
|
|
|
|
instance.send_progress_text("width: 1024, height: 768", "104")
|
|
|
|
instance.add_on_prompt_handler("handler")
|
|
assert instance.on_prompt_handlers == ["handler"]
|
|
|
|
instance.node_replace_manager.register("replacement")
|
|
assert instance.node_replace_manager.registrations == ["replacement"]
|
|
|
|
instance.prompt_queue.put((1, "prompt-id", {}, {}, []))
|
|
assert instance.prompt_queue.get_current_queue() == (
|
|
[],
|
|
[(1, "prompt-id", {}, {}, [])],
|
|
)
|
|
assert instance.prompt_queue.get_tasks_remaining() == 1
|
|
|
|
instance.prompt_queue.set_flag("free_memory", True)
|
|
assert instance.prompt_queue.get_flags() == {"free_memory": True}
|
|
assert instance.prompt_queue.get_flags() == {}
|
|
|
|
def test_modal_cloud_dynamic_prompt_preserves_thread_local_metadata_graph(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Hydrated execution graphs should not replace the JSON-safe hidden PROMPT graph."""
|
|
|
|
class FakeDynamicPrompt:
|
|
"""Minimal ComfyUI DynamicPrompt stand-in."""
|
|
|
|
def __init__(self, original_prompt: dict[str, Any]) -> None:
|
|
"""Retain the graph used for runtime dependency resolution."""
|
|
self.original_prompt = original_prompt
|
|
|
|
def get_original_prompt(self) -> dict[str, Any]:
|
|
"""Return the runtime graph when no metadata override is active."""
|
|
return self.original_prompt
|
|
|
|
fake_execution = types.SimpleNamespace(DynamicPrompt=FakeDynamicPrompt)
|
|
hydrated_prompt = {"1": {"inputs": {"image": object()}}}
|
|
metadata_prompt = {"1": {"inputs": {"image": ["upstream", 0]}}}
|
|
|
|
modal_cloud_module._install_metadata_safe_dynamic_prompt_wrapper(fake_execution)
|
|
with modal_cloud_module._temporary_prompt_metadata(metadata_prompt):
|
|
dynamic_prompt = fake_execution.DynamicPrompt(hydrated_prompt)
|
|
|
|
ordinary_prompt = fake_execution.DynamicPrompt(hydrated_prompt)
|
|
|
|
assert dynamic_prompt.original_prompt is hydrated_prompt
|
|
assert dynamic_prompt.get_original_prompt() is metadata_prompt
|
|
assert ordinary_prompt.get_original_prompt() is hydrated_prompt
|
|
|
|
def test_modal_cloud_hidden_prompt_stays_json_safe_after_tensor_boundary_hydration(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Metadata-writing output nodes should receive links, not hydrated tensors, in PROMPT."""
|
|
import torch
|
|
|
|
monkeypatch.setitem(sys.modules, "torchsde", types.ModuleType("torchsde"))
|
|
import comfy_execution.caching as comfy_caching
|
|
|
|
modal_cloud_module._ensure_comfy_runtime_initialized(None)
|
|
nodes_module = modal_cloud_module._load_nodes_module()
|
|
monkeypatch.setitem(
|
|
nodes_module.NODE_CLASS_MAPPINGS,
|
|
"PromptMetadataSerializationNode",
|
|
_PromptMetadataSerializationNode,
|
|
)
|
|
monkeypatch.setitem(
|
|
comfy_caching.nodes.NODE_CLASS_MAPPINGS,
|
|
"PromptMetadataSerializationNode",
|
|
_PromptMetadataSerializationNode,
|
|
)
|
|
monkeypatch.setitem(
|
|
nodes_module.NODE_DISPLAY_NAME_MAPPINGS,
|
|
"PromptMetadataSerializationNode",
|
|
"PromptMetadataSerializationNode",
|
|
)
|
|
monkeypatch.setattr(
|
|
_cloud_prompt_execution_owner(), "_node_output_cache_store", lambda: None
|
|
)
|
|
payload = {
|
|
"payload_kind": "subgraph",
|
|
"component_id": "metadata-output",
|
|
"prompt_id": "prompt-metadata-output",
|
|
"component_node_ids": ["output"],
|
|
"subgraph_prompt": {
|
|
"output": {
|
|
"class_type": "PromptMetadataSerializationNode",
|
|
"inputs": {"image": ["upstream", 0]},
|
|
}
|
|
},
|
|
"boundary_inputs": [
|
|
{
|
|
"proxy_input_name": "remote_image",
|
|
"io_type": "IMAGE",
|
|
"source_signature": "image-signature",
|
|
"targets": [{"node_id": "output", "input_name": "image"}],
|
|
}
|
|
],
|
|
"boundary_outputs": [
|
|
{
|
|
"proxy_output_name": "metadata",
|
|
"node_id": "output",
|
|
"output_index": 0,
|
|
"io_type": "STRING",
|
|
"is_list": False,
|
|
}
|
|
],
|
|
"execute_node_ids": ["output"],
|
|
"extra_data": {},
|
|
}
|
|
|
|
outputs = modal_cloud_module._execute_subgraph_prompt(
|
|
payload,
|
|
{"remote_image": torch.zeros((1, 2, 2, 3), dtype=torch.float32)},
|
|
None,
|
|
)
|
|
|
|
assert len(outputs) == 1
|
|
assert json.loads(outputs[0]) == payload["subgraph_prompt"]
|
|
|
|
def test_modal_cloud_only_reloads_volume_for_requests_with_new_uploads(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Steady-state requests should skip Modal volume reload when queue-time sync uploaded nothing."""
|
|
|
|
assert modal_cloud_module._should_reload_modal_volume({"requires_volume_reload": True}) is True
|
|
assert modal_cloud_module._should_reload_modal_volume({"requires_volume_reload": False}) is False
|
|
assert modal_cloud_module._should_reload_modal_volume({}) is True
|
|
|
|
def test_modal_cloud_skips_reload_when_uploaded_paths_are_already_visible(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Visible immutable uploaded paths should not force an extra Modal volume reload."""
|
|
storage_root = tmp_path / "storage"
|
|
storage_root.mkdir()
|
|
uploaded_path = storage_root / "assets" / "hash_model.safetensors"
|
|
uploaded_path.parent.mkdir(parents=True, exist_ok=True)
|
|
uploaded_path.write_bytes(b"weights")
|
|
recorded_markers: list[str] = []
|
|
|
|
_patch_cloud_storage_root(monkeypatch, modal_cloud_module, storage_root)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_record_modal_volume_reload_marker",
|
|
lambda marker: recorded_markers.append(marker),
|
|
)
|
|
|
|
payload = {
|
|
"requires_volume_reload": True,
|
|
"volume_reload_marker": "marker-1",
|
|
"uploaded_volume_paths": ["/assets/hash_model.safetensors"],
|
|
}
|
|
|
|
assert modal_cloud_module._should_reload_modal_volume(payload) is False
|
|
assert recorded_markers == ["marker-1"]
|
|
|
|
def test_modal_cloud_reads_missing_committed_asset_without_reloading_volume(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A stale warm mount should read a committed asset into ephemeral storage."""
|
|
|
|
class FakeVolume:
|
|
"""Modal Volume double that exposes committed file reads."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Record direct read paths."""
|
|
self.read_paths: list[str] = []
|
|
|
|
def read_file(self, path: str) -> Iterator[bytes]:
|
|
"""Yield one committed model file in chunks."""
|
|
self.read_paths.append(path)
|
|
yield b"model-"
|
|
yield b"weights"
|
|
|
|
storage_root = tmp_path / "storage"
|
|
storage_root.mkdir()
|
|
readthrough_root = tmp_path / "readthrough"
|
|
_patch_cloud_storage_root(monkeypatch, modal_cloud_module, storage_root)
|
|
bootstrap_owner = _cloud_comfy_bootstrap_owner()
|
|
monkeypatch.setattr(
|
|
bootstrap_owner,
|
|
"_REMOTE_VOLUME_READTHROUGH_ROOT",
|
|
readthrough_root,
|
|
)
|
|
payload = {
|
|
"component_id": "component-1",
|
|
"requires_volume_reload": False,
|
|
"subgraph_prompt": {
|
|
"14": {
|
|
"class_type": "LoraLoaderModelOnly",
|
|
"inputs": {"lora_name": "/assets/hash_style.safetensors"},
|
|
}
|
|
},
|
|
}
|
|
|
|
volume = FakeVolume()
|
|
hydrated_paths = modal_cloud_module._hydrate_missing_payload_volume_paths(volume, payload)
|
|
|
|
cached_path = readthrough_root / "assets" / "hash_style.safetensors"
|
|
assert hydrated_paths == [cached_path]
|
|
assert cached_path.read_bytes() == b"model-weights"
|
|
assert volume.read_paths == ["assets/hash_style.safetensors"]
|
|
assert modal_cloud_module._should_reload_modal_volume(payload) is False
|
|
assert (
|
|
modal_cloud_module._resolve_runtime_asset_path("/assets/hash_style.safetensors")
|
|
== str(cached_path)
|
|
)
|
|
|
|
def test_modal_cloud_reloads_when_reused_referenced_asset_is_still_missing(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""A sync-index hit must not suppress recovery for a missing runtime asset."""
|
|
storage_root = tmp_path / "storage"
|
|
storage_root.mkdir()
|
|
_patch_cloud_storage_root(monkeypatch, modal_cloud_module, storage_root)
|
|
monkeypatch.setattr(
|
|
_cloud_comfy_bootstrap_owner(),
|
|
"_REMOTE_VOLUME_READTHROUGH_ROOT",
|
|
tmp_path / "readthrough",
|
|
)
|
|
payload = {
|
|
"requires_volume_reload": False,
|
|
"subgraph_prompt": {
|
|
"14": {
|
|
"class_type": "LoraLoaderModelOnly",
|
|
"inputs": {"lora_name": "/assets/missing_style.safetensors"},
|
|
}
|
|
},
|
|
}
|
|
|
|
assert modal_cloud_module._should_reload_modal_volume(payload) is True
|
|
|
|
def test_modal_cloud_preserves_worker_for_deterministic_remote_failures(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Ordinary node failures should not discard a healthy warm Modal worker."""
|
|
scheduled_exits: list[tuple[float, int]] = []
|
|
original_flag = modal_cloud_module._CONTAINER_TERMINATION_SCHEDULED
|
|
monkeypatch.setattr(modal_cloud_module, "_CONTAINER_TERMINATION_SCHEDULED", False)
|
|
monkeypatch.setattr(modal_cloud_module, "_is_modal_container_runtime", lambda: True)
|
|
monkeypatch.setattr(
|
|
modal_cloud_module,
|
|
"_schedule_process_exit",
|
|
lambda delay_seconds, exit_code: scheduled_exits.append((delay_seconds, exit_code)),
|
|
)
|
|
try:
|
|
scheduled = modal_cloud_module._maybe_schedule_container_termination_on_error(
|
|
{"component_id": "component-1", "terminate_container_on_error": True},
|
|
RuntimeError("boom"),
|
|
)
|
|
finally:
|
|
monkeypatch.setattr(modal_cloud_module, "_CONTAINER_TERMINATION_SCHEDULED", original_flag)
|
|
|
|
assert scheduled is False
|
|
assert scheduled_exits == []
|
|
|
|
def test_modal_cloud_retires_worker_for_poisoned_cuda_runtime(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""CUDA failures that can poison process state should retire the worker."""
|
|
scheduled_exits: list[tuple[float, int]] = []
|
|
original_flag = modal_cloud_module._CONTAINER_TERMINATION_SCHEDULED
|
|
monkeypatch.setattr(modal_cloud_module, "_CONTAINER_TERMINATION_SCHEDULED", False)
|
|
monkeypatch.setattr(modal_cloud_module, "_is_modal_container_runtime", lambda: True)
|
|
monkeypatch.setattr(
|
|
modal_cloud_module,
|
|
"_schedule_process_exit",
|
|
lambda delay_seconds, exit_code: scheduled_exits.append((delay_seconds, exit_code)),
|
|
)
|
|
try:
|
|
scheduled = modal_cloud_module._maybe_schedule_container_termination_on_error(
|
|
{"component_id": "component-1", "terminate_container_on_error": True},
|
|
RuntimeError("CUDA error: an illegal memory access was encountered"),
|
|
)
|
|
finally:
|
|
monkeypatch.setattr(modal_cloud_module, "_CONTAINER_TERMINATION_SCHEDULED", original_flag)
|
|
|
|
assert scheduled is True
|
|
assert scheduled_exits == [(modal_cloud_module._REMOTE_ERROR_CONTAINER_EXIT_DELAY_SECONDS, 1)]
|
|
|
|
def test_modal_cloud_retires_worker_after_llm_memory_recovery_timeout(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""An unrecovered eviction should retire dirty process state before retry."""
|
|
scheduled_exits: list[tuple[float, int]] = []
|
|
original_flag = modal_cloud_module._CONTAINER_TERMINATION_SCHEDULED
|
|
monkeypatch.setattr(modal_cloud_module, "_CONTAINER_TERMINATION_SCHEDULED", False)
|
|
monkeypatch.setattr(modal_cloud_module, "_is_modal_container_runtime", lambda: True)
|
|
monkeypatch.setattr(
|
|
modal_cloud_module,
|
|
"_schedule_process_exit",
|
|
lambda delay_seconds, exit_code: scheduled_exits.append((delay_seconds, exit_code)),
|
|
)
|
|
try:
|
|
scheduled = modal_cloud_module._maybe_schedule_container_termination_on_error(
|
|
{"component_id": "llm-1", "terminate_container_on_error": True},
|
|
RuntimeError("[comfy-modal-llm-memory-recovery-exhausted] still low"),
|
|
)
|
|
finally:
|
|
monkeypatch.setattr(modal_cloud_module, "_CONTAINER_TERMINATION_SCHEDULED", original_flag)
|
|
|
|
assert scheduled is True
|
|
assert scheduled_exits == [(modal_cloud_module._REMOTE_ERROR_CONTAINER_EXIT_DELAY_SECONDS, 1)]
|
|
|
|
def test_modal_cloud_raises_after_exhausting_open_file_reload_retries(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Modal volume reload should surface persistent open-file errors after bounded retries."""
|
|
|
|
class FakeVolume:
|
|
"""Simple Modal volume double that always fails with open files."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the reload attempt counter."""
|
|
self.reload_calls = 0
|
|
|
|
def reload(self) -> None:
|
|
"""Always fail with the same open-file reload error."""
|
|
self.reload_calls += 1
|
|
raise RuntimeError("there are open files preventing the operation")
|
|
|
|
prepare_calls: list[str] = []
|
|
sleep_calls: list[float] = []
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_prepare_for_modal_volume_reload",
|
|
lambda: prepare_calls.append("prepared"),
|
|
)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_sleep_before_modal_volume_reload_retry",
|
|
lambda delay_seconds: sleep_calls.append(delay_seconds),
|
|
)
|
|
|
|
volume = FakeVolume()
|
|
with pytest.raises(RuntimeError, match="open files"):
|
|
modal_cloud_module._reload_modal_volume_for_request(volume, "component-1")
|
|
|
|
assert volume.reload_calls == len(
|
|
modal_cloud_module._MODAL_VOLUME_RELOAD_OPEN_FILE_RETRY_DELAYS_SECONDS
|
|
)
|
|
assert prepare_calls == ["prepared"] * (
|
|
len(modal_cloud_module._MODAL_VOLUME_RELOAD_OPEN_FILE_RETRY_DELAYS_SECONDS) - 1
|
|
)
|
|
assert sleep_calls == list(
|
|
modal_cloud_module._MODAL_VOLUME_RELOAD_OPEN_FILE_RETRY_DELAYS_SECONDS[1:]
|
|
)
|
|
|
|
def test_modal_cloud_proceeds_when_referenced_volume_paths_are_already_visible(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Persistent open-file reload errors may be ignored when the payload's mounted files are already visible."""
|
|
|
|
class FakeVolume:
|
|
"""Simple Modal volume double that always fails with open files."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the reload attempt counter."""
|
|
self.reload_calls = 0
|
|
|
|
def reload(self) -> None:
|
|
"""Always fail with the same open-file reload error."""
|
|
self.reload_calls += 1
|
|
raise RuntimeError("there are open files preventing the operation")
|
|
|
|
storage_root = tmp_path / "storage"
|
|
storage_root.mkdir()
|
|
asset_path = storage_root / "assets" / "hash_model.safetensors"
|
|
asset_path.parent.mkdir(parents=True, exist_ok=True)
|
|
asset_path.write_bytes(b"weights")
|
|
bundle_path = storage_root / "custom_nodes" / "hash_bundle.zip"
|
|
bundle_path.parent.mkdir(parents=True, exist_ok=True)
|
|
bundle_path.write_bytes(b"bundle")
|
|
|
|
recorded_markers: list[str] = []
|
|
prepare_calls: list[str] = []
|
|
sleep_calls: list[float] = []
|
|
_patch_cloud_storage_root(monkeypatch, modal_cloud_module, storage_root)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_prepare_for_modal_volume_reload",
|
|
lambda: prepare_calls.append("prepared"),
|
|
)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_sleep_before_modal_volume_reload_retry",
|
|
lambda delay_seconds: sleep_calls.append(delay_seconds),
|
|
)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_record_modal_volume_reload_marker",
|
|
lambda marker: recorded_markers.append(marker),
|
|
)
|
|
|
|
payload = {
|
|
"custom_nodes_bundle": "/custom_nodes/hash_bundle.zip",
|
|
"subgraph_prompt": {
|
|
"1": {
|
|
"class_type": "CheckpointLoaderSimple",
|
|
"inputs": {"ckpt_name": "/assets/hash_model.safetensors"},
|
|
}
|
|
},
|
|
}
|
|
|
|
volume = FakeVolume()
|
|
modal_cloud_module._reload_modal_volume_for_request(
|
|
volume,
|
|
"component-1",
|
|
reload_marker="marker-1",
|
|
payload=payload,
|
|
)
|
|
|
|
assert volume.reload_calls == len(
|
|
modal_cloud_module._MODAL_VOLUME_RELOAD_OPEN_FILE_RETRY_DELAYS_SECONDS
|
|
)
|
|
assert recorded_markers == ["marker-1"]
|
|
assert prepare_calls == ["prepared"] * (
|
|
len(modal_cloud_module._MODAL_VOLUME_RELOAD_OPEN_FILE_RETRY_DELAYS_SECONDS) - 1
|
|
)
|
|
assert sleep_calls == list(
|
|
modal_cloud_module._MODAL_VOLUME_RELOAD_OPEN_FILE_RETRY_DELAYS_SECONDS[1:]
|
|
)
|
|
|
|
def test_modal_cloud_logs_volume_reload_diagnostics_for_open_file_retries(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Open-file retries should log which uploaded and referenced volume paths matter."""
|
|
|
|
class FakeVolume:
|
|
"""Simple Modal volume double that always fails with open files."""
|
|
|
|
def reload(self) -> None:
|
|
"""Always fail with the same open-file reload error."""
|
|
raise RuntimeError("there are open files preventing the operation")
|
|
|
|
storage_root = tmp_path / "storage"
|
|
storage_root.mkdir()
|
|
uploaded_path = storage_root / "assets" / "missing_model.safetensors"
|
|
logged_messages: list[tuple[str, tuple[Any, ...]]] = []
|
|
|
|
_patch_cloud_storage_root(monkeypatch, modal_cloud_module, storage_root)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_prepare_for_modal_volume_reload",
|
|
lambda: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner(),
|
|
"_sleep_before_modal_volume_reload_retry",
|
|
lambda delay_seconds: None,
|
|
)
|
|
monkeypatch.setattr(
|
|
_cloud_volume_reload_owner().logger,
|
|
"info",
|
|
lambda message, *args: logged_messages.append((message, args)),
|
|
)
|
|
|
|
payload = {
|
|
"uploaded_volume_paths": ["/assets/missing_model.safetensors"],
|
|
"subgraph_prompt": {
|
|
"1": {
|
|
"class_type": "CheckpointLoaderSimple",
|
|
"inputs": {"ckpt_name": "/assets/missing_model.safetensors"},
|
|
}
|
|
},
|
|
}
|
|
|
|
with pytest.raises(RuntimeError, match="open files"):
|
|
modal_cloud_module._reload_modal_volume_for_request(
|
|
FakeVolume(),
|
|
"component-1",
|
|
payload=payload,
|
|
)
|
|
|
|
assert any(
|
|
"Modal volume reload diagnostics for component=%s context=%s uploaded_paths=%s referenced_paths=%s visible_uploaded=%s visible_referenced=%s."
|
|
in message
|
|
and args[0] == "component-1"
|
|
and args[1] == "open_files_retry"
|
|
and args[2] == [str(uploaded_path)]
|
|
and args[3] == [str(uploaded_path)]
|
|
and args[4] is False
|
|
and args[5] is False
|
|
for message, args in logged_messages
|
|
)
|
|
|
|
def test_modal_cloud_ignores_heavy_comfyui_paths(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The Modal cloud module should skip heavyweight ComfyUI runtime artifacts."""
|
|
from pathlib import Path
|
|
|
|
assert modal_cloud_module._should_ignore_comfyui_path(Path("models/checkpoint.safetensors"))
|
|
assert modal_cloud_module._should_ignore_comfyui_path(Path("custom_nodes/example/__init__.py"))
|
|
assert modal_cloud_module._should_ignore_comfyui_path(Path("output/run/output.png"))
|
|
assert modal_cloud_module._should_ignore_comfyui_path(
|
|
Path(".cache/strings/acfca22bde9a1a1fee53fe6e1299f4fe54a78a6f1d306dbb6cac2e71cf35d2c2.txt")
|
|
)
|
|
assert modal_cloud_module._should_ignore_comfyui_path(Path("__pycache__/execution.pyc"))
|
|
assert modal_cloud_module._should_ignore_comfyui_path(Path("False/checkpoints/model.pth"))
|
|
assert modal_cloud_module._should_ignore_comfyui_path(Path("tests/test_execution.py"))
|
|
assert modal_cloud_module._should_ignore_comfyui_path(Path("unexpected/code.py"))
|
|
assert not modal_cloud_module._should_ignore_comfyui_path(Path("execution.py"))
|
|
assert not modal_cloud_module._should_ignore_comfyui_path(Path("requirements.txt"))
|
|
assert not modal_cloud_module._should_ignore_comfyui_path(Path("comfy/model_management.py"))
|
|
assert not modal_cloud_module._should_ignore_comfyui_path(Path("comfy/ldm/models/diffusion/ddpm.py"))
|
|
|
|
def test_modal_cloud_installs_comfyui_runtime_packages(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The Modal cloud image should include the core packages ComfyUI imports at runtime."""
|
|
assert modal_cloud_module._comfyui_apt_packages() == (
|
|
"build-essential",
|
|
"libgl1",
|
|
"libglib2.0-0",
|
|
)
|
|
|
|
packages = modal_cloud_module._comfyui_runtime_packages()
|
|
package_names = {package.split("==", maxsplit=1)[0] for package in packages}
|
|
|
|
assert all("==" in package for package in packages)
|
|
assert {
|
|
"aiohttp",
|
|
"alembic",
|
|
"blake3",
|
|
"comfy-angle",
|
|
"comfy-aimdo",
|
|
"comfy-kitchen",
|
|
"hf-xet",
|
|
"huggingface-hub",
|
|
"kornia",
|
|
"opencv-python-headless",
|
|
"pydantic-settings",
|
|
"psutil",
|
|
"pyopengl",
|
|
"sentencepiece",
|
|
"simpleeval",
|
|
"spandrel",
|
|
"torchsde",
|
|
"transformers",
|
|
} <= package_names
|
|
|
|
def test_modal_cloud_selects_gpu_compatible_pytorch_stack(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Every Modal GPU image should expose the shared pinned PyTorch build."""
|
|
default_build = modal_cloud_module._select_remote_torch_build("A100")
|
|
b300_build = modal_cloud_module._select_remote_torch_build("B300")
|
|
|
|
assert default_build.install_layers[0].packages == (
|
|
"torch==2.13.0",
|
|
"torchvision==0.28.0",
|
|
)
|
|
assert default_build.install_layers[0].index_url == "https://download.pytorch.org/whl/cu130"
|
|
assert default_build == b300_build
|
|
assert b300_build.install_layers[0].packages == (
|
|
"torch==2.13.0",
|
|
"torchvision==0.28.0",
|
|
)
|
|
assert b300_build.install_layers[0].index_url == "https://download.pytorch.org/whl/cu130"
|
|
assert b300_build.install_layers[1].packages == ("torchaudio==2.11.0+cpu",)
|
|
assert b300_build.install_layers[1].index_url == "https://download.pytorch.org/whl/cpu"
|
|
assert b300_build.install_layers[1].extra_options == "--no-deps"
|
|
|
|
def test_modal_cloud_installs_and_validates_torch_layers_in_order(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The CPU TorchAudio layer must not replace the CUDA-enabled Torch package."""
|
|
|
|
class RecordingImage:
|
|
"""Record Modal image package and command layers."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty call record."""
|
|
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
|
|
|
def pip_install(self, *packages: str, **options: Any) -> "RecordingImage":
|
|
"""Record one package installation layer."""
|
|
self.calls.append(("pip_install", packages, options))
|
|
return self
|
|
|
|
def run_commands(self, *commands: str) -> "RecordingImage":
|
|
"""Record one image validation command."""
|
|
self.calls.append(("run_commands", commands, {}))
|
|
return self
|
|
|
|
build = modal_cloud_module._select_remote_torch_build("B300")
|
|
image = RecordingImage()
|
|
|
|
result = modal_cloud_module._install_remote_torch_build(image, build)
|
|
|
|
assert result is image
|
|
assert image.calls[:2] == [
|
|
(
|
|
"pip_install",
|
|
("torch==2.13.0", "torchvision==0.28.0"),
|
|
{
|
|
"index_url": "https://download.pytorch.org/whl/cu130",
|
|
"extra_options": "",
|
|
},
|
|
),
|
|
(
|
|
"pip_install",
|
|
("torchaudio==2.11.0+cpu",),
|
|
{
|
|
"index_url": "https://download.pytorch.org/whl/cpu",
|
|
"extra_options": "--no-deps",
|
|
},
|
|
),
|
|
]
|
|
assert image.calls[2][0] == "run_commands"
|
|
assert "import torch, torchaudio, torchvision" in image.calls[2][1][0]
|
|
|
|
def test_modal_cloud_installs_and_validates_vllm_for_non_b300_gpu(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""A lower-cost GPU image should install and import the pinned vLLM wheel."""
|
|
|
|
class RecordingImage:
|
|
"""Record Modal image package and command layers."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize an empty call record."""
|
|
self.calls: list[tuple[str, tuple[Any, ...], dict[str, Any]]] = []
|
|
|
|
def pip_install(self, *packages: str, **options: Any) -> "RecordingImage":
|
|
"""Record one package installation layer."""
|
|
self.calls.append(("pip_install", packages, options))
|
|
return self
|
|
|
|
def run_commands(self, *commands: str) -> "RecordingImage":
|
|
"""Record one image validation command."""
|
|
self.calls.append(("run_commands", commands, {}))
|
|
return self
|
|
|
|
image = RecordingImage()
|
|
|
|
result = modal_cloud_module._install_remote_accelerator_packages(
|
|
image,
|
|
"RTX-PRO-6000",
|
|
)
|
|
|
|
assert result is image
|
|
assert image.calls[0] == (
|
|
"pip_install",
|
|
(modal_cloud_module._remote_accelerator_packages("RTX-PRO-6000")[0],),
|
|
{},
|
|
)
|
|
assert image.calls[1][0] == "run_commands"
|
|
assert "import cv2, numpy, torch, vllm" in image.calls[1][1][0]
|
|
|
|
def test_modal_cloud_missing_prompt_node_class_raises_clear_error(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Missing custom-node classes should fail before ComfyUI cache setup raises KeyError."""
|
|
async def fake_init_external_custom_nodes() -> None:
|
|
"""Leave the missing custom node unregistered."""
|
|
|
|
fake_nodes_module = types.SimpleNamespace(
|
|
NODE_CLASS_MAPPINGS={"KnownNode": object},
|
|
init_external_custom_nodes=fake_init_external_custom_nodes,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "nodes", fake_nodes_module)
|
|
custom_nodes_root = tmp_path / "custom_nodes"
|
|
package_dir = custom_nodes_root / "Skoogeer-Noise" / "src"
|
|
package_dir.mkdir(parents=True)
|
|
(package_dir / "qwen_noise_nodes.py").write_text(
|
|
"class KSamplerLoraSigmaInverse:\n pass\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(modal_cloud_module.RemoteSubgraphExecutionError) as exc_info:
|
|
modal_cloud_module._ensure_prompt_node_classes_registered(
|
|
component_id="component-1",
|
|
prompt={
|
|
"1": {"class_type": "KnownNode", "inputs": {}},
|
|
"2": {"class_type": "KSamplerLoraSigmaInverse", "inputs": {}},
|
|
},
|
|
custom_nodes_root=custom_nodes_root,
|
|
)
|
|
|
|
message = str(exc_info.value)
|
|
assert "KSamplerLoraSigmaInverse" in message
|
|
assert "custom-node sync is enabled" in message
|
|
assert "Skoogeer-Noise/src/qwen_noise_nodes.py" in message
|
|
assert "package=Skoogeer-Noise" in message
|
|
|
|
def test_modal_cloud_existing_app_guard_uses_non_creating_sdk_lookup(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Deploy-time app construction should fail if the configured Modal app already exists."""
|
|
|
|
class FakeNotFoundError(Exception):
|
|
"""Stand-in for Modal app lookup misses."""
|
|
|
|
lookup_calls: list[tuple[str, bool]] = []
|
|
|
|
class FakeApp:
|
|
"""Minimal Modal App namespace with non-creating lookup support."""
|
|
|
|
@staticmethod
|
|
def lookup(app_name: str, create_if_missing: bool = True) -> object:
|
|
"""Record lookup arguments and return an existing app."""
|
|
lookup_calls.append((app_name, create_if_missing))
|
|
return object()
|
|
|
|
fake_modal = types.SimpleNamespace(
|
|
App=FakeApp,
|
|
exception=types.SimpleNamespace(NotFoundError=FakeNotFoundError),
|
|
is_local=lambda: True,
|
|
)
|
|
settings = types.SimpleNamespace(app_name="comfy-modal-sync")
|
|
|
|
with pytest.raises(modal_cloud_module.ExistingModalAppError) as exc_info:
|
|
modal_cloud_module._guard_against_existing_modal_app(settings, fake_modal)
|
|
|
|
assert lookup_calls == [(DEFAULT_TEST_DEPLOYMENT_APP_NAME, False)]
|
|
assert "Delete the existing app" in str(exc_info.value)
|
|
assert "COMFY_MODAL_GPU" in str(exc_info.value)
|
|
|
|
def test_modal_cloud_existing_app_guard_allows_missing_sdk_lookup(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""A missing Modal app should not block first-run app construction."""
|
|
|
|
class FakeNotFoundError(Exception):
|
|
"""Stand-in for Modal app lookup misses."""
|
|
|
|
class FakeApp:
|
|
"""Minimal Modal App namespace that reports a missing app."""
|
|
|
|
@staticmethod
|
|
def lookup(app_name: str, create_if_missing: bool = True) -> object:
|
|
"""Raise the SDK's not-found error without creating the app."""
|
|
del app_name, create_if_missing
|
|
raise FakeNotFoundError("app not found")
|
|
|
|
fake_modal = types.SimpleNamespace(
|
|
App=FakeApp,
|
|
exception=types.SimpleNamespace(NotFoundError=FakeNotFoundError),
|
|
is_local=lambda: True,
|
|
)
|
|
|
|
modal_cloud_module._guard_against_existing_modal_app(
|
|
types.SimpleNamespace(app_name="comfy-modal-sync"),
|
|
fake_modal,
|
|
)
|
|
|
|
def test_modal_cloud_existing_app_guard_falls_back_to_cli_json(
|
|
modal_cloud_module: Any,
|
|
cloud_app_guard_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""SDKs without a non-creating lookup should fall back to `modal app list --json`."""
|
|
|
|
class FakeApp:
|
|
"""Modal App namespace whose lookup signature cannot safely check existence."""
|
|
|
|
@staticmethod
|
|
def lookup(app_name: str) -> object:
|
|
"""This must not be called because it may create the app."""
|
|
raise AssertionError(f"unsafe lookup called for {app_name}")
|
|
|
|
completed = subprocess.CompletedProcess(
|
|
args=["modal", "app", "list", "--json"],
|
|
returncode=0,
|
|
stdout=json.dumps([{"name": "other"}, {"name": DEFAULT_TEST_DEPLOYMENT_APP_NAME}]),
|
|
stderr="",
|
|
)
|
|
observed_commands: list[list[str]] = []
|
|
|
|
def fake_run(command: list[str], **kwargs: Any) -> subprocess.CompletedProcess[str]:
|
|
"""Return a deterministic Modal CLI app list response."""
|
|
del kwargs
|
|
observed_commands.append(command)
|
|
return completed
|
|
|
|
monkeypatch.setattr(
|
|
cloud_app_guard_module.shutil, "which", lambda name: f"/usr/bin/{name}"
|
|
)
|
|
monkeypatch.setattr(cloud_app_guard_module.subprocess, "run", fake_run)
|
|
fake_modal = types.SimpleNamespace(App=FakeApp, exception=types.SimpleNamespace(), is_local=lambda: True)
|
|
|
|
with pytest.raises(modal_cloud_module.ExistingModalAppError):
|
|
modal_cloud_module._guard_against_existing_modal_app(
|
|
types.SimpleNamespace(app_name="comfy-modal-sync"),
|
|
fake_modal,
|
|
)
|
|
|
|
assert observed_commands == [["/usr/bin/modal", "app", "list", "--json"]]
|
|
|
|
def test_modal_cloud_llm_compile_profiles_are_limited_to_executable_subgraph(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Nested metadata and disconnected LLM nodes must not trigger cache commits."""
|
|
payload = {
|
|
"payload_kind": "subgraph",
|
|
"execute_node_ids": ["3"],
|
|
"subgraph_prompt": {
|
|
"1": {
|
|
"class_type": "ModalLLM",
|
|
"inputs": {"model_profile": "executed-profile"},
|
|
},
|
|
"2": {
|
|
"class_type": "ModalLLM",
|
|
"inputs": {"model_profile": "disconnected-profile"},
|
|
},
|
|
"3": {
|
|
"class_type": "PreviewText",
|
|
"inputs": {"text": ["1", 0]},
|
|
},
|
|
},
|
|
"boundary_inputs": [
|
|
{
|
|
"source_signature": {
|
|
"class_type": "ModalLLM",
|
|
"inputs": {"model_profile": "metadata-profile"},
|
|
}
|
|
}
|
|
],
|
|
}
|
|
|
|
assert modal_cloud_module._llm_profiles_in_payload(payload) == (
|
|
"executed-profile",
|
|
)
|
|
assert (
|
|
modal_cloud_module._llm_profiles_in_payload(
|
|
{**payload, "payload_kind": "canary"}
|
|
)
|
|
== ()
|
|
)
|
|
|
|
def test_load_modal_cloud_module_reloads_stale_partial_module(
|
|
remote_modal_app_module: Any,
|
|
modal_deployment_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Stale partially imported cloud modules should be discarded and reloaded."""
|
|
original_module = sys.modules.get(modal_deployment_module._MODAL_CLOUD_MODULE_NAME)
|
|
stale_module = types.SimpleNamespace(app=None)
|
|
sys.modules[modal_deployment_module._MODAL_CLOUD_MODULE_NAME] = stale_module
|
|
|
|
loaded_module = types.SimpleNamespace(app="fresh-app")
|
|
|
|
class FakeLoader:
|
|
"""Populate the fresh replacement module during exec."""
|
|
|
|
def create_module(self, spec: Any) -> None:
|
|
"""Use the default module creation path."""
|
|
del spec
|
|
return None
|
|
|
|
def exec_module(self, module: Any) -> None:
|
|
"""Install the expected deployable app onto the reloaded module."""
|
|
module.app = loaded_module.app
|
|
|
|
monkeypatch.setattr(
|
|
modal_deployment_module.importlib.util,
|
|
"spec_from_file_location",
|
|
lambda *args, **kwargs: importlib.util.spec_from_loader(
|
|
modal_deployment_module._MODAL_CLOUD_MODULE_NAME,
|
|
FakeLoader(),
|
|
),
|
|
)
|
|
try:
|
|
reloaded_module = modal_deployment_module._load_modal_cloud_module()
|
|
finally:
|
|
sys.modules.pop(modal_deployment_module._MODAL_CLOUD_MODULE_NAME, None)
|
|
if original_module is not None:
|
|
sys.modules[modal_deployment_module._MODAL_CLOUD_MODULE_NAME] = original_module
|
|
|
|
assert reloaded_module is not stale_module
|
|
assert getattr(reloaded_module, "app", None) == "fresh-app"
|
|
|
|
def test_load_modal_cloud_module_reloads_for_workflow_gpu_change(
|
|
remote_modal_app_module: Any,
|
|
modal_deployment_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Cloud app construction should receive the workflow-selected GPU settings."""
|
|
module_name = modal_deployment_module._MODAL_CLOUD_MODULE_NAME
|
|
original_module = sys.modules.get(module_name)
|
|
sys.modules[module_name] = types.SimpleNamespace(
|
|
app="a100-app",
|
|
__comfy_modal_gpu__="A100",
|
|
)
|
|
observed_gpu_values: list[str] = []
|
|
|
|
class FakeLoader:
|
|
"""Populate a replacement cloud module from its injected settings."""
|
|
|
|
def create_module(self, spec: Any) -> None:
|
|
"""Use the default module creation path."""
|
|
del spec
|
|
return None
|
|
|
|
def exec_module(self, module: Any) -> None:
|
|
"""Capture the settings override and expose a deployable app."""
|
|
settings_override = module.__comfy_modal_settings_override__
|
|
observed_gpu_values.append(settings_override.modal_gpu)
|
|
module.app = "b300-app"
|
|
|
|
monkeypatch.setattr(
|
|
modal_deployment_module.importlib.util,
|
|
"spec_from_file_location",
|
|
lambda *args, **kwargs: importlib.util.spec_from_loader(module_name, FakeLoader()),
|
|
)
|
|
settings = remote_modal_app_module.settings_for_modal_gpu(
|
|
remote_modal_app_module.get_settings(),
|
|
"B300",
|
|
)
|
|
try:
|
|
with modal_deployment_module._modal_cloud_settings_override(settings):
|
|
reloaded_module = modal_deployment_module._load_modal_cloud_module()
|
|
finally:
|
|
sys.modules.pop(module_name, None)
|
|
if original_module is not None:
|
|
sys.modules[module_name] = original_module
|
|
|
|
assert observed_gpu_values == ["B300"]
|
|
assert reloaded_module.app == "b300-app"
|
|
assert reloaded_module.__comfy_modal_gpu__ == "B300"
|
|
assert reloaded_module.__comfy_modal_app_name__ == "comfy-modal-sync-gpu-b300"
|
|
|
|
def test_load_modal_cloud_module_reloads_for_secret_name_change(
|
|
remote_modal_app_module: Any,
|
|
modal_deployment_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Changing only the secret name should rebuild the cloud module with new settings."""
|
|
module_name = modal_deployment_module._MODAL_CLOUD_MODULE_NAME
|
|
original_module = sys.modules.get(module_name)
|
|
base_settings = remote_modal_app_module.get_settings()
|
|
sys.modules[module_name] = types.SimpleNamespace(
|
|
app="old-secret-app",
|
|
__comfy_modal_gpu__=base_settings.modal_gpu,
|
|
__comfy_modal_app_name__=remote_modal_app_module.modal_deployment_app_name(
|
|
base_settings
|
|
),
|
|
__comfy_modal_secret_name__="comfy",
|
|
)
|
|
observed_secret_names: list[str] = []
|
|
|
|
class FakeLoader:
|
|
"""Populate a replacement cloud module from its injected settings."""
|
|
|
|
def create_module(self, spec: Any) -> None:
|
|
"""Use the default module creation path."""
|
|
del spec
|
|
return None
|
|
|
|
def exec_module(self, module: Any) -> None:
|
|
"""Capture the secret setting and expose a deployable app."""
|
|
settings_override = module.__comfy_modal_settings_override__
|
|
observed_secret_names.append(settings_override.modal_secret_name)
|
|
module.app = "new-secret-app"
|
|
|
|
monkeypatch.setattr(
|
|
modal_deployment_module.importlib.util,
|
|
"spec_from_file_location",
|
|
lambda *args, **kwargs: importlib.util.spec_from_loader(module_name, FakeLoader()),
|
|
)
|
|
new_settings = replace(base_settings, modal_secret_name="workflow-credentials")
|
|
try:
|
|
with modal_deployment_module._modal_cloud_settings_override(new_settings):
|
|
reloaded_module = modal_deployment_module._load_modal_cloud_module()
|
|
finally:
|
|
sys.modules.pop(module_name, None)
|
|
if original_module is not None:
|
|
sys.modules[module_name] = original_module
|
|
|
|
assert observed_secret_names == ["workflow-credentials"]
|
|
assert reloaded_module.app == "new-secret-app"
|
|
assert reloaded_module.__comfy_modal_secret_name__ == "workflow-credentials"
|
|
|
|
def test_remote_modal_installs_cloud_exception_compatibility_module(
|
|
remote_modal_app_module: Any,
|
|
modal_deployment_module: Any,
|
|
) -> None:
|
|
"""Deployed remote exceptions should have importable local definitions."""
|
|
module_name = modal_deployment_module._MODAL_CLOUD_MODULE_NAME
|
|
original_module = sys.modules.pop(module_name, None)
|
|
serialization_module = types.ModuleType(module_name)
|
|
serialization_module.RemoteSubgraphExecutionError = type(
|
|
"RemoteSubgraphExecutionError",
|
|
(RuntimeError,),
|
|
{"__module__": module_name},
|
|
)
|
|
sys.modules[module_name] = serialization_module
|
|
serialized_error = pickle.dumps(
|
|
serialization_module.RemoteSubgraphExecutionError("remote failure")
|
|
)
|
|
sys.modules.pop(module_name)
|
|
try:
|
|
modal_deployment_module._install_modal_cloud_exception_compatibility_module()
|
|
|
|
compatibility_module = sys.modules[module_name]
|
|
assert (
|
|
compatibility_module.RemoteSubgraphExecutionError
|
|
is remote_modal_app_module.RemoteSubgraphExecutionError
|
|
)
|
|
assert issubclass(compatibility_module.RemoteInvocationAbandonedError, RuntimeError)
|
|
restored_error = pickle.loads(serialized_error)
|
|
assert isinstance(restored_error, remote_modal_app_module.RemoteSubgraphExecutionError)
|
|
assert str(restored_error) == "remote failure"
|
|
finally:
|
|
sys.modules.pop(module_name, None)
|
|
if original_module is not None:
|
|
sys.modules[module_name] = original_module
|
|
|
|
def test_load_modal_cloud_module_clears_failed_import_from_sys_modules(
|
|
remote_modal_app_module: Any,
|
|
modal_deployment_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Failed cloud-module imports should not leave a poisoned cache entry behind."""
|
|
original_module = sys.modules.pop(
|
|
modal_deployment_module._MODAL_CLOUD_MODULE_NAME,
|
|
None,
|
|
)
|
|
|
|
class FakeLoader:
|
|
"""Raise during module execution to simulate a partial import failure."""
|
|
|
|
def create_module(self, spec: Any) -> None:
|
|
"""Use the default module creation path."""
|
|
del spec
|
|
return None
|
|
|
|
def exec_module(self, module: Any) -> None:
|
|
"""Fail while the module is being initialized."""
|
|
module.app = None
|
|
raise RuntimeError("boom")
|
|
|
|
monkeypatch.setattr(
|
|
modal_deployment_module.importlib.util,
|
|
"spec_from_file_location",
|
|
lambda *args, **kwargs: importlib.util.spec_from_loader(
|
|
modal_deployment_module._MODAL_CLOUD_MODULE_NAME,
|
|
FakeLoader(),
|
|
),
|
|
)
|
|
try:
|
|
with pytest.raises(RuntimeError, match="boom"):
|
|
modal_deployment_module._load_modal_cloud_module()
|
|
assert modal_deployment_module._MODAL_CLOUD_MODULE_NAME not in sys.modules
|
|
finally:
|
|
sys.modules.pop(modal_deployment_module._MODAL_CLOUD_MODULE_NAME, None)
|
|
if original_module is not None:
|
|
sys.modules[modal_deployment_module._MODAL_CLOUD_MODULE_NAME] = original_module
|
|
|
|
def test_modal_cloud_tracing_prompt_server_emits_executed_outputs(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The cloud tracing prompt server should stream node UI outputs as executed events."""
|
|
observed_updates: list[dict[str, Any]] = []
|
|
server = modal_cloud_module._TracingPromptServer(
|
|
"component-1",
|
|
{"7": {"class_type": "PreviewImage", "inputs": {}}},
|
|
status_callback=observed_updates.append,
|
|
)
|
|
|
|
server.send_sync(
|
|
"executed",
|
|
{
|
|
"node": "7",
|
|
"display_node": "7",
|
|
"output": {"images": [{"filename": "preview.png"}]},
|
|
},
|
|
None,
|
|
)
|
|
|
|
assert observed_updates == [
|
|
{
|
|
"event_type": "executed",
|
|
"node_id": "7",
|
|
"display_node_id": "7",
|
|
"output": {"images": [{"filename": "preview.png"}]},
|
|
}
|
|
]
|
|
|
|
def test_modal_cloud_tracing_prompt_server_emits_boundary_image_outputs(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The cloud tracing prompt server should stream configured boundary IMAGE outputs once cached."""
|
|
torch = pytest.importorskip("torch")
|
|
image_tensor = torch.zeros((1, 4, 4, 3), dtype=torch.float32)
|
|
observed_updates: list[dict[str, Any]] = []
|
|
server = modal_cloud_module._TracingPromptServer(
|
|
"component-1",
|
|
{
|
|
"7": {"class_type": "VAEDecode", "inputs": {}},
|
|
"8": {"class_type": "OtherNode", "inputs": {}},
|
|
},
|
|
status_callback=observed_updates.append,
|
|
)
|
|
cache_entries = {
|
|
"7": types.SimpleNamespace(outputs=[image_tensor]),
|
|
}
|
|
server.configure_boundary_output_stream(
|
|
boundary_outputs=[
|
|
{
|
|
"node_id": "7",
|
|
"output_index": 0,
|
|
"io_type": "IMAGE",
|
|
"is_list": False,
|
|
"preview_target_node_ids": ["9"],
|
|
}
|
|
],
|
|
lookup_cache_entry=lambda node_id: cache_entries.get(node_id),
|
|
)
|
|
|
|
server.send_sync("executing", {"node": "7"}, None)
|
|
server.send_sync("executing", {"node": "8"}, None)
|
|
|
|
assert observed_updates[0]["phase"] == "executing"
|
|
assert observed_updates[1] == {
|
|
"event_type": "boundary_output",
|
|
"node_id": "7",
|
|
"output_index": 0,
|
|
"io_type": "IMAGE",
|
|
"is_list": False,
|
|
"preview_target_node_ids": ["9"],
|
|
"value": image_tensor,
|
|
}
|
|
assert observed_updates[2]["phase"] == "executing"
|
|
|
|
@pytest.mark.parametrize(
|
|
("module_fixture_name",),
|
|
[
|
|
("local_execution_module",),
|
|
("modal_cloud_module",),
|
|
],
|
|
)
|
|
def test_trim_subgraph_payload_to_required_nodes_drops_stale_execute_targets(
|
|
request: Any,
|
|
module_fixture_name: str,
|
|
) -> None:
|
|
"""Trimmed subgraph payloads should ignore execute targets that are absent from the current prompt."""
|
|
target_module = request.getfixturevalue(module_fixture_name)
|
|
payload = {
|
|
"component_id": "1::static",
|
|
"component_node_ids": ["1", "3"],
|
|
"subgraph_prompt": {
|
|
"1": {"class_type": "LoadDiffusionModel", "inputs": {}},
|
|
"3": {"class_type": "KSampler", "inputs": {"model": ["1", 0], "steps": 20}},
|
|
},
|
|
"boundary_inputs": [],
|
|
"boundary_outputs": [
|
|
{"node_id": "3", "output_index": 0, "io_type": "LATENT", "is_list": False},
|
|
],
|
|
"execute_node_ids": ["3", "5"],
|
|
"mapped_execute_node_ids": [],
|
|
"static_execute_node_ids": ["3", "5"],
|
|
}
|
|
|
|
trimmed_payload = target_module._trim_subgraph_payload_to_required_nodes(payload)
|
|
|
|
assert trimmed_payload["component_node_ids"] == ["1", "3"]
|
|
assert list(trimmed_payload["subgraph_prompt"].keys()) == ["1", "3"]
|
|
assert trimmed_payload["execute_node_ids"] == ["3"]
|
|
assert trimmed_payload["mapped_execute_node_ids"] == []
|
|
assert trimmed_payload["static_execute_node_ids"] == ["3"]
|
|
|
|
def test_modal_cloud_uses_current_comfy_explicit_ram_pressure_thresholds(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Two current cache-ram values should flow into active and inactive thresholds."""
|
|
fake_args = types.SimpleNamespace(
|
|
cache_classic=False,
|
|
cache_lru=0,
|
|
cache_ram=[3.0, 20.0],
|
|
cache_none=False,
|
|
)
|
|
fake_cli_args_module = types.SimpleNamespace(args=fake_args)
|
|
fake_model_management_module = types.SimpleNamespace(total_ram=64 * 1024)
|
|
fake_execution_module = types.SimpleNamespace(
|
|
CacheType=types.SimpleNamespace(
|
|
CLASSIC="classic",
|
|
LRU="lru",
|
|
RAM_PRESSURE="ram-pressure",
|
|
NONE="none",
|
|
)
|
|
)
|
|
monkeypatch.setitem(sys.modules, "comfy.cli_args", fake_cli_args_module)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"comfy.model_management",
|
|
fake_model_management_module,
|
|
)
|
|
|
|
cache_type, cache_args = modal_cloud_module._prompt_executor_cache_config(fake_execution_module)
|
|
|
|
assert cache_type == "ram-pressure"
|
|
assert cache_args == {"lru": 0, "ram": 3.0, "ram_inactive": 20.0}
|
|
|
|
def test_modal_cloud_awaits_async_prompt_executor_api(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""An asynchronous PromptExecutor compatibility API should finish before cache reads."""
|
|
observed_calls: list[tuple[dict[str, Any], str, dict[str, Any], list[str]]] = []
|
|
|
|
class FakeExecutor:
|
|
"""Minimal executor exposing a coroutine-based execute method."""
|
|
|
|
async def execute(
|
|
self,
|
|
*,
|
|
prompt: dict[str, Any],
|
|
prompt_id: str,
|
|
extra_data: dict[str, Any],
|
|
execute_outputs: list[str],
|
|
) -> None:
|
|
"""Record execution after yielding once."""
|
|
await asyncio.sleep(0)
|
|
observed_calls.append((prompt, prompt_id, extra_data, execute_outputs))
|
|
|
|
modal_cloud_module._execute_prompt_executor_compat(
|
|
FakeExecutor(),
|
|
prompt={"1": {"class_type": "Example", "inputs": {}}},
|
|
prompt_id="prompt-1",
|
|
extra_data={"client_id": "client-1"},
|
|
execute_outputs=["1"],
|
|
)
|
|
|
|
assert observed_calls == [
|
|
(
|
|
{"1": {"class_type": "Example", "inputs": {}}},
|
|
"prompt-1",
|
|
{"client_id": "client-1"},
|
|
["1"],
|
|
)
|
|
]
|
|
|
|
def test_modal_cloud_class_options_do_not_use_deprecated_concurrency_flag(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""The deployed Modal class options should avoid deprecated concurrency flags."""
|
|
fake_settings = types.SimpleNamespace(
|
|
modal_gpu="A100",
|
|
remote_storage_root="/vol/data",
|
|
scaledown_window_seconds=60,
|
|
min_containers=0,
|
|
max_containers=4,
|
|
buffer_containers=1,
|
|
execution_timeout_seconds=1800,
|
|
startup_timeout_seconds=600,
|
|
enable_memory_snapshot=True,
|
|
enable_gpu_memory_snapshot=False,
|
|
)
|
|
|
|
options = modal_cloud_module._remote_engine_cls_options(
|
|
fake_settings,
|
|
vol=object(),
|
|
image=object(),
|
|
)
|
|
|
|
assert "allow_concurrent_inputs" not in options
|
|
assert options["max_containers"] == 4
|
|
assert options["buffer_containers"] == 1
|
|
assert options["timeout"] == 1800
|
|
assert options["startup_timeout"] == 600
|
|
module_source = Path(modal_cloud_module.__file__).read_text(encoding="utf-8")
|
|
assert "@modal.concurrent(max_inputs=1)" in module_source
|
|
|
|
def test_modal_cloud_registered_execution_preserves_pre_start_interrupt_flags(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Remote execution registration should not delete a valid cancel flag on entry."""
|
|
|
|
class FakeInterruptFlags:
|
|
"""Simple Modal Dict double that records cleared keys."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize captured pop calls."""
|
|
self.pop_calls: list[tuple[str, Any]] = []
|
|
|
|
def pop(self, key: str, default: Any = None) -> Any:
|
|
"""Record one cleared interrupt flag."""
|
|
self.pop_calls.append((key, default))
|
|
return None
|
|
|
|
interrupt_flags = FakeInterruptFlags()
|
|
monkeypatch.setattr(_cloud_execution_control_owner(), "modal", object())
|
|
monkeypatch.setattr(
|
|
_cloud_execution_control_owner(),
|
|
"interrupt_flag_store",
|
|
lambda: interrupt_flags,
|
|
)
|
|
|
|
with modal_cloud_module._registered_remote_execution(
|
|
{"prompt_id": "prompt-1", "component_id": "component-2"}
|
|
) as execution_control:
|
|
assert execution_control.interrupt_flag_key == "prompt-1:component-2"
|
|
assert interrupt_flags.pop_calls == []
|
|
|
|
assert interrupt_flags.pop_calls == [
|
|
("prompt-1:component-2", None),
|
|
]
|
|
|
|
def test_modal_cloud_interrupt_monitor_consumes_shared_cancel_flag(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""The remote interrupt monitor should trip when the shared Modal Dict flag appears."""
|
|
|
|
class FakeInterruptFlags:
|
|
"""Simple Modal Dict double that exposes one shared cancel flag."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the backing key set."""
|
|
self.keys = {"prompt-1:component-2"}
|
|
self.contains_calls = 0
|
|
self.pop_calls: list[tuple[str, Any]] = []
|
|
|
|
def contains(self, key: str) -> bool:
|
|
"""Report whether the shared interrupt flag exists."""
|
|
self.contains_calls += 1
|
|
return key in self.keys
|
|
|
|
def pop(self, key: str, default: Any = None) -> Any:
|
|
"""Remove the shared interrupt flag once consumed."""
|
|
self.pop_calls.append((key, default))
|
|
self.keys.discard(key)
|
|
return None
|
|
|
|
interrupt_calls: list[str] = []
|
|
cancellation_event = threading.Event()
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"nodes",
|
|
types.SimpleNamespace(interrupt_processing=lambda: interrupt_calls.append("interrupt")),
|
|
)
|
|
|
|
with modal_cloud_module._temporary_remote_interrupt_monitor(
|
|
"component-2",
|
|
cancellation_event,
|
|
interrupt_store=FakeInterruptFlags(),
|
|
interrupt_flag_key="prompt-1:component-2",
|
|
):
|
|
deadline = time.time() + 1.0
|
|
while not interrupt_calls and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
|
|
assert interrupt_calls == ["interrupt"]
|
|
assert cancellation_event.is_set()
|
|
|
|
def test_modal_cloud_interrupt_monitor_ignores_modal_client_shutdown(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""The remote interrupt monitor should exit quietly if Modal tears down the client first."""
|
|
|
|
class FakeClientClosedError(RuntimeError):
|
|
"""Stand-in for `modal.exception.ClientClosed`."""
|
|
|
|
class FakeInterruptFlags:
|
|
"""Simple Modal Dict double that fails once the client is already shutting down."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize the poll counter."""
|
|
self.contains_calls = 0
|
|
self.pop_calls: list[tuple[str, Any]] = []
|
|
|
|
def contains(self, key: str) -> bool:
|
|
"""Raise the same client-shutdown error Modal emits during teardown."""
|
|
del key
|
|
self.contains_calls += 1
|
|
raise FakeClientClosedError("client closed")
|
|
|
|
def pop(self, key: str, default: Any = None) -> Any:
|
|
"""Record unexpected cleanup attempts."""
|
|
self.pop_calls.append((key, default))
|
|
return None
|
|
|
|
interrupt_calls: list[str] = []
|
|
thread_exceptions: list[BaseException] = []
|
|
cancellation_event = threading.Event()
|
|
fake_modal_module = types.ModuleType("modal")
|
|
fake_modal_exception_module = types.ModuleType("modal.exception")
|
|
fake_modal_exception_module.ClientClosed = FakeClientClosedError
|
|
fake_modal_module.exception = fake_modal_exception_module
|
|
monkeypatch.setitem(sys.modules, "modal", fake_modal_module)
|
|
monkeypatch.setitem(sys.modules, "modal.exception", fake_modal_exception_module)
|
|
monkeypatch.setitem(
|
|
sys.modules,
|
|
"nodes",
|
|
types.SimpleNamespace(interrupt_processing=lambda: interrupt_calls.append("interrupt")),
|
|
)
|
|
monkeypatch.setattr(
|
|
threading,
|
|
"excepthook",
|
|
lambda args: thread_exceptions.append(args.exc_value),
|
|
)
|
|
interrupt_store = FakeInterruptFlags()
|
|
|
|
with modal_cloud_module._temporary_remote_interrupt_monitor(
|
|
"component-2",
|
|
cancellation_event,
|
|
interrupt_store=interrupt_store,
|
|
interrupt_flag_key="prompt-1:component-2",
|
|
):
|
|
deadline = time.time() + 1.0
|
|
while interrupt_store.contains_calls == 0 and time.time() < deadline:
|
|
time.sleep(0.01)
|
|
|
|
assert interrupt_store.contains_calls >= 1
|
|
assert interrupt_store.pop_calls == []
|
|
assert interrupt_calls == []
|
|
assert not cancellation_event.is_set()
|
|
assert thread_exceptions == []
|
|
|
|
def test_modal_cloud_serializes_only_small_transport_safe_node_outputs(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Persisted node-cache records should keep small tensor outputs and skip oversized ones."""
|
|
import torch
|
|
|
|
execution = modal_cloud_module._load_execution_module()
|
|
small_entry = execution.CacheEntry(ui=None, outputs=[[torch.zeros((8,), dtype=torch.float32)]])
|
|
large_entry = execution.CacheEntry(ui=None, outputs=[[torch.zeros((512,), dtype=torch.float32)]])
|
|
|
|
small_record = modal_cloud_module._serialize_node_output_cache_entry(
|
|
small_entry,
|
|
max_bytes=1024,
|
|
)
|
|
large_record = modal_cloud_module._serialize_node_output_cache_entry(
|
|
large_entry,
|
|
max_bytes=1024,
|
|
)
|
|
|
|
assert small_record is not None
|
|
assert large_record is None
|
|
|
|
def test_modal_cloud_materializes_synced_asset_paths(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""Remote asset references should resolve to absolute files under the storage root."""
|
|
monkeypatch.setenv("COMFY_MODAL_REMOTE_STORAGE_ROOT", "/storage")
|
|
modal_cloud_module.get_settings.cache_clear()
|
|
try:
|
|
assert modal_cloud_module._materialize_remote_asset_path("/assets/model.safetensors") == (
|
|
"/storage/assets/model.safetensors"
|
|
)
|
|
assert modal_cloud_module._rewrite_modal_asset_references(
|
|
{"clip_name": "/assets/model.safetensors", "nested": ["/assets/other.pt", 3]}
|
|
) == {
|
|
"clip_name": "/storage/assets/model.safetensors",
|
|
"nested": ["/storage/assets/other.pt", 3],
|
|
}
|
|
finally:
|
|
modal_cloud_module.get_settings.cache_clear()
|
|
|
|
def test_modal_cloud_summarizes_suspicious_wrapped_prompt_inputs(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Remote failure diagnostics should flag remaining singleton-list prompt wrappers."""
|
|
prompt = {
|
|
"12": {
|
|
"class_type": "ExampleNode",
|
|
"inputs": {
|
|
"steps": [20],
|
|
"latent": ["7", [0]],
|
|
"ok_link": ["8", 0],
|
|
},
|
|
}
|
|
}
|
|
|
|
findings = modal_cloud_module._summarize_suspicious_prompt_inputs(prompt)
|
|
|
|
assert findings == [
|
|
"12.steps=[20]",
|
|
"12.latent=['7', [0]]",
|
|
]
|
|
|
|
def test_modal_cloud_accepts_absolute_asset_paths_in_folder_lookup(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""Patched folder lookups should return already-materialized absolute asset paths."""
|
|
remote_storage_root = tmp_path / "storage"
|
|
asset_path = remote_storage_root / "assets" / "clip.safetensors"
|
|
asset_path.parent.mkdir(parents=True)
|
|
asset_path.write_bytes(b"clip")
|
|
|
|
fake_folder_paths_module = types.SimpleNamespace(
|
|
get_full_path=lambda folder_name, filename: None,
|
|
get_full_path_or_raise=lambda folder_name, filename: (_ for _ in ()).throw(
|
|
FileNotFoundError(filename)
|
|
),
|
|
)
|
|
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths_module)
|
|
monkeypatch.setenv("COMFY_MODAL_REMOTE_STORAGE_ROOT", str(remote_storage_root))
|
|
modal_cloud_module.get_settings.cache_clear()
|
|
try:
|
|
with modal_cloud_module._patched_folder_paths_absolute_lookup():
|
|
resolved = fake_folder_paths_module.get_full_path(
|
|
"text_encoders",
|
|
"/assets/clip.safetensors",
|
|
)
|
|
assert resolved == str(asset_path)
|
|
assert (
|
|
fake_folder_paths_module.get_full_path_or_raise(
|
|
"text_encoders",
|
|
"/assets/clip.safetensors",
|
|
)
|
|
== str(asset_path)
|
|
)
|
|
finally:
|
|
modal_cloud_module.get_settings.cache_clear()
|
|
|
|
def test_modal_cloud_preserves_absolute_lookup_until_overlapping_contexts_exit(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""One loader finishing must not remove the lookup patch from another loader."""
|
|
asset_path = tmp_path / "unet.safetensors"
|
|
asset_path.write_bytes(b"unet")
|
|
|
|
def original_get_full_path(folder_name: str, filename: str) -> None:
|
|
"""Represent ComfyUI's original lookup missing the absolute asset."""
|
|
del folder_name, filename
|
|
return None
|
|
|
|
def original_get_full_path_or_raise(folder_name: str, filename: str) -> str:
|
|
"""Represent ComfyUI's original raising lookup."""
|
|
del folder_name
|
|
raise FileNotFoundError(filename)
|
|
|
|
fake_folder_paths_module = types.SimpleNamespace(
|
|
get_full_path=original_get_full_path,
|
|
get_full_path_or_raise=original_get_full_path_or_raise,
|
|
)
|
|
monkeypatch.setitem(sys.modules, "folder_paths", fake_folder_paths_module)
|
|
first_entered = threading.Event()
|
|
second_entered = threading.Event()
|
|
release_first = threading.Event()
|
|
first_exited = threading.Event()
|
|
release_second = threading.Event()
|
|
results: list[str | None] = []
|
|
|
|
def first_loader() -> None:
|
|
"""Enter first and exit while the second lookup context remains active."""
|
|
with modal_cloud_module._patched_folder_paths_absolute_lookup():
|
|
first_entered.set()
|
|
assert release_first.wait(timeout=2.0)
|
|
first_exited.set()
|
|
|
|
def second_loader() -> None:
|
|
"""Resolve an absolute model after the first overlapping loader exits."""
|
|
assert first_entered.wait(timeout=2.0)
|
|
with modal_cloud_module._patched_folder_paths_absolute_lookup():
|
|
second_entered.set()
|
|
assert first_exited.wait(timeout=2.0)
|
|
results.append(
|
|
fake_folder_paths_module.get_full_path("diffusion_models", str(asset_path))
|
|
)
|
|
assert release_second.wait(timeout=2.0)
|
|
|
|
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
first_future = executor.submit(first_loader)
|
|
second_future = executor.submit(second_loader)
|
|
assert second_entered.wait(timeout=2.0)
|
|
release_first.set()
|
|
assert first_exited.wait(timeout=2.0)
|
|
first_future.result(timeout=2.0)
|
|
release_second.set()
|
|
second_future.result(timeout=2.0)
|
|
|
|
assert results == [str(asset_path)]
|
|
assert fake_folder_paths_module.get_full_path is original_get_full_path
|
|
assert fake_folder_paths_module.get_full_path_or_raise is original_get_full_path_or_raise
|
|
|
|
def test_modal_cloud_aliases_flux_rms_norm_weight_keys_for_model_detection(
|
|
modal_cloud_module: Any,
|
|
) -> None:
|
|
"""Saved Flux models using RMSNorm `.weight` keys should gain `.scale` aliases."""
|
|
state_dict = {
|
|
"model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.weight": object(),
|
|
"model.diffusion_model.double_blocks.0.img_attn.norm.query_norm.weight": object(),
|
|
"model.diffusion_model.double_blocks.0.txt_attn.norm.key_norm.weight": object(),
|
|
"model.diffusion_model.double_blocks.0.txt_attn.norm.query_norm.weight": object(),
|
|
"model.diffusion_model.img_in.weight": object(),
|
|
}
|
|
|
|
alias_count = modal_cloud_module._alias_flux_rms_norm_weight_keys(state_dict)
|
|
|
|
assert alias_count == 4
|
|
assert (
|
|
state_dict["model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.scale"]
|
|
is state_dict["model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.weight"]
|
|
)
|
|
assert (
|
|
state_dict["model.diffusion_model.double_blocks.0.img_attn.norm.query_norm.scale"]
|
|
is state_dict["model.diffusion_model.double_blocks.0.img_attn.norm.query_norm.weight"]
|
|
)
|
|
assert (
|
|
state_dict["model.diffusion_model.double_blocks.0.txt_attn.norm.key_norm.scale"]
|
|
is state_dict["model.diffusion_model.double_blocks.0.txt_attn.norm.key_norm.weight"]
|
|
)
|
|
assert (
|
|
state_dict["model.diffusion_model.double_blocks.0.txt_attn.norm.query_norm.scale"]
|
|
is state_dict["model.diffusion_model.double_blocks.0.txt_attn.norm.query_norm.weight"]
|
|
)
|
|
assert modal_cloud_module._alias_flux_rms_norm_weight_keys(state_dict) == 0
|
|
|
|
def test_modal_cloud_model_state_dict_wrapper_forwards_new_comfyui_arguments(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""The compatibility wrapper should preserve evolving ComfyUI loader arguments."""
|
|
calls: list[tuple[dict[str, Any], tuple[Any, ...], dict[str, Any]]] = []
|
|
|
|
def original_loader(
|
|
state_dict: dict[str, Any],
|
|
*args: Any,
|
|
**kwargs: Any,
|
|
) -> str:
|
|
"""Record the arguments forwarded by the compatibility wrapper."""
|
|
calls.append((state_dict, args, kwargs))
|
|
return "loaded"
|
|
|
|
fake_comfy_module = types.ModuleType("comfy")
|
|
fake_comfy_module.__path__ = []
|
|
fake_sd_module = types.ModuleType("comfy.sd")
|
|
fake_sd_module.load_diffusion_model_state_dict = original_loader
|
|
fake_comfy_module.sd = fake_sd_module
|
|
monkeypatch.setitem(sys.modules, "comfy", fake_comfy_module)
|
|
monkeypatch.setitem(sys.modules, "comfy.sd", fake_sd_module)
|
|
monkeypatch.setattr(
|
|
_cloud_comfy_bootstrap_owner(),
|
|
"_MODEL_STATE_DICT_COMPAT_WRAPPED",
|
|
False,
|
|
)
|
|
|
|
modal_cloud_module._install_model_state_dict_compatibility_wrappers()
|
|
state_dict = {
|
|
"model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.weight": object()
|
|
}
|
|
|
|
result = fake_sd_module.load_diffusion_model_state_dict(
|
|
state_dict,
|
|
{"dtype": "default"},
|
|
metadata={"source": "test"},
|
|
disable_dynamic=True,
|
|
future_option="preserved",
|
|
)
|
|
|
|
assert result == "loaded"
|
|
assert calls == [
|
|
(
|
|
state_dict,
|
|
({"dtype": "default"},),
|
|
{
|
|
"metadata": {"source": "test"},
|
|
"disable_dynamic": True,
|
|
"future_option": "preserved",
|
|
},
|
|
)
|
|
]
|
|
assert (
|
|
state_dict["model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.scale"]
|
|
is state_dict["model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.weight"]
|
|
)
|
|
|
|
def test_modal_cloud_force_imports_comfyui_utils_package(
|
|
modal_cloud_module: Any,
|
|
monkeypatch: Any,
|
|
tmp_path: Path,
|
|
) -> None:
|
|
"""ComfyUI's utils package should override a shadowing non-package module."""
|
|
package_root = tmp_path / "comfyui"
|
|
utils_dir = package_root / "utils"
|
|
utils_dir.mkdir(parents=True)
|
|
(utils_dir / "__init__.py").write_text("SENTINEL = 'comfy-utils'\n", encoding="utf-8")
|
|
|
|
shadow_module = types.ModuleType("utils")
|
|
shadow_module.__file__ = str(tmp_path / "utils.py")
|
|
monkeypatch.setitem(sys.modules, "utils", shadow_module)
|
|
|
|
modal_cloud_module._force_import_package_from_root("utils", package_root)
|
|
|
|
imported_module = sys.modules["utils"]
|
|
assert getattr(imported_module, "SENTINEL", None) == "comfy-utils"
|
|
assert list(getattr(imported_module, "__path__", [])) == [str(utils_dir)]
|
|
|
|
def test_local_remote_app_executes_subgraph_payload(
|
|
remote_modal_app_module: Any,
|
|
serialization_module: Any,
|
|
) -> None:
|
|
"""The local fallback remote app should execute rewritten subgraph payloads."""
|
|
payload = remote_modal_app_module.execute_subgraph_locally(
|
|
payload={
|
|
"payload_kind": "subgraph",
|
|
"component_id": "component-1",
|
|
"subgraph_prompt": {
|
|
"remote_1": {
|
|
"class_type": "BoundarySource",
|
|
"inputs": {"value": 0},
|
|
"_meta": {},
|
|
},
|
|
"remote_2": {
|
|
"class_type": "BoundarySink",
|
|
"inputs": {"value": ["remote_1", 0]},
|
|
"_meta": {},
|
|
},
|
|
},
|
|
"boundary_inputs": [
|
|
{
|
|
"proxy_input_name": "remote_input_0",
|
|
"targets": [{"node_id": "remote_1", "input_name": "value"}],
|
|
}
|
|
],
|
|
"boundary_outputs": [
|
|
{
|
|
"proxy_output_name": "remote_2_value",
|
|
"node_id": "remote_2",
|
|
"output_index": 0,
|
|
"io_type": "INT",
|
|
"is_list": False,
|
|
}
|
|
],
|
|
"execute_node_ids": ["remote_2"],
|
|
"extra_data": {},
|
|
"custom_nodes_bundle": None,
|
|
},
|
|
kwargs_payload='{"remote_input_0": 4}',
|
|
node_mapping={
|
|
"BoundarySource": _BoundarySourceNode,
|
|
"BoundarySink": _BoundarySinkNode,
|
|
},
|
|
)
|
|
outputs = serialization_module.deserialize_node_outputs(payload)
|
|
assert outputs == (10,)
|
|
|
|
def test_local_remote_app_normalizes_wrapped_subgraph_link_indexes(
|
|
remote_modal_app_module: Any,
|
|
serialization_module: Any,
|
|
) -> None:
|
|
"""The local fallback runner should canonicalize singleton-list prompt link indexes."""
|
|
payload = remote_modal_app_module.execute_subgraph_locally(
|
|
payload={
|
|
"payload_kind": "subgraph",
|
|
"component_id": "component-1",
|
|
"subgraph_prompt": {
|
|
"remote_1": {
|
|
"class_type": "BoundarySource",
|
|
"inputs": {"value": 0},
|
|
"_meta": {},
|
|
},
|
|
"remote_2": {
|
|
"class_type": "BoundarySink",
|
|
"inputs": {"value": [[["remote_1", [0]]]]},
|
|
"_meta": {},
|
|
},
|
|
},
|
|
"boundary_inputs": [
|
|
{
|
|
"proxy_input_name": "remote_input_0",
|
|
"targets": [{"node_id": "remote_1", "input_name": "value"}],
|
|
}
|
|
],
|
|
"boundary_outputs": [
|
|
{
|
|
"proxy_output_name": "remote_2_value",
|
|
"node_id": "remote_2",
|
|
"output_index": [[0]],
|
|
"io_type": "INT",
|
|
"is_list": False,
|
|
}
|
|
],
|
|
"execute_node_ids": ["remote_2"],
|
|
"extra_data": {},
|
|
"custom_nodes_bundle": None,
|
|
},
|
|
kwargs_payload='{"remote_input_0": 4}',
|
|
node_mapping={
|
|
"BoundarySource": _BoundarySourceNode,
|
|
"BoundarySink": _BoundarySinkNode,
|
|
},
|
|
)
|
|
outputs = serialization_module.deserialize_node_outputs(payload)
|
|
assert outputs == (10,)
|
|
|
|
@pytest.mark.parametrize(
|
|
("module_fixture_name",),
|
|
[
|
|
("local_execution_module",),
|
|
("modal_cloud_module",),
|
|
],
|
|
)
|
|
def test_format_prompt_executor_error_payload_includes_node_context(
|
|
request: Any,
|
|
module_fixture_name: str,
|
|
) -> None:
|
|
"""PromptExecutor failure formatting should surface the failing node and current inputs."""
|
|
target_module = request.getfixturevalue(module_fixture_name)
|
|
|
|
message = target_module._format_prompt_executor_error_payload(
|
|
{
|
|
"exception_message": "int() argument must be a string, a bytes-like object or a real number, not 'list'",
|
|
"node_id": "12",
|
|
"node_type": "KSampler",
|
|
"current_inputs": [{"input_name": "steps", "value": [4, 5]}],
|
|
}
|
|
)
|
|
|
|
assert "node_id='12'" in message
|
|
assert "node_type='KSampler'" in message
|
|
assert "current_inputs=" in message
|