Skip to content

Commit c867354

Browse files
authored
fix: make NodeReplaceManager.register() idempotent (Comfy-Org#13596)
1 parent df7bf1d commit c867354

2 files changed

Lines changed: 108 additions & 2 deletions

File tree

app/node_replace_manager.py

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
from __future__ import annotations
22

3+
import logging
4+
35
from aiohttp import web
46

57
from typing import TYPE_CHECKING, TypedDict
@@ -31,8 +33,22 @@ def __init__(self):
3133
self._replacements: dict[str, list[NodeReplace]] = {}
3234

3335
def register(self, node_replace: NodeReplace):
34-
"""Register a node replacement mapping."""
35-
self._replacements.setdefault(node_replace.old_node_id, []).append(node_replace)
36+
"""Register a node replacement mapping.
37+
38+
Idempotent: if a replacement with the same (old_node_id, new_node_id)
39+
is already registered, the duplicate is ignored. This prevents stale
40+
entries from accumulating when custom nodes are reloaded in the same
41+
process (e.g. via ComfyUI-Manager).
42+
"""
43+
existing = self._replacements.setdefault(node_replace.old_node_id, [])
44+
for entry in existing:
45+
if entry.new_node_id == node_replace.new_node_id:
46+
logging.debug(
47+
"Node replacement %s -> %s already registered, ignoring duplicate.",
48+
node_replace.old_node_id, node_replace.new_node_id,
49+
)
50+
return
51+
existing.append(node_replace)
3652

3753
def get_replacement(self, old_node_id: str) -> list[NodeReplace] | None:
3854
"""Get replacements for an old node ID."""
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Tests for NodeReplaceManager registration behavior."""
2+
import importlib
3+
import sys
4+
import types
5+
6+
import pytest
7+
8+
9+
@pytest.fixture
10+
def NodeReplaceManager(monkeypatch):
11+
"""Provide NodeReplaceManager with `nodes` stubbed.
12+
13+
`app.node_replace_manager` does `import nodes` at module level, which pulls in
14+
torch + the full ComfyUI graph. register() doesn't actually need it, so we
15+
stub `nodes` per-test (via monkeypatch so it's torn down) and reload the
16+
module so it picks up the stub instead of any cached real import.
17+
"""
18+
fake_nodes = types.ModuleType("nodes")
19+
fake_nodes.NODE_CLASS_MAPPINGS = {}
20+
monkeypatch.setitem(sys.modules, "nodes", fake_nodes)
21+
monkeypatch.delitem(sys.modules, "app.node_replace_manager", raising=False)
22+
module = importlib.import_module("app.node_replace_manager")
23+
yield module.NodeReplaceManager
24+
# Drop the freshly-imported module so the next test (or a later real import
25+
# of `nodes`) starts from a clean slate.
26+
sys.modules.pop("app.node_replace_manager", None)
27+
28+
29+
class FakeNodeReplace:
30+
"""Lightweight stand-in for comfy_api.latest._io.NodeReplace."""
31+
def __init__(self, new_node_id, old_node_id, old_widget_ids=None,
32+
input_mapping=None, output_mapping=None):
33+
self.new_node_id = new_node_id
34+
self.old_node_id = old_node_id
35+
self.old_widget_ids = old_widget_ids
36+
self.input_mapping = input_mapping
37+
self.output_mapping = output_mapping
38+
39+
40+
def test_register_adds_replacement(NodeReplaceManager):
41+
manager = NodeReplaceManager()
42+
manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode"))
43+
assert manager.has_replacement("OldNode")
44+
assert len(manager.get_replacement("OldNode")) == 1
45+
46+
47+
def test_register_allows_multiple_alternatives_for_same_old_node(NodeReplaceManager):
48+
"""Different new_node_ids for the same old_node_id should all be kept."""
49+
manager = NodeReplaceManager()
50+
manager.register(FakeNodeReplace(new_node_id="AltA", old_node_id="OldNode"))
51+
manager.register(FakeNodeReplace(new_node_id="AltB", old_node_id="OldNode"))
52+
replacements = manager.get_replacement("OldNode")
53+
assert len(replacements) == 2
54+
assert {r.new_node_id for r in replacements} == {"AltA", "AltB"}
55+
56+
57+
def test_register_is_idempotent_for_duplicate_pair(NodeReplaceManager):
58+
"""Re-registering the same (old_node_id, new_node_id) should be a no-op."""
59+
manager = NodeReplaceManager()
60+
manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode"))
61+
manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode"))
62+
manager.register(FakeNodeReplace(new_node_id="NewNode", old_node_id="OldNode"))
63+
assert len(manager.get_replacement("OldNode")) == 1
64+
65+
66+
def test_register_idempotent_preserves_first_registration(NodeReplaceManager):
67+
"""First registration wins; later duplicates with different mappings are ignored."""
68+
manager = NodeReplaceManager()
69+
first = FakeNodeReplace(
70+
new_node_id="NewNode", old_node_id="OldNode",
71+
input_mapping=[{"new_id": "a", "old_id": "x"}],
72+
)
73+
second = FakeNodeReplace(
74+
new_node_id="NewNode", old_node_id="OldNode",
75+
input_mapping=[{"new_id": "b", "old_id": "y"}],
76+
)
77+
manager.register(first)
78+
manager.register(second)
79+
replacements = manager.get_replacement("OldNode")
80+
assert len(replacements) == 1
81+
assert replacements[0] is first
82+
83+
84+
def test_register_dedupe_does_not_affect_other_old_nodes(NodeReplaceManager):
85+
manager = NodeReplaceManager()
86+
manager.register(FakeNodeReplace(new_node_id="NewA", old_node_id="OldA"))
87+
manager.register(FakeNodeReplace(new_node_id="NewA", old_node_id="OldA"))
88+
manager.register(FakeNodeReplace(new_node_id="NewB", old_node_id="OldB"))
89+
assert len(manager.get_replacement("OldA")) == 1
90+
assert len(manager.get_replacement("OldB")) == 1

0 commit comments

Comments
 (0)