From 82c954bd2aa83b530d45972ea9f025ebdfabb834 Mon Sep 17 00:00:00 2001 From: Wei Hai Date: Fri, 26 Jun 2026 15:53:34 -0700 Subject: [PATCH 1/2] Validate that a node is executable before running the prompt A node whose FUNCTION points at a method that does not exist (e.g. a typo in a custom node), or a V3 node missing its execute override, was only detected once that node ran -- after every upstream node had already executed. In a multi-node workflow the user waited for the whole graph to run up to the broken node before seeing the error. validate_prompt already walks every node before execution; add an executability check there so the error is reported up front and attributed to the offending node (returned in node_errors), and nothing runs. The check resolves the V1 FUNCTION method on the class (the common case) and falls back to an instance, since the runtime invokes it on an instance and a node may define FUNCTION or its method in __init__. V3 nodes are checked via their existing VALIDATE_CLASS. Add tests for V1 typo, V3 typo, good nodes, and a node whose method is defined in __init__ (must not be falsely rejected). --- execution.py | 60 ++++++++ .../validate_node_executable_test.py | 134 ++++++++++++++++++ 2 files changed, 194 insertions(+) create mode 100644 tests-unit/execution_test/validate_node_executable_test.py diff --git a/execution.py b/execution.py index c45317593..ca8a02846 100644 --- a/execution.py +++ b/execution.py @@ -1113,6 +1113,37 @@ def full_type_name(klass): return klass.__qualname__ return module + '.' + klass.__qualname__ +def _v1_function_resolves(node): + """Whether node.FUNCTION names a callable on node (a class or an instance).""" + function_name = getattr(node, "FUNCTION", None) + return function_name is not None and callable(getattr(node, function_name, None)) + + +def node_not_executable_reason(class_def, class_type): + """Return a human-readable reason the node cannot be executed, or None if it's fine. + + Catches a node whose declared entry point doesn't resolve to a real method + (e.g. a V1 ``FUNCTION = "invert"`` where the method is misspelled, or a V3 node + missing its ``execute`` override). Running this during validation surfaces the + problem before execution starts, instead of after upstream nodes have run. + """ + try: + if issubclass(class_def, _ComfyNodeInternal): + # V3: validates that execute()/define_schema() overrides exist. + class_def.VALIDATE_CLASS() + return None + # V1: FUNCTION names the method to call. Check the class first (the common + # case); fall back to an instance, since the node is invoked on an instance + # and may define FUNCTION or its method in __init__. + if _v1_function_resolves(class_def) or _v1_function_resolves(class_def()): + return None + function_name = getattr(class_def, "FUNCTION", None) + if function_name is None: + return f"'{class_type}' does not define FUNCTION" + return f"'{class_type}' has no method '{function_name}' (declared in FUNCTION)" + except Exception as ex: + return str(ex) + async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[str], None]): outputs = set() for x in prompt: @@ -1148,6 +1179,35 @@ async def validate_prompt(prompt_id, prompt, partial_execution_list: Union[list[ } return (False, error, [], {}) + # Make sure the node is actually executable (its FUNCTION/execute entry + # point resolves to a real method) before we touch any schema-derived + # attributes below or start execution. Catches code typos up front and + # attributes the error to the offending node. + not_executable = node_not_executable_reason(class_, class_type) + if not_executable is not None: + node_title = prompt[x].get('_meta', {}).get('title', class_type) + error = { + "type": "invalid_node_definition", + "message": "Node is not executable", + "details": f"{not_executable} (Node ID '#{x}')", + "extra_info": { + "node_id": x, + "class_type": class_type, + "node_title": node_title, + } + } + node_errors = {x: { + "errors": [{ + "type": "invalid_node_definition", + "message": "Node is not executable", + "details": not_executable, + "extra_info": {}, + }], + "dependent_outputs": [], + "class_type": class_type, + }} + return (False, error, [], node_errors) + if hasattr(class_, 'OUTPUT_NODE') and class_.OUTPUT_NODE is True: if partial_execution_list is None or x in partial_execution_list: outputs.add(x) diff --git a/tests-unit/execution_test/validate_node_executable_test.py b/tests-unit/execution_test/validate_node_executable_test.py new file mode 100644 index 000000000..7c3203ccb --- /dev/null +++ b/tests-unit/execution_test/validate_node_executable_test.py @@ -0,0 +1,134 @@ +"""Tests for pre-execution validation that a node is actually executable. + +validate_prompt rejects a node whose declared entry point does not resolve to a +real method (a V1 FUNCTION typo, or a V3 node missing its execute override) before +any node runs, attributing the error to the offending node. +""" +import asyncio + +import nodes +from comfy_api.latest import io +from execution import node_not_executable_reason, validate_prompt + + +class _GoodV1Node: + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "run" + OUTPUT_NODE = True + CATEGORY = "Test" + + def run(self): + return (None,) + + +class _TypoV1Node: + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "invert" # method below is misspelled + OUTPUT_NODE = True + CATEGORY = "Test" + + def invvert(self): + return (None,) + + +class _InstanceMethodV1Node: + """Defines its FUNCTION method on the instance, as the runtime resolves it.""" + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "run" + OUTPUT_NODE = True + CATEGORY = "Test" + + def __init__(self): + self.run = lambda: (None,) + + +def _v3_schema(node_id): + return io.Schema( + node_id=node_id, + display_name=node_id, + category="Test", + inputs=[], + outputs=[io.Image.Output()], + is_output_node=True, + ) + + +class _GoodV3Node(io.ComfyNode): + @classmethod + def define_schema(cls): + return _v3_schema("GoodV3Node") + + @classmethod + def execute(cls): + return io.NodeOutput(None) + + +class _TypoV3Node(io.ComfyNode): + @classmethod + def define_schema(cls): + return _v3_schema("TypoV3Node") + + @classmethod + def exicute(cls): # typo: should be "execute" + return io.NodeOutput(None) + + +def _register(class_type, class_def): + nodes.NODE_CLASS_MAPPINGS[class_type] = class_def + + +def _validate(class_type): + prompt = {"1": {"class_type": class_type, "inputs": {}}} + return asyncio.run(validate_prompt("pid", prompt, None)) + + +def test_good_node_passes(): + _register("GoodV1Node", _GoodV1Node) + assert node_not_executable_reason(_GoodV1Node, "GoodV1Node") is None + valid, _, _, _ = _validate("GoodV1Node") + assert valid is True + + +def test_typo_node_rejected_with_node_error(): + _register("TypoV1Node", _TypoV1Node) + valid, error, _, node_errors = _validate("TypoV1Node") + assert valid is False + assert error["type"] == "invalid_node_definition" + assert node_errors["1"]["class_type"] == "TypoV1Node" + assert node_errors["1"]["errors"][0]["type"] == "invalid_node_definition" + assert "invert" in node_errors["1"]["errors"][0]["details"] + + +def test_instance_defined_method_not_false_positived(): + """A node whose method is defined in __init__ runs fine and must not be blocked.""" + _register("InstanceMethodV1Node", _InstanceMethodV1Node) + assert node_not_executable_reason(_InstanceMethodV1Node, "InstanceMethodV1Node") is None + valid, _, _, _ = _validate("InstanceMethodV1Node") + assert valid is True + + +def test_good_v3_node_passes(): + _register("GoodV3Node", _GoodV3Node) + assert node_not_executable_reason(_GoodV3Node, "GoodV3Node") is None + valid, _, _, _ = _validate("GoodV3Node") + assert valid is True + + +def test_typo_v3_node_rejected_with_node_error(): + _register("TypoV3Node", _TypoV3Node) + valid, error, _, node_errors = _validate("TypoV3Node") + assert valid is False + assert error["type"] == "invalid_node_definition" + assert node_errors["1"]["errors"][0]["type"] == "invalid_node_definition" From bf00c39705d85c44b2b88b6b8afb88134521bbd9 Mon Sep 17 00:00:00 2001 From: Wei Hai Date: Fri, 26 Jun 2026 16:04:29 -0700 Subject: [PATCH 2/2] Don't instantiate nodes during validation Addresses review feedback: the V1 executability check fell back to constructing the node (class_def()) when the FUNCTION method wasn't found on the class. That runs __init__ during validation, so a constructor's side effects or failure could be misreported as invalid_node_definition for an otherwise valid node. Inspect only the class. No core/extra node defines its FUNCTION method on the instance, so this loses no real coverage while removing the side-effect risk. Replace the instance-fallback test with one asserting a node with a raising __init__ but a valid class-level method still passes validation (i.e. it is never instantiated). --- execution.py | 19 +++++++------------ .../validate_node_executable_test.py | 19 +++++++++++-------- 2 files changed, 18 insertions(+), 20 deletions(-) diff --git a/execution.py b/execution.py index ca8a02846..00e4c2aae 100644 --- a/execution.py +++ b/execution.py @@ -1113,12 +1113,6 @@ def full_type_name(klass): return klass.__qualname__ return module + '.' + klass.__qualname__ -def _v1_function_resolves(node): - """Whether node.FUNCTION names a callable on node (a class or an instance).""" - function_name = getattr(node, "FUNCTION", None) - return function_name is not None and callable(getattr(node, function_name, None)) - - def node_not_executable_reason(class_def, class_type): """Return a human-readable reason the node cannot be executed, or None if it's fine. @@ -1126,21 +1120,22 @@ def node_not_executable_reason(class_def, class_type): (e.g. a V1 ``FUNCTION = "invert"`` where the method is misspelled, or a V3 node missing its ``execute`` override). Running this during validation surfaces the problem before execution starts, instead of after upstream nodes have run. + + Only the class is inspected; the node is never instantiated here, so a node's + ``__init__`` side effects cannot run (or fail) during validation. """ try: if issubclass(class_def, _ComfyNodeInternal): # V3: validates that execute()/define_schema() overrides exist. class_def.VALIDATE_CLASS() return None - # V1: FUNCTION names the method to call. Check the class first (the common - # case); fall back to an instance, since the node is invoked on an instance - # and may define FUNCTION or its method in __init__. - if _v1_function_resolves(class_def) or _v1_function_resolves(class_def()): - return None + # V1: FUNCTION names the method to call; it must exist on the class. function_name = getattr(class_def, "FUNCTION", None) if function_name is None: return f"'{class_type}' does not define FUNCTION" - return f"'{class_type}' has no method '{function_name}' (declared in FUNCTION)" + if not callable(getattr(class_def, function_name, None)): + return f"'{class_type}' has no method '{function_name}' (declared in FUNCTION)" + return None except Exception as ex: return str(ex) diff --git a/tests-unit/execution_test/validate_node_executable_test.py b/tests-unit/execution_test/validate_node_executable_test.py index 7c3203ccb..694163b58 100644 --- a/tests-unit/execution_test/validate_node_executable_test.py +++ b/tests-unit/execution_test/validate_node_executable_test.py @@ -39,8 +39,8 @@ class _TypoV1Node: return (None,) -class _InstanceMethodV1Node: - """Defines its FUNCTION method on the instance, as the runtime resolves it.""" +class _SideEffectInitV1Node: + """Valid class-level method, but a constructor that must never run in validation.""" @classmethod def INPUT_TYPES(cls): return {"required": {}} @@ -51,7 +51,10 @@ class _InstanceMethodV1Node: CATEGORY = "Test" def __init__(self): - self.run = lambda: (None,) + raise RuntimeError("__init__ must not run during validation") + + def run(self): + return (None,) def _v3_schema(node_id): @@ -111,11 +114,11 @@ def test_typo_node_rejected_with_node_error(): assert "invert" in node_errors["1"]["errors"][0]["details"] -def test_instance_defined_method_not_false_positived(): - """A node whose method is defined in __init__ runs fine and must not be blocked.""" - _register("InstanceMethodV1Node", _InstanceMethodV1Node) - assert node_not_executable_reason(_InstanceMethodV1Node, "InstanceMethodV1Node") is None - valid, _, _, _ = _validate("InstanceMethodV1Node") +def test_validation_does_not_instantiate_node(): + """A valid node is not constructed during validation, so __init__ never runs.""" + _register("SideEffectInitV1Node", _SideEffectInitV1Node) + assert node_not_executable_reason(_SideEffectInitV1Node, "SideEffectInitV1Node") is None + valid, _, _, _ = _validate("SideEffectInitV1Node") assert valid is True