From fabb1f626a5ddbd44407c1875a01b57458022595 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Fri, 26 Jun 2026 15:33:36 +0200 Subject: [PATCH 1/2] 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. --- invokeai/app/api/routers/utilities.py | 11 ++++- invokeai/app/invocations/prompt.py | 7 ++++ invokeai/app/util/dynamicprompts.py | 60 +++++++++++++++++++++++++++ tests/app/routers/test_utilities.py | 17 ++++++++ tests/app/util/test_dynamicprompts.py | 20 +++++++++ 5 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 invokeai/app/util/dynamicprompts.py create mode 100644 tests/app/util/test_dynamicprompts.py diff --git a/invokeai/app/api/routers/utilities.py b/invokeai/app/api/routers/utilities.py index 568546603ab..abde183f911 100644 --- a/invokeai/app/api/routers/utilities.py +++ b/invokeai/app/api/routers/utilities.py @@ -17,6 +17,7 @@ from invokeai.app.api.routers._access import assert_image_read_access from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException from invokeai.app.services.model_records.model_records_base import UnknownModelException +from invokeai.app.util.dynamicprompts import find_missing_wildcards from invokeai.backend.llava_onevision_pipeline import LlavaOnevisionPipeline from invokeai.backend.model_manager.taxonomy import ModelType from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline @@ -52,8 +53,16 @@ async def parse_dynamicprompts( """Creates a batch process""" max_prompts = min(max_prompts, 10000) generator: Union[RandomPromptGenerator, CombinatorialPromptGenerator] + error: Optional[str] = None + + # An unknown wildcard sends the combinatorial generator into an infinite loop, so bail out early + # with a clear message instead of hanging the request (and with it the UI preview). + missing_wildcards = find_missing_wildcards(prompt) + if missing_wildcards: + wildcards = ", ".join(missing_wildcards) + return DynamicPromptsResponse(prompts=[prompt], error=f"No values found for wildcard(s): {wildcards}") + try: - error: Optional[str] = None if combinatorial: generator = CombinatorialPromptGenerator() prompts = generator.generate(prompt, max_prompts=max_prompts) diff --git a/invokeai/app/invocations/prompt.py b/invokeai/app/invocations/prompt.py index 48eec0ac0ef..c00ab6242d8 100644 --- a/invokeai/app/invocations/prompt.py +++ b/invokeai/app/invocations/prompt.py @@ -9,6 +9,7 @@ from invokeai.app.invocations.fields import InputField, UIComponent from invokeai.app.invocations.primitives import StringCollectionOutput from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.app.util.dynamicprompts import find_missing_wildcards @invocation( @@ -30,6 +31,12 @@ class DynamicPromptInvocation(BaseInvocation): combinatorial: bool = InputField(default=False, description="Whether to use the combinatorial generator") def invoke(self, context: InvocationContext) -> StringCollectionOutput: + # An unknown wildcard sends the combinatorial generator into an infinite loop, so fail fast + # with a clear message instead of hanging the invocation. + missing_wildcards = find_missing_wildcards(self.prompt) + if missing_wildcards: + raise ValueError(f"No values found for wildcard(s): {', '.join(missing_wildcards)}") + if self.combinatorial: generator = CombinatorialPromptGenerator() prompts = generator.generate(self.prompt, max_prompts=self.max_prompts) diff --git a/invokeai/app/util/dynamicprompts.py b/invokeai/app/util/dynamicprompts.py new file mode 100644 index 00000000000..caaa9a33a03 --- /dev/null +++ b/invokeai/app/util/dynamicprompts.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from collections.abc import Iterator + +from dynamicprompts.commands import ( + Command, + SequenceCommand, + VariantCommand, + WildcardCommand, + WrapCommand, +) +from dynamicprompts.parser.parse import parse +from dynamicprompts.wildcards import WildcardManager +from pyparsing import ParseException + + +def _iter_wildcard_names(command: Command) -> Iterator[str]: + """Recursively yield the statically-known wildcard names referenced in a parsed prompt.""" + if isinstance(command, WildcardCommand): + # The wildcard name may itself be a dynamic Command (e.g. `__${var}__`). Only plain string + # names can be validated ahead of time, so the dynamic case is intentionally skipped. + if isinstance(command.wildcard, str): + yield command.wildcard + elif isinstance(command, SequenceCommand): + for token in command.tokens: + yield from _iter_wildcard_names(token) + elif isinstance(command, VariantCommand): + for value in command.values: + yield from _iter_wildcard_names(value) + elif isinstance(command, WrapCommand): + yield from _iter_wildcard_names(command.wrapper) + yield from _iter_wildcard_names(command.inner) + # LiteralCommand and variable commands reference no wildcards we can resolve statically. + + +def find_missing_wildcards(prompt: str, wildcard_manager: WildcardManager | None = None) -> list[str]: + """Return the unique wildcard names referenced in `prompt` that resolve to no values. + + Referencing an unknown wildcard makes dynamicprompts' combinatorial generator loop forever: its + not-found fallback (`get_wildcard_not_found_fallback`) yields the wrapped wildcard infinitely, and + the combinatorial variant logic dedupes those duplicates away without ever advancing. Detecting + the missing names up front lets callers report a clear error instead of hanging. + + Without a configured `wildcard_manager`, an empty one is used so that every referenced wildcard is + treated as missing (wildcards are not resolved against any files here). + """ + if wildcard_manager is None: + wildcard_manager = WildcardManager() + + try: + tree = parse(prompt) + except ParseException: + # Malformed prompts are surfaced separately by the generators; nothing to validate here. + return [] + + missing: list[str] = [] + for name in _iter_wildcard_names(tree): + if name not in missing and not wildcard_manager.get_values(name): + missing.append(name) + return missing diff --git a/tests/app/routers/test_utilities.py b/tests/app/routers/test_utilities.py index ce91f2efd24..2403efb1af0 100644 --- a/tests/app/routers/test_utilities.py +++ b/tests/app/routers/test_utilities.py @@ -67,6 +67,23 @@ def test_dynamicprompts_works_for_user(client: TestClient, user1_token: str): assert "prompts" in body +def test_dynamicprompts_unknown_wildcard_returns_error_without_hanging(client: TestClient, user1_token: str): + """An unknown `__wildcard__` would otherwise loop forever in the combinatorial generator. + + The endpoint must instead return promptly with a clear error and the original prompt echoed back. + """ + r = client.post( + "/api/v1/utilities/dynamicprompts", + json={"prompt": "{__random__8chan|fenster|stuff}"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert body["error"] is not None + assert "random" in body["error"] + assert body["prompts"] == ["{__random__8chan|fenster|stuff}"] + + # ----------------------------- image_to_prompt: ownership / read-access ----------------------------- diff --git a/tests/app/util/test_dynamicprompts.py b/tests/app/util/test_dynamicprompts.py new file mode 100644 index 00000000000..9b443042dda --- /dev/null +++ b/tests/app/util/test_dynamicprompts.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import pytest + +from invokeai.app.util.dynamicprompts import find_missing_wildcards + + +def test_find_missing_wildcards_detects_unknown_wildcard_in_variant() -> None: + # Regression: `__random__` inside a variant is parsed as a wildcard reference. Left unchecked it + # sends the combinatorial generator into an infinite loop, so it must be reported up front. + assert find_missing_wildcards("{__random__8chan|fenster|stuff}") == ["random"] + + +@pytest.mark.parametrize("prompt", ["plain text", "{a|b|c}", "a {2$$x|y|z}"]) +def test_find_missing_wildcards_ignores_prompts_without_wildcards(prompt: str) -> None: + assert find_missing_wildcards(prompt) == [] + + +def test_find_missing_wildcards_dedupes_repeated_unknown_wildcards() -> None: + assert find_missing_wildcards("__nope__ and __nope__ and __other__") == ["nope", "other"] From a647c71a3a8872927f7f0fda81c4957ddbc189b6 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Sun, 28 Jun 2026 16:26:19 +0200 Subject: [PATCH 2/2] 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. --- invokeai/app/api/routers/utilities.py | 15 ++++++++----- invokeai/app/invocations/prompt.py | 13 ++++++----- invokeai/app/util/dynamicprompts.py | 32 +++++++++++++++++---------- tests/app/routers/test_utilities.py | 29 +++++++++++++++++++++++- tests/app/util/test_dynamicprompts.py | 13 ++++++++++- 5 files changed, 76 insertions(+), 26 deletions(-) diff --git a/invokeai/app/api/routers/utilities.py b/invokeai/app/api/routers/utilities.py index abde183f911..7356e688512 100644 --- a/invokeai/app/api/routers/utilities.py +++ b/invokeai/app/api/routers/utilities.py @@ -55,12 +55,15 @@ async def parse_dynamicprompts( generator: Union[RandomPromptGenerator, CombinatorialPromptGenerator] error: Optional[str] = None - # An unknown wildcard sends the combinatorial generator into an infinite loop, so bail out early - # with a clear message instead of hanging the request (and with it the UI preview). - missing_wildcards = find_missing_wildcards(prompt) - if missing_wildcards: - wildcards = ", ".join(missing_wildcards) - return DynamicPromptsResponse(prompts=[prompt], error=f"No values found for wildcard(s): {wildcards}") + # An unknown wildcard used as a variant value sends the combinatorial generator into an infinite + # loop, so bail out early with a clear message instead of hanging the request (and with it the UI + # preview). The random generator handles unknown wildcards gracefully, so only the combinatorial + # path is guarded. + if combinatorial: + missing_wildcards = find_missing_wildcards(prompt) + if missing_wildcards: + wildcards = ", ".join(missing_wildcards) + return DynamicPromptsResponse(prompts=[prompt], error=f"No values found for wildcard(s): {wildcards}") try: if combinatorial: diff --git a/invokeai/app/invocations/prompt.py b/invokeai/app/invocations/prompt.py index c00ab6242d8..6658b0da7d0 100644 --- a/invokeai/app/invocations/prompt.py +++ b/invokeai/app/invocations/prompt.py @@ -31,13 +31,14 @@ class DynamicPromptInvocation(BaseInvocation): combinatorial: bool = InputField(default=False, description="Whether to use the combinatorial generator") def invoke(self, context: InvocationContext) -> StringCollectionOutput: - # An unknown wildcard sends the combinatorial generator into an infinite loop, so fail fast - # with a clear message instead of hanging the invocation. - missing_wildcards = find_missing_wildcards(self.prompt) - if missing_wildcards: - raise ValueError(f"No values found for wildcard(s): {', '.join(missing_wildcards)}") - if self.combinatorial: + # An unknown wildcard used as a variant value sends the combinatorial generator into an + # infinite loop, so fail fast with a clear message instead of hanging the invocation. The + # random generator handles unknown wildcards gracefully and needs no guard. + missing_wildcards = find_missing_wildcards(self.prompt) + if missing_wildcards: + raise ValueError(f"No values found for wildcard(s): {', '.join(missing_wildcards)}") + generator = CombinatorialPromptGenerator() prompts = generator.generate(self.prompt, max_prompts=self.max_prompts) else: diff --git a/invokeai/app/util/dynamicprompts.py b/invokeai/app/util/dynamicprompts.py index caaa9a33a03..5c922318218 100644 --- a/invokeai/app/util/dynamicprompts.py +++ b/invokeai/app/util/dynamicprompts.py @@ -14,32 +14,40 @@ from pyparsing import ParseException -def _iter_wildcard_names(command: Command) -> Iterator[str]: - """Recursively yield the statically-known wildcard names referenced in a parsed prompt.""" +def _iter_wildcard_names(command: Command, in_variant: bool = False) -> Iterator[str]: + """Recursively yield the statically-known wildcard names that appear as (part of) a variant value. + + Only wildcards reachable from a `VariantCommand` value are yielded: those are the references that + hang the combinatorial generator (see `find_missing_wildcards`). The same wildcard used as plain + literal text (e.g. `a __nope__ b`) generates fine, so it is intentionally not reported. + """ if isinstance(command, WildcardCommand): # The wildcard name may itself be a dynamic Command (e.g. `__${var}__`). Only plain string # names can be validated ahead of time, so the dynamic case is intentionally skipped. - if isinstance(command.wildcard, str): + if in_variant and isinstance(command.wildcard, str): yield command.wildcard elif isinstance(command, SequenceCommand): for token in command.tokens: - yield from _iter_wildcard_names(token) + yield from _iter_wildcard_names(token, in_variant) elif isinstance(command, VariantCommand): + # Everything below a variant value is a variant-nested reference, even across sequences. for value in command.values: - yield from _iter_wildcard_names(value) + yield from _iter_wildcard_names(value, in_variant=True) elif isinstance(command, WrapCommand): - yield from _iter_wildcard_names(command.wrapper) - yield from _iter_wildcard_names(command.inner) + yield from _iter_wildcard_names(command.wrapper, in_variant) + yield from _iter_wildcard_names(command.inner, in_variant) # LiteralCommand and variable commands reference no wildcards we can resolve statically. def find_missing_wildcards(prompt: str, wildcard_manager: WildcardManager | None = None) -> list[str]: - """Return the unique wildcard names referenced in `prompt` that resolve to no values. + """Return the unique unknown wildcard names in `prompt` that hang the combinatorial generator. - Referencing an unknown wildcard makes dynamicprompts' combinatorial generator loop forever: its - not-found fallback (`get_wildcard_not_found_fallback`) yields the wrapped wildcard infinitely, and - the combinatorial variant logic dedupes those duplicates away without ever advancing. Detecting - the missing names up front lets callers report a clear error instead of hanging. + Referencing an unknown wildcard *as a variant value* (e.g. `{__nope__|x}`) makes dynamicprompts' + combinatorial generator loop forever: its not-found fallback (`get_wildcard_not_found_fallback`) + yields the wrapped wildcard infinitely, and the combinatorial variant logic dedupes those + duplicates away without ever advancing. Detecting these names up front lets callers report a clear + error instead of hanging. Only the combinatorial generator is affected, and only for wildcards + nested in a variant — a bare `a __nope__ b` generates fine and is not reported. Without a configured `wildcard_manager`, an empty one is used so that every referenced wildcard is treated as missing (wildcards are not resolved against any files here). diff --git a/tests/app/routers/test_utilities.py b/tests/app/routers/test_utilities.py index 2403efb1af0..84a69696881 100644 --- a/tests/app/routers/test_utilities.py +++ b/tests/app/routers/test_utilities.py @@ -68,7 +68,7 @@ def test_dynamicprompts_works_for_user(client: TestClient, user1_token: str): def test_dynamicprompts_unknown_wildcard_returns_error_without_hanging(client: TestClient, user1_token: str): - """An unknown `__wildcard__` would otherwise loop forever in the combinatorial generator. + """An unknown wildcard used as a variant value would otherwise loop forever in the combinatorial generator. The endpoint must instead return promptly with a clear error and the original prompt echoed back. """ @@ -84,6 +84,33 @@ def test_dynamicprompts_unknown_wildcard_returns_error_without_hanging(client: T assert body["prompts"] == ["{__random__8chan|fenster|stuff}"] +def test_dynamicprompts_bare_unknown_wildcard_still_generates(client: TestClient, user1_token: str): + """A wildcard used as plain literal text (not a variant value) does not hang and must not error.""" + r = client.post( + "/api/v1/utilities/dynamicprompts", + json={"prompt": "a photo, __my_style__"}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert body["error"] is None + assert body["prompts"] # non-empty + assert all(p == "a photo, __my_style__" for p in body["prompts"]) + + +def test_dynamicprompts_random_generator_ignores_unknown_wildcard(client: TestClient, user1_token: str): + """The random generator handles unknown wildcards gracefully, so the guard must not fire for it.""" + r = client.post( + "/api/v1/utilities/dynamicprompts", + json={"prompt": "{__random__8chan|fenster|stuff}", "combinatorial": False, "seed": 0}, + headers={"Authorization": f"Bearer {user1_token}"}, + ) + assert r.status_code == status.HTTP_200_OK + body = r.json() + assert body["error"] is None + assert body["prompts"] # non-empty; the random generator expanded the variant + + # ----------------------------- image_to_prompt: ownership / read-access ----------------------------- diff --git a/tests/app/util/test_dynamicprompts.py b/tests/app/util/test_dynamicprompts.py index 9b443042dda..83ff02f3c4c 100644 --- a/tests/app/util/test_dynamicprompts.py +++ b/tests/app/util/test_dynamicprompts.py @@ -11,10 +11,21 @@ def test_find_missing_wildcards_detects_unknown_wildcard_in_variant() -> None: assert find_missing_wildcards("{__random__8chan|fenster|stuff}") == ["random"] +def test_find_missing_wildcards_detects_unknown_wildcard_nested_in_sequence_in_variant() -> None: + # The wildcard hangs the generator even when wrapped in other text inside the variant value. + assert find_missing_wildcards("{a __nope__|b}") == ["nope"] + + +@pytest.mark.parametrize("prompt", ["a __nope__ b", "__nope__", "a photo, __my_style__"]) +def test_find_missing_wildcards_ignores_wildcards_outside_variants(prompt: str) -> None: + # A wildcard used as plain literal text generates fine (no hang), so it must not be reported. + assert find_missing_wildcards(prompt) == [] + + @pytest.mark.parametrize("prompt", ["plain text", "{a|b|c}", "a {2$$x|y|z}"]) def test_find_missing_wildcards_ignores_prompts_without_wildcards(prompt: str) -> None: assert find_missing_wildcards(prompt) == [] def test_find_missing_wildcards_dedupes_repeated_unknown_wildcards() -> None: - assert find_missing_wildcards("__nope__ and __nope__ and __other__") == ["nope", "other"] + assert find_missing_wildcards("{__nope__|a} {__nope__|b} {__other__|c}") == ["nope", "other"]