Files
robertvoy-ComfyUI-Distributed/tests/test_collector_exact_transfer.py
Robert Wojciechowski 5159cc7166 fix: integrate alpha preservation with current collector transports
Merge main while retaining exact transfer, serverless cleanup and image assembly error propagation. Update RGB fixtures and verify mixed RGB/RGBA exact HTTP transport without value loss.
2026-10-14 10:46:16 +00:00

553 lines
26 KiB
Python

"""Exact Collector transport through real serializers, HTTP, route and queue.
Only ComfyUI's process-wide server/model-management and unrelated orchestration
are replaced. Tensor/audio codecs, multipart bodies and aiohttp are real; no
running ComfyUI service, GPU, provider or external network access is required.
"""
import asyncio
from contextlib import asynccontextmanager
import importlib
import importlib.util
import json
from pathlib import Path
import sys
import types
from unittest.mock import AsyncMock
import pytest
import torch
ROOT = Path(__file__).resolve().parents[1]
PKG = "dist_collector_exact_testpkg"
JOB_ID = "exact-job"
WORKER_ID = "worker-1"
@pytest.fixture
def transport(monkeypatch):
# Other legacy test loaders may leave aiohttp stubs behind. Replace only
# those entries, never unload/reload real extension-backed dependencies.
for name, loaded_module in list(sys.modules.items()):
if (name == "aiohttp" or name.startswith("aiohttp.")) and not getattr(loaded_module, "__file__", None):
monkeypatch.delitem(sys.modules, name)
aiohttp = importlib.import_module("aiohttp")
web = importlib.import_module("aiohttp.web")
def module(name, **attrs):
result = types.ModuleType(name)
result.__dict__.update(attrs)
monkeypatch.setitem(sys.modules, name, result)
return result
def load(relative):
name = f"{PKG}.{relative}"
spec = importlib.util.spec_from_file_location(name, ROOT.joinpath(*relative.split(".")).with_suffix(".py"))
assert spec is not None and spec.loader is not None
result = importlib.util.module_from_spec(spec)
monkeypatch.setitem(sys.modules, name, result)
spec.loader.exec_module(result)
parent, _, child = name.rpartition(".")
setattr(sys.modules[parent], child, result)
return result
module(PKG, __path__=[str(ROOT)])
for package in ("nodes", "api", "utils", "serverless_bridge"):
module(f"{PKG}.{package}", __path__=[str(ROOT / package)])
server = types.SimpleNamespace(routes=web.RouteTableDef(), distributed_pending_jobs={})
module("server", PromptServer=types.SimpleNamespace(instance=server))
mm = module("comfy.model_management", throw_exception_if_processing_interrupted=lambda: None)
progress = module("comfy.utils", ProgressBar=lambda _total: types.SimpleNamespace(update=lambda _n: None))
module("comfy", __path__=[], model_management=mm, utils=progress)
module(f"{PKG}.utils.logging", debug_log=lambda *_a, **_kw: None, log=lambda *_a, **_kw: None)
module(f"{PKG}.utils.config", get_worker_timeout_seconds=lambda: 0.1,
load_config=lambda: {"workers": []}, is_master_delegate_only=lambda: False)
async def handle_api_error(_request, error, status=500):
return web.json_response({"error": str(error)}, status=status)
module(f"{PKG}.utils.network", handle_api_error=handle_api_error,
get_client_session=AsyncMock(), build_worker_url=lambda _worker: "http://unused.invalid",
probe_worker=AsyncMock(return_value=None))
module(f"{PKG}.utils.async_helpers", run_async_in_server_loop=asyncio.run)
module(f"{PKG}.api.queue_orchestration", ensure_distributed_state=lambda: None,
orchestrate_distributed_execution=AsyncMock())
module(f"{PKG}.api.queue_request", parse_queue_request_payload=lambda value: value)
for name in ("utils.constants", "utils.image", "utils.audio_payload", "utils.transfer_codec"):
load(name)
bridge_codec = load("serverless_bridge.codec")
load("serverless_bridge.image_transfer")
image_codec = load("serverless_bridge.image_codec")
collector = load("nodes.collector")
routes = load("api.job_routes")
return types.SimpleNamespace(aiohttp=aiohttp, web=web, server=server,
collector=collector, routes=routes, bridge_codec=bridge_codec,
image_codec=image_codec)
@asynccontextmanager
async def endpoint(transport, monkeypatch, *, request_limit=2 * 1024 * 1024,
chunk_target=1024 * 1024, client_max_size=None, handler=None):
"""A disposable loopback endpoint; record actual HTTP lengths, not estimates."""
queue = asyncio.Queue()
transport.server.distributed_jobs_lock = asyncio.Lock()
transport.server.distributed_pending_jobs = {JOB_ID: queue}
requests = []
web = transport.web
@web.middleware
async def record_request(request, handler):
record = {"bytes": request.content_length, "content_type": request.content_type}
requests.append(record)
response = await handler(request)
record["status"] = response.status
if response.status < 400 and request.content_type.startswith("multipart/"):
form = await request.post()
record["envelope"] = json.loads(form["envelope"])
return response
app = web.Application(client_max_size=client_max_size or request_limit, middlewares=[record_request])
app.router.add_post("/distributed/job_complete", handler or transport.routes.job_complete_endpoint)
runner = web.AppRunner(app)
await runner.setup()
try:
site = web.TCPSite(runner, "127.0.0.1", 0)
await site.start()
port = site._server.sockets[0].getsockname()[1]
async with transport.aiohttp.ClientSession() as session:
monkeypatch.setattr(transport.collector, "get_client_session", AsyncMock(return_value=session))
monkeypatch.setattr(transport.collector, "TRANSFER_MAX_REQUEST_BYTES", request_limit)
monkeypatch.setattr(transport.collector, "TRANSFER_MAX_CHUNK_BYTES", chunk_target)
yield types.SimpleNamespace(
session=session, url=f"http://127.0.0.1:{port}", requests=requests, queue=queue,
node=transport.collector.DistributedCollectorNode(),
)
finally:
await runner.cleanup()
def frames(dtype=torch.float32, channels=3, count=5, height=7, width=9):
# Non-contiguous, out-of-range and non-8-bit-representable values exercise
# detach/contiguous without permitting clipping, alpha loss or quantization.
values = torch.arange(count * height * width * channels, dtype=torch.float64)
values = (values.remainder(41) - 11) / 17
images = values.reshape(count, width, height, channels).transpose(1, 2).to(dtype)
images[0, 0, 0, 0] = -0.0
return images.requires_grad_(True)
def assert_exact(actual, expected):
assert actual.dtype == expected.dtype
assert actual.shape == expected.shape
assert actual.device.type == "cpu"
assert actual.is_contiguous()
# Compare raw bits as well as values, including BF16 and signed zero.
assert torch.equal(actual, expected)
assert torch.equal(actual.view(torch.uint8), expected.detach().contiguous().view(torch.uint8))
def queued_items(queue):
return [queue.get_nowait() for _ in range(queue.qsize())]
async def send(ctx, images, *, audio=None, fmt="exact"):
await ctx.node.send_batch_to_master(images, audio, JOB_ID, ctx.url, WORKER_ID, fmt, 17)
@pytest.mark.parametrize("delegate_only", [False, True])
@pytest.mark.parametrize("dtype", [torch.float16, torch.bfloat16, torch.float32, torch.float64])
@pytest.mark.parametrize("channels", [1, 3, 4])
def test_exact_sender_route_and_collector_preserve_dtype_values_and_channels(
transport, monkeypatch, dtype, channels, delegate_only):
images = frames(dtype, channels)
frame_bytes = images[0].numel() * images.element_size()
reserve = transport.collector.DistributedCollectorNode.TRANSFER_ENVELOPE_RESERVE_BYTES
async def scenario():
async with endpoint(transport, monkeypatch, request_limit=reserve + 2 * frame_bytes,
chunk_target=2 * frame_bytes) as ctx:
await send(ctx, images)
items = queued_items(ctx.queue)
assert [item["image_index"] for item in items] == list(range(len(images)))
assert [item["is_last"] for item in items] == [False] * 4 + [True]
assert all(item["worker_id"] == WORKER_ID and item["format"] == "exact" for item in items)
for item, expected in zip(items, images.split(1)):
assert_exact(item["tensor"], expected)
await ctx.queue.put(item)
envelopes = [record["envelope"] for record in ctx.requests]
assert [(e["start_index"], e["count"]) for e in envelopes] == [(0, 2), (2, 2), (4, 1)]
assert all(record["bytes"] <= reserve + 2 * frame_bytes for record in ctx.requests)
master_images = None if delegate_only else images[:1].detach().clone().add(0.03125)
actual, _audio = await ctx.node.execute(
master_images, None, multi_job_id=JOB_ID, enabled_worker_ids=json.dumps([WORKER_ID]),
delegate_only=delegate_only, transfer_format="exact",
)
expected = images if delegate_only else torch.cat([master_images, images])
assert_exact(actual, expected)
assert JOB_ID not in transport.server.distributed_pending_jobs
asyncio.run(scenario())
def test_exact_single_frame_can_exceed_chunk_target_but_not_request_limit(transport, monkeypatch):
images = frames(count=3)
frame_bytes = images[0].numel() * images.element_size()
reserve = transport.collector.DistributedCollectorNode.TRANSFER_ENVELOPE_RESERVE_BYTES
async def scenario():
async with endpoint(transport, monkeypatch, request_limit=reserve + frame_bytes,
chunk_target=frame_bytes // 2) as ctx:
await send(ctx, images)
assert [record["envelope"]["count"] for record in ctx.requests] == [1, 1, 1]
assert all(record["bytes"] <= reserve + frame_bytes for record in ctx.requests)
assert_exact(torch.cat([item["tensor"] for item in queued_items(ctx.queue)]), images)
asyncio.run(scenario())
def test_exact_oversized_frame_is_rejected_before_encoding_or_post(transport, monkeypatch):
images = frames(count=3)
frame_bytes = images[0].numel() * images.element_size()
reserve = transport.collector.DistributedCollectorNode.TRANSFER_ENVELOPE_RESERVE_BYTES
encode = AsyncMock(side_effect=AssertionError("oversized frame must not be encoded"))
monkeypatch.setattr(transport.collector, "_encode_exact_frames", encode)
async def scenario():
async with endpoint(transport, monkeypatch, request_limit=reserve + frame_bytes - 1) as ctx:
with pytest.raises(transport.collector.transfer_codec.FrameTooLargeError, match="request limit"):
await send(ctx, images)
assert ctx.requests == []
assert ctx.queue.empty()
encode.assert_not_called()
asyncio.run(scenario())
@pytest.mark.parametrize("shape", [(0, 2, 3, 3), (1, 0, 3, 3), (1, 2, 0, 3), (1, 2, 3, 0),
(1, 2, 3, 2), (1, 2, 3, 5), (1, 2, 3), (2, 3)])
def test_exact_sender_rejects_invalid_geometry_before_post(transport, monkeypatch, shape):
image = torch.empty(shape)
with pytest.raises(transport.collector.transfer_codec.TransferCodecError):
transport.collector._exact_frame_bytes(image)
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
# An empty batch is also a no-media completion error (ValueError).
with pytest.raises(ValueError):
await send(ctx, image)
assert ctx.requests == []
assert ctx.queue.empty()
asyncio.run(scenario())
@pytest.mark.parametrize("dtype", [torch.uint8, torch.int32, torch.bool, torch.complex64])
def test_exact_sender_rejects_unsupported_dtype_before_post(transport, monkeypatch, dtype):
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
with pytest.raises(transport.collector.transfer_codec.TransferCodecError, match="dtype"):
await send(ctx, torch.zeros(1, 2, 3, 3, dtype=dtype))
assert ctx.requests == []
asyncio.run(scenario())
def test_exact_frame_planning_enforces_bridge_byte_limit(transport, monkeypatch):
monkeypatch.setattr(transport.bridge_codec, "MAX_PAYLOAD_BYTES", 32)
with pytest.raises(transport.collector.transfer_codec.TransferCodecError, match="[Bb]yte|limit"):
transport.collector._exact_frame_bytes(torch.zeros(1, 2, 3, 3))
def test_exact_frame_planning_rejects_non_dense_layout(transport):
image = torch.zeros(1, 2, 3, 3).to_sparse()
with pytest.raises(transport.collector.transfer_codec.TransferCodecError, match="dense|layout|strided"):
transport.collector._exact_frame_bytes(image)
@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")])
def test_exact_sender_rejects_nonfinite_payload(transport, monkeypatch, value):
images = torch.full((1, 2, 3, 3), value)
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
with pytest.raises(transport.collector.transfer_codec.TransferCodecError, match="finite"):
await send(ctx, images)
assert ctx.requests == []
assert ctx.queue.empty()
asyncio.run(scenario())
def exact_chunk(transport, count=2):
images = frames(count=count).detach()
encoded = [transport.bridge_codec.encode_payload("IMAGE", frame) for frame in images.split(1)]
return {
"job_id": JOB_ID, "worker_id": WORKER_ID, "format": "exact",
"start_index": 0, "count": count, "width": images.shape[2], "height": images.shape[1],
"is_last": True, "descriptors": [descriptor for descriptor, _ in encoded],
}, [payload for _, payload in encoded]
@pytest.mark.parametrize("case", [
"zero_count", "bool_width", "negative_height", "negative_index", "bool_index",
"missing_descriptor", "extra_descriptor", "missing_part", "extra_part", "not_a_descriptor",
"wrong_height", "wrong_batch", "bad_channels", "bad_dtype", "bad_layout", "bad_encoding",
"extra_descriptor_field", "truncated", "trailing_bytes", "nonfinite", "compressed_spoof",
])
def test_exact_route_rejects_bad_geometry_and_payload_atomically(transport, monkeypatch, case):
envelope, parts = exact_chunk(transport)
descriptor = envelope["descriptors"][-1]
if case == "zero_count":
envelope["count"] = 0
elif case == "bool_width":
envelope["width"] = True
elif case == "negative_height":
envelope["height"] = -1
elif case == "negative_index":
envelope["start_index"] = -1
elif case == "bool_index":
envelope["start_index"] = True
elif case == "missing_descriptor":
envelope["descriptors"].pop()
elif case == "extra_descriptor":
envelope["descriptors"].append(dict(descriptor))
elif case == "missing_part":
parts.pop()
elif case == "extra_part":
parts.append(parts[-1])
elif case == "not_a_descriptor":
envelope["descriptors"][-1] = None
elif case == "wrong_height":
descriptor["shape"][1] += 1
elif case == "wrong_batch":
descriptor["shape"][0] = 2
elif case == "bad_channels":
descriptor["shape"][-1] = 2
elif case == "bad_dtype":
descriptor["dtype"] = "uint8"
elif case == "bad_layout":
descriptor["layout"] = "BCHW"
elif case == "bad_encoding":
descriptor["encoding"] = "pickle"
elif case == "extra_descriptor_field":
descriptor["unknown"] = True
elif case == "truncated":
parts[-1] = parts[-1][:-1]
elif case == "trailing_bytes":
parts[-1] += b"\0"
elif case == "nonfinite":
bad = torch.full(tuple(descriptor["shape"]), float("nan"))
parts[-1] = bad.numpy().tobytes()
elif case == "compressed_spoof":
# A valid serverless image-transfer descriptor must NOT be accepted as
# exact by the direct route, even though the shared bridge can decode it.
envelope["descriptors"][-1], parts[-1] = transport.image_codec.encode_image(
frames(count=1).detach(), transfer_format="png", transfer_quality=90,
)
# Prove the spoof is a valid compressed bridge payload, not simply junk.
decoded = transport.bridge_codec.decode_payload("IMAGE", envelope["descriptors"][-1], parts[-1])
assert list(decoded.shape) == descriptor["shape"]
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
async with ctx.session.post(
ctx.url + "/distributed/job_complete",
data=ctx.node._build_multipart(envelope, parts),
) as response:
body = await response.text()
assert response.status == 400, body
# A corrupt second frame must not leave the first in the queue.
assert ctx.queue.empty()
asyncio.run(scenario())
@pytest.mark.parametrize("status,body", [(400, "Invalid JSON payload: old master"),
(413, "upload limit"), (500, "unexpected failure")])
def test_exact_master_rejection_never_falls_back(transport, monkeypatch, status, body):
async def reject(_request):
return transport.web.Response(status=status, text=body)
async def scenario():
async with endpoint(transport, monkeypatch, handler=reject, chunk_target=1) as ctx:
legacy = AsyncMock(side_effect=AssertionError("exact must never use legacy transport"))
compressed = AsyncMock(side_effect=AssertionError("exact must never use compressed transport"))
monkeypatch.setattr(ctx.node, "_send_legacy_batch", legacy)
monkeypatch.setattr(ctx.node, "_send_compressed_batch", compressed)
with pytest.raises(RuntimeError, match=f"HTTP {status}") as raised:
await send(ctx, frames(count=3))
assert "never" in str(raised.value) and "8-bit" in str(raised.value)
assert len(ctx.requests) == 1
assert ctx.requests[0]["content_type"].startswith("multipart/")
assert ctx.queue.empty()
legacy.assert_not_called()
compressed.assert_not_called()
asyncio.run(scenario())
@pytest.mark.parametrize("fmt", ["legacy_png", "png"])
def test_exact_collector_rejects_old_worker_8bit_result(transport, monkeypatch, fmt):
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
await send(ctx, frames(count=1).detach(), fmt=fmt)
assert ctx.queue.qsize() == 1
with pytest.raises(RuntimeError, match="requested exact"):
await ctx.node.execute(
None, None, multi_job_id=JOB_ID, enabled_worker_ids=json.dumps([WORKER_ID]),
delegate_only=True, transfer_format="exact",
)
assert JOB_ID not in transport.server.distributed_pending_jobs
asyncio.run(scenario())
def test_exact_collector_shape_mismatch_raises_instead_of_returning_local_images(transport, monkeypatch):
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
# Channel mixing is supported; incompatible spatial sizes still fail.
await send(ctx, frames(count=1, channels=4, width=10))
with pytest.raises(RuntimeError, match="spatial dimensions"):
await ctx.node.execute(
frames(count=1, channels=3), None, multi_job_id=JOB_ID,
enabled_worker_ids=json.dumps([WORKER_ID]), transfer_format="exact",
)
assert JOB_ID not in transport.server.distributed_pending_jobs
asyncio.run(scenario())
@pytest.mark.parametrize("local_channels,worker_channels", [(3, 4), (4, 3)])
def test_exact_collector_mixed_rgb_rgba_preserves_every_value(transport, monkeypatch, local_channels, worker_channels):
local = frames(count=1, channels=local_channels)
worker = frames(count=1, channels=worker_channels)
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
await send(ctx, worker)
result, _audio = await ctx.node.execute(
local, None, multi_job_id=JOB_ID,
enabled_worker_ids=json.dumps([WORKER_ID]), transfer_format="exact",
)
assert result.shape == (2, 7, 9, 4)
assert result.is_contiguous()
# RGB views into RGBA have a four-channel stride; materialize the
# sliced comparison without changing the output-contiguity check.
assert_exact(result[:1, ..., :local_channels].contiguous(), local)
assert_exact(result[1:, ..., :worker_channels].contiguous(), worker)
if local_channels == 3:
assert torch.all(result[:1, ..., 3] == 1)
if worker_channels == 3:
assert torch.all(result[1:, ..., 3] == 1)
assert JOB_ID not in transport.server.distributed_pending_jobs
asyncio.run(scenario())
def test_exact_incomplete_worker_raises_and_cleans_queue(transport, monkeypatch):
"""A timed-out worker's first frame is not a successful partial result."""
envelope, parts = exact_chunk(transport, count=1)
envelope["is_last"] = False
async def scenario():
async with endpoint(transport, monkeypatch) as ctx:
async with ctx.session.post(
ctx.url + "/distributed/job_complete", data=ctx.node._build_multipart(envelope, parts),
) as response:
assert response.status == 200, await response.text()
assert ctx.queue.qsize() == 1
with pytest.raises(RuntimeError, match="Exact transfer incomplete"):
await asyncio.wait_for(ctx.node.execute(
frames(count=1).detach(), None, multi_job_id=JOB_ID,
enabled_worker_ids=json.dumps([WORKER_ID]), transfer_format="exact",
), timeout=2)
assert JOB_ID not in transport.server.distributed_pending_jobs
asyncio.run(scenario())
def test_exact_oversized_multipart_metadata_is_rejected_before_post(transport, monkeypatch):
images = frames(count=1)
reserve = transport.collector.DistributedCollectorNode.TRANSFER_ENVELOPE_RESERVE_BYTES
limit = reserve + images.numel() * images.element_size()
async def scenario():
async with endpoint(transport, monkeypatch, request_limit=limit) as ctx:
with pytest.raises(transport.collector.transfer_codec.FrameTooLargeError, match="request limit"):
await ctx.node.send_batch_to_master(
images, None, JOB_ID, ctx.url, "w" * limit, "exact", 90,
)
assert ctx.requests == []
assert ctx.queue.empty()
asyncio.run(scenario())
def test_exact_actual_server_upload_rejection_is_413_without_retry(transport, monkeypatch):
async def scenario():
async with endpoint(transport, monkeypatch, client_max_size=1024) as ctx:
with pytest.raises(RuntimeError, match="HTTP 413"):
await send(ctx, frames(count=3, height=64, width=64))
assert len(ctx.requests) == 1
assert ctx.requests[0]["status"] == 413
assert ctx.queue.empty()
asyncio.run(scenario())
def test_exact_oversized_audio_is_rejected_before_any_image_post(transport, monkeypatch):
images = frames(count=3)
audio = {"waveform": torch.zeros(1, 1, 100000), "sample_rate": 48000}
reserve = transport.collector.DistributedCollectorNode.TRANSFER_ENVELOPE_RESERVE_BYTES
async def scenario():
async with endpoint(transport, monkeypatch, request_limit=reserve + 1024) as ctx:
with pytest.raises(transport.collector.transfer_codec.FrameTooLargeError, match="audio.*request limit"):
await send(ctx, images, audio=audio)
assert ctx.requests == []
assert ctx.queue.empty()
asyncio.run(scenario())
def test_exact_tiny_frames_cannot_overflow_multipart_metadata_budget(transport, monkeypatch):
images = torch.full((2048, 1, 1, 1), 0.125, dtype=torch.float16)
reserve = transport.collector.DistributedCollectorNode.TRANSFER_ENVELOPE_RESERVE_BYTES
limit = reserve + images.numel() * images.element_size()
async def scenario():
async with endpoint(transport, monkeypatch, request_limit=limit, chunk_target=limit) as ctx:
await send(ctx, images)
assert len(ctx.requests) > 1
assert all(record["bytes"] <= limit for record in ctx.requests)
assert_exact(torch.cat([item["tensor"] for item in queued_items(ctx.queue)]), images)
asyncio.run(scenario())
@pytest.mark.parametrize("separate", [False, True])
def test_exact_audio_is_preserved_and_only_final_completion_is_terminal(transport, monkeypatch, separate):
images = frames(count=3)
audio = {"waveform": torch.linspace(-0.9, 0.9, 60).reshape(1, 1, -1), "sample_rate": 48000}
frame_bytes = images[0].numel() * images.element_size()
reserve = transport.collector.DistributedCollectorNode.TRANSFER_ENVELOPE_RESERVE_BYTES
media_budget = frame_bytes if separate else frame_bytes + 8192
limit = reserve + media_budget
async def scenario():
async with endpoint(transport, monkeypatch, request_limit=limit, chunk_target=frame_bytes) as ctx:
await send(ctx, images, audio=audio)
items = queued_items(ctx.queue)
assert len(ctx.requests) == 3 + int(separate)
assert [item["is_last"] for item in items] == [False] * (len(items) - 1) + [True]
assert all(item["audio"] is None for item in items[:-1])
assert items[-1]["audio"]["sample_rate"] == audio["sample_rate"]
assert_exact(items[-1]["audio"]["waveform"], audio["waveform"])
assert_exact(torch.cat([item["tensor"] for item in items if item["tensor"] is not None]), images)
assert all(record["bytes"] <= limit for record in ctx.requests)
if separate:
assert items[-1]["tensor"] is None
assert ctx.requests[-1]["content_type"] == "application/json"
asyncio.run(scenario())