mirror of
https://github.com/comfyanonymous/ComfyUI.git
synced 2026-05-14 19:17:32 +08:00
Merge 6220400ad5 into 26515acd23
This commit is contained in:
commit
c957303a1e
9
main.py
9
main.py
@ -147,7 +147,16 @@ def execute_prestartup_script():
|
|||||||
spec.loader.exec_module(module)
|
spec.loader.exec_module(module)
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
import traceback
|
||||||
logging.error(f"Failed to execute startup-script: {script_path} / {e}")
|
logging.error(f"Failed to execute startup-script: {script_path} / {e}")
|
||||||
|
from nodes import record_node_startup_error
|
||||||
|
record_node_startup_error(
|
||||||
|
module_path=os.path.dirname(script_path),
|
||||||
|
source="custom_node",
|
||||||
|
phase="prestartup",
|
||||||
|
error=e,
|
||||||
|
tb=traceback.format_exc(),
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
node_paths = folder_paths.get_folder_paths("custom_nodes")
|
node_paths = folder_paths.get_folder_paths("custom_nodes")
|
||||||
|
|||||||
90
nodes.py
90
nodes.py
@ -2152,6 +2152,78 @@ EXTENSION_WEB_DIRS = {}
|
|||||||
# Dictionary of successfully loaded module names and associated directories.
|
# Dictionary of successfully loaded module names and associated directories.
|
||||||
LOADED_MODULE_DIRS = {}
|
LOADED_MODULE_DIRS = {}
|
||||||
|
|
||||||
|
# Mapping from internal module_parent values to the public "source"
|
||||||
|
# category the API exposes. Keeps the on-disk layout decoupled from
|
||||||
|
# the names the frontend/Manager switches on.
|
||||||
|
_NODE_SOURCE_BY_PARENT = {
|
||||||
|
"custom_nodes": "custom_node",
|
||||||
|
"comfy_extras": "comfy_extra",
|
||||||
|
"comfy_api_nodes": "api_node",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _node_source_from_parent(module_parent: str) -> str:
|
||||||
|
return _NODE_SOURCE_BY_PARENT.get(module_parent, "custom_node")
|
||||||
|
|
||||||
|
|
||||||
|
# Dictionary of custom node startup errors, keyed by "<source>:<module_name>"
|
||||||
|
# so that name collisions across custom_nodes / comfy_extras / comfy_api_nodes
|
||||||
|
# do not overwrite each other. Each value contains: source, module_name,
|
||||||
|
# module_path, error, traceback, phase.
|
||||||
|
NODE_STARTUP_ERRORS: dict[str, dict] = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _read_pyproject_metadata(module_path: str) -> dict | None:
|
||||||
|
"""Best-effort extraction of node-pack identity from pyproject.toml.
|
||||||
|
|
||||||
|
Returns a dict with the Comfy Registry-style identity (pack_id,
|
||||||
|
display_name, publisher_id, version, repository) when the module
|
||||||
|
directory contains a pyproject.toml. Returns None when no toml is
|
||||||
|
present or parsing fails for any reason — startup-error tracking
|
||||||
|
must never itself raise.
|
||||||
|
"""
|
||||||
|
if not module_path or not os.path.isdir(module_path):
|
||||||
|
return None
|
||||||
|
toml_path = os.path.join(module_path, "pyproject.toml")
|
||||||
|
if not os.path.isfile(toml_path):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
from comfy_config import config_parser
|
||||||
|
|
||||||
|
cfg = config_parser.extract_node_configuration(module_path)
|
||||||
|
if cfg is None:
|
||||||
|
return None
|
||||||
|
meta = {
|
||||||
|
"pack_id": cfg.project.name or None,
|
||||||
|
"display_name": cfg.tool_comfy.display_name or None,
|
||||||
|
"publisher_id": cfg.tool_comfy.publisher_id or None,
|
||||||
|
"version": cfg.project.version or None,
|
||||||
|
"repository": cfg.project.urls.repository or None,
|
||||||
|
}
|
||||||
|
# Drop empty fields so the API payload stays compact.
|
||||||
|
return {k: v for k, v in meta.items() if v}
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def record_node_startup_error(
|
||||||
|
*, module_path: str, source: str, phase: str, error: BaseException, tb: str
|
||||||
|
) -> None:
|
||||||
|
"""Record a startup error for a node module so it can be exposed via the API."""
|
||||||
|
module_name = get_module_name(module_path)
|
||||||
|
entry = {
|
||||||
|
"source": source,
|
||||||
|
"module_name": module_name,
|
||||||
|
"module_path": module_path,
|
||||||
|
"error": str(error),
|
||||||
|
"traceback": tb,
|
||||||
|
"phase": phase,
|
||||||
|
}
|
||||||
|
pyproject = _read_pyproject_metadata(module_path)
|
||||||
|
if pyproject:
|
||||||
|
entry["pyproject"] = pyproject
|
||||||
|
NODE_STARTUP_ERRORS[f"{source}:{module_name}"] = entry
|
||||||
|
|
||||||
|
|
||||||
def get_module_name(module_path: str) -> str:
|
def get_module_name(module_path: str) -> str:
|
||||||
"""
|
"""
|
||||||
@ -2261,14 +2333,30 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom
|
|||||||
NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name
|
NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name
|
||||||
return True
|
return True
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
tb = traceback.format_exc()
|
||||||
logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}")
|
logging.warning(f"Error while calling comfy_entrypoint in {module_path}: {e}")
|
||||||
|
record_node_startup_error(
|
||||||
|
module_path=module_path,
|
||||||
|
source=_node_source_from_parent(module_parent),
|
||||||
|
phase="entrypoint",
|
||||||
|
error=e,
|
||||||
|
tb=tb,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or comfy_entrypoint (need one).")
|
logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or comfy_entrypoint (need one).")
|
||||||
return False
|
return False
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logging.warning(traceback.format_exc())
|
tb = traceback.format_exc()
|
||||||
|
logging.warning(tb)
|
||||||
logging.warning(f"Cannot import {module_path} module for custom nodes: {e}")
|
logging.warning(f"Cannot import {module_path} module for custom nodes: {e}")
|
||||||
|
record_node_startup_error(
|
||||||
|
module_path=module_path,
|
||||||
|
source=_node_source_from_parent(module_parent),
|
||||||
|
phase="import",
|
||||||
|
error=e,
|
||||||
|
tb=tb,
|
||||||
|
)
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def init_external_custom_nodes():
|
async def init_external_custom_nodes():
|
||||||
|
|||||||
17
server.py
17
server.py
@ -763,6 +763,23 @@ class PromptServer():
|
|||||||
out[node_class] = node_info(node_class)
|
out[node_class] = node_info(node_class)
|
||||||
return web.json_response(out)
|
return web.json_response(out)
|
||||||
|
|
||||||
|
@routes.get("/custom_node_startup_errors")
|
||||||
|
async def get_custom_node_startup_errors(request):
|
||||||
|
# Group errors by source ("custom_node" / "comfy_extra" / "api_node")
|
||||||
|
# so the frontend/Manager can render them in distinct sections.
|
||||||
|
# `module_path` is stripped because the absolute on-disk path is
|
||||||
|
# internal detail that the frontend has no use for.
|
||||||
|
grouped: dict[str, dict[str, dict]] = {
|
||||||
|
"custom_node": {},
|
||||||
|
"comfy_extra": {},
|
||||||
|
"api_node": {},
|
||||||
|
}
|
||||||
|
for entry in nodes.NODE_STARTUP_ERRORS.values():
|
||||||
|
source = entry.get("source", "custom_node")
|
||||||
|
public_entry = {k: v for k, v in entry.items() if k != "module_path"}
|
||||||
|
grouped.setdefault(source, {})[entry["module_name"]] = public_entry
|
||||||
|
return web.json_response(grouped)
|
||||||
|
|
||||||
@routes.get("/api/jobs")
|
@routes.get("/api/jobs")
|
||||||
async def get_jobs(request):
|
async def get_jobs(request):
|
||||||
"""List all jobs with filtering, sorting, and pagination.
|
"""List all jobs with filtering, sorting, and pagination.
|
||||||
|
|||||||
145
tests-unit/node_startup_errors_test.py
Normal file
145
tests-unit/node_startup_errors_test.py
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
"""Tests for the custom node startup error tracking introduced for
|
||||||
|
Comfy-Org/ComfyUI-Launcher#303.
|
||||||
|
|
||||||
|
Covers:
|
||||||
|
- load_custom_node populates NODE_STARTUP_ERRORS with the correct source
|
||||||
|
for each module_parent (custom_nodes / comfy_extras / comfy_api_nodes).
|
||||||
|
- Composite keying prevents collisions between modules with the same name
|
||||||
|
in different sources.
|
||||||
|
- record_node_startup_error stores the expected fields.
|
||||||
|
"""
|
||||||
|
import textwrap
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import nodes
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _clear_startup_errors():
|
||||||
|
nodes.NODE_STARTUP_ERRORS.clear()
|
||||||
|
yield
|
||||||
|
nodes.NODE_STARTUP_ERRORS.clear()
|
||||||
|
|
||||||
|
|
||||||
|
def _write_broken_module(tmp_path, name: str) -> str:
|
||||||
|
path = tmp_path / f"{name}.py"
|
||||||
|
path.write_text(textwrap.dedent("""\
|
||||||
|
# Deliberately broken module to exercise startup-error tracking.
|
||||||
|
raise RuntimeError("boom from " + __name__)
|
||||||
|
"""))
|
||||||
|
return str(path)
|
||||||
|
|
||||||
|
|
||||||
|
def test_record_node_startup_error_fields(tmp_path):
|
||||||
|
err = ValueError("kaboom")
|
||||||
|
nodes.record_node_startup_error(
|
||||||
|
module_path=str(tmp_path / "my_pack"),
|
||||||
|
source="custom_node",
|
||||||
|
phase="import",
|
||||||
|
error=err,
|
||||||
|
tb="traceback-text",
|
||||||
|
)
|
||||||
|
assert "custom_node:my_pack" in nodes.NODE_STARTUP_ERRORS
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS["custom_node:my_pack"]
|
||||||
|
assert entry["source"] == "custom_node"
|
||||||
|
assert entry["module_name"] == "my_pack"
|
||||||
|
assert entry["phase"] == "import"
|
||||||
|
assert entry["error"] == "kaboom"
|
||||||
|
assert entry["traceback"] == "traceback-text"
|
||||||
|
assert entry["module_path"].endswith("my_pack")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"module_parent,expected_source",
|
||||||
|
[
|
||||||
|
("custom_nodes", "custom_node"),
|
||||||
|
("comfy_extras", "comfy_extra"),
|
||||||
|
("comfy_api_nodes", "api_node"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
async def test_load_custom_node_records_source(tmp_path, module_parent, expected_source):
|
||||||
|
module_path = _write_broken_module(tmp_path, "broken_pack")
|
||||||
|
|
||||||
|
success = await nodes.load_custom_node(module_path, module_parent=module_parent)
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
key = f"{expected_source}:broken_pack"
|
||||||
|
assert key in nodes.NODE_STARTUP_ERRORS, nodes.NODE_STARTUP_ERRORS
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS[key]
|
||||||
|
assert entry["source"] == expected_source
|
||||||
|
assert entry["module_name"] == "broken_pack"
|
||||||
|
assert entry["phase"] == "import"
|
||||||
|
assert "boom from" in entry["error"]
|
||||||
|
assert "RuntimeError" in entry["traceback"]
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_custom_node_collision_across_sources(tmp_path):
|
||||||
|
# Same module name registered as both a custom_node and a comfy_extra;
|
||||||
|
# composite keying should keep both entries.
|
||||||
|
cn_dir = tmp_path / "cn"
|
||||||
|
extras_dir = tmp_path / "extras"
|
||||||
|
cn_dir.mkdir()
|
||||||
|
extras_dir.mkdir()
|
||||||
|
cn_path = _write_broken_module(cn_dir, "nodes_audio")
|
||||||
|
extras_path = _write_broken_module(extras_dir, "nodes_audio")
|
||||||
|
|
||||||
|
assert await nodes.load_custom_node(cn_path, module_parent="custom_nodes") is False
|
||||||
|
assert await nodes.load_custom_node(extras_path, module_parent="comfy_extras") is False
|
||||||
|
|
||||||
|
assert "custom_node:nodes_audio" in nodes.NODE_STARTUP_ERRORS
|
||||||
|
assert "comfy_extra:nodes_audio" in nodes.NODE_STARTUP_ERRORS
|
||||||
|
assert (
|
||||||
|
nodes.NODE_STARTUP_ERRORS["custom_node:nodes_audio"]["module_path"]
|
||||||
|
!= nodes.NODE_STARTUP_ERRORS["comfy_extra:nodes_audio"]["module_path"]
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_custom_node_attaches_pyproject_metadata(tmp_path):
|
||||||
|
pack_dir = tmp_path / "MyCoolPack"
|
||||||
|
pack_dir.mkdir()
|
||||||
|
(pack_dir / "__init__.py").write_text("raise RuntimeError('boom')\n")
|
||||||
|
(pack_dir / "pyproject.toml").write_text(textwrap.dedent("""\
|
||||||
|
[project]
|
||||||
|
name = "comfyui-mycoolpack"
|
||||||
|
version = "1.2.3"
|
||||||
|
|
||||||
|
[project.urls]
|
||||||
|
Repository = "https://github.com/example/comfyui-mycoolpack"
|
||||||
|
|
||||||
|
[tool.comfy]
|
||||||
|
PublisherId = "example"
|
||||||
|
DisplayName = "My Cool Pack"
|
||||||
|
"""))
|
||||||
|
|
||||||
|
success = await nodes.load_custom_node(str(pack_dir), module_parent="custom_nodes")
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS["custom_node:MyCoolPack"]
|
||||||
|
assert "pyproject" in entry, entry
|
||||||
|
py = entry["pyproject"]
|
||||||
|
assert py["pack_id"] == "comfyui-mycoolpack"
|
||||||
|
assert py["display_name"] == "My Cool Pack"
|
||||||
|
assert py["publisher_id"] == "example"
|
||||||
|
assert py["version"] == "1.2.3"
|
||||||
|
assert py["repository"] == "https://github.com/example/comfyui-mycoolpack"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_load_custom_node_no_pyproject_skips_metadata(tmp_path):
|
||||||
|
# Single-file extras-style module: no pyproject.toml exists alongside it,
|
||||||
|
# so the entry must not contain a 'pyproject' key.
|
||||||
|
module_path = _write_broken_module(tmp_path, "lonely")
|
||||||
|
assert await nodes.load_custom_node(module_path, module_parent="comfy_extras") is False
|
||||||
|
entry = nodes.NODE_STARTUP_ERRORS["comfy_extra:lonely"]
|
||||||
|
assert "pyproject" not in entry
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_module_parent_defaults_to_custom_node():
|
||||||
|
assert nodes._node_source_from_parent("custom_nodes") == "custom_node"
|
||||||
|
assert nodes._node_source_from_parent("comfy_extras") == "comfy_extra"
|
||||||
|
assert nodes._node_source_from_parent("comfy_api_nodes") == "api_node"
|
||||||
|
assert nodes._node_source_from_parent("something_else") == "custom_node"
|
||||||
Loading…
Reference in New Issue
Block a user