fix: never treat negative-only PromptManager nodes as the run prompt

run_prompt_nodes fell back to every PromptManager node whenever no positive
node was traced, including graphs that do have a sampler and whose only
PromptManager node feeds its negative input. Fall back only when the graph
has no recognisable sampler (new has_sampler helper). Samplers named like
one are recognised even without a model link. Adversarial graphs (cycles,
10k-node fan-out, non-dict inputs) are covered.
This commit is contained in:
Vito Sansevero
2026-10-08 18:01:34 -07:00
parent 2cf6bdd393
commit 248d5880e6
2 changed files with 128 additions and 13 deletions
+101
View File
@@ -2,11 +2,13 @@
import os
import sys
import time
import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils.prompt_graph import (
has_sampler,
positive_prompt_nodes,
resolve_text,
run_prompt_nodes,
@@ -167,6 +169,105 @@ class TestRunPromptNodes(unittest.TestCase):
def test_malformed_graph(self):
self.assertEqual(run_prompt_nodes(None), [])
def test_run_prompt_nodes_negative_only_graph_is_empty(self):
graph = {
"1": {"class_type": "PromptManager", "inputs": {"text": "bad hands"}},
"2": {
"class_type": "KSampler",
"inputs": {"negative": ["1", 0], "positive": ["9", 0]},
},
"9": {"class_type": "CLIPTextEncode", "inputs": {"text": "a cat"}},
}
self.assertEqual(run_prompt_nodes(graph), [])
def test_run_prompt_nodes_falls_back_when_no_sampler(self):
graph = {"1": {"class_type": "PromptManagerText", "inputs": {"text": "x"}}}
self.assertEqual(run_prompt_nodes(graph), ["1"])
class TestHasSampler(unittest.TestCase):
def test_structural_sampler_and_guider_are_detected(self):
self.assertTrue(has_sampler({"5": ksampler("1", "2"), "20": LOADER}))
guider = {
"class_type": "BasicGuider",
"inputs": {"model": ["20", 0], "conditioning": ["1", 0]},
}
self.assertTrue(has_sampler({"6": guider, "20": LOADER}))
def test_sampler_without_model_link_is_detected_by_name(self):
sampler = {
"class_type": "KSamplerAdvanced",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
}
self.assertTrue(has_sampler({"5": sampler}))
def test_non_samplers_and_malformed_graphs(self):
controlnet = {
"class_type": "ControlNetApplyAdvanced",
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
}
self.assertFalse(has_sampler({"7": controlnet}))
exotic = {"class_type": "ExoticSampler", "inputs": {"cond": ["1", 0]}}
self.assertFalse(has_sampler({"9": exotic}))
for bad in (None, [], "x", {"1": None}, {"1": {"inputs": None}}, {}):
self.assertFalse(has_sampler(bad), repr(bad))
class TestAdversarialGraphs(unittest.TestCase):
"""Queued and embedded graphs are untrusted: never hang, never raise."""
def _assert_fast(self, graph):
started = time.monotonic()
for fn in (positive_prompt_nodes, run_prompt_nodes, has_sampler):
fn(graph)
resolve_text(graph, "1")
self.assertLess(time.monotonic() - started, 1.0)
def test_cyclic_graph_terminates(self):
graph = {
"1": pm(["2", 0]),
"2": {"class_type": "StringConcatenate", "inputs": {"string_a": ["1", 0]}},
"3": {
"class_type": "ConditioningCombine",
"inputs": {"conditioning_1": ["3", 0], "conditioning_2": ["1", 0]},
},
"5": ksampler("3", "3"),
"20": LOADER,
}
self._assert_fast(graph)
self.assertEqual(run_prompt_nodes(graph), ["1"])
def test_ten_thousand_node_fan_out_terminates(self):
graph = {"20": LOADER}
combine_inputs = {}
for i in range(10_000):
graph[str(100 + i)] = pm(f"prompt {i}")
combine_inputs[f"conditioning_{i}"] = [str(100 + i), 0]
graph["3"] = {"class_type": "ConditioningCombine", "inputs": combine_inputs}
graph["5"] = ksampler("3", "3")
self._assert_fast(graph)
self.assertTrue(run_prompt_nodes(graph))
def test_non_dict_inputs_and_odd_values(self):
graph = {
"1": {"class_type": "PromptManager", "inputs": ["not", "a", "dict"]},
"2": {"class_type": "KSampler", "inputs": 42},
"3": {"class_type": None, "inputs": {"positive": "1", "negative": [1]}},
"4": "just a string",
"5": {
"class_type": "KSampler",
"inputs": {
"positive": [None, 0],
"negative": [{"x": 1}, 0],
"model": [[], 0],
},
},
6: {"class_type": "PromptManager", "inputs": {"text": 7}},
}
self._assert_fast(graph)
for fn in (positive_prompt_nodes, run_prompt_nodes):
self.assertIsInstance(fn(graph), list)
class TestResolveText(unittest.TestCase):
"""Text known at queue time: typed, or from known pure string nodes."""
+27 -13
View File
@@ -36,19 +36,26 @@ def _inputs(node: Any) -> Dict[str, Any]:
def _positive_roots(graph: Dict[str, Any]) -> List[Any]:
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...)."""
"""Links into samplers' positive inputs (KSampler, CFGGuider, BasicGuider, ...).
A sampler is a node taking a model plus positive/negative conditioning, or a
guider taking a model plus conditioning. Nodes named like a sampler also count
without a model link, so a negative-only graph is never mistaken for one with
no sampler at all.
"""
roots = []
for node in graph.values():
inputs = _inputs(node)
if not _is_link(inputs.get("model")):
if not isinstance(node, dict):
continue
inputs = _inputs(node)
class_name = str(node.get("class_type", "")).lower()
has_model = _is_link(inputs.get("model"))
if _is_link(inputs.get("positive")) and _is_link(inputs.get("negative")):
roots.append(inputs["positive"])
elif (
_is_link(inputs.get("conditioning"))
and "guider" in str(node.get("class_type", "")).lower()
):
roots.append(inputs["conditioning"])
if has_model or "sampler" in class_name:
roots.append(inputs["positive"])
elif has_model and _is_link(inputs.get("conditioning")):
if "guider" in class_name:
roots.append(inputs["conditioning"])
return roots
@@ -63,6 +70,11 @@ def _upstream_links(inputs: Dict[str, Any]) -> List[Any]:
return links
def has_sampler(graph: Any) -> bool:
"""Whether the graph contains a node recognised as a sampler or guider."""
return isinstance(graph, dict) and bool(_positive_roots(graph))
def positive_prompt_nodes(graph: Any) -> List[str]:
"""Ids of PromptManager/PromptManagerText nodes that feed a positive input.
@@ -92,12 +104,14 @@ def positive_prompt_nodes(graph: Any) -> List[str]:
def run_prompt_nodes(graph: Any) -> List[str]:
"""Prompt nodes that represent a run: the positive ones, if any can be found.
When no PromptManager node can be traced to a positive input (an unrecognised
custom sampler, say), every PromptManager node counts, as before 3.2.4, so an
exotic workflow never silently loses usage counting or image linking.
When the graph has no recognisable sampler (an unrecognised custom sampler,
say), every PromptManager node counts, as before 3.2.4, so an exotic workflow
never silently loses usage counting or image linking. When a sampler exists
and no PromptManager node feeds its positive input, nothing counts: the only
PromptManager nodes are negative prompts.
"""
positive = positive_prompt_nodes(graph)
if positive or not isinstance(graph, dict):
if positive or not isinstance(graph, dict) or has_sampler(graph):
return positive
return [
str(node_id)