From a145651cc02e6b68046dd04bf3e2afa4a9785d12 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Tue, 24 Mar 2026 23:41:01 -0700 Subject: [PATCH 1/5] Track custom node startup errors and expose via API endpoint Store import and prestartup errors in NODE_STARTUP_ERRORS dict (nodes.py, main.py) and add GET /custom_node_startup_errors endpoint (server.py) so the frontend/Manager can distinguish failed imports from missing nodes. Ref: ComfyUI-Launcher#303 Amp-Thread-ID: https://ampcode.com/threads/T-019d2346-6e6f-75e0-a97f-cdb6e26859f7 Co-authored-by: Amp --- main.py | 9 +++++++++ nodes.py | 10 ++++++++++ server.py | 4 ++++ 3 files changed, 23 insertions(+) diff --git a/main.py b/main.py index 058e8e2de..575ea9cd9 100644 --- a/main.py +++ b/main.py @@ -139,7 +139,16 @@ def execute_prestartup_script(): spec.loader.exec_module(module) return True except Exception as e: + import traceback logging.error(f"Failed to execute startup-script: {script_path} / {e}") + from nodes import NODE_STARTUP_ERRORS, get_module_name + node_module_name = get_module_name(os.path.dirname(script_path)) + NODE_STARTUP_ERRORS[node_module_name] = { + "module_path": os.path.dirname(script_path), + "error": str(e), + "traceback": traceback.format_exc(), + "phase": "prestartup", + } return False node_paths = folder_paths.get_folder_paths("custom_nodes") diff --git a/nodes.py b/nodes.py index 37ceac2fc..5430e4bc2 100644 --- a/nodes.py +++ b/nodes.py @@ -2181,6 +2181,9 @@ EXTENSION_WEB_DIRS = {} # Dictionary of successfully loaded module names and associated directories. LOADED_MODULE_DIRS = {} +# Dictionary of custom node startup errors, keyed by module name. +NODE_STARTUP_ERRORS: dict[str, dict] = {} + def get_module_name(module_path: str) -> str: """ @@ -2298,6 +2301,13 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom except Exception as e: logging.warning(traceback.format_exc()) logging.warning(f"Cannot import {module_path} module for custom nodes: {e}") + module_name = get_module_name(module_path) + NODE_STARTUP_ERRORS[module_name] = { + "module_path": module_path, + "error": str(e), + "traceback": traceback.format_exc(), + "phase": "import", + } return False async def init_external_custom_nodes(): diff --git a/server.py b/server.py index 173a28376..8bb60b01b 100644 --- a/server.py +++ b/server.py @@ -753,6 +753,10 @@ class PromptServer(): out[node_class] = node_info(node_class) return web.json_response(out) + @routes.get("/custom_node_startup_errors") + async def get_custom_node_startup_errors(request): + return web.json_response(nodes.NODE_STARTUP_ERRORS) + @routes.get("/api/jobs") async def get_jobs(request): """List all jobs with filtering, sorting, and pagination. From 3a649984f2ce3aa04ae3fad9428b8dee21daddaa Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 13 May 2026 16:29:17 -0700 Subject: [PATCH 2/5] Categorize startup errors by source (custom_node / comfy_extra / api_node) Expand custom-node startup error tracking to differentiate between user-installed custom_nodes, built-in comfy_extras, and partner comfy_api_nodes. Each NODE_STARTUP_ERRORS entry now carries a 'source' field and is keyed by ':' so colliding module names across the three locations don't overwrite each other. The /custom_node_startup_errors endpoint returns errors grouped by source so the frontend/Manager can render distinct sections. Also captures previously-missed failures from comfy_entrypoint() (phase='entrypoint'). Introduces nodes.record_node_startup_error() helper used by load_custom_node and main.execute_prestartup_script. Adds tests-unit/node_startup_errors_test.py (6 tests) covering field shape, source mapping for each module_parent, cross-source collisions, and default fallback. Ref: ComfyUI-Launcher#303 Amp-Thread-ID: https://ampcode.com/threads/T-019e23a1-2acc-7619-bd0e-f783d1368ef3 Co-authored-by: Amp --- main.py | 16 ++-- nodes.py | 59 +++++++++++--- server.py | 12 ++- tests-unit/node_startup_errors_test.py | 104 +++++++++++++++++++++++++ 4 files changed, 173 insertions(+), 18 deletions(-) create mode 100644 tests-unit/node_startup_errors_test.py diff --git a/main.py b/main.py index 575ea9cd9..153dc229a 100644 --- a/main.py +++ b/main.py @@ -141,14 +141,14 @@ def execute_prestartup_script(): except Exception as e: import traceback logging.error(f"Failed to execute startup-script: {script_path} / {e}") - from nodes import NODE_STARTUP_ERRORS, get_module_name - node_module_name = get_module_name(os.path.dirname(script_path)) - NODE_STARTUP_ERRORS[node_module_name] = { - "module_path": os.path.dirname(script_path), - "error": str(e), - "traceback": traceback.format_exc(), - "phase": "prestartup", - } + 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 node_paths = folder_paths.get_folder_paths("custom_nodes") diff --git a/nodes.py b/nodes.py index 5430e4bc2..e1e8eeb64 100644 --- a/nodes.py +++ b/nodes.py @@ -2181,10 +2181,42 @@ EXTENSION_WEB_DIRS = {} # Dictionary of successfully loaded module names and associated directories. LOADED_MODULE_DIRS = {} -# Dictionary of custom node startup errors, keyed by module name. +# 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 ":" +# 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 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) + NODE_STARTUP_ERRORS[f"{source}:{module_name}"] = { + "source": source, + "module_name": module_name, + "module_path": module_path, + "error": str(error), + "traceback": tb, + "phase": phase, + } + + def get_module_name(module_path: str) -> str: """ Returns the module name based on the given module path. @@ -2293,21 +2325,30 @@ async def load_custom_node(module_path: str, ignore=set(), module_parent="custom NODE_DISPLAY_NAME_MAPPINGS[schema.node_id] = schema.display_name return True except Exception as e: + tb = traceback.format_exc() 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 else: logging.warning(f"Skip {module_path} module for custom nodes due to the lack of NODE_CLASS_MAPPINGS or NODES_LIST (need one).") return False 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}") - module_name = get_module_name(module_path) - NODE_STARTUP_ERRORS[module_name] = { - "module_path": module_path, - "error": str(e), - "traceback": traceback.format_exc(), - "phase": "import", - } + record_node_startup_error( + module_path=module_path, + source=_node_source_from_parent(module_parent), + phase="import", + error=e, + tb=tb, + ) return False async def init_external_custom_nodes(): diff --git a/server.py b/server.py index 8bb60b01b..f39a32a87 100644 --- a/server.py +++ b/server.py @@ -755,7 +755,17 @@ class PromptServer(): @routes.get("/custom_node_startup_errors") async def get_custom_node_startup_errors(request): - return web.json_response(nodes.NODE_STARTUP_ERRORS) + # Group errors by source ("custom_node" / "comfy_extra" / "api_node") + # so the frontend/Manager can render them in distinct sections. + 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") + grouped.setdefault(source, {})[entry["module_name"]] = entry + return web.json_response(grouped) @routes.get("/api/jobs") async def get_jobs(request): diff --git a/tests-unit/node_startup_errors_test.py b/tests-unit/node_startup_errors_test.py new file mode 100644 index 000000000..2a86b7653 --- /dev/null +++ b/tests-unit/node_startup_errors_test.py @@ -0,0 +1,104 @@ +"""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"] + ) + + +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" From af55a2308f5ee6a73c7f3e5b618bb29d23057819 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 13 May 2026 16:31:44 -0700 Subject: [PATCH 3/5] Attach pyproject.toml node-pack identity to startup error entries When a failing module has a pyproject.toml, parse it via comfy_config.config_parser and attach a 'pyproject' field with the Comfy Registry-style identity (pack_id, display_name, publisher_id, version, repository). This gives the frontend/Manager a stable, user-recognizable handle for the failed pack beyond the on-disk folder name. The lookup is best-effort and never raises: missing toml, missing pydantic-settings dependency, or any parse error simply omits the 'pyproject' key. Ref: ComfyUI-Launcher#303 Amp-Thread-ID: https://ampcode.com/threads/T-019e23a1-2acc-7619-bd0e-f783d1368ef3 Co-authored-by: Amp --- nodes.py | 39 +++++++++++++++++++++++- tests-unit/node_startup_errors_test.py | 41 ++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/nodes.py b/nodes.py index e1e8eeb64..0775a78f2 100644 --- a/nodes.py +++ b/nodes.py @@ -2202,12 +2202,45 @@ def _node_source_from_parent(module_parent: str) -> str: 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) - NODE_STARTUP_ERRORS[f"{source}:{module_name}"] = { + entry = { "source": source, "module_name": module_name, "module_path": module_path, @@ -2215,6 +2248,10 @@ def record_node_startup_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: diff --git a/tests-unit/node_startup_errors_test.py b/tests-unit/node_startup_errors_test.py index 2a86b7653..533ba78e7 100644 --- a/tests-unit/node_startup_errors_test.py +++ b/tests-unit/node_startup_errors_test.py @@ -97,6 +97,47 @@ async def test_load_custom_node_collision_across_sources(tmp_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" From 6220400ad5baee41f05dd0cb3a79660e28b6cd09 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 13 May 2026 18:10:50 -0700 Subject: [PATCH 4/5] Strip absolute module_path from /custom_node_startup_errors response The absolute on-disk path is internal detail the frontend/Manager has no use for. Keep it in the in-memory NODE_STARTUP_ERRORS dict for server-side debugging, but exclude it from the public API payload. The user-facing identifier remains module_name (and pyproject.pack_id when available). Ref: ComfyUI-Launcher#303 Amp-Thread-ID: https://ampcode.com/threads/T-019e23a1-2acc-7619-bd0e-f783d1368ef3 Co-authored-by: Amp --- server.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/server.py b/server.py index f39a32a87..1a8b67ba7 100644 --- a/server.py +++ b/server.py @@ -757,6 +757,8 @@ class PromptServer(): 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": {}, @@ -764,7 +766,8 @@ class PromptServer(): } for entry in nodes.NODE_STARTUP_ERRORS.values(): source = entry.get("source", "custom_node") - grouped.setdefault(source, {})[entry["module_name"]] = entry + 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") From ba1c039a046e8189fcc9bb0af095a9cec3da8448 Mon Sep 17 00:00:00 2001 From: Jedrzej Kosinski Date: Wed, 13 May 2026 21:05:15 -0700 Subject: [PATCH 5/5] Rename /custom_node_startup_errors -> /node_startup_errors The endpoint covers comfy_extras and comfy_api_nodes failures too, not just user-installed custom nodes, so the path should not pretend otherwise. Ref: ComfyUI-Launcher#303 Amp-Thread-ID: https://ampcode.com/threads/T-019e23a1-2acc-7619-bd0e-f783d1368ef3 Co-authored-by: Amp --- server.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server.py b/server.py index 1a8b67ba7..749c89f1d 100644 --- a/server.py +++ b/server.py @@ -753,8 +753,8 @@ class PromptServer(): out[node_class] = node_info(node_class) return web.json_response(out) - @routes.get("/custom_node_startup_errors") - async def get_custom_node_startup_errors(request): + @routes.get("/node_startup_errors") + async def get_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