Make node-ordering heuristics defensive instead of blaming available[0]

Addresses review feedback: the scheduler error path blamed available[0]
even when picking failed while inspecting a later ready node, misreporting
the node to the frontend.

Instead of threading the node id through the exception, make is_output and
is_async fully defensive. They are pure ordering heuristics, so a malformed
node (a FUNCTION typo, or schema-derived attributes that raise) just means
"not prioritized"; the node then runs through normal execution, where the
error is reported against the correct node. The stage_node_execution
try/except remains as a backstop only.

Add a test for a node whose attribute access raises during the heuristics.
This commit is contained in:
Wei Hai 2026-06-26 15:41:11 -07:00
parent 91f3c0c4d9
commit ffdc23c6dd
2 changed files with 45 additions and 17 deletions

View File

@ -267,10 +267,10 @@ class ExecutionList(TopologicalSort):
try:
self.staged_node_id = self.ux_friendly_pick_node(available)
except Exception as ex:
# Picking a node is a scheduling heuristic that inspects node
# definitions; a malformed custom node must not crash the prompt
# worker thread silently. Blame an available node and surface the
# error to the frontend like any other execution error.
# Backstop: the ordering heuristics in ux_friendly_pick_node are
# defensive, but should anything else there fail, surface it as an
# execution error instead of letting it kill the prompt worker
# thread. Blame an available node (best effort).
blamed_node = self.dynprompt.get_display_node_id(available[0])
exception_type = type(ex).__qualname__
if type(ex).__module__ != "builtins":
@ -290,29 +290,28 @@ class ExecutionList(TopologicalSort):
# Technically this has no effect on the overall length of execution, but it feels better as a user
# for a PreviewImage to display a result as soon as it can
# Some other heuristics could probably be used here to improve the UX further.
# These node-ordering heuristics only affect *order*, never correctness.
# A malformed node (e.g. a FUNCTION typo, or a node whose schema-derived
# attributes raise) must not crash scheduling: failing a heuristic just
# means "not prioritized". The node then proceeds to normal execution,
# where the real error is raised and reported against the correct node.
def is_output(node_id):
class_type = self.dynprompt.get_node(node_id)["class_type"]
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
if hasattr(class_def, 'OUTPUT_NODE') and class_def.OUTPUT_NODE == True:
return True
return False
try:
return hasattr(class_def, 'OUTPUT_NODE') and class_def.OUTPUT_NODE == True
except Exception:
return False
# If an available node is async, do that first.
# This will execute the asynchronous function earlier, reducing the overall time.
def is_async(node_id):
class_type = self.dynprompt.get_node(node_id)["class_type"]
class_def = nodes.NODE_CLASS_MAPPINGS[class_type]
# A malformed node (e.g. FUNCTION pointing at a method that does not
# exist because of a typo) must not crash scheduling here. Treat it as
# non-async so it proceeds to normal execution, where the missing
# method raises an error that is caught and reported to the frontend.
function_name = getattr(class_def, "FUNCTION", None)
if function_name is None:
try:
return inspect.iscoroutinefunction(getattr(class_def, class_def.FUNCTION))
except Exception:
return False
func = getattr(class_def, function_name, None)
if func is None:
return False
return inspect.iscoroutinefunction(func)
for node_id in node_list:
if is_output(node_id) or is_async(node_id):

View File

@ -26,6 +26,26 @@ class _MalformedV1Node:
return (None,)
class _RaisingDescriptor:
def __get__(self, obj, owner):
raise RuntimeError("schema error")
class _SchemaRaisesNode:
"""A node whose schema-derived attribute access raises, as a broken V3 node would."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "run"
OUTPUT_NODE = _RaisingDescriptor()
CATEGORY = "Test"
def run(self):
return (None,)
class _FakeOutputCache:
def all_node_ids(self):
return set()
@ -51,6 +71,15 @@ def test_malformed_function_does_not_crash_scheduler():
assert node_id == "1"
def test_schema_attribute_error_does_not_crash_scheduler():
"""A node whose attribute access raises during heuristics still schedules."""
execution_list = _make_execution_list("SchemaRaisesNode", _SchemaRaisesNode)
node_id, error, ex = asyncio.run(execution_list.stage_node_execution())
assert ex is None
assert error is None
assert node_id == "1"
def test_pick_node_failure_is_reported_not_raised():
"""An unexpected scheduling error is returned as an error, not raised."""
execution_list = _make_execution_list("MalformedV1Node", _MalformedV1Node)