Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import faulthandler
import logging
import sys
import traceback
from comfy_execution.progress import get_progress_state
from comfy_execution.utils import get_executing_context
from comfy_api import feature_flags
Expand Down Expand Up @@ -135,7 +136,20 @@ def apply_custom_paths():
folder_paths.set_user_directory(user_dir)


# Buffer for prestartup failures. Recorded into `nodes.NODE_STARTUP_ERRORS`
# only AFTER the normal `import nodes` line below, so a failing prestartup
# script never triggers an early `import nodes` (and therefore `import torch`)
# on the error path.
_PRESTARTUP_FAILURES: list[dict] = []


def execute_prestartup_script():
"""Run every custom_nodes/*/prestartup_script.py once, before importing nodes.

Failures are buffered into the module-level ``_PRESTARTUP_FAILURES`` list and
must be flushed via ``record_node_startup_error`` after ``import nodes`` has
happened at its normal bootstrap point.
"""
if args.disable_all_custom_nodes and len(args.whitelist_custom_nodes) == 0:
return

Expand All @@ -148,6 +162,15 @@ def execute_script(script_path):
return True
except Exception as e:
logging.error(f"Failed to execute startup-script: {script_path} / {e}")
# Buffer the failure - do NOT `import nodes` here, that would drag
# torch in before the intended bootstrap point.
_PRESTARTUP_FAILURES.append({
"module_path": os.path.dirname(script_path),
"source": "custom_nodes",
"phase": "prestartup",
"error": e,
"tb": traceback.format_exc(),
})
return False

node_paths = folder_paths.get_folder_paths("custom_nodes")
Expand Down Expand Up @@ -207,6 +230,16 @@ def execute_script(script_path):
import server
from protocol import BinaryEventTypes
import nodes

# Flush any prestartup failures that were buffered before `nodes` was
# importable. Doing this here (rather than from the prestartup error
# handler) keeps the bootstrap order deterministic: `nodes` (and torch)
# import at this single line whether prestartup succeeded or failed.
if _PRESTARTUP_FAILURES:
for _failure in _PRESTARTUP_FAILURES:
nodes.record_node_startup_error(**_failure)
_PRESTARTUP_FAILURES.clear()

import comfy.model_management
import comfyui_version
import app.logger
Expand Down
149 changes: 148 additions & 1 deletion nodes.py
Original file line number Diff line number Diff line change
Expand Up @@ -2158,6 +2158,137 @@ def expand_image(self, image, left, top, right, bottom, feathering):
# Dictionary of successfully loaded module names and associated directories.
LOADED_MODULE_DIRS = {}

# 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.
#
# `source` is the same string as the internal `module_parent` used at load
# time (e.g. "custom_nodes", "comfy_extras", "comfy_api_nodes"). It is
# intentionally a free-form string rather than a fixed enum so the contract
# survives node-source layouts evolving (e.g. comfy_api_nodes eventually
# moving out of core). Consumers should treat any new value as a new bucket
# rather than rejecting it.
NODE_STARTUP_ERRORS: dict[str, dict] = {}


_EMPTY_LEAF_VALUES = (None, "", [], {})


def _prune_empty(value):
"""Recursively drop empty strings / lists / dicts / None from a nested structure.

Used to keep the on-wire pyproject payload tight without altering the
nesting that callers see (so consumers can still parse it back through
``PyProjectConfig`` if they want a typed object).
"""
if isinstance(value, dict):
out = {}
for k, v in value.items():
cleaned = _prune_empty(v)
if cleaned not in _EMPTY_LEAF_VALUES:
out[k] = cleaned
return out
if isinstance(value, list):
return [
cleaned
for cleaned in (_prune_empty(v) for v in value)
if cleaned not in _EMPTY_LEAF_VALUES
]
return value


def _read_pyproject_metadata(module_path: str) -> dict | None:
"""Best-effort extraction of pyproject.toml for a node module.

Returns a dict mirroring the ``PyProjectConfig`` shape produced by
``comfy_config.config_parser.extract_node_configuration`` (i.e. with
``project`` and ``tool_comfy`` nesting and the same field names) when the
module directory contains a pyproject.toml. Empty / default-valued leaves
are pruned so the API payload stays compact, but the nesting is kept
intact so API consumers can parse the result back through
``PyProjectConfig`` directly.

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
pruned = _prune_empty(cfg.model_dump())
return pruned or None
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 filter_node_startup_errors(
*,
source: str | None = None,
module_name: str | None = None,
pack_id: str | None = None,
) -> dict[str, dict[str, dict]]:
"""Return `NODE_STARTUP_ERRORS` reshaped for the public HTTP endpoint.

Entries are grouped by their ``source`` bucket (the same string as the
internal ``module_parent`` used at load time). The on-disk
``module_path`` is stripped from each entry — it's an internal detail
useful only for server-side logging and would leak absolute filesystem
layout otherwise.
Comment on lines +2259 to +2263

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don’t expose raw tracebacks from the public endpoint.

Stripping module_path is not enough here: traceback.format_exc() already includes absolute filenames and other internal details. Returning it unchanged from the public API still leaks the server’s filesystem layout. Consider omitting or redacting traceback in public_entry and keeping the full traceback server-side only.

Also applies to: 2288-2289

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nodes.py` around lines 2259 - 2263, The public API is leaking internal paths
by returning raw tracebacks (via traceback.format_exc()) in public_entry and
related places (also around the code at 2288-2289); update the serializer that
builds public_entry to omit or redact the traceback field (e.g., replace with a
generic message like "internal error" or strip filenames) and keep the full
traceback only in server-side logs/attributes (retain module_path stripping
as-is but ensure any use of traceback.format_exc() is not sent to callers);
locate the code that constructs public_entry and the nearby traceback usage and
modify it to remove/replace the traceback before returning the public-facing
structure.


Optional filters narrow the response and combine with AND:

* ``source`` — only entries from this source bucket.
* ``module_name`` — only entries whose module name matches exactly.
* ``pack_id`` — only entries whose ``pyproject.project.name``
matches exactly. Entries without a parsed
pyproject.toml can never match this filter.

A non-matching filter returns an empty dict, not an error — absence of
a failure is a valid answer for this query.
"""
Comment on lines +2257 to +2275

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve multiple phase failures per module.

The new response shape keys each source bucket by module_name, so a pack that fails in more than one startup phase will only expose the last one written. That collapses the new phase dimension the endpoint is supposed to report. Please return a per-module collection (for example, a list or phase-keyed map) instead of a single entry.

Also applies to: 2276-2290

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@nodes.py` around lines 2257 - 2275, The current reshaping of
NODE_STARTUP_ERRORS collapses multiple failures per module by using module_name
as a single object value; change the response so each module_name maps to a
collection of failures (e.g., a list of failure entries or a dict keyed by
phase) instead of a single entry, preserving all original fields except
stripping module_path; update the code that builds the source->module_name
mapping (the logic that currently overwrites entries) to append or merge entries
for the same module_name, and ensure existing filters (source, module_name,
pack_id) still apply and that module_path remains removed from each returned
failure entry.

grouped: dict[str, dict[str, dict]] = {}
for entry in NODE_STARTUP_ERRORS.values():
entry_source = entry.get("source", "custom_nodes")
if source is not None and entry_source != source:
continue
if module_name is not None and entry.get("module_name") != module_name:
continue
if pack_id is not None:
pyproject = entry.get("pyproject") or {}
project = pyproject.get("project") or {}
if project.get("name") != pack_id:
continue
public_entry = {k: v for k, v in entry.items() if k != "module_path"}
grouped.setdefault(entry_source, {})[entry["module_name"]] = public_entry
return grouped


def get_module_name(module_path: str) -> str:
"""
Expand Down Expand Up @@ -2267,14 +2398,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=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 comfy_entrypoint (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}")
record_node_startup_error(
module_path=module_path,
source=module_parent,
phase="import",
error=e,
tb=tb,
)
return False

async def init_external_custom_nodes():
Expand Down
40 changes: 40 additions & 0 deletions server.py
Original file line number Diff line number Diff line change
Expand Up @@ -765,6 +765,46 @@ async def get_object_info_node(request):
out[node_class] = node_info(node_class)
return web.json_response(out)

@routes.get("/node_startup_errors")
async def get_node_startup_errors(request):
"""Return startup errors recorded during node loading, grouped by source.

Group errors by source so the frontend/Manager can render them in
distinct sections. ``source`` is the same string as the
``module_parent`` used at load time (e.g. ``"custom_nodes"``,
``"comfy_extras"``, ``"comfy_api_nodes"``) and is left as a
free-form string so the contract survives node-source layouts
evolving. The response only contains source buckets that actually
had a failure; consumers should not assume any particular set of
keys is always present.

``module_path`` is stripped because the absolute on-disk path is
internal detail that the frontend has no use for.

Optional query parameters narrow the response:

* ``source`` — only entries from this source bucket.
* ``module_name`` — only entries whose module name matches exactly.
(Folder name for directory-style packs, file
stem for single-file modules.)
* ``pack_id`` — only entries whose ``pyproject.project.name``
matches exactly. Entries without a parsed
pyproject.toml are skipped under this filter.

Filters are combined with AND. Filtering an empty / non-matching
result still returns ``{}`` with HTTP 200 rather than 404 — absence
of an error is a valid answer for this endpoint.
"""
# Coalesce empty-string query values to None so `?source=` (param
# present but blank) is treated the same as the param being absent
# — rather than filtering for entries whose source is literally "".
grouped = nodes.filter_node_startup_errors(
source=request.query.get("source") or None,
module_name=request.query.get("module_name") or None,
pack_id=request.query.get("pack_id") or None,
)
return web.json_response(grouped)

@routes.get("/api/jobs")
async def get_jobs(request):
"""List all jobs with filtering, sorting, and pagination.
Expand Down
Loading
Loading