fix(images): bound digit-string seeds and isolate each card's parse

A seed recorded as a 5000-digit string reached int(), which raises
ValueError past Python's integer string limit, and the listing answered
500. A digit-string seed now has at most 20 digits (2**64 fits), and the
conversion falls back to no seed on ValueError.

Each card's parse is also guarded in the page builder: an unexpected
error in one file's records logs a warning and gives that card no seed
and no first-line title, and the rest of the page still lists.
This commit is contained in:
Vito Sansevero
2026-10-19 23:30:14 -07:00
parent 48f23499d3
commit 46e0f6cf79
4 changed files with 100 additions and 11 deletions
+20 -3
View File
@@ -2351,6 +2351,23 @@ class ImageRoutesMixin:
self.logger.warning("Workspace link summaries could not be read")
return {}
def _card_parse(self, path: Path, records: dict | None) -> tuple:
"""One card's ``(seed, parsed positive)``; ``(None, None)`` on any error.
One file's unexpected parse failure must not fail the whole page: its
card shows no seed and withholds a first-line title.
"""
try:
params = header_params(records)
return card_seed(params), parsed_positive(params)
except Exception:
self.logger.warning(
"Workspace card facts could not be parsed for %s",
path.name,
exc_info=True,
)
return None, None
def _format_workspace_page(
self, page: list[tuple], *, linked: bool = True
) -> list[dict]:
@@ -2384,10 +2401,10 @@ class ImageRoutesMixin:
continue
# Parsed after the handle closes, so a concurrent rename or delete
# (which Windows refuses on open files) is not held up by it.
params = header_params(records)
image["seed"] = card_seed(params)
seed, positive = self._card_parse(path, records)
image["seed"] = seed
summary = summaries.get(str(path)) or _unavailable_link()
image["linked"] = _card_link(summary, parsed_positive(params))
image["linked"] = _card_link(summary, positive)
if linked:
# Image cards carry the star; Library previews show none.
favorites = self._workspace_favorites(page)
+42
View File
@@ -643,6 +643,48 @@ class TestWorkspaceImages(ImageAPITestCase):
else:
self.assertEqual(shown, {"status": "known", "value": card})
async def test_unbounded_digit_string_seed_still_lists(self):
records_png(
self.output_dir / "huge.png", seeded_records("a fox", "9" * 5000), 200
)
records_png(self.output_dir / "ok.png", seeded_records("a fox", 5))
response, data = await self.get()
self.assertEqual(response.status, 200, data)
items = {item["filename"]: item for item in data["images"]}
self.assertIsNone(items["huge.png"]["seed"])
self.assertEqual(items["ok.png"]["seed"], "5")
async def test_one_cards_parse_error_spares_the_page(self):
prompt = self._save_prompt("a fox")
for name, mtime in (("bad.png", 200), ("ok.png", 100)):
path = records_png(
self.output_dir / name, seeded_records("a fox", 5), mtime
)
self.api.db.link_image_to_prompt(prompt, str(path))
real_parse = header_params_of_images()
def parsing(info):
if parsing.calls == 0:
parsing.calls += 1
raise RuntimeError("unexpected parser bug")
return real_parse(info)
parsing.calls = 0
with (
patch("py.api.images.header_params", side_effect=parsing),
self.assertLogs(self.api.logger, "WARNING") as logged,
):
response, data = await self.get()
self.assertEqual(response.status, 200, data)
items = {item["filename"]: item for item in data["images"]}
self.assertIsNone(items["bad.png"]["seed"])
self.assertIsNone(items["bad.png"]["linked"]["title"])
self.assertEqual(items["bad.png"]["linked"]["status"], "linked")
self.assertEqual(items["ok.png"]["seed"], "5")
self.assertEqual(items["ok.png"]["linked"]["title"], "a fox")
self.assertNotIn("unexpected parser bug", json.dumps(data))
self.assertIn("unexpected parser bug", "\n".join(logged.output))
async def test_library_previews_skip_card_facts(self):
records_png(self.output_dir / "a.png", seeded_records("a fox", 5))
page = await self.api._collect_output_images(
+16
View File
@@ -165,6 +165,22 @@ class CardSeed(unittest.TestCase):
self.assertEqual(card_seed({"seed": value}), str(value))
self.assertEqual(card_seed({"seed": "18446744073709551616"}), str(2**64))
def test_digit_string_seed_is_bounded(self):
# 2**64 has 20 digits; a longer string is no seed, and an unbounded one
# must not reach int() (Python's int max-str-digits raises ValueError).
self.assertEqual(card_seed({"seed": "9" * 20}), "9" * 20)
self.assertEqual(card_seed({"seed": "-" + "9" * 20}), "-" + "9" * 20)
self.assertIsNone(card_seed({"seed": "1" * 21}))
self.assertIsNone(card_seed({"seed": "9" * 5000}))
self.assertIsNone(info_seed(graph(seeded("9" * 5000))))
def test_seed_conversion_error_gives_none(self):
class Huge(int):
def __str__(self):
raise ValueError("Exceeds the limit for integer string conversion")
self.assertIsNone(card_seed({"seed": Huge(5)}))
def test_workflow_only_record(self):
case = next(
case
+22 -8
View File
@@ -20,12 +20,21 @@ from .generation_params import (
)
from .inspection_response import project_graph
__all__ = ["MAX_RECORD_BYTES", "card_seed", "header_params", "parsed_positive"]
__all__ = [
"MAX_RECORD_BYTES",
"MAX_SEED_DIGITS",
"card_seed",
"header_params",
"parsed_positive",
]
# Each record is parsed only up to this many UTF-8 bytes; past it, nothing is read.
MAX_RECORD_BYTES = 2 * 1024 * 1024
_RECORD_KEYS = ("prompt", "workflow")
_INTEGER = re.compile(r"-?[0-9]+\Z")
# A digit-string seed has at most this many digits (2**64 has 20). Longer text
# is no seed, and never reaches int(), whose max-str-digits guard raises.
MAX_SEED_DIGITS = 20
_INTEGER = re.compile(r"-?[0-9]{1,%d}\Z" % MAX_SEED_DIGITS)
def _utf8_size_over(text: str, limit: int) -> bool:
@@ -76,18 +85,23 @@ def card_seed(params: dict | None) -> str | None:
Always text: ``JSON.parse`` in the browser rounds a number past 2**53, and
ComfyUI's randomized seeds are often that large. The projection carries
integers beyond 2**53 as their exact digits, so a digit string is the
recorded seed (the inspector shows it too). A placeholder, other text, a
boolean or a decimal gives None.
recorded seed (the inspector shows it too), up to ``MAX_SEED_DIGITS``
digits. A placeholder, other or longer text, a boolean or a decimal gives
None.
"""
if not isinstance(params, Mapping):
return None
value = params.get("seed")
if isinstance(value, bool):
return None
if isinstance(value, int):
return str(value)
if isinstance(value, str) and _INTEGER.match(value):
return str(int(value))
try:
if isinstance(value, int):
return str(value)
if isinstance(value, str) and _INTEGER.match(value):
return str(int(value))
except ValueError:
# Python's integer string conversion limit: no seed, never a 500.
return None
return None