Files
Comfy-Org-comfy-remote-nodes/notes/run_batch_uri_followups.py
Jedrzej KosinskiandAmp 35b8a3b17f feat(serialization): tighten batch dispatch + accept response-side batch URIs
Three follow-ups identified in the PR-2 / PR-3 review pass:

1. ``serialization.maybe_externalize`` now keys batch dispatch off
   the ``encoding`` string (``png_base64_batch``) instead of the
   presence of a ``frames`` field. The old ``isinstance(frames, list)
   and frames`` heuristic would silently take the batch branch on a
   malformed envelope that mixed both fields and externalise garbage
   under a non-batch encoding.

2. An empty / missing ``frames`` list on a ``png_base64_batch``
   envelope now raises ``RnpProtocolError(INTERNAL)`` rather than
   silently no-op-ing through to the wire (where the server's
   ``resolve_image_envelope_pngs`` rejects it after the round trip
   anyway). Mirrors the server-side ``make_image_batch_envelope``
   contract: an empty batch is a hard wire bug.

3. ``proxy_node._deserialize_output`` now accepts the externalised
   ``png_base64_batch_uri`` shape on the response path. No server
   emits this shape today (future-proofing only — the symmetric
   counterpart of the request-side externalisation), but the decoder
   owns the symmetry: any envelope shape the client may externalise
   on the way out, the client must also accept on the way in.
   Per-frame URIs are fetched in parallel via ``asyncio.gather`` so
   a 9-frame batch import is one round-trip rather than N. Frame
   ordering is preserved (gather returns positional results) — the
   IMAGE tensor's batch axis carries semantic order. Fetched bytes
   are re-stamped as inline ``frames`` so the existing
   ``_decode_image_batch_envelope`` path handles them uniformly.

notes/run_batch_uri_followups.py — new standalone smoke test that
exercises all three changes (encoding-keyed dispatch, empty-frames
hard-error, response-side batch URI parallel fetch + ordering).

Amp-Thread-ID: https://ampcode.com/threads/T-019e2ce6-9403-720c-ae5b-1597b8068be7
Co-authored-by: Amp <amp@ampcode.com>
2026-05-15 12:05:58 -07:00

353 lines
13 KiB
Python

"""Smoke test for the three batch-URI followups (PR-2/PR-3 cleanup):
1. ``serialization.maybe_externalize`` keys batch dispatch off the
``encoding`` string (``png_base64_batch``) — NOT field presence
(``frames``).
2. ``serialization.maybe_externalize`` rejects an empty/missing
``frames`` list on a ``png_base64_batch`` envelope as a hard
``RnpProtocolError`` rather than silently no-op-ing.
3. ``proxy_node._deserialize_output`` accepts the externalised
``png_base64_batch_uri`` shape on the response path: fetches every
``uri`` (in parallel via ``asyncio.gather``), rewrites to inline
``frames``, and decodes into a ``[B,H,W,C]`` tensor with frame
ordering preserved.
Run with any python that has ``torch`` and ``Pillow`` available:
python notes/run_batch_uri_followups.py
"""
from __future__ import annotations
import asyncio
import base64
import http.server
import importlib.util
import io
import os
import socketserver
import sys
import threading
import time
import types
import numpy as np
import torch
from PIL import Image
# ---------------------------------------------------------------------------
# Locate the worktree under test.
# ---------------------------------------------------------------------------
_HERE = os.path.dirname(os.path.abspath(__file__))
_CLIENT_DIR = os.path.dirname(_HERE)
def _load_module(name: str, path: str) -> types.ModuleType:
spec = importlib.util.spec_from_file_location(name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[name] = mod
spec.loader.exec_module(mod)
return mod
# Stub out client.upload_asset so the dispatch tests don't need a real
# RNP server — the externalize batch path will call this once per frame.
_uploads: list[bytes] = []
async def _fake_upload_asset(server_url, raw, *, content_type, auth_headers=None): # noqa: ANN001
_uploads.append(bytes(raw))
return f"https://fake-storage/{len(_uploads) - 1}"
def _install_fake_client() -> None:
fake_client = types.ModuleType("comfy_remote_nodes.client")
fake_client.upload_asset = _fake_upload_asset
sys.modules["comfy_remote_nodes.client"] = fake_client
def _png_b64_for_color(color: tuple[int, int, int]) -> str:
img = Image.new("RGB", (4, 4), color=color)
buf = io.BytesIO()
img.save(buf, format="PNG")
return base64.b64encode(buf.getvalue()).decode("ascii")
def _png_bytes_for_color(color: tuple[int, int, int]) -> bytes:
img = Image.new("RGB", (4, 4), color=color)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
# ---------------------------------------------------------------------------
# Test 1+2: maybe_externalize dispatch + empty-frames
# ---------------------------------------------------------------------------
async def _test_maybe_externalize() -> list[str]:
failures: list[str] = []
# Re-import serialization fresh because it caches a `client` ref.
sys.modules.pop("comfy_remote_nodes", None)
sys.modules.pop("comfy_remote_nodes.serialization", None)
sys.modules.pop("comfy_remote_nodes.protocol", None)
# Build a minimal package shim so `from . import client` works.
pkg = types.ModuleType("comfy_remote_nodes")
pkg.__path__ = [_CLIENT_DIR]
sys.modules["comfy_remote_nodes"] = pkg
_install_fake_client()
protocol = _load_module(
"comfy_remote_nodes.protocol",
os.path.join(_CLIENT_DIR, "protocol.py"),
)
serialization = _load_module(
"comfy_remote_nodes.serialization",
os.path.join(_CLIENT_DIR, "serialization.py"),
)
# --- Case A: batch envelope with payload bigger than cap → externalises
_uploads.clear()
big_frames = [_png_b64_for_color((i * 30, 0, 0)) for i in range(4)]
env = {
"type": "image",
"encoding": "png_base64_batch",
"frames": big_frames,
"shape": [4, 4, 4, 3],
"dtype": "uint8",
"color_space": "srgb",
}
out = await serialization.maybe_externalize(
env, server_url="http://fake", max_inline_bytes=1, auth_headers={},
)
if out.get("encoding") != "png_base64_batch_uri":
failures.append(
f"A: expected encoding=png_base64_batch_uri, got {out.get('encoding')!r}"
)
if "frames" in out:
failures.append("A: 'frames' field still present after externalise")
if not isinstance(out.get("uris"), list) or len(out["uris"]) != 4:
failures.append(f"A: expected 4 uris, got {out.get('uris')!r}")
if len(_uploads) != 4:
failures.append(f"A: expected 4 uploads, got {len(_uploads)}")
# --- Case B: dispatch keys off encoding, NOT field presence.
# An envelope with a stray `frames` field but a non-batch encoding
# must NOT be treated as a batch (would have under the old
# `isinstance(frames, list) and frames` dispatch).
_uploads.clear()
rogue = {
"type": "image",
"encoding": "png_base64", # singleton encoding
"data": _png_b64_for_color((10, 20, 30)) * 200, # > cap
"frames": ["bogus-extra-field"],
}
out_b = await serialization.maybe_externalize(
rogue, server_url="http://fake", max_inline_bytes=1, auth_headers={},
)
if out_b.get("encoding") == "png_base64_batch_uri":
failures.append(
"B: dispatch took batch branch on a singleton encoding (rogue frames field)"
)
if "uri" not in out_b:
failures.append("B: singleton encoding should have externalised to 'uri'")
if len(_uploads) != 1:
failures.append(f"B: expected 1 upload (singleton path), got {len(_uploads)}")
# --- Case C: empty frames on a batch encoding is a hard error.
bad = {
"type": "image",
"encoding": "png_base64_batch",
"frames": [],
"shape": [0, 4, 4, 3],
}
try:
await serialization.maybe_externalize(
bad, server_url="http://fake", max_inline_bytes=1, auth_headers={},
)
except protocol.RnpProtocolError:
pass
else:
failures.append("C: empty 'frames' on batch encoding did not raise RnpProtocolError")
# --- Case D: under-cap batch: no upload, envelope unchanged.
_uploads.clear()
small = {
"type": "image",
"encoding": "png_base64_batch",
"frames": big_frames,
"shape": [4, 4, 4, 3],
}
out_d = await serialization.maybe_externalize(
small,
server_url="http://fake",
max_inline_bytes=10 * 1024 * 1024,
auth_headers={},
)
if out_d is not small and out_d != small:
failures.append("D: under-cap batch should be a no-op")
if _uploads:
failures.append(f"D: under-cap batch should not upload, got {len(_uploads)}")
return failures
# ---------------------------------------------------------------------------
# Test 3: _deserialize_output handles png_base64_batch_uri on response
# ---------------------------------------------------------------------------
DELAY_S = 0.4
N_FRAMES = 4
class _SlowPngHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self) -> None: # noqa: N802
time.sleep(DELAY_S)
try:
idx = int(self.path.rsplit("/", 1)[-1])
except ValueError:
self.send_response(404); self.end_headers(); return
# Distinct color per frame so we can verify ordering after decode.
body = _png_bytes_for_color((idx * 60 % 256, 80, 120))
self.send_response(200)
self.send_header("Content-Type", "image/png")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def log_message(self, *_a, **_kw) -> None:
return
async def _test_deserialize_output() -> list[str]:
failures: list[str] = []
server = socketserver.ThreadingTCPServer(("127.0.0.1", 0), _SlowPngHandler)
host, port = server.server_address
th = threading.Thread(target=server.serve_forever, daemon=True)
th.start()
try:
# Stub the proxy_node's _fetch_url_envelope so we don't have to
# bring up the whole comfy_api_nodes.util.client stack.
import urllib.request as _urlreq
async def fake_fetch(envelope, policy, *, auth_headers, node_cls): # noqa: ANN001, ARG001
# Real ``_fetch_url_envelope`` is properly async (aiohttp);
# the smoke stub uses sync urllib so we hop to a thread to
# preserve the parallel-gather semantics under test.
uri = envelope["uri"]
def _do_fetch() -> bytes:
with _urlreq.urlopen(uri, timeout=10.0) as resp:
return resp.read()
return await asyncio.to_thread(_do_fetch)
# Re-import proxy_node helpers via AST to avoid loading ComfyUI.
import ast
with open(os.path.join(_CLIENT_DIR, "proxy_node.py"), "r", encoding="utf-8") as fh:
src = fh.read()
tree = ast.parse(src)
wanted = {"_deserialize_output"}
nodes = [n for n in tree.body if isinstance(n, ast.AsyncFunctionDef) and n.name in wanted]
if len(nodes) != len(wanted):
failures.append(f"missing AST nodes; found {[n.name for n in nodes]}")
return failures
# Re-import the protocol + serialization modules with the package
# shim so `from . import` works inside serialization.
sys.modules.pop("comfy_remote_nodes.serialization", None)
sys.modules.pop("comfy_remote_nodes.protocol", None)
pkg = types.ModuleType("comfy_remote_nodes")
pkg.__path__ = [_CLIENT_DIR]
sys.modules["comfy_remote_nodes"] = pkg
_install_fake_client()
protocol = _load_module(
"comfy_remote_nodes.protocol",
os.path.join(_CLIENT_DIR, "protocol.py"),
)
serialization = _load_module(
"comfy_remote_nodes.serialization",
os.path.join(_CLIENT_DIR, "serialization.py"),
)
ns: dict = {
"Any": object,
"RnpProtocolError": protocol.RnpProtocolError,
"ErrorCode": protocol.ErrorCode,
"is_envelope": protocol.is_envelope,
"serialization": serialization,
"_fetch_url_envelope": fake_fetch,
"log": types.SimpleNamespace(warning=lambda *a, **kw: None),
}
mod_src = ast.Module(body=nodes, type_ignores=[])
exec(compile(mod_src, "<batch_uri_smoke>", "exec"), ns)
deserialize = ns["_deserialize_output"]
# Build the externalised batch envelope on the response path.
uris = [f"http://{host}:{port}/frame/{i}" for i in range(N_FRAMES)]
envelope = {
"type": "image",
"encoding": "png_base64_batch_uri",
"uris": uris,
"shape": [N_FRAMES, 4, 4, 3],
"dtype": "uint8",
"color_space": "srgb",
}
url_fetch_policy = {"image": {"url_kind": "presigned", "timeout_s": 10}}
t0 = time.monotonic()
tensor = await deserialize(
envelope, meta=None,
url_fetch_policy=url_fetch_policy,
server_url="http://fake",
auth_headers={},
node_cls=None,
)
elapsed = time.monotonic() - t0
if not isinstance(tensor, torch.Tensor):
failures.append(f"expected torch.Tensor, got {type(tensor).__name__}")
return failures
if tuple(tensor.shape) != (N_FRAMES, 4, 4, 3):
failures.append(f"shape {tuple(tensor.shape)} != ({N_FRAMES}, 4, 4, 3)")
# Each frame's R-channel should match the expected color.
for i in range(N_FRAMES):
expected_r = (i * 60 % 256) / 255.0
actual_r = float(tensor[i, 0, 0, 0])
if abs(actual_r - expected_r) > 1e-3:
failures.append(
f"frame {i}: R channel {actual_r:.3f} != expected {expected_r:.3f} "
f"(ordering broken or wrong fetch)"
)
# Wall-clock: parallel should be ~DELAY_S, sequential would be ~N*DELAY_S.
sequential_lower = N_FRAMES * DELAY_S * 0.7
if elapsed >= sequential_lower:
failures.append(
f"wall clock {elapsed:.2f}s >= sequential lower bound "
f"{sequential_lower:.2f}s — gather not running in parallel"
)
return failures
finally:
server.shutdown(); server.server_close()
def main() -> int:
failures: list[str] = []
failures += asyncio.run(_test_maybe_externalize())
failures += asyncio.run(_test_deserialize_output())
if failures:
print()
print("FAIL:")
for f in failures:
print(" -", f)
return 1
print("PASS: maybe_externalize dispatches on encoding + rejects empty frames; "
"_deserialize_output handles png_base64_batch_uri in parallel")
return 0
if __name__ == "__main__":
sys.exit(main())