fix: unwrap scalar remote prompt inputs

This commit is contained in:
Ken Simpson
2026-04-19 11:00:48 -07:00
parent 7912937bf5
commit 9116817dea
5 changed files with 79 additions and 18 deletions
+1 -1
View File
@@ -79,7 +79,7 @@ Remote mode requires:
The normal remote path is deployed-app lookup by name. On first remote use, the extension can auto-deploy the stable cloud entrypoint from [`comfyui_modal_sync_cloud.py`](comfyui_modal_sync_cloud.py) if the configured app does not exist yet.
If a previous attempt imported that cloud entrypoint only partially before failing, the loader now discards the stale cached module and retries the import cleanly before auto-deploy.
The local and remote subgraph runners also canonicalize malformed singleton-list socket indexes like `["node", [0]]` back to `["node", 0]` before executing a rewritten prompt.
The local and remote subgraph runners also canonicalize malformed singleton-list wrappers in rewritten prompts, including socket indexes like `["node", [0]]`, scalar inputs like `[4]`, and wrapped execute node ids like `["12"]`, before handing the prompt to ComfyUI.
```bash
export COMFY_MODAL_EXECUTION_MODE=remote
+19 -8
View File
@@ -1190,6 +1190,17 @@ def _normalize_link_output_index(value: Any) -> Any:
return value
def _normalize_prompt_input_value(value: Any) -> Any:
"""Unwrap transport-added singleton lists around scalar prompt input values."""
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str):
return [value[0], _normalize_link_output_index(value[1])]
if isinstance(value, list) and len(value) == 1:
candidate = value[0]
if candidate is None or isinstance(candidate, bool | int | float | str):
return candidate
return value
def _normalize_subgraph_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return a subgraph payload with canonical prompt-link and output-index shapes."""
normalized_payload = copy.deepcopy(payload)
@@ -1197,21 +1208,21 @@ def _normalize_subgraph_payload(payload: dict[str, Any]) -> dict[str, Any]:
for node_info in normalized_payload.get("subgraph_prompt", {}).values():
inputs = node_info.get("inputs") or {}
for input_name, input_value in list(inputs.items()):
if not isinstance(input_value, list) or len(input_value) != 2:
continue
if not isinstance(input_value[0], str):
continue
inputs[input_name] = [
input_value[0],
_normalize_link_output_index(input_value[1]),
]
inputs[input_name] = _normalize_prompt_input_value(input_value)
for boundary_output in normalized_payload.get("boundary_outputs", []):
if "node_id" in boundary_output and isinstance(boundary_output["node_id"], list):
boundary_output["node_id"] = _normalize_prompt_input_value(boundary_output["node_id"])
if "output_index" in boundary_output:
boundary_output["output_index"] = _normalize_link_output_index(
boundary_output["output_index"]
)
normalized_payload["execute_node_ids"] = [
_normalize_prompt_input_value(node_id)
for node_id in normalized_payload.get("execute_node_ids", [])
]
return normalized_payload
+1 -1
View File
@@ -82,7 +82,7 @@
- Hybrid mapped components also need stream-level UI filtering. A warm PromptExecutor can still emit cached `executed` or preview UI events for static sibling nodes during per-item mapped runs, so the local relay has to drop any streamed UI event whose node id is outside the current mapped/static payload's execute and boundary-output node set.
- Modal proxy nodes that sit on a mapped boundary must opt into ComfyUI `INPUT_IS_LIST` semantics themselves. Otherwise ComfyUI can auto-map the proxy once per item before Modal-Sync sees the batch, which makes the entire hybrid component rerun per item instead of letting the internal mapped scheduler split only the intended branch.
- Once a proxy opts into `INPUT_IS_LIST`, ComfyUI also wraps the proxy's hidden payload input itself. The proxy has to unwrap singleton lists on `original_node_data` as well as ordinary kwargs, or it will reject valid rewritten payloads with a misleading "must be a mapping" error before remote execution even starts.
- Rewritten remote prompts need a last-mile canonicalization pass before execution. If a socket index leaks through as `["node", [0]]` or a boundary output index arrives as `[0]`, ComfyUI's executor eventually crashes trying to treat the list like an integer; normalizing those singleton wrappers on both the local and Modal-side runners avoids that failure mode.
- Rewritten remote prompts need a last-mile canonicalization pass before execution. If a socket index leaks through as `["node", [0]]`, a scalar input arrives as `[4]`, or an execute node id arrives as `["12"]`, ComfyUI's executor eventually crashes trying to treat the list like an integer or node id; normalizing those singleton wrappers on both the local and Modal-side runners avoids that failure mode.
- Content-addressed sync should trust the deterministic payload path, not only the sidecar marker. If a crash happens after uploading a hash-named asset but before writing its `.done` marker, the next run should backfill the marker and reuse the existing payload instead of trying the same upload again and failing with `FileExistsError`.
- The streamed Modal generator path needs the same tensor-aware serialization rules as ordinary node input/output transport. If a progress or result envelope leaks a raw `torch.Tensor`, Modal's JSON encoding fails before the local client ever sees it, so the stream layer should normalize event payloads through the shared serializer too.
- Modal volume metadata calls can become a bottleneck on their own. Repeated `VolumeListFiles` checks for the same marker path are worth memoizing for the life of the queue request, and transient `ResourceExhaustedError` responses should be retried with backoff instead of surfacing immediately as hard prompt failures.
+19 -8
View File
@@ -271,6 +271,17 @@ def _normalize_link_output_index(value: Any) -> Any:
return value
def _normalize_prompt_input_value(value: Any) -> Any:
"""Unwrap transport-added singleton lists around scalar prompt input values."""
if isinstance(value, list) and len(value) == 2 and isinstance(value[0], str):
return [value[0], _normalize_link_output_index(value[1])]
if isinstance(value, list) and len(value) == 1:
candidate = value[0]
if candidate is None or isinstance(candidate, bool | int | float | str):
return candidate
return value
def _normalize_subgraph_payload(payload: dict[str, Any]) -> dict[str, Any]:
"""Return a subgraph payload with canonical prompt-link and output-index shapes."""
normalized_payload = copy.deepcopy(payload)
@@ -278,21 +289,21 @@ def _normalize_subgraph_payload(payload: dict[str, Any]) -> dict[str, Any]:
for node_info in normalized_payload.get("subgraph_prompt", {}).values():
inputs = node_info.get("inputs") or {}
for input_name, input_value in list(inputs.items()):
if not isinstance(input_value, list) or len(input_value) != 2:
continue
if not isinstance(input_value[0], str):
continue
inputs[input_name] = [
input_value[0],
_normalize_link_output_index(input_value[1]),
]
inputs[input_name] = _normalize_prompt_input_value(input_value)
for boundary_output in normalized_payload.get("boundary_outputs", []):
if "node_id" in boundary_output and isinstance(boundary_output["node_id"], list):
boundary_output["node_id"] = _normalize_prompt_input_value(boundary_output["node_id"])
if "output_index" in boundary_output:
boundary_output["output_index"] = _normalize_link_output_index(
boundary_output["output_index"]
)
normalized_payload["execute_node_ids"] = [
_normalize_prompt_input_value(node_id)
for node_id in normalized_payload.get("execute_node_ids", [])
]
return normalized_payload
+39
View File
@@ -2529,3 +2529,42 @@ def test_local_remote_app_normalizes_wrapped_subgraph_link_indexes(
)
outputs = serialization_module.deserialize_node_outputs(payload)
assert outputs == (10,)
def test_local_remote_app_normalizes_wrapped_scalar_prompt_inputs(
remote_modal_app_module: Any,
serialization_module: Any,
) -> None:
"""The local fallback runner should unwrap singleton-list scalar prompt inputs."""
payload = remote_modal_app_module.execute_subgraph_locally(
payload={
"payload_kind": "subgraph",
"component_id": "component-1",
"subgraph_prompt": {
"remote_1": {
"class_type": "BoundarySource",
"inputs": {"value": [4]},
"_meta": {},
}
},
"boundary_inputs": [],
"boundary_outputs": [
{
"proxy_output_name": "remote_1_value",
"node_id": "remote_1",
"output_index": 0,
"io_type": "INT",
"is_list": False,
}
],
"execute_node_ids": [["remote_1"]],
"extra_data": {},
"custom_nodes_bundle": None,
},
kwargs_payload="{}",
node_mapping={
"BoundarySource": _BoundarySourceNode,
},
)
outputs = serialization_module.deserialize_node_outputs(payload)
assert outputs == (5,)