feat(Core): support partial graph execution

This commit is contained in:
Alexander Piskun
2026-08-05 23:14:19 +03:00
parent 6f7cd7fcea
commit cbbce9da2b
15 changed files with 940 additions and 35 deletions

View File

@@ -281,6 +281,41 @@ class TestAsyncNodes:
# Verify the sync error was caught even though async was running
assert 'prompt_id' in e.args[0]
def test_async_sibling_completes_after_error(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestAsyncError", value=image.out(0), error_after=0.05)
sleep_node = g.node("TestSleep", value=image.out(0), seconds=0.1)
g.node("PreviewImage", images=error_node.out(0))
successful_output = g.node("SaveImage", images=sleep_node.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.did_run(error_node)
assert result.did_run(sleep_node)
assert result.was_executed(successful_output)
assert len(result.get_images(successful_output)) == 1
assert result.execution_success['completion_status'] == 'partial_success'
assert result.node_errors[0]['node_id'] == error_node.id
def test_async_sibling_completes_after_multiple_errors(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error1 = g.node("TestAsyncError", value=image.out(0), error_after=0.02)
error2 = g.node("TestAsyncError", value=image.out(0), error_after=0.04)
sleep_node = g.node("TestSleep", value=image.out(0), seconds=0.06)
g.node("PreviewImage", images=error1.out(0))
g.node("PreviewImage", images=error2.out(0))
successful_output = g.node("SaveImage", images=sleep_node.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert {error['node_id'] for error in result.node_errors} == {error1.id, error2.id}
assert result.did_run(sleep_node)
assert result.was_executed(successful_output)
assert result.execution_success['completion_status'] == 'partial_success'
assert result.execution_success['execution_error_count'] == 2
# Edge Cases
def test_async_with_execution_blocker(self, client: ComfyClient, builder: GraphBuilder):

View File

@@ -1,3 +1,4 @@
from collections import Counter
from io import BytesIO
import numpy
from PIL import Image
@@ -28,6 +29,9 @@ class RunResult:
self.runs: Dict[str,bool] = {}
self.cached: Dict[str,bool] = {}
self.prompt_id: str = prompt_id
self.node_errors = []
self.execution_success = None
self.run_counts: Dict[str, int] = {}
def get_output(self, node: Node):
return self.outputs.get(node.id, None)
@@ -66,10 +70,12 @@ class ComfyClient:
ws.connect("ws://{}/ws?clientId={}".format(self.server_address, self.client_id))
self.ws = ws
def queue_prompt(self, prompt, partial_execution_targets=None):
def queue_prompt(self, prompt, partial_execution_targets=None, node_failure_policy=None):
p = {"prompt": prompt, "client_id": self.client_id}
if partial_execution_targets is not None:
p["partial_execution_targets"] = partial_execution_targets
if node_failure_policy is not None:
p["node_failure_policy"] = node_failure_policy
data = json.dumps(p).encode('utf-8')
req = urllib.request.Request("http://{}/prompt".format(self.server_address), data=data)
return json.loads(urllib.request.urlopen(req).read())
@@ -133,13 +139,13 @@ class ComfyClient:
def set_test_name(self, name):
self.test_name = name
def run(self, graph, partial_execution_targets=None):
def run(self, graph, partial_execution_targets=None, node_failure_policy=None):
prompt = graph.finalize()
for node in graph.nodes.values():
if node.class_type == 'SaveImage':
node.inputs['filename_prefix'] = self.test_name
prompt_id = self.queue_prompt(prompt, partial_execution_targets)['prompt_id']
prompt_id = self.queue_prompt(prompt, partial_execution_targets, node_failure_policy)['prompt_id']
result = RunResult(prompt_id)
while True:
out = self.ws.recv()
@@ -152,8 +158,15 @@ class ComfyClient:
if data['node'] is None:
break
result.runs[data['node']] = True
result.run_counts[data['node']] = result.run_counts.get(data['node'], 0) + 1
elif message['type'] == 'execution_error':
raise Exception(message['data'])
elif message['type'] == 'execution_node_error':
if message['data']['prompt_id'] == prompt_id:
result.node_errors.append(message['data'])
elif message['type'] == 'execution_success':
if message['data']['prompt_id'] == prompt_id:
result.execution_success = message['data']
elif message['type'] == 'execution_cached':
if message['data']['prompt_id'] == prompt_id:
cached_nodes = message['data'].get('nodes', [])
@@ -305,6 +318,299 @@ class TestExecution:
except Exception as e:
assert 'prompt_id' in e.args[0], f"Did not get back a proper error message: {e}"
def test_continue_independent_after_error(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
blocked_output = g.node("PreviewImage", images=error_node.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.did_run(error_node)
assert result.was_executed(successful_output)
assert len(result.get_images(successful_output)) == 1
assert len(result.node_errors) == 1
assert result.node_errors[0]['node_id'] == error_node.id
assert result.execution_success['completion_status'] == 'partial_success'
assert result.execution_success['has_errors'] is True
assert result.execution_success['execution_error_count'] == 1
assert blocked_output.id in result.execution_success['blocked_output_node_ids']
assert successful_output.id in result.execution_success['successful_output_node_ids']
history = client.get_history(result.prompt_id)[result.prompt_id]
assert '_node_failure_policy' not in history['prompt'][3]
retry = client.run(g, node_failure_policy="continue_independent")
assert retry.did_run(error_node), "Failed nodes must be retried on a new prompt"
assert len(retry.node_errors) == 1
def test_continue_independent_reuses_failed_node_for_late_lazy_link(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
mask = g.node("StubMask", value=0.0, height=32, width=32, batch_size=1)
unused_image = g.node("StubImage", content="WHITE", height=32, width=32, batch_size=1)
lazy_mix = g.node("TestLazyMixImages", image1=error_node.out(0), image2=unused_image.out(0), mask=mask.out(0))
g.node("PreviewImage", images=lazy_mix.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.run_counts[error_node.id] == 1
assert lazy_mix.id in result.execution_success['blocked_node_ids']
assert successful_output.id in result.execution_success['successful_output_node_ids']
assert result.execution_success['completion_status'] == 'partial_success'
def test_continue_independent_handles_mixed_dynamic_results(self, client: ComfyClient, builder: GraphBuilder):
g = builder
zero = g.node("StubInt", value=0)
one = g.node("StubInt", value=1)
values = g.node("TestMakeListNode", value1=zero.out(0), value2=one.out(0))
mixed = g.node("TestMixedExpansionFailure", value=values.out(0))
output = g.node("PreviewImage", images=mixed.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert len(result.node_errors) == 1
assert result.node_errors[0]['node_id'] == mixed.id
assert len(result.get_images(output)) == 1
assert output.id in result.execution_success['successful_output_node_ids']
assert output.id not in result.execution_success['blocked_output_node_ids']
assert result.execution_success['completion_status'] == 'partial_success'
retry = client.run(g, node_failure_policy="continue_independent")
assert retry.did_run(mixed), "Failure-tainted dynamic parents must not be reused from cache"
assert len(retry.node_errors) == 1
def test_continue_independent_keeps_oom_terminal(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
oom_node = g.node("TestOOMError", value=image.out(0))
g.node("PreviewImage", images=oom_node.out(0))
g.node("SaveImage", images=image.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="continue_independent")
assert exc_info.value.args[0]['node_id'] == oom_node.id
assert exc_info.value.args[0]['exception_type'] == 'torch.OutOfMemoryError'
def test_continue_independent_accepts_cached_output(self, client: ComfyClient, builder: GraphBuilder, server):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
client.run(g, partial_execution_targets=[successful_output.id])
result = client.run(g, node_failure_policy="continue_independent")
if server["should_cache_results"]:
assert result.was_cached(successful_output)
assert len(result.node_errors) == 1
assert result.execution_success['completion_status'] == 'partial_success'
assert successful_output.id in result.execution_success['successful_output_node_ids']
def test_continue_independent_keeps_executor_errors_terminal(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
bad = g.node("TestMalformedExpansion", value=image.out(0))
g.node("PreviewImage", images=bad.out(0))
g.node("SaveImage", images=image.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="continue_independent")
assert exc_info.value.args[0]['node_id'] == bad.id
assert exc_info.value.args[0]['exception_type'] == 'KeyError'
def test_continue_independent_malformed_result_terminal(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
bad = g.node("TestMalformedResult", value=image.out(0))
g.node("PreviewImage", images=bad.out(0))
g.node("SaveImage", images=image.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="continue_independent")
assert exc_info.value.args[0]['node_id'] == bad.id
assert exc_info.value.args[0]['exception_type'] == 'TypeError'
def test_continue_independent_cyclic_expansion_reports_cycle(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
cyclic = g.node("TestCyclicExpansion", value=image.out(0))
g.node("PreviewImage", images=cyclic.out(0))
g.node("SaveImage", images=image.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="continue_independent")
assert exc_info.value.args[0]['exception_type'] == 'graph.DependencyCycleError'
def test_continue_independent_async_output_partial_failure(self, client: ComfyClient, builder: GraphBuilder):
g = builder
zero = g.node("StubInt", value=0)
one = g.node("StubInt", value=1)
values = g.node("TestMakeListNode", value1=zero.out(0), value2=one.out(0))
mixed = g.node("TestMixedExpansionFailure", value=values.out(0))
async_output = g.node("TestAsyncOutput", value=mixed.out(0), seconds=0.1)
result = client.run(g, node_failure_policy="continue_independent")
assert len(result.node_errors) == 1
assert result.execution_success['completion_status'] == 'partial_success'
assert async_output.id in result.execution_success['successful_output_node_ids']
assert async_output.id not in result.execution_success['blocked_node_ids']
retry = client.run(g, node_failure_policy="continue_independent")
assert retry.did_run(async_output), "Async outputs with failure-blocked invocations must be retried"
def test_continue_independent_failure_blocker_beats_user_blocker(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
user_blocker = g.node("TestExecutionBlocker", input=image.out(0), block=True, verbose=False)
combo = g.node("TestMakeListNode", value1=user_blocker.out(0), value2=error_node.out(0))
g.node("PreviewImage", images=combo.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.execution_success['completion_status'] == 'partial_success'
assert combo.id in result.execution_success['blocked_node_ids']
assert successful_output.id in result.execution_success['successful_output_node_ids']
retry = client.run(g, node_failure_policy="continue_independent")
assert retry.did_run(combo), "Nodes blocked by a failure must be retried even when also user-blocked"
def test_continue_independent_retries_failed_expansion_side_branch(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
expander = g.node("TestExpansionWithFailingOutput", image=image.out(0))
output = g.node("PreviewImage", images=expander.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert len(result.node_errors) == 1
assert result.node_errors[0]['node_id'] == expander.id
assert result.execution_success['completion_status'] == 'partial_success'
assert len(result.get_images(output)) == 1
retry = client.run(g, node_failure_policy="continue_independent")
assert retry.did_run(expander), "Expansion parents with failed side branches must be retried"
assert len(retry.node_errors) == 1
def test_continue_independent_with_partial_targets(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
failing_output = g.node("PreviewImage", images=error_node.out(0))
successful_output = g.node("SaveImage", images=image.out(0))
unselected_output = g.node("SaveImage", images=image.out(0))
result = client.run(
g,
partial_execution_targets=[failing_output.id, successful_output.id],
node_failure_policy="continue_independent",
)
assert len(result.node_errors) == 1
assert result.execution_success['completion_status'] == 'partial_success'
assert successful_output.id in result.execution_success['successful_output_node_ids']
assert failing_output.id in result.execution_success['blocked_output_node_ids']
assert not result.was_executed(unselected_output), "Unselected outputs must not execute"
def test_explicit_fail_fast_policy_matches_default(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
g.node("SaveImage", images=image.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="fail_fast")
assert exc_info.value.args[0]['node_id'] == error_node.id
history = client.get_history(exc_info.value.args[0]['prompt_id'])
entry = next(iter(history.values()))
assert entry['status']['status_str'] == 'error'
assert 'execution_summary' not in entry['status']
def test_continue_independent_failure_after_sibling_output(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
fast_output = g.node("TestAsyncOutput", value=image.out(0), seconds=0.05)
slow_error = g.node("TestAsyncError", value=image.out(0), error_after=0.5)
g.node("PreviewImage", images=slow_error.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert len(result.node_errors) == 1
assert result.node_errors[0]['node_id'] == slow_error.id
assert result.execution_success['completion_status'] == 'partial_success'
assert fast_output.id in result.execution_success['successful_output_node_ids']
def test_continue_independent_never_stores_failed_outputs_externally(self, client: ComfyClient, builder: GraphBuilder, server):
def record_stored_counts(prefix):
record_graph = GraphBuilder(prefix=prefix)
record = record_graph.node("TestCacheProviderRecord")
record_result = client.run(record_graph)
return Counter(record_result.get_output(record)['stored_class_types'])
baseline = record_stored_counts("cache_baseline")
g = builder
# Unique 31x31 signature so StubImage executes fresh instead of hitting the cache
image = g.node("StubImage", content="BLACK", height=31, width=31, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
g.node("SaveImage", images=image.out(0))
result = client.run(g, node_failure_policy="continue_independent")
assert result.execution_success['completion_status'] == 'partial_success'
delta = record_stored_counts("cache_record") - baseline
if server["should_cache_results"]:
assert delta["StubImage"] >= 1, "Successful outputs should reach external cache providers"
assert delta["TestSyncError"] == 0, "Failed node outputs must never reach external cache providers"
assert delta["PreviewImage"] == 0, "Failure-blocked outputs must never reach external cache providers"
def test_history_status_omits_summary_by_default(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
g.node("PreviewImage", images=image.out(0))
result = client.run(g)
history = client.get_history(result.prompt_id)[result.prompt_id]
assert history['status']['status_str'] == 'success'
assert 'execution_summary' not in history['status']
def test_continue_independent_fails_when_no_output_survives(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
error_node = g.node("TestSyncError", value=image.out(0))
g.node("PreviewImage", images=error_node.out(0))
with pytest.raises(Exception) as exc_info:
client.run(g, node_failure_policy="continue_independent")
assert exc_info.value.args[0]['node_id'] == error_node.id
def test_invalid_node_failure_policy(self, client: ComfyClient, builder: GraphBuilder):
g = builder
image = g.node("StubImage", content="BLACK", height=32, width=32, batch_size=1)
g.node("PreviewImage", images=image.out(0))
with pytest.raises(urllib.error.HTTPError) as exc_info:
client.queue_prompt(g.finalize(), node_failure_policy="continue_everything")
assert exc_info.value.code == 400
@pytest.mark.parametrize("test_value, expect_error", [
(5, True),
("foo", True),

View File

@@ -580,6 +580,76 @@ class TestNormalizeHistoryItem:
'extra_data': {'create_time': 1234567890, 'client_id': 'abc'},
}
def test_missing_status(self):
history_item = {
'prompt': (
5,
'prompt-without-status',
{'nodes': {}},
{'create_time': 100},
[],
),
'status': None,
'outputs': {},
}
job = normalize_history_item('prompt-without-status', history_item)
assert job['status'] == 'completed'
assert 'completion_status' not in job
def test_partial_success_metadata_and_errors(self):
node_error = {
'prompt_id': 'prompt-partial',
'node_id': '2',
'node_type': 'TestSyncError',
'exception_message': 'failed',
'exception_type': 'RuntimeError',
'traceback': [],
'current_inputs': {},
'current_outputs': [],
'timestamp': 200,
}
history_item = {
'prompt': (
5,
'prompt-partial',
{'nodes': {}},
{'create_time': 100},
['3', '4'],
),
'status': {
'status_str': 'success',
'completed': True,
'execution_summary': {
'completion_status': 'partial_success',
'has_errors': True,
'execution_error_count': 1,
},
'messages': [
('execution_start', {'prompt_id': 'prompt-partial', 'timestamp': 150}),
('execution_node_error', node_error),
('execution_success', {
'prompt_id': 'prompt-partial',
'completion_status': 'partial_success',
'has_errors': True,
'execution_error_count': 1,
'timestamp': 300,
}),
],
},
'outputs': {'4': {'images': [{'filename': 'survived.png'}]}},
}
job = normalize_history_item('prompt-partial', history_item, include_outputs=True)
assert job['status'] == 'completed'
assert job['completion_status'] == 'partial_success'
assert job['has_errors'] is True
assert job['execution_error_count'] == 1
assert job['execution_errors'] == [node_error]
assert job['outputs']['4']['images'] == [{'filename': 'survived.png'}]
def test_include_outputs_normalizes_3d_strings(self):
"""Detail view should transform string 3D filenames into file output dicts."""
history_item = {

View File

@@ -5,6 +5,7 @@ from .conditions import CONDITION_NODE_CLASS_MAPPINGS, CONDITION_NODE_DISPLAY_NA
from .stubs import TEST_STUB_NODE_CLASS_MAPPINGS, TEST_STUB_NODE_DISPLAY_NAME_MAPPINGS
from .async_test_nodes import ASYNC_TEST_NODE_CLASS_MAPPINGS, ASYNC_TEST_NODE_DISPLAY_NAME_MAPPINGS
from .api_test_nodes import API_TEST_NODE_CLASS_MAPPINGS, API_TEST_NODE_DISPLAY_NAME_MAPPINGS
from .cache_provider_test_nodes import CACHE_PROVIDER_TEST_NODE_CLASS_MAPPINGS, CACHE_PROVIDER_TEST_NODE_DISPLAY_NAME_MAPPINGS
# NODE_CLASS_MAPPINGS = GENERAL_NODE_CLASS_MAPPINGS.update(COMPONENT_NODE_CLASS_MAPPINGS)
# NODE_DISPLAY_NAME_MAPPINGS = GENERAL_NODE_DISPLAY_NAME_MAPPINGS.update(COMPONENT_NODE_DISPLAY_NAME_MAPPINGS)
@@ -17,6 +18,7 @@ NODE_CLASS_MAPPINGS.update(CONDITION_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(TEST_STUB_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(ASYNC_TEST_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(API_TEST_NODE_CLASS_MAPPINGS)
NODE_CLASS_MAPPINGS.update(CACHE_PROVIDER_TEST_NODE_CLASS_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS = {}
NODE_DISPLAY_NAME_MAPPINGS.update(TEST_NODE_DISPLAY_NAME_MAPPINGS)
@@ -26,3 +28,4 @@ NODE_DISPLAY_NAME_MAPPINGS.update(CONDITION_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(TEST_STUB_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(ASYNC_TEST_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(API_TEST_NODE_DISPLAY_NAME_MAPPINGS)
NODE_DISPLAY_NAME_MAPPINGS.update(CACHE_PROVIDER_TEST_NODE_DISPLAY_NAME_MAPPINGS)

View File

@@ -135,6 +135,148 @@ class TestSyncError(ComfyNodeABC):
raise RuntimeError("Intentional sync execution error for testing")
class TestOOMError(ComfyNodeABC):
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": (IO.ANY, {})}}
RETURN_TYPES = (IO.ANY,)
FUNCTION = "oom_error"
CATEGORY = "experimental/async"
def oom_error(self, value):
raise torch.cuda.OutOfMemoryError("Intentional out of memory error for testing")
class TestMixedExpansionFailure(ComfyNodeABC):
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": ("INT", {})}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "expand"
CATEGORY = "experimental/async"
def expand(self, value):
image = torch.zeros([1, 32, 32, 3])
if value == 0:
return (image,)
graph = GraphBuilder()
error = graph.node("TestSyncError", value=image)
return {
"result": (error.out(0),),
"expand": graph.finalize(),
}
class TestMalformedExpansion(ComfyNodeABC):
"""Expands to a graph referencing a missing node class, so the failure
happens in the executor after the node function has returned."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": (IO.ANY, {})}}
RETURN_TYPES = (IO.ANY,)
FUNCTION = "expand"
CATEGORY = "experimental/async"
def expand(self, value):
graph = GraphBuilder()
missing = graph.node("TestNodeClassThatDoesNotExist", value=value)
return {
"result": (missing.out(0),),
"expand": graph.finalize(),
}
class TestMalformedResult(ComfyNodeABC):
"""Returns a non-tuple result so the failure happens while the executor
merges results, after the node function has returned."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"value": (IO.ANY, {})}}
RETURN_TYPES = (IO.ANY,)
FUNCTION = "run"
CATEGORY = "experimental/async"
def run(self, value):
return 5
class TestCyclicExpansion(ComfyNodeABC):
"""Expands to an output node that consumes this node's own pending output,
forming a cycle through the expansion completion link."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {"value": (IO.ANY, {})},
"hidden": {"unique_id": "UNIQUE_ID"},
}
RETURN_TYPES = (IO.ANY,)
FUNCTION = "expand"
CATEGORY = "experimental/async"
def expand(self, value, unique_id):
graph = GraphBuilder()
graph.node("TestAsyncOutput", value=[unique_id, 0], seconds=0.0)
return {
"result": (value,),
"expand": graph.finalize(),
}
class TestExpansionWithFailingOutput(ComfyNodeABC):
"""Expands to a subgraph whose result succeeds while a side branch ending
in an output node fails."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {"image": (IO.IMAGE, {})}}
RETURN_TYPES = ("IMAGE",)
FUNCTION = "expand"
CATEGORY = "experimental/async"
def expand(self, image):
graph = GraphBuilder()
error = graph.node("TestSyncError", value=image)
graph.node("PreviewImage", images=error.out(0))
passthrough = graph.node("StubImage", content="WHITE", height=32, width=32, batch_size=1)
return {
"result": (passthrough.out(0),),
"expand": graph.finalize(),
}
class TestAsyncOutput(ComfyNodeABC):
"""Async output node with no return sockets, used to test partial failure
handling across pending async invocations."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"value": (IO.ANY, {}),
"seconds": (IO.FLOAT, {"default": 0.1}),
},
}
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "run"
CATEGORY = "experimental/async"
async def run(self, value, seconds=0.1):
await asyncio.sleep(seconds)
return {"ui": {"values": [1]}}
class TestAsyncLazyCheck(ComfyNodeABC):
"""Test node with async check_lazy_status."""
@@ -322,6 +464,13 @@ ASYNC_TEST_NODE_CLASS_MAPPINGS = {
"TestAsyncValidationError": TestAsyncValidationError,
"TestAsyncTimeout": TestAsyncTimeout,
"TestSyncError": TestSyncError,
"TestOOMError": TestOOMError,
"TestMixedExpansionFailure": TestMixedExpansionFailure,
"TestMalformedExpansion": TestMalformedExpansion,
"TestMalformedResult": TestMalformedResult,
"TestCyclicExpansion": TestCyclicExpansion,
"TestExpansionWithFailingOutput": TestExpansionWithFailingOutput,
"TestAsyncOutput": TestAsyncOutput,
"TestAsyncLazyCheck": TestAsyncLazyCheck,
"TestDynamicAsyncGeneration": TestDynamicAsyncGeneration,
"TestAsyncResourceUser": TestAsyncResourceUser,
@@ -335,6 +484,13 @@ ASYNC_TEST_NODE_DISPLAY_NAME_MAPPINGS = {
"TestAsyncValidationError": "Test Async Validation Error",
"TestAsyncTimeout": "Test Async Timeout",
"TestSyncError": "Test Sync Error",
"TestOOMError": "Test OOM Error",
"TestMixedExpansionFailure": "Test Mixed Expansion Failure",
"TestMalformedExpansion": "Test Malformed Expansion",
"TestMalformedResult": "Test Malformed Result",
"TestCyclicExpansion": "Test Cyclic Expansion",
"TestExpansionWithFailingOutput": "Test Expansion With Failing Output",
"TestAsyncOutput": "Test Async Output",
"TestAsyncLazyCheck": "Test Async Lazy Check",
"TestDynamicAsyncGeneration": "Test Dynamic Async Generation",
"TestAsyncResourceUser": "Test Async Resource User",

View File

@@ -0,0 +1,51 @@
from comfy.comfy_types.node_typing import ComfyNodeABC
from comfy_api.latest._caching import CacheProvider
from comfy_execution.cache_provider import register_cache_provider
class _RecordingCacheProvider(CacheProvider):
"""Records the class types of every externally stored cache entry so tests
can assert that failed or failure-blocked outputs never leave the process."""
def __init__(self):
self.stored_class_types = []
async def on_lookup(self, context):
return None
async def on_store(self, context, value):
self.stored_class_types.append(context.class_type)
RECORDING_CACHE_PROVIDER = _RecordingCacheProvider()
register_cache_provider(RECORDING_CACHE_PROVIDER)
class TestCacheProviderRecord(ComfyNodeABC):
"""Reports which node class types have been stored through the external
cache provider interface since the server started."""
@classmethod
def INPUT_TYPES(cls):
return {"required": {}}
@classmethod
def IS_CHANGED(cls):
return float("NaN")
RETURN_TYPES = ()
OUTPUT_NODE = True
FUNCTION = "report"
CATEGORY = "Testing/Nodes"
def report(self):
return {"ui": {"stored_class_types": list(RECORDING_CACHE_PROVIDER.stored_class_types)}}
CACHE_PROVIDER_TEST_NODE_CLASS_MAPPINGS = {
"TestCacheProviderRecord": TestCacheProviderRecord,
}
CACHE_PROVIDER_TEST_NODE_DISPLAY_NAME_MAPPINGS = {
"TestCacheProviderRecord": "Test Cache Provider Record",
}