Skip to content

Commit 8d8a115

Browse files
fix(dynamicprompts): prevent hang on unknown wildcards (invoke-ai#9307)
* fix(dynamicprompts): prevent hang on unknown wildcards Referencing an unknown wildcard previously hung the combinatorial generator forever: its not-found fallback yields the wrapped wildcard infinitely, and the variant dedup logic discards those duplicates without ever advancing. This froze the UI prompt preview. Unknown wildcards are now detected up front and reported as a clear error instead of attempting generation. * fix(dynamicprompts): narrow unknown-wildcard guard to combinatorial variant values Only an unknown wildcard used as a variant value hangs the combinatorial generator; a bare `a __nope__ b` and the random generator both handle unknown wildcards fine. Restrict detection to variant-nested wildcards and gate the check on the combinatorial path so previously-working prompts no longer error.
1 parent a6728d9 commit 8d8a115

5 files changed

Lines changed: 164 additions & 1 deletion

File tree

invokeai/app/api/routers/utilities.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from invokeai.app.api.routers._access import assert_image_read_access
1818
from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException
1919
from invokeai.app.services.model_records.model_records_base import UnknownModelException
20+
from invokeai.app.util.dynamicprompts import find_missing_wildcards
2021
from invokeai.backend.llava_onevision_pipeline import LlavaOnevisionPipeline
2122
from invokeai.backend.model_manager.taxonomy import ModelType
2223
from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline
@@ -52,8 +53,19 @@ async def parse_dynamicprompts(
5253
"""Creates a batch process"""
5354
max_prompts = min(max_prompts, 10000)
5455
generator: Union[RandomPromptGenerator, CombinatorialPromptGenerator]
56+
error: Optional[str] = None
57+
58+
# An unknown wildcard used as a variant value sends the combinatorial generator into an infinite
59+
# loop, so bail out early with a clear message instead of hanging the request (and with it the UI
60+
# preview). The random generator handles unknown wildcards gracefully, so only the combinatorial
61+
# path is guarded.
62+
if combinatorial:
63+
missing_wildcards = find_missing_wildcards(prompt)
64+
if missing_wildcards:
65+
wildcards = ", ".join(missing_wildcards)
66+
return DynamicPromptsResponse(prompts=[prompt], error=f"No values found for wildcard(s): {wildcards}")
67+
5568
try:
56-
error: Optional[str] = None
5769
if combinatorial:
5870
generator = CombinatorialPromptGenerator()
5971
prompts = generator.generate(prompt, max_prompts=max_prompts)

invokeai/app/invocations/prompt.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
from invokeai.app.invocations.fields import InputField, UIComponent
1010
from invokeai.app.invocations.primitives import StringCollectionOutput
1111
from invokeai.app.services.shared.invocation_context import InvocationContext
12+
from invokeai.app.util.dynamicprompts import find_missing_wildcards
1213

1314

1415
@invocation(
@@ -31,6 +32,13 @@ class DynamicPromptInvocation(BaseInvocation):
3132

3233
def invoke(self, context: InvocationContext) -> StringCollectionOutput:
3334
if self.combinatorial:
35+
# An unknown wildcard used as a variant value sends the combinatorial generator into an
36+
# infinite loop, so fail fast with a clear message instead of hanging the invocation. The
37+
# random generator handles unknown wildcards gracefully and needs no guard.
38+
missing_wildcards = find_missing_wildcards(self.prompt)
39+
if missing_wildcards:
40+
raise ValueError(f"No values found for wildcard(s): {', '.join(missing_wildcards)}")
41+
3442
generator = CombinatorialPromptGenerator()
3543
prompts = generator.generate(self.prompt, max_prompts=self.max_prompts)
3644
else:
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Iterator
4+
5+
from dynamicprompts.commands import (
6+
Command,
7+
SequenceCommand,
8+
VariantCommand,
9+
WildcardCommand,
10+
WrapCommand,
11+
)
12+
from dynamicprompts.parser.parse import parse
13+
from dynamicprompts.wildcards import WildcardManager
14+
from pyparsing import ParseException
15+
16+
17+
def _iter_wildcard_names(command: Command, in_variant: bool = False) -> Iterator[str]:
18+
"""Recursively yield the statically-known wildcard names that appear as (part of) a variant value.
19+
20+
Only wildcards reachable from a `VariantCommand` value are yielded: those are the references that
21+
hang the combinatorial generator (see `find_missing_wildcards`). The same wildcard used as plain
22+
literal text (e.g. `a __nope__ b`) generates fine, so it is intentionally not reported.
23+
"""
24+
if isinstance(command, WildcardCommand):
25+
# The wildcard name may itself be a dynamic Command (e.g. `__${var}__`). Only plain string
26+
# names can be validated ahead of time, so the dynamic case is intentionally skipped.
27+
if in_variant and isinstance(command.wildcard, str):
28+
yield command.wildcard
29+
elif isinstance(command, SequenceCommand):
30+
for token in command.tokens:
31+
yield from _iter_wildcard_names(token, in_variant)
32+
elif isinstance(command, VariantCommand):
33+
# Everything below a variant value is a variant-nested reference, even across sequences.
34+
for value in command.values:
35+
yield from _iter_wildcard_names(value, in_variant=True)
36+
elif isinstance(command, WrapCommand):
37+
yield from _iter_wildcard_names(command.wrapper, in_variant)
38+
yield from _iter_wildcard_names(command.inner, in_variant)
39+
# LiteralCommand and variable commands reference no wildcards we can resolve statically.
40+
41+
42+
def find_missing_wildcards(prompt: str, wildcard_manager: WildcardManager | None = None) -> list[str]:
43+
"""Return the unique unknown wildcard names in `prompt` that hang the combinatorial generator.
44+
45+
Referencing an unknown wildcard *as a variant value* (e.g. `{__nope__|x}`) makes dynamicprompts'
46+
combinatorial generator loop forever: its not-found fallback (`get_wildcard_not_found_fallback`)
47+
yields the wrapped wildcard infinitely, and the combinatorial variant logic dedupes those
48+
duplicates away without ever advancing. Detecting these names up front lets callers report a clear
49+
error instead of hanging. Only the combinatorial generator is affected, and only for wildcards
50+
nested in a variant — a bare `a __nope__ b` generates fine and is not reported.
51+
52+
Without a configured `wildcard_manager`, an empty one is used so that every referenced wildcard is
53+
treated as missing (wildcards are not resolved against any files here).
54+
"""
55+
if wildcard_manager is None:
56+
wildcard_manager = WildcardManager()
57+
58+
try:
59+
tree = parse(prompt)
60+
except ParseException:
61+
# Malformed prompts are surfaced separately by the generators; nothing to validate here.
62+
return []
63+
64+
missing: list[str] = []
65+
for name in _iter_wildcard_names(tree):
66+
if name not in missing and not wildcard_manager.get_values(name):
67+
missing.append(name)
68+
return missing

tests/app/routers/test_utilities.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,50 @@ def test_dynamicprompts_works_for_user(client: TestClient, user1_token: str):
6767
assert "prompts" in body
6868

6969

70+
def test_dynamicprompts_unknown_wildcard_returns_error_without_hanging(client: TestClient, user1_token: str):
71+
"""An unknown wildcard used as a variant value would otherwise loop forever in the combinatorial generator.
72+
73+
The endpoint must instead return promptly with a clear error and the original prompt echoed back.
74+
"""
75+
r = client.post(
76+
"/api/v1/utilities/dynamicprompts",
77+
json={"prompt": "{__random__8chan|fenster|stuff}"},
78+
headers={"Authorization": f"Bearer {user1_token}"},
79+
)
80+
assert r.status_code == status.HTTP_200_OK
81+
body = r.json()
82+
assert body["error"] is not None
83+
assert "random" in body["error"]
84+
assert body["prompts"] == ["{__random__8chan|fenster|stuff}"]
85+
86+
87+
def test_dynamicprompts_bare_unknown_wildcard_still_generates(client: TestClient, user1_token: str):
88+
"""A wildcard used as plain literal text (not a variant value) does not hang and must not error."""
89+
r = client.post(
90+
"/api/v1/utilities/dynamicprompts",
91+
json={"prompt": "a photo, __my_style__"},
92+
headers={"Authorization": f"Bearer {user1_token}"},
93+
)
94+
assert r.status_code == status.HTTP_200_OK
95+
body = r.json()
96+
assert body["error"] is None
97+
assert body["prompts"] # non-empty
98+
assert all(p == "a photo, __my_style__" for p in body["prompts"])
99+
100+
101+
def test_dynamicprompts_random_generator_ignores_unknown_wildcard(client: TestClient, user1_token: str):
102+
"""The random generator handles unknown wildcards gracefully, so the guard must not fire for it."""
103+
r = client.post(
104+
"/api/v1/utilities/dynamicprompts",
105+
json={"prompt": "{__random__8chan|fenster|stuff}", "combinatorial": False, "seed": 0},
106+
headers={"Authorization": f"Bearer {user1_token}"},
107+
)
108+
assert r.status_code == status.HTTP_200_OK
109+
body = r.json()
110+
assert body["error"] is None
111+
assert body["prompts"] # non-empty; the random generator expanded the variant
112+
113+
70114
# ----------------------------- image_to_prompt: ownership / read-access -----------------------------
71115

72116

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from invokeai.app.util.dynamicprompts import find_missing_wildcards
6+
7+
8+
def test_find_missing_wildcards_detects_unknown_wildcard_in_variant() -> None:
9+
# Regression: `__random__` inside a variant is parsed as a wildcard reference. Left unchecked it
10+
# sends the combinatorial generator into an infinite loop, so it must be reported up front.
11+
assert find_missing_wildcards("{__random__8chan|fenster|stuff}") == ["random"]
12+
13+
14+
def test_find_missing_wildcards_detects_unknown_wildcard_nested_in_sequence_in_variant() -> None:
15+
# The wildcard hangs the generator even when wrapped in other text inside the variant value.
16+
assert find_missing_wildcards("{a __nope__|b}") == ["nope"]
17+
18+
19+
@pytest.mark.parametrize("prompt", ["a __nope__ b", "__nope__", "a photo, __my_style__"])
20+
def test_find_missing_wildcards_ignores_wildcards_outside_variants(prompt: str) -> None:
21+
# A wildcard used as plain literal text generates fine (no hang), so it must not be reported.
22+
assert find_missing_wildcards(prompt) == []
23+
24+
25+
@pytest.mark.parametrize("prompt", ["plain text", "{a|b|c}", "a {2$$x|y|z}"])
26+
def test_find_missing_wildcards_ignores_prompts_without_wildcards(prompt: str) -> None:
27+
assert find_missing_wildcards(prompt) == []
28+
29+
30+
def test_find_missing_wildcards_dedupes_repeated_unknown_wildcards() -> None:
31+
assert find_missing_wildcards("{__nope__|a} {__nope__|b} {__other__|c}") == ["nope", "other"]

0 commit comments

Comments
 (0)