Compare commits

..

10 Commits

Author SHA1 Message Date
7259e664ef Defer record_node_startup_error in prestartup error path; add docstrings
Buffer prestartup failures into a module-level list inside main.py
instead of importing 'nodes' (and therefore 'torch') from within the
exception handler. After the normal 'import nodes' line, drain the
buffer via nodes.record_node_startup_error so bootstrap order stays
deterministic regardless of whether a prestartup script succeeded.

Also convert the explanatory '#' comment on the new
/node_startup_errors endpoint into a proper docstring and add a
docstring to execute_prestartup_script, addressing CodeRabbit's
docstring-coverage warning on this PR.

Addresses review feedback on PR #13184.

Amp-Thread-ID: https://ampcode.com/threads/T-019e2f90-26fe-7048-9855-5ff39d08a3e0
Co-authored-by: Amp <amp@ampcode.com>
2026-05-21 14:09:01 -07:00
ae539cfa0a Merge branch 'master' into feature/custom-node-startup-errors 2026-05-21 12:58:06 -07:00
8f82b16993 Merge branch 'master' into feature/custom-node-startup-errors 2026-05-15 16:31:50 -07:00
72fe66a18b Hoist 'import traceback' to top of main.py
Minor cleanup from code review: traceback is stdlib so there's no circular-import concern keeping it inline. The 'from nodes import record_node_startup_error' stays inline because nodes.py imports from contexts that would create a cycle at module load time.

Ref: ComfyUI-Launcher#303
Amp-Thread-ID: https://ampcode.com/threads/T-019e23a1-2acc-7619-bd0e-f783d1368ef3
Co-authored-by: Amp <amp@ampcode.com>
2026-05-15 00:48:23 -07:00
07ff14ae02 Use module_parent string directly as 'source'; drop fixed-enum mapping
The public 'source' field on each NODE_STARTUP_ERRORS entry is now the same string as the internal module_parent passed to load_custom_node ('custom_nodes', 'comfy_extras', 'comfy_api_nodes'), rather than being translated to a separate fixed enum. Treating it as a free-form string keeps the contract durable in case the node-source layout evolves (e.g. comfy_api_nodes eventually moving out of core).

The API endpoint now also dynamically groups by whatever sources are present rather than hardcoding the three known top-level keys; consumers should not assume any particular set of keys is always present.

Drops the _NODE_SOURCE_BY_PARENT map, _node_source_from_parent helper, and the related test. Adds a test covering an arbitrary unknown module_parent value passing through unchanged.

Ref: ComfyUI-Launcher#303
Amp-Thread-ID: https://ampcode.com/threads/T-019e23a1-2acc-7619-bd0e-f783d1368ef3
Co-authored-by: Amp <amp@ampcode.com>
2026-05-14 20:49:35 -07:00
ba1c039a04 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 <amp@ampcode.com>
2026-05-13 21:05:15 -07:00
6220400ad5 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 <amp@ampcode.com>
2026-05-13 18:10:50 -07:00
af55a2308f 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 <amp@ampcode.com>
2026-05-13 16:31:44 -07:00
3a649984f2 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 '<source>:<module_name>' 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 <amp@ampcode.com>
2026-05-13 16:29:17 -07:00
a145651cc0 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 <amp@ampcode.com>
2026-03-24 23:41:01 -07:00
12 changed files with 315 additions and 805 deletions

View File

@ -1,484 +0,0 @@
name: Backport Release
on:
workflow_dispatch:
inputs:
commit:
description: 'Full 40-char SHA of the tip commit of the backport source branch (the PR head commit that passed tests). The branch is resolved from this SHA and must be unique.'
required: true
type: string
permissions:
contents: read
pull-requests: read
checks: read
jobs:
backport-release:
name: Create backport release
runs-on: ubuntu-latest
environment: backport release
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1
with:
app-id: ${{ secrets.FEN_RELEASE_APP_ID }}
private-key: ${{ secrets.FEN_RELEASE_PRIVATE_KEY }}
- name: Checkout repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
with:
token: ${{ steps.app-token.outputs.token }}
fetch-depth: 0
fetch-tags: true
- name: Configure git
run: |
git config user.name "fen-release[bot]"
git config user.email "fen-release[bot]@users.noreply.github.com"
- name: Resolve source branch from commit SHA
id: resolve
env:
SOURCE_COMMIT: ${{ inputs.commit }}
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
run: |
set -euo pipefail
# Require a full 40-char lowercase-hex SHA. Short SHAs are ambiguous
# and we will be comparing this value against API responses (PR head
# SHA, ref tips) that always return the full form.
if [[ ! "${SOURCE_COMMIT}" =~ ^[0-9a-f]{40}$ ]]; then
echo "::error::Input commit '${SOURCE_COMMIT}' is not a full 40-char lowercase hex SHA."
exit 1
fi
# Fetch all remote branches so we can search for which one(s) point
# at this SHA. `actions/checkout` with fetch-depth: 0 fetches full
# history of the checked-out ref but does not necessarily populate
# every refs/remotes/origin/*, so do it explicitly.
git fetch --prune origin '+refs/heads/*:refs/remotes/origin/*'
# Verify the commit actually exists in this repo's object DB.
if ! git cat-file -e "${SOURCE_COMMIT}^{commit}" 2>/dev/null; then
echo "::error::Commit ${SOURCE_COMMIT} was not found in the repository."
exit 1
fi
# Find every remote branch whose tip == SOURCE_COMMIT. Exactly one
# branch must point at it. If zero, the commit isn't anyone's tip
# (likely stale, force-pushed past, or never the PR head). If more
# than one, the (branch -> SHA) mapping is ambiguous and we refuse
# to guess — the operator must give us a unique branch to release.
mapfile -t matching_branches < <(
git for-each-ref \
--format='%(refname:strip=3)' \
--points-at="${SOURCE_COMMIT}" \
refs/remotes/origin/ \
| grep -vx 'HEAD' || true
)
if [[ "${#matching_branches[@]}" -eq 0 ]]; then
echo "::error::No branch on origin has ${SOURCE_COMMIT} as its tip."
echo "::error::Either the branch was updated after you copied this SHA, or this commit was never the head of a branch."
exit 1
fi
if [[ "${#matching_branches[@]}" -gt 1 ]]; then
echo "::error::More than one branch on origin has ${SOURCE_COMMIT} as its tip; cannot pick one:"
for b in "${matching_branches[@]}"; do
echo "::error:: - ${b}"
done
echo "::error::Refusing to proceed with an ambiguous source branch."
exit 1
fi
source_branch="${matching_branches[0]}"
if [[ "${source_branch}" == "${DEFAULT_BRANCH}" ]]; then
echo "::error::Source branch must not be the default branch ('${DEFAULT_BRANCH}')."
exit 1
fi
echo "Resolved commit ${SOURCE_COMMIT} to branch '${source_branch}'."
echo "source_branch=${source_branch}" >> "$GITHUB_OUTPUT"
- name: Determine latest stable release
id: latest
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
run: |
set -euo pipefail
# List all tags matching vMAJOR.MINOR.PATCH and pick the highest by numeric
# comparison of each component. We DO NOT use `sort -V` because it treats
# v0.19.99 as higher than v0.20.1.
latest_tag="$(
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| awk -F'[v.]' '{ printf "%010d %010d %010d %s\n", $2, $3, $4, $0 }' \
| sort -k1,1n -k2,2n -k3,3n \
| tail -n1 \
| awk '{print $4}'
)"
if [[ -z "${latest_tag}" ]]; then
echo "::error::No stable release tags (vMAJOR.MINOR.PATCH) were found."
exit 1
fi
# Parse components
ver="${latest_tag#v}"
major="${ver%%.*}"
rest="${ver#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
new_patch=$((patch + 1))
new_version="v${major}.${minor}.${new_patch}"
release_branch="release/v${major}.${minor}"
latest_sha="$(git rev-list -n 1 "refs/tags/${latest_tag}")"
echo "latest_tag=${latest_tag}" >> "$GITHUB_OUTPUT"
echo "latest_sha=${latest_sha}" >> "$GITHUB_OUTPUT"
echo "major=${major}" >> "$GITHUB_OUTPUT"
echo "minor=${minor}" >> "$GITHUB_OUTPUT"
echo "patch=${patch}" >> "$GITHUB_OUTPUT"
echo "new_version=${new_version}" >> "$GITHUB_OUTPUT"
echo "new_version_no_v=${major}.${minor}.${new_patch}" >> "$GITHUB_OUTPUT"
echo "release_branch=${release_branch}" >> "$GITHUB_OUTPUT"
echo "Latest stable release: ${latest_tag} (${latest_sha})"
echo "New version will be: ${new_version}"
echo "Release branch: ${release_branch}"
- name: Validate source branch is cut directly from the latest stable release
env:
SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }}
SOURCE_COMMIT: ${{ inputs.commit }}
LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }}
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
run: |
set -euo pipefail
# Use the user-provided SHA directly rather than re-resolving the branch
# tip — the resolve step already proved the branch tip equals SOURCE_COMMIT,
# and pinning to the SHA here makes the rest of the job TOCTOU-safe against
# someone pushing to the branch mid-run.
source_sha="${SOURCE_COMMIT}"
# Walking first-parent from the source tip must reach LATEST_TAG_SHA.
# We capture rev-list into a variable and grep against a here-string
# rather than piping `rev-list | grep -q`: under `set -o pipefail`,
# `grep -q` would exit on first match and SIGPIPE the still-streaming
# `rev-list`, propagating exit 141 as a spurious "not found".
first_parent_chain="$(git rev-list --first-parent "${source_sha}")"
if ! grep -Fxq "${LATEST_TAG_SHA}" <<< "${first_parent_chain}"; then
echo "::error::Source branch '${SOURCE_BRANCH}' is not cut from '${LATEST_TAG}'."
echo "::error::Its first-parent history does not include ${LATEST_TAG_SHA}."
exit 1
fi
# Additionally, every commit added on top of the tag (the set we are
# about to publish) must itself be a descendant of the tag along
# first-parent — i.e. no sibling commits from master sneak in via a
# non-first-parent path. Enforce by requiring that the symmetric
# difference is empty in one direction: commits in source that are
# NOT first-parent-reachable from source starting at the tag.
# We do this by intersecting:
# A = commits reachable from source but not from tag (full DAG)
# B = commits on the first-parent chain from source down to tag
# and requiring A == B.
all_added="$(git rev-list "${LATEST_TAG_SHA}..${source_sha}" | sort)"
first_parent_added="$(
git rev-list --first-parent "${LATEST_TAG_SHA}..${source_sha}" | sort
)"
if [[ "${all_added}" != "${first_parent_added}" ]]; then
echo "::error::Source branch '${SOURCE_BRANCH}' contains commits not on its first-parent chain from '${LATEST_TAG}'."
echo "::error::This usually means the branch was cut from master (not from the tag) or contains a merge from master."
echo "Commits reachable but not on first-parent chain:"
comm -23 <(printf '%s\n' "${all_added}") <(printf '%s\n' "${first_parent_added}") \
| while read -r sha; do
echo " $(git log -1 --format='%h %s' "${sha}")"
done
exit 1
fi
added_count="$(printf '%s\n' "${all_added}" | grep -c . || true)"
echo "Source branch is cut directly from ${LATEST_TAG} with ${added_count} commit(s) on top."
- name: Validate PR exists, is open, named correctly, has latest commit, and checks pass
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }}
SOURCE_COMMIT: ${{ inputs.commit }}
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
REPO: ${{ github.repository }}
run: |
set -euo pipefail
expected_title="ComfyUI backport release ${NEW_VERSION}"
# Find open PRs from this branch into master. The --state open filter
# is load-bearing: a closed/merged PR with passing checks must not be
# accepted as authorization for a new release.
pr_json="$(
gh pr list \
--repo "${REPO}" \
--state open \
--head "${SOURCE_BRANCH}" \
--base master \
--json number,title,headRefOid,state \
--limit 10
)"
pr_count="$(echo "${pr_json}" | jq 'length')"
if [[ "${pr_count}" -eq 0 ]]; then
echo "::error::No open PR found from '${SOURCE_BRANCH}' into 'master'. The PR must exist and be open."
exit 1
fi
# Pick the PR matching the expected title
pr_number="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" '
map(select(.title == $t)) | .[0].number // empty
')"
pr_head_sha="$(echo "${pr_json}" | jq -r --arg t "${expected_title}" '
map(select(.title == $t)) | .[0].headRefOid // empty
')"
if [[ -z "${pr_number}" ]]; then
echo "::error::No open PR from '${SOURCE_BRANCH}' into 'master' is titled '${expected_title}'."
echo "Found PRs:"
echo "${pr_json}" | jq -r '.[] | " #\(.number): \(.title)"'
exit 1
fi
# The PR's current head commit must equal the SHA the operator gave us.
# This is what closes the door on releasing stale code: if anyone has
# pushed to the branch since the operator validated tests passed, the
# PR head will have advanced past SOURCE_COMMIT and we abort. (The
# resolve step already proved the branch tip == SOURCE_COMMIT; this
# ties that same SHA to the PR that authorizes the release.)
if [[ "${pr_head_sha}" != "${SOURCE_COMMIT}" ]]; then
echo "::error::PR #${pr_number} head commit is ${pr_head_sha}, but the operator-provided commit is ${SOURCE_COMMIT}."
echo "::error::The PR has new commits since this release was authorized. Re-run with the new head SHA after verifying its checks."
exit 1
fi
echo "Found open PR #${pr_number} titled '${expected_title}' at head ${pr_head_sha} (matches operator-provided commit)."
# Verify all check runs on the head commit have completed successfully.
# A check is considered passing if conclusion is success, neutral, or skipped.
checks_json="$(
gh api \
--paginate \
"repos/${REPO}/commits/${pr_head_sha}/check-runs" \
--jq '.check_runs[] | {name: .name, status: .status, conclusion: .conclusion}'
)"
if [[ -z "${checks_json}" ]]; then
echo "::error::No check runs found on PR head commit ${pr_head_sha}."
exit 1
fi
echo "Check runs on ${pr_head_sha}:"
echo "${checks_json}" | jq -s '.'
failing="$(echo "${checks_json}" | jq -s '
map(select(
.status != "completed"
or (.conclusion as $c
| ["success","neutral","skipped"]
| index($c) | not)
))
')"
failing_count="$(echo "${failing}" | jq 'length')"
if [[ "${failing_count}" -gt 0 ]]; then
echo "::error::One or more checks have not passed on PR head commit ${pr_head_sha}:"
echo "${failing}" | jq -r '.[] | " - \(.name): status=\(.status) conclusion=\(.conclusion)"'
exit 1
fi
echo "All checks have passed on ${pr_head_sha}."
- name: Prepare release branch
id: prepare
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
REPO: ${{ github.repository }}
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
LATEST_TAG_SHA: ${{ steps.latest.outputs.latest_sha }}
PATCH: ${{ steps.latest.outputs.patch }}
run: |
set -euo pipefail
# Try to fetch the release branch. If patch == 0, it shouldn't exist yet
# and we'll create it from the latest stable tag. If patch > 0, it must
# already exist and its tip must equal the latest stable tag commit (i.e.
# the previous patch release).
if git ls-remote --exit-code --heads origin "${RELEASE_BRANCH}" >/dev/null 2>&1; then
echo "Release branch '${RELEASE_BRANCH}' already exists on origin."
git fetch origin "refs/heads/${RELEASE_BRANCH}:refs/remotes/origin/${RELEASE_BRANCH}"
git checkout -B "${RELEASE_BRANCH}" "refs/remotes/origin/${RELEASE_BRANCH}"
current_tip="$(git rev-parse HEAD)"
if [[ "${current_tip}" != "${LATEST_TAG_SHA}" ]]; then
echo "::error::Release branch '${RELEASE_BRANCH}' tip (${current_tip}) is not at the latest stable release '${LATEST_TAG}' (${LATEST_TAG_SHA})."
echo "::error::Refusing to release on top of a divergent branch."
exit 1
fi
echo "branch_existed=true" >> "$GITHUB_OUTPUT"
else
if [[ "${PATCH}" != "0" ]]; then
echo "::error::Release branch '${RELEASE_BRANCH}' does not exist on origin, but the latest stable release '${LATEST_TAG}' has patch=${PATCH} (>0). This is inconsistent."
exit 1
fi
echo "Release branch '${RELEASE_BRANCH}' does not exist. Creating from ${LATEST_TAG}."
git checkout -B "${RELEASE_BRANCH}" "refs/tags/${LATEST_TAG}"
echo "branch_existed=false" >> "$GITHUB_OUTPUT"
fi
- name: Fast-forward merge source branch into release branch
env:
SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }}
SOURCE_COMMIT: ${{ inputs.commit }}
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
run: |
set -euo pipefail
# --ff-only guarantees no merge commit is created. If a fast-forward is
# not possible (i.e. the release branch has commits the source branch
# doesn't), the merge will fail and we abort. Because we already validated
# that the source branch is rooted on the latest stable tag, and the
# release branch tip equals that same tag, this fast-forward should
# always succeed for a well-formed backport branch.
#
# We merge the operator-provided SHA, not the branch ref, so a push to
# the branch in the window between resolve and now cannot smuggle new
# commits into the release.
if ! git merge --ff-only "${SOURCE_COMMIT}"; then
echo "::error::Cannot fast-forward '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}'). A merge commit would be required. Aborting."
exit 1
fi
echo "Fast-forwarded '${RELEASE_BRANCH}' to ${SOURCE_COMMIT} (tip of '${SOURCE_BRANCH}')."
- name: Bump version files
env:
NEW_VERSION_NO_V: ${{ steps.latest.outputs.new_version_no_v }}
run: |
set -euo pipefail
if [[ ! -f comfyui_version.py ]]; then
echo "::error::comfyui_version.py not found in repo root."
exit 1
fi
if [[ ! -f pyproject.toml ]]; then
echo "::error::pyproject.toml not found in repo root."
exit 1
fi
# Replace the version string in comfyui_version.py.
# Expected format: __version__ = "X.Y.Z"
python3 - "$NEW_VERSION_NO_V" <<'PY'
import re, sys, pathlib
new = sys.argv[1]
p = pathlib.Path("comfyui_version.py")
src = p.read_text()
new_src, n = re.subn(
r'(__version__\s*=\s*[\'"])[^\'"]+([\'"])',
lambda m: f'{m.group(1)}{new}{m.group(2)}',
src,
count=1,
)
if n != 1:
sys.exit("Could not find __version__ assignment in comfyui_version.py")
p.write_text(new_src)
p = pathlib.Path("pyproject.toml")
src = p.read_text()
# Replace the first `version = "..."` inside [project] or [tool.poetry].
new_src, n = re.subn(
r'(?m)^(version\s*=\s*")[^"]+(")',
lambda m: f'{m.group(1)}{new}{m.group(2)}',
src,
count=1,
)
if n != 1:
sys.exit("Could not find version assignment in pyproject.toml")
p.write_text(new_src)
PY
echo "Updated version to ${NEW_VERSION_NO_V} in comfyui_version.py and pyproject.toml."
git --no-pager diff -- comfyui_version.py pyproject.toml
- name: Commit version bump and tag release
env:
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
run: |
set -euo pipefail
git add comfyui_version.py pyproject.toml
git commit -m "ComfyUI ${NEW_VERSION}"
if git rev-parse -q --verify "refs/tags/${NEW_VERSION}" >/dev/null; then
echo "::error::Tag ${NEW_VERSION} already exists locally."
exit 1
fi
git tag "${NEW_VERSION}"
- name: Verify tag does not already exist on origin
env:
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
run: |
set -euo pipefail
if git ls-remote --exit-code --tags origin "refs/tags/${NEW_VERSION}" >/dev/null 2>&1; then
echo "::error::Tag ${NEW_VERSION} already exists on origin. Aborting."
exit 1
fi
- name: Push release branch and tag
env:
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
run: |
set -euo pipefail
# Push the branch first, then the tag. Atomic-ish: if the branch push
# fails we never publish the tag.
git push origin "refs/heads/${RELEASE_BRANCH}:refs/heads/${RELEASE_BRANCH}"
git push origin "refs/tags/${NEW_VERSION}"
echo "Released ${NEW_VERSION} on ${RELEASE_BRANCH}."
- name: Summary
if: always()
env:
NEW_VERSION: ${{ steps.latest.outputs.new_version }}
RELEASE_BRANCH: ${{ steps.latest.outputs.release_branch }}
LATEST_TAG: ${{ steps.latest.outputs.latest_tag }}
SOURCE_BRANCH: ${{ steps.resolve.outputs.source_branch }}
SOURCE_COMMIT: ${{ inputs.commit }}
run: |
# SOURCE_BRANCH is empty if the resolve step never produced an output
# (e.g. the workflow failed in or before that step). Show a placeholder
# in that case so the summary table still renders cleanly.
source_branch_display="${SOURCE_BRANCH:-(unresolved)}"
{
echo "## Backport release"
echo ""
echo "| Field | Value |"
echo "|---|---|"
echo "| Source commit | \`${SOURCE_COMMIT}\` |"
echo "| Source branch | \`${source_branch_display}\` |"
echo "| Previous stable | \`${LATEST_TAG}\` |"
echo "| New version | \`${NEW_VERSION}\` |"
echo "| Release branch | \`${RELEASE_BRANCH}\` |"
} >> "$GITHUB_STEP_SUMMARY"

View File

@ -20,7 +20,7 @@
[website-url]: https://www.comfy.org/
<!-- Workaround to display total user from https://github.com/badges/shields/issues/4500#issuecomment-2060079995 -->
[discord-shield]: https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fdiscord.com%2Fapi%2Finvites%2Fcomfyorg%3Fwith_counts%3Dtrue&query=%24.approximate_member_count&logo=discord&logoColor=white&label=Discord&color=green&suffix=%20total
[discord-url]: https://discord.com/invite/comfyorg
[discord-url]: https://www.comfy.org/discord
[twitter-shield]: https://img.shields.io/twitter/follow/ComfyUI
[twitter-url]: https://x.com/ComfyUI

View File

@ -62,8 +62,6 @@ def get_comfy_package_versions():
def check_comfy_packages_versions():
"""Warn for every comfy* package whose installed version is below requirements.txt."""
from packaging.version import InvalidVersion, parse as parse_pep440
outdated_packages = []
for pkg in get_comfy_package_versions():
installed_str = pkg["installed"]
required_str = pkg["required"]
@ -75,26 +73,19 @@ def check_comfy_packages_versions():
logging.error(f"Failed to check {pkg['name']} version: {e}")
continue
if outdated:
outdated_packages.append((pkg["name"], installed_str, required_str))
else:
logging.info("{} version: {}".format(pkg["name"], installed_str))
if outdated_packages:
package_warnings = "\n".join(
f"Installed {name} version {installed} is lower than the recommended version {required}."
for name, installed, required in outdated_packages
)
app.logger.log_startup_warning(
f"""
app.logger.log_startup_warning(
f"""
________________________________________________________________________
WARNING WARNING WARNING WARNING WARNING
{package_warnings}
Installed {pkg["name"]} version {installed_str} is lower than the recommended version {required_str}.
{get_missing_requirements_message()}
________________________________________________________________________
""".strip()
)
)
else:
logging.info("{} version: {}".format(pkg["name"], installed_str))
REQUEST_TIMEOUT = 10 # seconds

View File

@ -17,8 +17,7 @@ class MESH:
vertex_colors: torch.Tensor | None = None,
texture: torch.Tensor | None = None,
vertex_counts: torch.Tensor | None = None,
face_counts: torch.Tensor | None = None,
material_props: dict | None = None):
face_counts: torch.Tensor | None = None):
assert (vertex_counts is None) == (face_counts is None), \
"vertex_counts and face_counts must be provided together (both or neither)"
@ -31,7 +30,6 @@ class MESH:
# these hold the real per-item lengths (B,). None means rows are uniform and no slicing is needed.
self.vertex_counts = vertex_counts
self.face_counts = face_counts
self.material_props = material_props
class File3D:

View File

@ -3,141 +3,15 @@ import folder_paths
import os
import uuid
import numpy as np
import torch
from typing_extensions import override
from comfy_api.latest import IO, UI, ComfyExtension, InputImpl, Types
from pathlib import Path
_SUPPORTED_MESH_FORMATS = {"glb", "obj"}
def normalize_path(path):
return path.replace('\\', '/')
def _normalize_color_factor(value, length: int):
# trimesh stores baseColorFactor/emissiveFactor as either uint8 (0-255) or float (0-1).
# glTF spec values are float [0, 1]; normalize here.
arr = np.asarray(value, dtype=np.float64).reshape(-1)
if arr.size < length:
return None
arr = arr[:length]
if np.issubdtype(np.asarray(value).dtype, np.integer) or arr.max() > 1.0 + 1e-6:
arr = arr / 255.0
return tuple(float(x) for x in np.clip(arr, 0.0, 1.0))
def _extract_material_props(material) -> dict | None:
if material is None:
return None
props: dict = {}
bcf = getattr(material, "baseColorFactor", None)
if bcf is not None:
v = _normalize_color_factor(bcf, 4)
if v is not None:
props["base_color_factor"] = v
ef = getattr(material, "emissiveFactor", None)
if ef is not None:
v = _normalize_color_factor(ef, 3)
if v is not None:
props["emissive_factor"] = v
for src_attr, dst_key in (
("metallicFactor", "metallic_factor"),
("roughnessFactor", "roughness_factor"),
("alphaCutoff", "alpha_cutoff"),
):
v = getattr(material, src_attr, None)
if v is not None:
props[dst_key] = float(v)
ds = getattr(material, "doubleSided", None)
if ds is not None:
props["double_sided"] = bool(ds)
am = getattr(material, "alphaMode", None)
if am is not None:
props["alpha_mode"] = getattr(am, "name", None) or str(am)
if "base_color_factor" not in props:
# SimpleMaterial.diffuse always exists and defaults to [102, 102, 102, 255]
# (40% gray) even when the source MTL doesn't declare Kd. Compare against the
# trimesh default to avoid silently darkening textures that only specified map_Kd.
diffuse = getattr(material, "diffuse", None)
if diffuse is not None:
d_arr = np.asarray(diffuse)
is_default = (d_arr.dtype == np.uint8 and d_arr.shape == (4,)
and bool(np.array_equal(d_arr, [102, 102, 102, 255])))
if not is_default:
v = _normalize_color_factor(diffuse, 4)
if v is not None:
props["base_color_factor"] = v
return props or None
def _file3d_to_mesh(file_3d: Types.File3D) -> Types.MESH:
import trimesh
fmt = (file_3d.format or "").lower()
if fmt not in _SUPPORTED_MESH_FORMATS:
raise ValueError(
f"File3DToMesh only supports {sorted(_SUPPORTED_MESH_FORMATS)}, got '.{fmt}'"
)
source = file_3d.get_source() if file_3d.is_disk_backed else file_3d.get_data()
loaded = trimesh.load(source, file_type=fmt, process=False)
if isinstance(loaded, trimesh.Scene):
geometries = [g for g in loaded.dump(concatenate=False) if isinstance(g, trimesh.Trimesh)]
if not geometries:
raise ValueError("File3DToMesh: scene contains no triangle meshes")
mesh = trimesh.util.concatenate(geometries) if len(geometries) > 1 else geometries[0]
elif isinstance(loaded, trimesh.Trimesh):
mesh = loaded
else:
raise ValueError(f"File3DToMesh: unsupported geometry type '{type(loaded).__name__}'")
if len(mesh.faces) == 0:
raise ValueError("File3DToMesh: mesh has no faces (point clouds are not supported)")
vertices = torch.from_numpy(np.ascontiguousarray(mesh.vertices, dtype=np.float32)).unsqueeze(0)
faces = torch.from_numpy(np.ascontiguousarray(mesh.faces, dtype=np.int64)).unsqueeze(0)
n_verts = vertices.shape[1]
uvs = None
vertex_colors = None
texture = None
material_props = None
visual = getattr(mesh, "visual", None)
if visual is not None:
uv = getattr(visual, "uv", None)
if uv is not None and len(uv) == n_verts:
uvs = torch.from_numpy(np.ascontiguousarray(uv, dtype=np.float32)).unsqueeze(0)
try:
vc = getattr(visual, "vertex_colors", None)
except (AttributeError, ValueError, KeyError):
vc = None
if vc is not None and len(vc) == n_verts:
vc_arr = np.asarray(vc, dtype=np.float32) / 255.0
if vc_arr.ndim == 2 and vc_arr.shape[1] >= 3:
vc_arr = vc_arr[:, :4] if vc_arr.shape[1] >= 4 else vc_arr[:, :3]
vertex_colors = torch.from_numpy(np.ascontiguousarray(vc_arr)).unsqueeze(0)
material = getattr(visual, "material", None)
if material is not None:
tex_img = getattr(material, "baseColorTexture", None) or getattr(material, "image", None)
if tex_img is not None:
tex_np = np.asarray(tex_img.convert("RGB"), dtype=np.float32) / 255.0
texture = torch.from_numpy(np.ascontiguousarray(tex_np)).unsqueeze(0)
material_props = _extract_material_props(material)
return Types.MESH(vertices, faces, uvs=uvs, vertex_colors=vertex_colors,
texture=texture, material_props=material_props)
class Load3D(IO.ComfyNode):
@classmethod
def define_schema(cls):
@ -244,39 +118,12 @@ class Preview3D(IO.ComfyNode):
process = execute # TODO: remove
class File3DToMesh(IO.ComfyNode):
@classmethod
def define_schema(cls):
return IO.Schema(
node_id="File3DToMesh",
display_name="File3D to Mesh",
search_aliases=["parse 3d file", "load mesh"],
category="3d",
is_experimental=True,
inputs=[
IO.MultiType.Input(
IO.File3DAny.Input("file_3d"),
types=[IO.File3DGLB, IO.File3DOBJ],
tooltip="3D file to parse into a MESH (.glb or .obj only)",
),
],
outputs=[
IO.Mesh.Output(),
],
)
@classmethod
def execute(cls, file_3d: Types.File3D) -> IO.NodeOutput:
return IO.NodeOutput(_file3d_to_mesh(file_3d))
class Load3DExtension(ComfyExtension):
@override
async def get_node_list(self) -> list[type[IO.ComfyNode]]:
return [
Load3D,
Preview3D,
File3DToMesh,
]

View File

@ -8,82 +8,6 @@ from comfy_api.latest import _io
MISSING = object()
class NotNode(io.ComfyNode):
@classmethod
def define_schema(cls):
return io.Schema(
node_id="ComfyNotNode",
display_name="Not",
category="utils/logic",
description="Logical NOT operation. Returns true if the value is falsy. Uses Python's rules for truthiness.",
search_aliases=["invert", "toggle", "negate", "flip boolean"],
inputs=[
io.AnyType.Input("value"),
],
outputs=[
io.Boolean.Output(),
],
)
@classmethod
def execute(cls, value) -> io.NodeOutput:
return io.NodeOutput(not value)
class AndNode(io.ComfyNode):
@classmethod
def define_schema(cls):
template = io.Autogrow.TemplatePrefix(
input=io.AnyType.Input("value"),
prefix="value",
min=1,
)
return io.Schema(
node_id="ComfyAndNode",
display_name="And",
category="utils/logic",
description="Logical AND operation. Returns true if all of the values are truthy. Uses Python's rules for truthiness.",
search_aliases=["all", "every"],
inputs=[
io.Autogrow.Input("values", template=template),
],
outputs=[
io.Boolean.Output(),
],
)
@classmethod
def execute(cls, values: io.Autogrow.Type) -> io.NodeOutput:
return io.NodeOutput(all(values.values()))
class OrNode(io.ComfyNode):
@classmethod
def define_schema(cls):
template = io.Autogrow.TemplatePrefix(
input=io.AnyType.Input("value"),
prefix="value",
min=1,
)
return io.Schema(
node_id="ComfyOrNode",
display_name="Or",
category="utils/logic",
description="Logical OR operation. Returns true if any of the values are truthy. Uses Python's rules for truthiness.",
search_aliases=["any", "some"],
inputs=[
io.Autogrow.Input("values", template=template),
],
outputs=[
io.Boolean.Output(),
],
)
@classmethod
def execute(cls, values: io.Autogrow.Type) -> io.NodeOutput:
return io.NodeOutput(any(values.values()))
class SwitchNode(io.ComfyNode):
@classmethod
def define_schema(cls):
@ -337,9 +261,6 @@ class LogicExtension(ComfyExtension):
return [
SwitchNode,
CustomComboNode,
NotNode,
AndNode,
OrNode,
# SoftSwitchNode,
# ConvertStringToComboNode,
# DCTestNode,

View File

@ -16,12 +16,11 @@ from comfy.cli_args import args
from comfy_api.latest import ComfyExtension, IO, Types
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None, material_props=None):
def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=None):
# Pack lists of (Nᵢ, *) vertex/face/color/uv tensors into padded batched tensors,
# stashing per-item lengths as runtime attrs so consumers can recover the real slice.
# colors and uvs are 1:1 with vertices, so they're padded to max_vertices and read with vertex_counts.
# texture is (B, H, W, 3) — passed through unchanged
# material_props is shared across the batch — passed through unchanged
batch_size = len(vertices)
max_vertices = max(v.shape[0] for v in vertices)
max_faces = max(f.shape[0] for f in faces)
@ -55,8 +54,7 @@ def pack_variable_mesh_batch(vertices, faces, colors=None, uvs=None, texture=Non
return Types.MESH(packed_vertices, packed_faces,
uvs=packed_uvs, vertex_colors=packed_colors, texture=texture,
vertex_counts=vertex_counts, face_counts=face_counts,
material_props=material_props)
vertex_counts=vertex_counts, face_counts=face_counts)
def get_mesh_batch_item(mesh, index):
@ -79,8 +77,7 @@ def get_mesh_batch_item(mesh, index):
def save_glb(vertices, faces, filepath, metadata=None,
uvs=None, vertex_colors=None, texture_image=None,
material_props=None):
uvs=None, vertex_colors=None, texture_image=None):
"""
Save PyTorch tensor vertices and faces as a GLB file without external dependencies.
@ -89,25 +86,15 @@ def save_glb(vertices, faces, filepath, metadata=None,
faces: torch.Tensor of shape (M, 3) - The face indices (triangle faces)
filepath: str - Output filepath (should end with .glb)
metadata: dict - Optional asset.extras metadata
uvs: torch.Tensor of shape (N, 2) - Optional per-vertex texture coordinates in OpenGL/trimesh
convention (V=0 at bottom of texture). save_glb flips V
to satisfy the glTF spec convention (V=0 at top) on disk.
uvs: torch.Tensor of shape (N, 2) - Optional per-vertex texture coordinates
vertex_colors: torch.Tensor of shape (N, 3) or (N, 4) - Optional per-vertex colors in [0, 1]
texture_image: PIL.Image - Optional baseColor texture, embedded as PNG
material_props: dict - Optional PBR factors
"""
# Convert tensors to numpy arrays
vertices_np = vertices.cpu().numpy().astype(np.float32)
faces_signed = faces.cpu().numpy().astype(np.int64)
uvs_np = uvs.cpu().numpy().astype(np.float32) if uvs is not None else None
if uvs_np is not None:
# MESH stores UVs with V=0 at the bottom of the texture (OpenGL / trimesh / OBJ
# convention). glTF stores V=0 at the top of the texture. Flip V here so the
# written GLB renders correctly in spec-compliant viewers (Three.js, glTF Sample
# Viewer, etc.). Copy first to avoid mutating the caller's tensor-backed array.
uvs_np = uvs_np.copy()
uvs_np[:, 1] = 1.0 - uvs_np[:, 1]
colors_np = vertex_colors.cpu().numpy().astype(np.float32) if vertex_colors is not None else None
if colors_np is not None:
colors_np = np.clip(colors_np, 0.0, 1.0)
@ -247,53 +234,23 @@ def save_glb(vertices, faces, filepath, metadata=None,
textures = []
samplers = []
materials = []
write_texture = texture_png_bytes is not None and "TEXCOORD_0" in primitive_attributes
if write_texture or material_props:
pbr: dict = {}
material: dict = {"pbrMetallicRoughness": pbr}
if write_texture:
buffer_views.append({
"buffer": 0,
"byteOffset": texture_byte_offset,
"byteLength": len(texture_buffer),
})
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
textures.append({"source": 0, "sampler": 0})
pbr["baseColorTexture"] = {"index": 0, "texCoord": 0}
if material_props is None:
# Legacy default: matte plastic, double-sided. Kept for backward compatibility
# with producers (e.g., VoxelToMesh) that never carried PBR factors.
if write_texture:
pbr["metallicFactor"] = 0.0
pbr["roughnessFactor"] = 1.0
material["doubleSided"] = True
else:
bcf = material_props.get("base_color_factor")
if bcf is not None:
pbr["baseColorFactor"] = [float(x) for x in bcf]
mf = material_props.get("metallic_factor")
if mf is not None:
pbr["metallicFactor"] = float(mf)
rf = material_props.get("roughness_factor")
if rf is not None:
pbr["roughnessFactor"] = float(rf)
ef = material_props.get("emissive_factor")
if ef is not None:
material["emissiveFactor"] = [float(x) for x in ef]
ds = material_props.get("double_sided")
if ds is not None:
material["doubleSided"] = bool(ds)
am = material_props.get("alpha_mode")
if am is not None:
material["alphaMode"] = str(am)
ac = material_props.get("alpha_cutoff")
if ac is not None:
material["alphaCutoff"] = float(ac)
materials.append(material)
if texture_png_bytes is not None and "TEXCOORD_0" in primitive_attributes:
buffer_views.append({
"buffer": 0,
"byteOffset": texture_byte_offset,
"byteLength": len(texture_buffer),
})
images.append({"bufferView": len(buffer_views) - 1, "mimeType": "image/png"})
samplers.append({"magFilter": 9729, "minFilter": 9729, "wrapS": 33071, "wrapT": 33071})
textures.append({"source": 0, "sampler": 0})
materials.append({
"pbrMetallicRoughness": {
"baseColorTexture": {"index": 0, "texCoord": 0},
"metallicFactor": 0.0,
"roughnessFactor": 1.0,
},
"doubleSided": True,
})
primitive["material"] = 0
gltf = {
@ -401,7 +358,7 @@ class SaveGLB(IO.ComfyNode):
})
counter += 1
else:
# Handle Mesh input - save vertices and faces as GLB; carry optional UVs / colors / texture / material props.
# Handle Mesh input - save vertices and faces as GLB; carry optional UVs / colors / texture.
texture_b = getattr(mesh, "texture", None)
texture_np = None
if texture_b is not None:
@ -409,7 +366,6 @@ class SaveGLB(IO.ComfyNode):
assert texture_np.ndim == 4 and texture_np.shape[-1] == 3, (
f"texture must be (B, H, W, 3) RGB, got shape {tuple(texture_np.shape)}"
)
material_props = getattr(mesh, "material_props", None)
for i in range(mesh.vertices.shape[0]):
vertices_i, faces_i, v_colors, uvs_i = get_mesh_batch_item(mesh, i)
if vertices_i.shape[0] == 0 or faces_i.shape[0] == 0:
@ -420,8 +376,7 @@ class SaveGLB(IO.ComfyNode):
save_glb(vertices_i, faces_i, os.path.join(full_output_folder, f), metadata,
uvs=uvs_i,
vertex_colors=v_colors,
texture_image=tex_img,
material_props=material_props)
texture_image=tex_img)
results.append({
"filename": f,
"subfolder": subfolder,

33
main.py
View File

@ -27,6 +27,7 @@ from utils.mime_types import init_mime_types
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
@ -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
@ -148,6 +162,15 @@ def execute_prestartup_script():
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")
@ -207,6 +230,16 @@ import execution
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

View File

@ -2158,6 +2158,71 @@ EXTENSION_WEB_DIRS = {}
# 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] = {}
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:
"""
@ -2267,14 +2332,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():

View File

@ -35,4 +35,3 @@ pydantic~=2.0
pydantic-settings~=2.0
PyOpenGL
glfw
trimesh>=4.0

View File

@ -765,6 +765,29 @@ class PromptServer():
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.
"""
grouped: dict[str, dict[str, dict]] = {}
for entry in nodes.NODE_STARTUP_ERRORS.values():
source = entry.get("source", "custom_nodes")
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")
async def get_jobs(request):
"""List all jobs with filtering, sorting, and pagination.

View File

@ -0,0 +1,146 @@
"""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.
- pyproject.toml metadata is attached when present and omitted when absent.
"""
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_nodes",
phase="import",
error=err,
tb="traceback-text",
)
assert "custom_nodes:my_pack" in nodes.NODE_STARTUP_ERRORS
entry = nodes.NODE_STARTUP_ERRORS["custom_nodes:my_pack"]
assert entry["source"] == "custom_nodes"
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",
["custom_nodes", "comfy_extras", "comfy_api_nodes"],
)
async def test_load_custom_node_records_source(tmp_path, module_parent):
# `source` in the entry should be the same string as `module_parent`.
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"{module_parent}:broken_pack"
assert key in nodes.NODE_STARTUP_ERRORS, nodes.NODE_STARTUP_ERRORS
entry = nodes.NODE_STARTUP_ERRORS[key]
assert entry["source"] == module_parent
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_nodes:nodes_audio" in nodes.NODE_STARTUP_ERRORS
assert "comfy_extras:nodes_audio" in nodes.NODE_STARTUP_ERRORS
assert (
nodes.NODE_STARTUP_ERRORS["custom_nodes:nodes_audio"]["module_path"]
!= nodes.NODE_STARTUP_ERRORS["comfy_extras: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_nodes: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_extras:lonely"]
assert "pyproject" not in entry
@pytest.mark.asyncio
async def test_load_custom_node_arbitrary_module_parent_passes_through(tmp_path):
# `source` is a free-form string — an unknown module_parent (e.g. a future
# node-source bucket) should be recorded as-is, not coerced or rejected.
module_path = _write_broken_module(tmp_path, "future_pack")
assert await nodes.load_custom_node(module_path, module_parent="future_source") is False
entry = nodes.NODE_STARTUP_ERRORS["future_source:future_pack"]
assert entry["source"] == "future_source"