Compare commits

..
Author SHA1 Message Date
Vito Sansevero 34dabe2cd2 ci: stop testing macOS in CI
macOS is not a popular ComfyUI platform. Drop the macOS legs from the
portability workflow and its native-trash proof; Windows keeps its
Python 3.12-3.14 legs. The macOS code stays, and README calls its
support best-effort.

Closes #571
2026-10-31 10:49:10 -07:00
54 changed files with 322 additions and 3758 deletions
-2
View File
@@ -167,8 +167,6 @@ Searches your library by text, category, tags and minimum rating, then outputs t
![Prompt Search List node producing three images](assets/readme/node-search.webp)
To get the images of a Search List batch linked to their prompts, wire the Search List output into a Prompt Manager Text node, not straight into CLIP Text Encode. Wired straight in, the images stay unlinked: the search text is never saved as a prompt.
## Install
**Requirements:** ComfyUI and Python 3.12 or newer. `watchdog` and `Pillow` are the only dependencies of their own; the rest comes with ComfyUI.
+13 -83
View File
@@ -345,16 +345,11 @@ and the time left. The steps, in order:
monitor's callbacks (Needs review recorder, preview queue, Images listing
listener).
2. `ImageMonitor.stop_monitoring` works against one 5 s deadline. Closing the
image handler joins the image worker; watchdog's `observer.stop()` and the
observer join get what is left, which can be 0 s. `stop()` joins watchdog's
emitters without a limit, so it runs on a daemon thread and is waited for
only until the deadline; a later stop waits for that same call (#561).
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s),
kills ffmpeg and shuts the interactive image pool down.
4. `shutdown_job_pool` (`utils/parallel.py`) cancels the long-lived job pool's
queued items and joins its threads (5 s; part `workers`, only when a job
used the pool, #523).
5. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
image handler joins the image worker; watchdog's `observer.stop()` (not
bounded here) and the observer join get what is left, which can be 0 s.
3. `close_auto_thumbnails(final=True)` stops the preview worker (30 s) and
kills ffmpeg.
4. `PromptTracker.shutdown` stops the prompt cleanup thread (2 s).
A failing step, or a second Ctrl+C, is logged with its traceback and does not
skip the later steps. A clean run logs one INFO line to PromptManager's log:
@@ -366,15 +361,6 @@ stored, with the parts not reached False, and the `atexit` callback is
removed: the later steps are skipped, no summary line is logged, and a later
call returns that result and runs nothing.
The observer is started from a short-lived daemon thread, so every thread it
creates is a daemon (a thread inherits that flag from the thread that creates
it). watchdog 7 on Windows starts a plain `threading.Thread` per watch for its
`ReadDirectoryChangesW` reader; created from ComfyUI's main thread it would not
be a daemon, and the interpreter waits for every such thread before it runs any
`atexit` callback, so the teardown would never start (#561).
`tests/test_package_exit.py` runs a child whose observer does both (a plain
reader thread, a `stop()` that never returns) and requires its exit.
ComfyUI never runs aiohttp's `on_cleanup` hooks, because it never calls
`runner.cleanup()`. So `atexit` is the live host's path. It runs on Ctrl+C
(`main.py` catches `KeyboardInterrupt` and returns) and on a normal exit.
@@ -459,38 +445,6 @@ A hot SQLite read that returns many rows aggregates them into one step
(`json_group_array`) instead of stepping once per row, so it does not wait for
the GIL once per row while ComfyUI generates (#354, `get_image_link_matches`).
A metadata read never decodes pixels (#523). Pillow's `PngImageFile.text` loads
every pixel to reach text chunks after IDAT; the scan (`_extract_comfyui_metadata`)
and the image monitor (`ComfyUIMetadataExtractor.extract_metadata`) read PNG text
through `utils/png_text.py`, which runs Pillow's own chunk handlers after the
first IDAT and seeks over pixel data. The legacy `.text` read is the floor:
`tests/test_png_text.py` compares both readers on every image under
`tests/fixtures`, a curated corpus (text after IDAT, zTXt, iTXt, split IDAT, bad
checksums, truncations, oversized text, APNG) and a seeded fuzz, and asserts that
neither reader decodes pixels by patching `ImageFile.load`. The only reads that
differ are files whose pixel data is undecodable or cut short: legacy found
nothing, the walk keeps the text before the pixels. Animated PNGs still use
`.text`.
Image work runs on PromptManager's own threads (#523). glibc keeps each thread's
malloc arena at its high-water mark, so image buffers spread over the default
executor's threads stay resident. Interactive image work (view previews behind their
gate, workspace card facts, both inspector routes) runs on the API's fixed
`pm-image` pool (`IMAGE_POOL_SIZE` 3, `_run_image`, `_map_image`; card facts already
cached answer without a trip; while the gate's 3 preview writes hold every thread, a
card or inspector read waits for one of them, by design of the merged pool; after the
package teardown the pool is not recreated and late calls use the default executor). Jobs (scan metadata and file facts, thumbnail and
convert jobs, the legacy gallery generation, capture previews) run through
`map_parallel` on one long-lived `pm-worker` pool of Worker threads, reused between
jobs; a new Worker threads value replaces it (the teardown also joins a replaced pool
still finishing), and a call made from a job thread runs inline. After a scan, thumbnail or convert job, or the legacy gallery generation,
`release_free_memory` (`utils/native_memory.py`) calls glibc's `malloc_trim(0)` once;
it does nothing on Windows, macOS and musl and never raises. No allocator setting
changes. `tests/test_image_pools.py` asserts the thread of each read and write and the
trim; `tests/test_utils_parallel.py` the pool's reuse, resize, nesting and shutdown.
`tools/perf/memory_drive.py` (manual, Linux, never in CI) measures RSS, heap, threads
and arenas under the #523 load against the disposable browser test server.
The `perf budget` job in `.github/workflows/test.yml` is the reference
measurement: `ubuntu-latest`, one Python, no coverage, only the wall-clock tests.
It runs on every push to `main` and on `workflow_dispatch`, never on pull
@@ -1454,8 +1408,6 @@ is shorter than the row it reserves.
Each Overview card is the count of the page it names: the shell's Library, Images and Tags sidebar counts, one source,
so a card and its badge cannot differ. `/prompt_manager/stats` is for the legacy page and counts raw rows. A visit re-reads
the three unless a read is in flight. An unread or failed card shows a dash; Retry re-reads only the failed counts.
System › Overview's Images metric reads the same shell count (#555), so it, the Overview card and the Images badge show
one number; its Statistics dialog labels the legacy row counts as "Database records".
Tests: `tests/js/workspace-state.test.js`, `tests/browser/workspace.spec.mjs`, `tests/comfyui/workspace.spec.mjs`.
### Images page controls and recorded deviations (#249 PR C)
@@ -3893,8 +3845,8 @@ workspace never asks; previews are a cache the server keeps:
- `GET /images/serve/thumbnails/<rel>_thumb<ext>` for a missing preview is refused as
text first (traversal, a backslash, a drive or stream colon, empty or dot segments, so
`D:/x`, `\\server\share` and a leading `/` never reach a joined path), then admitted
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's
interactive `pm-image` pool (never the default executor; #523), one flight runs per file (the history
on the event loop (`_PreviewGate`): 3 previews are written at once on the API's own
`pm-preview` pool (never the default executor), one flight runs per file (the history
route shares the flight key and the admission), and at most 64 requests wait; one
more gets its fallback at once without listing a folder. Inside the admission the
preview maps back to its media file under the same root (exact names, then one folder
@@ -3927,7 +3879,7 @@ workspace never asks; previews are a cache the server keeps:
`exclusive` job runs (`_auto_thumbnail_blocker`), checked when a write starts (after
its slot) and again just before its rename.
- Shutdown is bounded and logged: the worker stops at a file boundary, ffmpeg is
killed, an idle worker leaves (the next output starts a fresh one), the image pool
killed, an idle worker leaves (the next output starts a fresh one), the view pool
shuts down, and after the app's cleanup a late monitor event
starts nothing. A package reload stops the workers the previous import left.
- Cards and strips load previews only: Images in every layout and size, Library cards,
@@ -4055,11 +4007,7 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
"Save preferences" is `aria-disabled` (opacity .55, `not-allowed`) while clean or
saving; a setting the last save could not save gets a red outline. CSS under D92.
- **D93** (Overview): real values. The metrics are the Library's eligible positives
and active saved negatives and the Images count (#555, amending Q2's indexed
`generated_images` rows): the shell's Images sidebar count, the Images page's own
total, which also lists files no row links, as the concept's `images.length` is
both. A visit re-reads it unless a read is in flight; "–" until read or after a
failed read. `images.indexed` stays in the API and is not shown. "Database
and active saved negatives and the indexed `generated_images` rows (Q2). "Database
integrity" is "Not checked" until a check ran this session, then "N issues" (checks
that found issues, even beside a check that could not run), "Passed" or "Incomplete".
The review row is "N ambiguous image links" (the Needs review queue's pending count,
@@ -4097,11 +4045,10 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
- **D96** (Statistics; amended by D153): "Usage statistics" without the sample note: the
concept's "Runs by category" table without its Avg. rating column (Category / Runs)
with the real runs of eligible parents per category ("Uncategorized"), then a second
table "Database records" (Total / Count: Prompt records, Tags on prompts, Image
records: the raw row counts of the legacy statistics, labelled as rows so they never
read as the Library or Images page counts, #555; no Rated images). The API still reports `average_rating` and
table "Library totals" (Total / Count: Prompts, Tags, Images from the legacy
statistics; no Rated images). The API still reports `average_rating` and
`rated_images`; the workspace does not show them.
The Overview row says "Runs by category, with database record counts." because
The Overview row says "Runs by category, with library totals." because
that is what the dialog shows (the concept's own dialog shows only "Runs by
category"); per-prompt run counts, prompt tags and dated runs are recorded, but no
table of them exists yet. A failed read toasts "Statistics could not
@@ -4404,23 +4351,6 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati
choice. The host's checks still refuse a linked field or a changed workflow, and the
sender also refuses if the destination's `revision` (node object plus graph version)
moved since discovery; it is undefined on an older tab, which skips the comparison. A host error reads as a refusal, not "may have applied".
- **D191** (#542, beyond the concept; maintainer 2026-10-31, Q542-1 A to 5 A): a window with two or more
eligible nodes adds a last Destination node option "All N eligible nodes" (`ALL_NODES` in
`prompt-sender.mjs`), never selected for you (D173). Eligible is D173's filter; the nodes it leaves out
stay counted in the discovery sentence. One window only. Preview stays optional (D175): Preview changes
shows one `section.prompt-sender-node` per node, headed "#<id> <title>", in send order (node id,
numeric). Apply previews every node first and writes nothing if any refuses ("#<id> <title>: <reason>
Nothing was sent."). Then it sends one node after another. Only the first node of a pass taken in that
click keeps its ticket; every other node, and every node of a held Preview (host tickets expire after
30 s), takes a fresh ticket that is refused unless its Before texts and composition equal the reviewed
ones. That fresh preview checks the discovered revision until something has been posted, and not after,
since our own write may move the graph version. Refusals are best effort: "Sent to 2 of 3 nodes in
<window>. #12 <title>: <host's refusal>."; Apply stays available and a retry sends to every node again
(rewriting the same text). A lost reply stops the run, names that node as unknown and lists the nodes
not sent. Closing the dialog stops after the current node. The review sections are labelled by their
`h4` node heading. Unit tests in `tests/js/prompt-sender.test.js`, a fake three-node host in
`tests/browser/prompt-inspector.spec.mjs`, real Classic and Nodes 2.0 widgets in
`tests/comfyui/prompt-sender.spec.mjs`.
- **D106** (Integrations, S10, Q8): the send bridge always asks for the ComfyUI window
and node, and the nodes always follow ComfyUI's own renderer, so "Default send
destination" offers only "Ask each time" and "Host compatibility" only "Follow host
@@ -5484,7 +5414,7 @@ list stay unscoped: any row's control proves them.
| Worker threads | `admin.html:437–445` | S4 Sources | covered | `sources` · slider · "Worker threads" | worker range max is cpu_count (workspace-system-sources.spec.mjs) |
| LoraManager badge, enable, path, trigger words, CivitAI key, clear key | `admin.html:448–490`; `admin.js:1052–1156` | S10 Integrations | covered | `integrations` · switch · "LoraManager integration" | LoRA rows show detected / enabled / cache-loaded separately (workspace-system-integrations.spec.mjs) |
| LoRA data import with progress | `admin.html:492–498,517–541`; `admin.js:1157` | S10 Integrations | covered | `integrations` · button · "Review import" | Review import shows replacement, blocked items and network effects with the backup box; Start opens the job (D107) (workspace-system-integrations.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and database record counts (workspace-system.spec.mjs) |
| Statistics | `admin.html:159`; `admin.js:1627` | S2 Overview | covered | `overview` · button · "Explore" | statistics dialog lists runs by category and library totals (workspace-system.spec.mjs) |
| Export components JSON `/export` | `admin.html:163`; `admin.js:1607`; `py/api/prompts.py:238` | S9 Backup & export | covered | `backups` · button · "Export all prompts JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Saved pairs export | `py/api/pairs.py:84` | S9 Backup & export | covered | `backups` · button · "Export saved pairs JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
| Library export | `py/api/library.py:223` | S9 Backup & export | covered | `backups` · button · "Export JSON" | Export JSON downloads /library/export; secondary buttons download all prompts (/export) and /pairs/export (D103) (workspace-system-backups.spec.mjs) |
+13 -23
View File
@@ -280,11 +280,11 @@ def _teardown_logger():
def _stop_background_work():
"""Stop the image monitor, the preview worker, the job worker threads
and the prompt cleanup thread against one TEARDOWN_SECONDS deadline, once.
"""Stop the image monitor, the preview worker and the prompt cleanup
thread against one TEARDOWN_SECONDS deadline, once.
Returns ``{"monitor", "thumbnails", "workers", "tracker"}`` booleans
(True: it stopped), leaving out parts that never started. Later calls return a
Returns ``{"monitor", "thumbnails", "tracker"}`` booleans (True: it
stopped), leaving out parts that never started. Later calls return a
copy of the first result. A Ctrl+C inside a step is logged and the
step is False; one between steps is re-raised after the result (parts
not reached are False) is stored and the ``atexit`` entry removed.
@@ -298,14 +298,11 @@ def _stop_background_work():
api,
_global_prompt_tracker,
)
from .utils import parallel
# A part whose global is set has started; False until its step says
# otherwise, so an interrupt between steps leaves an honest result.
started = (
("monitor", monitor),
("thumbnails", thumbnails),
("workers", parallel.job_pool_started() or None),
("tracker", tracker),
)
result = {name: False for name, part in started if part is not None}
@@ -348,16 +345,6 @@ def _stop_background_work():
result["thumbnails"] = _teardown_step(
"the preview worker", close_previews, logger
)
if "workers" in result:
def stop_workers():
from .utils.parallel import JOB_POOL_JOIN_SECONDS
return parallel.shutdown_job_pool(left(JOB_POOL_JOIN_SECONDS))
result["workers"] = _teardown_step(
"the job worker threads", stop_workers, logger
)
if tracker is not None:
def stop_tracker():
@@ -448,9 +435,12 @@ except Exception as e:
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
# One startup line. ASCII only: a Windows console using a legacy code page
# cannot print emoji, and a UnicodeEncodeError here would abort loading the node.
print(
f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}"
f" - {len(NODE_CLASS_MAPPINGS)} tools loaded"
)
# Print startup message with loaded tools
print()
print(f"\033[94m[ComfyUI-PromptManager] Version:\033[0m {get_version()}")
for node_key, display_name in NODE_DISPLAY_NAME_MAPPINGS.items():
# ASCII only: a Windows console using a legacy code page cannot print emoji,
# and a UnicodeEncodeError here would abort loading the whole node.
print(f" \033[94mLoaded:\033[0m {display_name}")
print(f"\033[94mTotal: {len(NODE_CLASS_MAPPINGS)} tools loaded\033[0m")
print()
+1 -15
View File
@@ -85,8 +85,6 @@ try:
from ...database.operations import PromptDatabase
from ...utils.file_prompt import parse_comfyui_prompt
from ...utils.logging_config import get_logger
from ...utils.native_memory import release_free_memory
from ...utils.png_text import png_text
from ...utils.video_metadata import is_video_path, read_video_metadata
except ImportError:
import sys
@@ -97,8 +95,6 @@ except ImportError:
from database.operations import PromptDatabase
from utils.file_prompt import parse_comfyui_prompt
from utils.logging_config import get_logger
from utils.native_memory import release_free_memory
from utils.png_text import png_text
from utils.video_metadata import is_video_path, read_video_metadata
@@ -530,14 +526,6 @@ class PromptManagerAPI(
return await loop.run_in_executor(None, call)
return await loop.run_in_executor(None, func, *args)
async def _release_job_memory(self):
"""After a scan or thumbnail job: glibc's free heap pages go back to
the OS once (``malloc_trim(0)``; nothing elsewhere, #523)."""
try:
await self._run_in_executor(release_free_memory)
except Exception: # best effort: the job's result already stands
self.logger.debug("Releasing free memory failed", exc_info=True)
def _root_listing(self, root) -> RootListing:
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
@@ -1060,8 +1048,6 @@ class PromptManagerAPI(
def _extract_comfyui_metadata(self, image_path):
"""Embedded ComfyUI metadata of an image (PNG text chunks) or a video.
PNG text is read without decoding the pixels (``utils.png_text``, #523).
Videos go through ffprobe (see utils/video_metadata). Unreadable files
are a warning, not an error: a scan over a large output tree meets
truncated and foreign files, and it carries on past them.
@@ -1070,7 +1056,7 @@ class PromptManagerAPI(
return read_video_metadata(image_path)
try:
with Image.open(image_path) as img:
return png_text(img)
return dict(getattr(img, "text", None) or {})
except Exception as e:
self.logger.warning(
f"Unreadable image {os.path.basename(str(image_path))}: {e}"
+3 -5
View File
@@ -1454,7 +1454,6 @@ class AdminRoutesMixin:
verb = "cancelled" if state == "cancelled" else "finished"
job.log_line(f"Scan {verb} · {_plural(job.processed, 'file')} processed")
job.finish(state)
await self._release_job_memory()
if state == "finished":
# After the scan, so the guarded thumbnail job is admitted (#249).
await self._hand_off_scan_thumbnails(job, output_dirs)
@@ -1669,10 +1668,9 @@ class AdminRoutesMixin:
def _extract_batch_metadata_sync(self, file_batch):
"""Extract metadata from a batch of files in one executor call.
Files are read on the long-lived job pool of
``PromptManagerConfig.WORKER_THREADS`` threads (#523).
PNG text is read chunk by chunk without decoding pixels (#523); file
reads and ffprobe release the GIL.
Files are read on ``PromptManagerConfig.WORKER_THREADS`` threads:
Pillow decodes the whole image to reach trailing text chunks and
releases the GIL while doing so.
"""
from ..config import PromptManagerConfig
+2 -3
View File
@@ -20,7 +20,7 @@ from .jobs import (
from .maintenance import disk_plan
from .prompts import thumbnail_url_for
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .. import model_gate
try:
@@ -1371,8 +1371,7 @@ class AutotagRoutesMixin:
files = await self._run_in_executor(
self._without_removed, output_path, files
)
store = preview_store.store_dir(output_path)
has_thumbnails = store is not None and store.is_dir()
has_thumbnails = (output_path / "thumbnails").is_dir()
images = []
for image_path in files:
+1 -4
View File
@@ -843,10 +843,7 @@ class HistoryRoutesMixin:
raise
async def execute():
# An inspection reads the file's header and text: the
# interactive image pool (#523); media stays on the default.
run = self._run_image if kind == "inspection" else self._run_in_executor
pending = asyncio.create_task(run(project))
pending = asyncio.create_task(self._run_in_executor(project))
try:
return await asyncio.shield(pending)
except asyncio.CancelledError:
+10 -12
View File
@@ -15,10 +15,10 @@ from typing import BinaryIO
from aiohttp import web
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
from .images import in_trash_below_roots
from .images import existing_media_thumbnail, media_preview_path
from .images import existing_media_thumbnail, media_thumbnail_rel
try:
from ...utils.execution_outputs import (
@@ -326,10 +326,12 @@ class _OpenedCurrentMedia:
def _preview_writable(root: Path, preview: Path) -> bool:
"""A preview may be written: the store of *root* and every link segment
of the preview's folder resolve inside it (the serve route's rule, #249;
``preview_store.owns``)."""
return preview_store.owns(root, preview)
"""A preview may be written: ``thumbnails`` and every link segment of the
preview's folder resolve inside *root* (the serve route's rule, #249)."""
folder = root / "thumbnails"
return _canonical(folder).is_relative_to(_canonical(root)) and _canonical(
preview
).is_relative_to(_canonical(folder))
def _is_jxl(path) -> bool:
@@ -379,12 +381,8 @@ def _cover_target(resolved, ensure_preview, placeholder, accepts_jxl):
return _canonical(preview)
if preview is not None:
return _canonical(preview)
preview = media_preview_path(resolved.gallery_root, relative)
if (
ensure_preview is not None
and preview is not None
and _preview_writable(resolved.gallery_root, preview)
):
preview = resolved.gallery_root / media_thumbnail_rel(relative)
if ensure_preview is not None and _preview_writable(resolved.gallery_root, preview):
ensure_preview(target, preview, is_video)
if is_video and placeholder is not None:
return _placeholder_media(placeholder)
+27 -84
View File
@@ -34,7 +34,7 @@ from .gallery_listing import (
WalkOutcome,
walk_root,
)
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .thumbnail_encode import (
PREVIEW_HELD,
EncoderClosed,
@@ -1068,20 +1068,13 @@ def _iter_media_files(output_path, max_files):
def media_thumbnail_rel(rel_path, settings=None):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX URL text) for the media file
*rel_path*: the name the preview gets when it is written now (#317),
images and videos alike (``writer_settings``' extension). Where it is on
disk is ``media_preview_path``."""
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
the name the preview gets when it is written now (#317), images and
videos alike (``writer_settings``' extension)."""
settings = settings or writer_settings()
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
def media_preview_path(output_path, rel_path, settings=None):
"""Where the preview of *rel_path* is written now (``preview_store``)."""
settings = settings or writer_settings()
return preview_store.preview_path(output_path, rel_path, settings.extension)
def existing_media_thumbnail(output_path, rel_path):
"""The existing preview *rel_path* owns that a listing or cover shows
(the format written now first, then older ones), or None. The Images
@@ -2805,32 +2798,13 @@ class ImageRoutesMixin:
output_dirs[0], filepath, int(root_param), len(outputs)
)
# Preview text is mapped by the resolver (#320); any other text,
# ``Thumbnails/...`` media on a case-sensitive disk included,
# keeps the plain join.
preview_parts = (
tuple(filepath.split("/"))
if preview_store.is_preview_text(filepath)
else None
)
for output_path in output_dirs:
if preview_parts is None:
image_path = output_path / filepath
else:
image_path = preview_store.request_path(output_path, preview_parts)
if image_path is None:
continue
image_path = output_path / filepath
if not image_path.exists():
continue
# A symlink that points outside the root (a preview: outside
# the folder holding its store) is a hard deny, not a
# fall-through to the next root.
container = (
output_path
if preview_parts is None
else preview_store.store_container(output_path)
)
if not _is_within(image_path, container):
# A symlink that points outside the root is a hard deny, not
# a fall-through to the next root.
if not _is_within(image_path, output_path):
return _forbidden()
if in_trash_below_roots(image_path, output_dirs):
return _file_not_found()
@@ -3001,7 +2975,6 @@ class ImageRoutesMixin:
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs
)
await self._release_job_memory()
return web.json_response(result)
except Exception as e:
@@ -3014,7 +2987,6 @@ class ImageRoutesMixin:
"""Blocking thumbnail generation loop (run in executor)."""
settings = writer_settings(settings)
start = _time.monotonic()
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(thumbnails_dir)
@@ -3027,8 +2999,7 @@ class ImageRoutesMixin:
start = _time.monotonic()
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
store = preview_store.store_dir(output_dirs[0])
payload["thumbnails_path"] = None if store is None else _public_path(store)
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
return payload
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
@@ -3071,12 +3042,9 @@ class ImageRoutesMixin:
if stop is not None and stop():
break
root = Path(root)
store = preview_store.store_dir(root)
if store is None:
continue
plan = self._plan_thumbnails(
root,
store,
root / "thumbnails",
mode=mode,
stop=stop,
settings=settings,
@@ -3160,7 +3128,7 @@ class ImageRoutesMixin:
"""
settings = writer_settings(settings)
output_path = Path(output_path)
thumbnails_dir = preview_store.given_store(output_path, thumbnails_dir)
thumbnails_dir = Path(thumbnails_dir)
plan = {key: 0 for key in _PLAN_COUNTS}
plan.update(targets=[], fresh_files=[])
folders = {}
@@ -3252,9 +3220,6 @@ class ImageRoutesMixin:
"dir": src.parent.name,
"type": "video" if is_video else "image",
}
# Taken when the write starts: a thumbnail location switched since
# then drops the write at its rename (#320).
guard = preview_store.write_guard(guard)
dst = Path(dst)
if dst.suffix != settings.extension:
dst = dst.with_suffix(settings.extension)
@@ -3535,8 +3500,8 @@ class ImageRoutesMixin:
self.logger.debug(f"No roots to sweep for preview temp files: {exc}")
return 0
for output_path in output_dirs:
folder = preview_store.store_dir(output_path)
if folder is None or folder.is_symlink() or not folder.is_dir():
folder = Path(output_path) / "thumbnails"
if folder.is_symlink() or not folder.is_dir():
continue
removed += sweep_stray_temps(folder)[0]
if removed:
@@ -3554,16 +3519,13 @@ class ImageRoutesMixin:
directories = []
for output_path in output_dirs:
output_path = Path(output_path)
thumbnails_dir = preview_store.store_dir(output_path)
if thumbnails_dir is None:
continue
thumbnails_dir = output_path / "thumbnails"
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
continue
container = preview_store.store_container(output_path)
if (
thumbnails_dir.is_symlink()
or not _is_within(thumbnails_dir, container)
or _canonical(thumbnails_dir) == _canonical(container)
or not _is_within(thumbnails_dir, output_path)
or _canonical(thumbnails_dir) == _canonical(output_path)
):
raise ValueError(
"Safety check failed: invalid thumbnails directory path"
@@ -4035,8 +3997,7 @@ class ImageRoutesMixin:
**observed,
)
# Header and text reads: the interactive image pool (#523).
body = await self._run_image(inspect)
body = await self._run_in_executor(inspect)
if body is None:
return web.json_response(
{
@@ -4249,22 +4210,6 @@ class ImageRoutesMixin:
# (which Windows refuses on open files) is not held up by it.
return _CardFacts(width, height, *self._card_parse(path, records))
def _cached_card_facts(self, entry: tuple) -> _CardFacts | None:
path, mtime, root, index = entry[:4]
return self._workspace_card_cache.get((index, str(root)), {}).get((path, mtime))
def _card_facts_of(self, entries: list, *, parse: bool) -> list[_CardFacts]:
"""Card facts of *entries*, in order; files not cached yet are read on
the interactive image pool (#523), cached ones answer here."""
found = [self._cached_card_facts(entry) for entry in entries]
missing = [i for i, facts in enumerate(found) if facts is None]
read = self._map_image(
lambda i: self._card_facts(entries[i], parse=parse), missing
)
for i, facts in zip(missing, read):
found[i] = facts
return found
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
"""One listed image's card facts, read once per ``(path, mtime)``.
@@ -4273,11 +4218,11 @@ class ImageRoutesMixin:
and drops the rest, and a roots change drops them all. Only files of
the current scan are stored, so the cache is bounded by the listing.
"""
cached = self._cached_card_facts(entry)
if cached is not None:
return cached
path, mtime, root, index = entry[:4]
root_key, key = (index, str(root)), (path, mtime)
cached = self._workspace_card_cache.get(root_key, {}).get(key)
if cached is not None:
return cached
facts = self._read_card_facts(path, parse=parse)
if facts is None:
# Not stored: a locked or half-written file may read next time.
@@ -4303,17 +4248,15 @@ class ImageRoutesMixin:
if linked
else {}
)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
stills = [image for image in images if not image["is_video"]]
read = self._card_facts_of(
[entries[(image["root_index"], image["path"])] for image in stills],
parse=linked,
)
facts_of = {id(image): facts for image, facts in zip(stills, read)}
for image in images:
entry = entries[(image["root_index"], image["path"])]
facts = facts_of.get(id(image), _NO_CARD_FACTS)
# Videos: the card reads no video metadata. Library previews show
# no card facts, so they skip the parse (``parse=linked``).
facts = (
_NO_CARD_FACTS
if image["is_video"]
else self._card_facts(entry, parse=linked)
)
image["width"], image["height"] = facts.width, facts.height
if not linked:
continue
-206
View File
@@ -1,206 +0,0 @@
"""Where previews live on disk (#320): the one resolver.
Every mapping between an image source (a "root"), one of its media files and
that file's preview on disk goes through this module. URL text stays in
``thumbnail_settings`` (``thumbnail_rel``: ``thumbnails/<rel>_thumb<ext>``,
the shape the serve route, ``inspection-state.mjs`` and ``admin.js`` share);
this module answers where such a preview is on disk.
The store of a source is where its previews live. With the default location
(mode ``source``, "Inside each source") it is ``<source>/thumbnails``, exactly
the folder every caller joined by hand before, so nothing moves.
Writes and the switch: every preview writer takes ``write_guard`` when it
starts (it records the location's ``generation``); the final rename runs
under ``write_lock`` and only when the generation is unchanged and no hold
applies (``thumbnail_encode.replace_unless_held``). Changing the location
bumps the generation under the same lock, so no write that began before can
land in the old store afterwards. Nothing reads or commits config while
``write_lock`` is held. The hold check runs under it too, so hold checks take
no lock (the job registry's ``running()`` is a lock-free snapshot) and
``bump_generation`` is never called with a registry or config lock held.
All functions are blocking: call them off the event loop.
"""
import os
import threading
from contextlib import contextmanager
from dataclasses import dataclass
from pathlib import Path
from . import thumbnail_settings
try:
from ...utils.media_folders import PREVIEW_FOLDER
except ImportError: # tests import the package top-level
from utils.media_folders import PREVIEW_FOLDER
SOURCE_MODE = "source"
@dataclass(frozen=True)
class Location:
"""One snapshot of the thumbnail location (take one per request or job)."""
mode: str = SOURCE_MODE
path: str | None = None
available: bool = True
problem: str | None = None
generation: int = 0
write_lock = threading.Lock()
_generation = 0
def current():
"""The thumbnail location now."""
return Location(generation=_generation)
def generation():
return _generation
def bump_generation():
"""Invalidate every write that started before (under ``write_lock``)."""
global _generation
with write_lock:
_generation += 1
return _generation
def _canonical(path):
"""Resolved and case-folded, so paths compare on every OS."""
return Path(os.path.normcase(os.path.realpath(str(path))))
def _within(path, folder):
return _canonical(path).is_relative_to(_canonical(folder))
# ── stores ───────────────────────────────────────────────────────────
def store_container(root, location=None):
"""The folder a store of *root* must stay inside: the source itself."""
return Path(root)
def store_dir(root, location=None):
"""The store of *root* (``<source>/thumbnails``)."""
return Path(root) / PREVIEW_FOLDER
def ensure_store(root, location=None):
"""``store_dir`` for a writer. Preview folders below it are made by the
write itself."""
return store_dir(root, location)
def given_store(root, store):
"""A caller's explicit store argument: the default spelling
(``<root>/thumbnails``) means the resolver's store; another folder (only
tests pass one) is used as given."""
store = Path(store)
if store == Path(root) / PREVIEW_FOLDER:
return store_dir(root)
return store
def preview_path(root, media_rel, ext=None, location=None):
"""Where the preview of *media_rel* (relative to *root*) is, or is to be
written, in *ext* (the extension written now when None)."""
store = store_dir(root, location)
if store is None:
return None
if ext is None:
from .thumbnail_encode import writer_settings
ext = writer_settings().extension
return store / (thumbnail_settings.preview_stem(media_rel) + ext)
def request_path(root, parts, location=None):
"""The disk path of the URL parts ``("thumbnails", ...)``, or None when
*parts* is not under an exact ``thumbnails`` first part. The text is
checked before (``is_preview_text``)."""
parts = tuple(parts)
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return None
store = store_dir(root, location)
if store is None:
return None
return store.joinpath(*parts[1:])
def owns(root, path, location=None):
"""Whether *path* lies inside the store of *root*, resolved: the store
stays inside its container and no link takes *path* out of the store."""
store = store_dir(root, location)
if store is None:
return False
return _within(store, store_container(root, location)) and _within(path, store)
# ── preview text ─────────────────────────────────────────────────────
def is_preview_text(filepath):
"""URL text naming a preview: ``thumbnails/<...>/<name>_thumb<ext>``.
Text only, before any join: no backslash, drive or stream colon, and no
empty, ``.`` or ``..`` segment; ``thumbnails`` exactly as the first part
and nowhere else among the folders; the extension, in any case, one a
preview may carry (``X_thumb.JPG`` of an older release included).
"""
if not filepath or "\\" in filepath or ":" in filepath:
return False
parts = filepath.split("/")
if len(parts) < 2 or parts[0] != PREVIEW_FOLDER:
return False
if any(part in ("", ".", "..") for part in parts):
return False
if PREVIEW_FOLDER in parts[1:-1]:
return False
stem, extension = os.path.splitext(parts[-1])
return (
stem.endswith(thumbnail_settings.THUMB_SUFFIX)
and len(stem) > len(thumbnail_settings.THUMB_SUFFIX)
and extension.lower() in thumbnail_settings.THUMBNAIL_EXTENSIONS
)
# ── writes ───────────────────────────────────────────────────────────
class _WriteGuard:
"""``guard()`` for one preview write: true while the location has not
changed since the write started and *hold* (if any) allows it."""
__slots__ = ("generation", "_hold")
def __init__(self, hold=None):
self.generation = _generation
self._hold = hold
def __call__(self):
if self.generation != _generation:
return False
return self._hold is None or bool(self._hold())
def write_guard(hold=None):
"""Take when a preview write starts; hand to its final rename."""
if isinstance(hold, _WriteGuard):
return hold
return _WriteGuard(hold)
@contextmanager
def committed(guard=None):
"""Around a preview's final rename: holds ``write_lock`` and yields
whether the write may land (``guard()``, re-checked under the lock)."""
with write_lock:
yield guard is None or bool(guard())
+49 -183
View File
@@ -8,8 +8,7 @@ previews behind those URLs are made here, in three ways:
- **On first view.** ``GET /prompt_manager/images/serve/thumbnails/<rel>_thumb
<ext>`` for a preview that does not exist yet is admitted on the event loop
(``_PreviewGate``): at most ``ON_DEMAND_LIMIT`` previews are written at once,
on this API's interactive image pool (``IMAGE_POOL_SIZE`` threads, shared
with card facts and inspector reads; never the default executor), one flight runs
on this API's own small pool (never the default executor), one flight runs
per file, and the requests waiting are capped (``MAX_WAITING``); past the
cap a request gets its fallback at once. Inside the admission the preview is
mapped back to its media file under the same root (the request text is
@@ -25,8 +24,7 @@ previews behind those URLs are made here, in three ways:
- **On capture.** The image monitor hands every new output it settles to
``queue_capture_thumbnail``, which queues its preview for one background
worker thread (``AutoThumbnailer``). Each wake takes at most Worker threads
(``WORKER_THREADS``) files and writes them on the long-lived job pool
(``utils.parallel``, shared with scans and thumbnail jobs); enqueuing
(``WORKER_THREADS``) files and writes them on that many threads; enqueuing
is O(1), so the monitor and ComfyUI's queue never wait. A file already
queued is not queued again, the queue is bounded (``MAX_QUEUED``) and a
preview left out is made on its first view instead.
@@ -51,7 +49,7 @@ write starts (after its slot) and again just before its rename.
Shutdown: ``close_auto_thumbnails`` stops the worker at a file boundary,
kills a running ffmpeg, waits a bounded time (and logs when the wait ran
out) and shuts the image pool down; after the app's cleanup
out) and shuts the view pool down; after the app's cleanup
(``final=True``) nothing starts a worker again. Importing this module again
(a package reload) stops the workers the previous import left.
"""
@@ -62,12 +60,12 @@ import functools
import io
import os
import threading
from concurrent.futures import CancelledError, ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from aiohttp import web
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
from .images import (
in_trash_below_roots,
MEDIA_EXTENSIONS,
@@ -78,7 +76,7 @@ from .images import (
_thumbnail_is_fresh,
_video_placeholder_image,
_worker_threads,
media_preview_path,
media_thumbnail_rel,
)
from .jobs import (
DUPLICATE_CLEANUP_JOB_KIND,
@@ -106,11 +104,8 @@ PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_
PLACEHOLDER_QUALITY = 85
# Source extensions in the order a shared preview name looks for them (R2).
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + tuple(sorted(VIDEO_EXTENSIONS))
# Threads of the interactive image pool (#523): view previews, card facts and
# inspector reads. Fixed, so image buffers stay in a few threads' arenas.
IMAGE_POOL_SIZE = 3
# Previews views may write at once (the whole image pool).
ON_DEMAND_LIMIT = IMAGE_POOL_SIZE
# Previews views may write at once (the size of the view pool).
ON_DEMAND_LIMIT = 3
# View requests that may wait on the gate; one more gets its fallback at once.
MAX_WAITING = 64
# How long a pool thread waits for the capture worker's flight of the same file.
@@ -129,7 +124,7 @@ AUTO_REFUSED_KINDS = frozenset(
{RESTORE_JOB_KIND, DUPLICATE_CLEANUP_JOB_KIND, SOURCE_CHANGE_JOB_KIND}
)
WORKER_NAME = "PromptManagerThumbnailWorker"
POOL_PREFIX = "pm-image"
POOL_PREFIX = "pm-preview"
# Identifies this import's workers: a reload stops the ones it did not start.
_MODULE_TOKEN = object()
@@ -158,36 +153,6 @@ def _dst_key(dst):
return flight_key(Path(dst).parent, (Path(dst).name,))
class DeferredPreview:
"""The preview of *rel* under *root*, resolved when its write starts
(#320): a queued capture follows the thumbnail location at write time,
not the one when it was queued."""
__slots__ = ("root", "rel", "strict")
def __init__(self, root, rel, *, strict=True):
self.root = Path(root)
self.rel = Path(rel)
# A capture refuses a store linked out of its container (the serve
# route's rule); a planned target keeps the plan's rule: inside the
# store, as the thumbnail job writes it.
self.strict = strict
def resolve(self):
"""The preview's path now, or None when nothing may be written."""
dst = media_preview_path(self.root, self.rel)
if dst is None:
return None
if self.strict:
return dst if preview_store.owns(self.root, dst) else None
store = preview_store.store_dir(self.root)
return dst if _is_within(dst, store) else None
def _resolved(dst):
return dst.resolve() if isinstance(dst, DeferredPreview) else Path(dst)
@functools.lru_cache(maxsize=1)
def video_placeholder_jpeg():
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
@@ -245,19 +210,10 @@ def _candidates(parts):
def _current_preview(root, parts):
"""The preview *parts* names, in the format written now (#317): a request
for an older name (``_thumb.png`` before a conversion) is answered with
the current file. None when the store of *root* has no place for it."""
the current file."""
_folder, base, _wanted = _candidates(parts)
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
return preview_store.request_path(root, (*parts[:-1], name))
def _request_key(root, parts):
"""The flight key of the preview *parts* under *root*: the key of its
disk path (so the serve and history routes share one flight)."""
path = preview_store.request_path(root, parts)
if path is None:
return flight_key(root, parts)
return _dst_key(path)
return Path(root).joinpath(*parts[:-1], name)
def _quick_source(root, parts):
@@ -298,10 +254,12 @@ def _thumbnail_source(root, parts):
if in_trash_below_roots(found, [root]):
return None
rel = Path(*folder_parts, found.name)
dst = media_preview_path(root, rel)
if dst is None or dst != _current_preview(root, parts):
dst = root / media_thumbnail_rel(rel)
if dst != _current_preview(root, parts):
return None
if not preview_store.owns(root, dst):
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
dst = None
return found, dst, found.suffix.lower() in VIDEO_EXTENSIONS
@@ -321,21 +279,6 @@ def _scan_folder(folder, base, extensions):
return None
def _deferred(roots, src, dst):
"""The ``DeferredPreview`` of a planned ``(src, dst)``: the root whose
store the plan wrote *dst* into (nested roots plan one each); *dst* as
planned when none matches."""
src = Path(src)
for root in roots:
root = Path(root)
if not src.is_relative_to(root):
continue
rel = src.relative_to(root)
if media_preview_path(root, rel) == Path(dst):
return DeferredPreview(root, rel, strict=False)
return dst
def _stop_stale_workers():
"""Stop capture workers a previous import of this module started."""
for thread in threading.enumerate():
@@ -351,7 +294,7 @@ class AutoThumbnailer:
``generate(src, dst, is_video, *, stop, guard, track)`` writes one
preview and returns ``_generate_one``'s result dict; ``may_write()``
says whether automatic previews may be written now. Views call
``ensure`` on the image pool, the monitor calls ``enqueue`` from its
``ensure`` on the view pool, the monitor calls ``enqueue`` from its
thread.
"""
@@ -477,23 +420,16 @@ class AutoThumbnailer:
def enqueue(self, src, dst, is_video):
"""Queue *dst*'s preview for the worker; False when already queued,
full, closed, nowhere to write it or its worker is refused at
interpreter exit. Never blocks on a write. *dst* may be a
``DeferredPreview``: its path is resolved again when the write
starts."""
now = _resolved(dst)
if now is None:
return False
key = _dst_key(now)
if not isinstance(dst, DeferredPreview):
dst = now
full, closed or its worker is refused at interpreter exit. Never
blocks on a write."""
key = _dst_key(dst)
with self._changed:
if self._closed or key in self._queued:
return False
if len(self._queue) >= self._max_queued:
return False
self._queued.add(key)
self._queue.append((key, Path(src), dst, bool(is_video)))
self._queue.append((key, Path(src), Path(dst), bool(is_video)))
self._busy = True
started = True
if self._start_worker and not self._worker_running:
@@ -555,15 +491,12 @@ class AutoThumbnailer:
self._worker_running = False
self._changed.notify_all()
return
workers = max(1, int(self._workers() or 1))
take = min(len(self._queue), workers)
take = min(len(self._queue), max(1, int(self._workers() or 1)))
batch = [self._queue.popleft() for _ in range(take)]
self._batch = len(batch)
self._changed.notify_all()
try:
# The job pool's size is the setting; a smaller batch uses
# fewer of its threads (#523: one long-lived pool).
map_parallel(self._write_queued, batch, workers)
map_parallel(self._write_queued, batch, len(batch))
finally:
with self._changed:
self._batch = 0
@@ -573,9 +506,6 @@ class AutoThumbnailer:
def _write_queued(self, item):
_key, src, dst, is_video = item
try:
dst = _resolved(dst)
if dst is None:
return # nowhere to write it now: made on its first view
self.ensure(src, dst, is_video)
except Exception: # one file never stops the worker
self._logger.warning(
@@ -637,7 +567,7 @@ class _PreviewGate:
"""Admission of view requests on the event loop (#249 I2).
Waiters are coroutines: at most ``limit`` previews are written at once
on the image pool, one flight runs per key, and past ``MAX_WAITING``
on the view pool, one flight runs per key, and past ``MAX_WAITING``
waiting requests ``run`` answers ``OVER`` at once.
"""
@@ -692,17 +622,13 @@ class _PreviewGate:
flight.set_result(result)
def _on_image_thread():
return threading.current_thread().name.startswith(POOL_PREFIX + "_")
class AutoThumbnailMixin:
"""Automatic previews on the API: views, the monitor's queue, scans."""
_auto_thumbnailer = None
_auto_thumbnails_shut = False
_preview_gate_obj = None
_image_pool_obj = None
_preview_pool_obj = None
_auto_thumbnailer_lock = threading.Lock()
def _auto_thumbnails(self):
@@ -725,7 +651,7 @@ class AutoThumbnailMixin:
self._auto_thumbnails_shut = True
def close_auto_thumbnails(self, final=False, timeout=None):
"""Stop the capture worker and the image pool, and forget them.
"""Stop the capture worker and the view pool, and forget them.
Bounded by ``timeout`` (see AutoThumbnailer.close). ``final`` (the
package teardown) also keeps a late monitor event from starting a
@@ -736,7 +662,7 @@ class AutoThumbnailMixin:
if final:
self._auto_thumbnails_shut = True
thumbnailer, self._auto_thumbnailer = self._auto_thumbnailer, None
pool, self._image_pool_obj = self._image_pool_obj, None
pool, self._preview_pool_obj = self._preview_pool_obj, None
self._preview_gate_obj = None
closed = True
if thumbnailer is not None:
@@ -780,67 +706,16 @@ class AutoThumbnailMixin:
gate = self._preview_gate_obj = _PreviewGate(ON_DEMAND_LIMIT, self.logger)
return gate
def _image_pool(self):
"""The interactive image pool (#523): IMAGE_POOL_SIZE threads for view
previews (behind their gate), card facts and inspector reads, so
image buffers never spread over the default executor's threads.
None after the package teardown (``close_auto_thumbnails(final=True)``):
a late request then runs on the default executor (``run_in_executor``
with None) and starts no thread the teardown no longer stops.
"""
def _preview_pool(self):
with AutoThumbnailMixin._auto_thumbnailer_lock:
if self._image_pool_obj is None and not self._auto_thumbnails_shut:
self._image_pool_obj = ThreadPoolExecutor(
IMAGE_POOL_SIZE, thread_name_prefix=POOL_PREFIX
if self._preview_pool_obj is None:
self._preview_pool_obj = ThreadPoolExecutor(
ON_DEMAND_LIMIT, thread_name_prefix=POOL_PREFIX
)
return self._image_pool_obj
return self._preview_pool_obj
async def _run_image(self, func, *args, **kwargs):
"""``_run_in_executor`` for interactive image work: the image pool.
After the teardown, or with the pool shut down in between (a test
reset), the call runs on the default executor as before.
"""
call = functools.partial(func, *args, **kwargs)
loop = asyncio.get_running_loop()
try:
future = loop.run_in_executor(self._image_pool(), call)
except RuntimeError: # submit refused: the pool was shut down
future = loop.run_in_executor(None, call)
return await future
def _map_image(self, func, items):
"""``func`` over *items* on the image pool, in order (blocking).
Called from an image pool thread, after the teardown, or with the
pool shut down in between (its queued items are cancelled), the
items it did not run run on the calling thread.
"""
items = list(items)
pool = None if not items or _on_image_thread() else self._image_pool()
if pool is None:
return [func(item) for item in items]
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError:
pass # shut down in between: the rest run here
results = []
for index, item in enumerate(items):
future = futures[index] if index < len(futures) else None
if future is None or future.cancelled():
results.append(func(item))
continue
try:
results.append(future.result())
except CancelledError: # cancelled while this caller waited
results.append(func(item))
return results
def _image_pool_threads(self):
pool = self._image_pool_obj
def _preview_pool_threads(self):
pool = self._preview_pool_obj
return [] if pool is None else list(getattr(pool, "_threads", ()))
def _video_placeholder_bytes(self):
@@ -855,7 +730,7 @@ class AutoThumbnailMixin:
return await self._preview_gate().run(
flight_key(Path(dst).parent, (Path(dst).name,)),
functools.partial(self._ensure_view, src, dst, is_video),
self._image_pool(),
self._preview_pool(),
)
def _ensure_view(self, src, dst, is_video):
@@ -899,17 +774,17 @@ class AutoThumbnailMixin:
The flight answers only the outcome (a history flight may be the one
joined); the file it previews is derived here: the preview path is
the request's own, and a fallback looks its source up again on the
image pool (bounded by it, never the default executor).
view pool (bounded by it, never the default executor).
"""
if _has_traversal(filepath) or not _is_preview_request(filepath):
return None
parts = tuple(filepath.split("/"))
gate = self._preview_gate()
pool = self._image_pool()
pool = self._preview_pool()
for root in output_dirs:
root = Path(root)
outcome = await gate.run(
_request_key(root, parts),
flight_key(root, parts),
functools.partial(self._preview_on_demand, root, parts),
pool,
)
@@ -922,7 +797,7 @@ class AutoThumbnailMixin:
continue
return self._preview_response(BUSY, quick[0], None, quick[1])
dst = _current_preview(root, parts)
if outcome == READY and dst is not None:
if outcome == READY:
return self._file_response(dst)
located = await gate.loop.run_in_executor(
pool, _thumbnail_source, root, parts
@@ -945,31 +820,17 @@ class AutoThumbnailMixin:
"""
if self._auto_thumbnails_shut:
return "skipped"
located = self._capture_source(image_path)
located = self._capture_target(image_path)
if located is None:
return "skipped"
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
if dst is None:
return "skipped"
src, dst, is_video = located
if _thumbnail_is_fresh(src, dst):
return "fresh"
if self._auto_thumbnails().enqueue(src, DeferredPreview(root, rel), is_video):
if self._auto_thumbnails().enqueue(src, dst, is_video):
return "queued"
return "skipped"
def _capture_target(self, image_path):
"""``(src, dst, is_video)`` of a new output's preview now, or None."""
located = self._capture_source(image_path)
if located is None:
return None
src, root, rel, is_video = located
dst = DeferredPreview(root, rel).resolve()
return None if dst is None else (src, dst, is_video)
def _capture_source(self, image_path):
"""``(src, root, rel, is_video)`` of a media file under a current
root (not a preview, a link or in a trash folder), or None."""
path = Path(os.path.abspath(str(image_path)))
if path.suffix.lower() not in MEDIA_EXTENSIONS or not _regular_file(path):
return None
@@ -982,7 +843,12 @@ class AutoThumbnailMixin:
rel = Path(os.path.relpath(path, root))
if "thumbnails" in rel.parts[:-1] or in_trash_below_roots(path, [root]):
return None
return path, root, rel, path.suffix.lower() in VIDEO_EXTENSIONS
dst = root / media_thumbnail_rel(rel)
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
):
return None
return path, dst, path.suffix.lower() in VIDEO_EXTENSIONS
return None
# ── after a scan ──────────────────────────────────────────────────
@@ -1024,7 +890,7 @@ class AutoThumbnailMixin:
return "none"
thumbnailer = self._auto_thumbnails()
for src, dst, is_video in plan["targets"]:
thumbnailer.enqueue(src, _deferred(output_dirs, src, dst), is_video)
thumbnailer.enqueue(src, dst, is_video)
self.logger.info(f"{missing:,} previews missing after the scan; queued")
return "queued"
-1
View File
@@ -153,7 +153,6 @@ class ThumbnailConvertMixin:
job.log_line("Conversion failed · internal error")
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _convert_job_body(self, job, options):
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
+5 -10
View File
@@ -46,7 +46,7 @@ from pathlib import Path
from PIL import Image
from . import preview_store, thumbnail_settings
from . import thumbnail_settings
try:
from ...utils.logging_config import get_logger
@@ -101,15 +101,10 @@ def temp_sibling(dst, tag="part"):
def replace_unless_held(tmp, dst, guard=None):
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld).
The check and the rename run under ``preview_store.write_lock`` (#320): a
guard from ``preview_store.write_guard`` also refuses a write that started
before the thumbnail location changed."""
with preview_store.committed(guard) as allowed:
if not allowed:
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
if guard is not None and not guard():
raise PreviewWriteHeld(PREVIEW_HELD)
os.replace(tmp, dst)
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
-1
View File
@@ -226,7 +226,6 @@ class ThumbnailJobsMixin:
)
state = "failed"
job.finish(state)
await self._release_job_memory()
async def _thumbnail_job_body(self, job, options):
"""Plan, then generate in worker batches; returns the final state.
+3 -15
View File
@@ -114,16 +114,9 @@ def validate_edge(value):
# ── names ────────────────────────────────────────────────────────────
def preview_stem(rel_path):
"""``<rel without extension>_thumb`` (POSIX): a preview's name inside the
store of its source (``preview_store``)."""
return f"{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_stem_rel(rel_path):
"""``thumbnails/<rel without extension>_thumb`` (POSIX): URL text only.
Where the preview is on disk is ``preview_store``'s answer."""
return f"thumbnails/{preview_stem(rel_path)}"
"""``thumbnails/<rel without extension>_thumb`` (POSIX)."""
return f"thumbnails/{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
def thumbnail_rel(rel_path, extension=None):
@@ -214,13 +207,8 @@ def owned_in_folder(folder, stem, source_extension, preferred=None):
def _owned(root, rel_path, preferred):
from . import preview_store
rel_path = Path(rel_path)
store = preview_store.store_dir(root)
if store is None:
return iter(())
stem = store / preview_stem(rel_path)
stem = Path(root) / thumbnail_stem_rel(rel_path)
return owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
+1 -1
View File
@@ -4,7 +4,7 @@ description = "A powerful ComfyUI custom node that extends the standard text enc
version = "3.4.1"
license = {file = "LICENSE"}
requires-python = ">=3.12"
dependencies = ["watchdog>=2.1.0", "Pillow>=8.0.0"]
dependencies = ["watchdog>=2.1.0,<7", "Pillow>=8.0.0"]
[build-system]
requires = ["setuptools>=84.2.1"]
+1 -1
View File
@@ -1,5 +1,5 @@
# Runtime dependencies
watchdog>=6.0.0
watchdog>=6.0.0,<7
Pillow>=12.3.0
aiohttp>=3.14.5
+1 -1
View File
@@ -1,4 +1,4 @@
# Runtime dependencies for PromptManager.
# aiohttp, numpy and torch come from ComfyUI itself and are not listed here.
watchdog>=2.1.0 # Output directory monitoring
watchdog>=2.1.0,<7 # Output directory monitoring
Pillow>=8.0.0 # Image metadata extraction and thumbnails
-41
View File
@@ -320,44 +320,3 @@ test('copy toast and fallback dialog',async({page})=>{
await expect(fallback).toContainText('Automatic clipboard access requires a secure browser context.');
await page.keyboard.press('Escape');await expect(fallback).toBeHidden();await expect(copy).toBeFocused();
});
// #542 D191: "All N eligible nodes" previews every node, sends one after another and names each refusal.
test('All eligible nodes previews each node, sends in node id order and names a refusal',async({page,context},testInfo)=>{
const host=await context.newPage();await host.goto('/prompt_manager/workspace#/overview');
await host.evaluate(async()=>{
const {createBridgeHost}=await import('/prompt_manager/js/shared/toolkit/prompt-bridge.js');
const targets=['12','3','7'].map(id=>({id,label:id==='7'?'Edited in ComfyUI':'Prompt Manager (+/-)',type:'PromptManagerPair',roles:['positive','negative']}));
window.hostValues=Object.fromEntries(targets.map(target=>[target.id,{positive:`Existing ${target.id}`,negative:'Existing negative'}]));window.applied=[];
window.fixtureHost=createBridgeHost({origin:location.origin,namespace:'/',label:'Test ComfyUI',adapter:{
listTargets:()=>targets,
preview:(id,roles)=>{const target=targets.find(item=>item.id===id);return{preview:{target,fields:Object.entries(roles).map(([role,after])=>({role,before:window.hostValues[id][role],after}))},snapshot:{id,roles}};},
apply:({id,roles})=>{if(id==='7')throw new Error('Workflow or prompt changed. Preview again.');Object.assign(window.hostValues[id],roles);window.applied.push(id);return{targetId:id,values:roles};},
}});
});
try {
await page.goto(workspace);await select(page,'0:shared/same.png');await details(page).getByRole('button',{name:/^Send prompt pair to ComfyUI: /}).click();
const sender=dialog(page);const nodes=sender.getByRole('combobox',{name:'Destination node',exact:true});
await sender.getByRole('combobox',{name:'ComfyUI window',exact:true}).selectOption({label:'Test ComfyUI'});
await expect(nodes.locator('option')).toHaveText(['Choose a destination node','#12 Prompt Manager (+/-)','#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','All 3 eligible nodes']);
await expect(nodes).toHaveValue('');
await nodes.selectOption({label:'All 3 eligible nodes'});
await expect(sender.getByRole('status')).toHaveText('Sending to all 3 eligible nodes in Test ComfyUI. Apply to send it, or preview the changes first.');
await page.screenshot({path:testInfo.outputPath('send-all-option.png')});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
const reviews=sender.locator('section.prompt-sender-node');
await expect(reviews.locator('> h4')).toHaveText(['#3 Prompt Manager (+/-)','#7 Edited in ComfyUI','#12 Prompt Manager (+/-)']);
await expect(sender.getByRole('region',{name:'#12 Prompt Manager (+/-)',exact:true})).toContainText('Existing 12');
expect(await host.evaluate(()=>window.applied)).toEqual([]);
await sender.getByRole('button',{name:'Apply to ComfyUI',exact:true}).click();
await expect(sender.getByRole('alert')).toHaveText('Sent to 2 of 3 nodes in Test ComfyUI. #7 Edited in ComfyUI: Workflow or prompt changed. Preview again.');
expect(await host.evaluate(()=>window.applied)).toEqual(['3','12']);
expect(await host.evaluate(()=>window.hostValues['7'].positive)).toBe('Existing 7');
await page.screenshot({path:testInfo.outputPath('send-all-result.png')});
await page.setViewportSize({width:390,height:844});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();await expect(reviews).toHaveCount(3);
await reviews.last().scrollIntoViewIfNeeded();
expect(await page.evaluate(()=>document.querySelector('dialog.prompt-sender').scrollWidth<=document.querySelector('dialog.prompt-sender').clientWidth+1)).toBe(true);
await page.screenshot({path:testInfo.outputPath('send-all-mobile.png')});
const apply=sender.getByRole('button',{name:'Apply to ComfyUI',exact:true});await apply.focus();await expect(apply).toBeFocused();
} finally {await host.evaluate(()=>window.fixtureHost.dispose());await host.close();}
});
+11 -19
View File
@@ -162,15 +162,13 @@ async function serverJson(request, url) {
}
const number = value => value.toLocaleString('en-US');
// The sidebar's Images count (state.mjs), which System's Images metric shows (#555).
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
/** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */
function mapOverviewConcept(reference, overview, images) {
function mapOverviewConcept(reference, overview) {
// D165: the discreet legacy-pages line follows the settings layout.
reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] };
const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts),
'Negative variants': number(overview.library.negative_variants), Images: number(images) };
'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) };
const rows = {
// D93: the real pending count; Review metadata opens Needs review (the concept's go-review).
'3 ambiguous image links': row => ({ ...row,
@@ -181,9 +179,8 @@ function mapOverviewConcept(reference, overview, images) {
'One display configuration': row => ({ ...row, description: 'Grid and density settings are shared across views.' }),
// D93: the real sources, not samples.
'2 sample output sources': row => ({ ...row, title: `${number(overview.sources.effective)} output sources` }),
// D96: the dialog shows runs by category and database record counts, not prompt, tag or date usage (D153: no
// ratings; #555: the counts are labelled as records, not as the Library or Images page counts).
Statistics: row => ({ ...row, description: 'Runs by category, with database record counts.' }),
// D96: the dialog shows runs by category and library totals, not prompt, tag or date usage (D153: no ratings).
Statistics: row => ({ ...row, description: 'Runs by category, with library totals.' }),
};
const surfaces = reference.columns.surfaces.map(surface => ({ ...surface,
rows: surface.rows.map(row => (rows[row.title] ?? (value => value))(row)) }));
@@ -221,8 +218,6 @@ async function conceptOverview(page, theme) {
async function productionOverview(page, theme) {
await openProduction(page, 'system/overview', theme);
await expect(page.locator('#workspace-main .summary-metrics')).toBeVisible();
// #555: the Images metric is the shell's Images count: compare once it was read.
await expect(page.locator('#workspace-main .summary-metrics > .metric:nth-child(3) strong')).toHaveText(/^[\d,]+$/);
// The thumbnail row is its own read (I2): compare once it answered.
await expect(page.locator('#workspace-main .operation h3').filter({ hasText: 'thumbnails' })).toHaveText('No missing thumbnails');
// So is the backup row (S9).
@@ -236,7 +231,6 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
test(`system shell and overview match the concept ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
await page.setViewportSize({ width, height });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The fixture's thumbnails all exist, so the concept's "No missing thumbnails" is also the real title.
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).pending).toBe(0);
const concept = await conceptOverview(page, theme);
@@ -245,7 +239,7 @@ for (const theme of THEMES) for (const [width, height] of SIZES) {
await page.screenshot({ path: testInfo.outputPath(`concept-system-${theme}-${width}.png`), fullPage: true });
const production = await productionOverview(page, theme);
await page.screenshot({ path: testInfo.outputPath(`production-system-${theme}-${width}.png`), fullPage: true });
expect(diffInventory(mapOverviewConcept(concept, overview, images), production)).toEqual([]);
expect(diffInventory(mapOverviewConcept(concept, overview), production)).toEqual([]);
expect(production.pageScroll).toBe(false);
});
}
@@ -271,9 +265,8 @@ const UNMAPPED_PATHS = [
test('system comparison detects mutations', async ({ page, request }) => {
await page.setViewportSize({ width: 1600, height: 1100 });
const overview = await serverJson(request, '/prompt_manager/system/overview');
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
const concept = await conceptOverview(page, 'dark');
const expected = mapOverviewConcept(concept, overview, images);
const expected = mapOverviewConcept(concept, overview);
const production = await productionOverview(page, 'dark');
expect(diffInventory(expected, production)).toEqual([]);
// Unmapped, the concept differs exactly where a deviation is recorded, and nowhere else.
@@ -297,7 +290,7 @@ test('system comparison detects mutations', async ({ page, request }) => {
});
/**
* D96: the concept's table, its rows the real ones; then "Database records" (#555) in the same table markup. D153 (#447): the
* D96: the concept's table, its rows the real ones; then "Library totals" in the same table markup. D153 (#447): the
* concept's "Avg. rating" column and a "Rated images" total are not drawn.
*/
function mapStatisticsConcept(reference, statistics) {
@@ -309,10 +302,9 @@ function mapStatisticsConcept(reference, statistics) {
tables: [
{ ...runs, head: runs.head.filter(([, , text]) => text !== 'Avg. rating'),
rows: statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]) },
{ caption: 'Database records', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)],
['Image records', number(statistics.totals.total_images)]] },
{ caption: 'Library totals', head: [['th', 'col', 'Total'], ['th', 'col', 'Count']],
rows: [['Prompts', number(statistics.totals.total_prompts)], ['Tags', number(statistics.totals.total_tags)],
['Images', number(statistics.totals.total_images)]] },
],
};
}
@@ -428,7 +420,7 @@ for (const theme of THEMES) test(`statistics and health dialogs match the concep
expect(diffInventory(mapHealthConcept(concept.health), await page.evaluate(measureDialog)).map(item => item.path))
.toEqual(['operations.rows.0.title']);
await page.evaluate(() => { document.querySelector('#modal .operation h3').textContent = 'Integrity check'; });
// And a removed "Database records" table and a changed caption in the statistics dialog.
// And a removed "Library totals" table and a changed caption in the statistics dialog.
await page.keyboard.press('Escape');
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await page.locator('#workspace-main').getByRole('button', { name: 'Explore', exact: true }).click();
@@ -284,23 +284,6 @@ test('a restore seen finishing elsewhere closes an open restore review and disca
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(0);
});
// #583: in WebKit, while a page.route is armed, a file part of a FormData body is read only when the request goes on;
// a garbage collection in between dropped the form once nothing referenced it, and the server got an empty file
// ("Uploaded file is empty"). The page keeps the form until its upload is answered. The route holds the upload
// through a collection; without the fix WebKit lost the file every time (0/10 in the pinned image). Chromium and
// Firefox keep the body anyway, so there it only checks that a held upload still opens the review.
test('a review upload survives a garbage collection while it is in flight', async ({ page, request }) => {
const bytes = await backupBytes(request);
await page.route(`**${PREVIEW_URL}`, async route => {
await page.requestGC();
await route.continue();
});
await openBackups(page);
expect((await chooseBackup(page, bytes)).status()).toBe(200);
await expect(dialog(page).locator('#modal-title')).toHaveText('Restore preview');
expect((await serverJson(request, '/__test__/system/backups')).stages).toBe(1);
});
test('invalid file shows the validator message; nothing is staged', async ({ page, request }) => {
const before = await serverJson(request, '/__test__/system/digest');
await openBackups(page);
+6 -31
View File
@@ -8,8 +8,6 @@ import { readCoverageTable, parseControl, CONTROL_EXEMPTIONS } from '../../tools
const workspace = '/prompt_manager/workspace';
const OVERVIEW_URL = '/prompt_manager/system/overview';
const THUMBNAILS_URL = '/prompt_manager/images/thumbnails/status';
// The sidebar's Images count (state.mjs NAV_COUNT_SOURCES): the Images page's own total.
const IMAGES_COUNT_URL = '/prompt_manager/images/output?mode=workspace&limit=1';
const number = value => value.toLocaleString('en-US');
test.beforeEach(async ({ page, request, browserName }) => {
@@ -57,7 +55,6 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
page.on('request', item => { if (!['GET', 'HEAD', 'OPTIONS'].includes(item.method())) writes.push(item.url()); });
const overview = await serverJson(request, OVERVIEW_URL);
const thumbnails = await serverJson(request, THUMBNAILS_URL);
const images = (await serverJson(request, IMAGES_COUNT_URL)).total;
// The concept fixture: six positives, their saved negatives and 24 linked images, all with thumbnails.
expect([overview.library.positive_prompts, overview.images.indexed, thumbnails.pending]).toEqual([6, 24, 0]);
// I2: the Overview never walks the sources for thumbnails; the row reads the status route itself.
@@ -69,8 +66,7 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
[item.querySelector('strong').textContent, item.querySelector('span').textContent]))).toEqual([
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
// #555: the Images page's own count, the same number as its sidebar badge.
[number(images), 'Images'],
[number(overview.images.indexed), 'Images'],
['Not checked', 'Database integrity'],
]);
expect(await rowsOf(layout, 'Library health')).toEqual([
@@ -83,31 +79,11 @@ test('overview shows real counts and Not checked', async ({ page, request }) =>
expect(await rowsOf(layout, 'Workspace preferences')).toEqual([
['One display configuration', 'Grid and density settings are shared across views.', 'Appearance'],
['2 output sources', 'Monitoring and enrichment share one source list.', 'Manage sources'],
['Statistics', 'Runs by category, with database record counts.', 'Explore'],
['Statistics', 'Runs by category, with library totals.', 'Explore'],
]);
expect(writes).toEqual([]);
});
test('#555: the Images metric is the sidebar Images count, not the indexed image rows', async ({ page, request }) => {
const overview = await serverJson(request, OVERVIEW_URL);
// The Images page lists files no row links (the demo library: 122 files, 110 rows); the listing says so.
const listed = overview.images.indexed + 12;
await page.route(`**${IMAGES_COUNT_URL}`, async route => {
const response = await route.fetch();
await route.fulfill({ response, json: { ...(await response.json()), total: listed } });
});
const layout = await openOverview(page);
const imagesMetric = layout.locator('.summary-metrics > .metric').filter({ has: page.locator('span', { hasText: /^Images$/ }) });
await expect(imagesMetric.locator('strong')).toHaveText(number(listed));
await expect(page.locator('#primary-nav a[data-route="images"] .nav-count')).toHaveText(number(listed));
// A failed count reads as a dash, never the row count.
await page.unroute(`**${IMAGES_COUNT_URL}`);
await page.route(`**${IMAGES_COUNT_URL}`, route => route.fulfill({ status: 503, json: { success: false, error: 'Unavailable' } }));
await page.goto(`${workspace}#/system/appearance`);
await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Overview', exact: true }).click();
await expect(imagesMetric.locator('strong')).toHaveText('–');
});
// D129 (#249 S6b): where the library lives. The hook makes the fixture's database read as a node-folder library; its
// Move library is a stand-in that touches no file (workspace_system_fixture.py).
const banner = page => page.locator('#library-banner');
@@ -388,7 +364,7 @@ test('View backups / Appearance / Manage sources switch sections', async ({ page
}
});
test('statistics dialog lists runs by category and database record counts', async ({ page, request }) => {
test('statistics dialog lists runs by category and library totals', async ({ page, request }) => {
const statistics = await serverJson(request, '/prompt_manager/system/statistics');
expect(statistics.by_category.length).toBeGreaterThan(1);
const layout = await openOverview(page);
@@ -397,14 +373,13 @@ test('statistics dialog lists runs by category and database record counts', asyn
const dialog = page.locator('dialog#modal[open]');
await expect(dialog.locator('#modal-title')).toHaveText('Usage statistics');
const tables = dialog.locator('#modal-body > table');
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Database records']);
await expect(tables.locator('caption')).toHaveText(['Runs by category', 'Library totals']);
// D153 (#447): no rating column and no rated-images total.
await expect(tables.first().locator('thead th')).toHaveText(['Category', 'Runs']);
const cells = table => table.locator('tbody tr').evaluateAll(rows => rows.map(row => [...row.cells].map(cell => cell.textContent)));
expect(await cells(tables.first())).toEqual(statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]));
// #555: raw rows of the legacy /stats, labelled as rows so they never read as the Library or Images page counts.
expect(await cells(tables.nth(1))).toEqual([['Prompt records', number(statistics.totals.total_prompts)],
['Tags on prompts', number(statistics.totals.total_tags)], ['Image records', number(statistics.totals.total_images)]]);
expect(await cells(tables.nth(1))).toEqual([['Prompts', number(statistics.totals.total_prompts)],
['Tags', number(statistics.totals.total_tags)], ['Images', number(statistics.totals.total_images)]]);
await page.keyboard.press('Escape');
await expect(explore).toBeFocused();
// A statistics read that fails says so; the Overview stays.
+3 -4
View File
@@ -145,10 +145,9 @@ test('long vocabularies append and Load more is keyboard reachable',async({page,
await expect(loadMore(page)).toHaveCount(0);
await expect(rows(page).nth(200).getByRole('button',{name:'Browse',exact:true})).toBeFocused();
await expect(rows(page).locator('td:nth-child(1)')).toHaveText(manifest.tags);
// Open the screen as a new history entry, not a reload: Firefox restores a reloaded page's scrolled tag window, and
// its end then appends a page before the check (#567). Scrolling the window to its top first does not always stick.
await page.goto('about:blank');
await page.goto(route);
// Reload with the window at its top: Firefox restores a scrolled window, whose end then appends a page before the check.
await main(page).locator('.two-col .table-wrap').evaluate(node=>new Promise(resolve=>{node.scrollTop=0;requestAnimationFrame(()=>requestAnimationFrame(resolve));}));
await page.reload();
await expect(rows(page)).toHaveCount(100);
// D183: the end of the list arrives inside the tag window.
const scroll=top=>main(page).locator('.two-col .table-wrap').evaluate((node,y)=>new Promise(resolve=>{node.scrollTop=y ?? node.scrollHeight;requestAnimationFrame(()=>requestAnimationFrame(resolve));}),top);
+2 -31
View File
@@ -131,40 +131,11 @@ test('native image inspector previews and sends a pair into real visible widgets
const twinId=await page.evaluate(async()=>{const {app}=await import('/scripts/app.js');const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);return node.id;});
await find0.click();
const targets=sender.getByRole('combobox',{name:'Destination node',exact:true});
await expect(targets.locator('option')).toHaveCount(4);
await expect(targets.locator('option')).toHaveCount(3);
await expect(targets).toHaveValue('');
await expect(apply).toBeDisabled();
await expect(targets.locator('option').nth(2)).toHaveText(new RegExp(`^#${twinId} `));
// #542 D191: "All 2 eligible nodes" writes both real nodes, one after another; a linked third node is not offered.
const linkedId=await page.evaluate(async()=>{
const {app}=await import('/scripts/app.js');const {convertToInput}=await import('/extensions/core/widgetInputs.js');
const node=LiteGraph.createNode('PromptManagerPair');app.graph.add(node);
const widget=node.widgets.find(widget=>widget.name==='text');
if(!node.inputs.some(input=>input.name==='text'))convertToInput(node,widget);
const source=LiteGraph.createNode('PrimitiveString');app.graph.add(source);
source.connect(0,node,node.inputs.findIndex(input=>input.name==='text'));
for(const each of app.graph._nodes.filter(item=>item.type==='PromptManagerPair')){const text=each.widgets.find(widget=>widget.name==='text');if(text)text.value=`Before all ${each.id}`;}
return node.id;
});
await find0.click();
await expect(targets.locator('option')).toHaveText(['Choose a destination node',new RegExp(`^#${id} `),new RegExp(`^#${twinId} `),'All 2 eligible nodes']);
await expect(sender.getByRole('status')).toContainText('1 other Prompt Manager node cannot receive this prompt.');
await targets.selectOption({label:'All 2 eligible nodes'});
await sender.getByRole('button',{name:'Preview changes',exact:true}).click();
await expect(sender.locator('section.prompt-sender-node > h4')).toHaveText([new RegExp(`^#${id} `),new RegExp(`^#${twinId} `)]);
await apply.click();
// The tab title gains ComfyUI's unsaved mark once the workflow changed, so the window is matched loosely.
await expect(sender.getByRole('status')).toHaveText(/^Sent to 2 of 2 nodes in .*ComfyUI\.$/);
await expect(apply).toBeDisabled();
const all=await page.evaluate(async ids=>{
const {app}=await import('/scripts/app.js');const output=(await app.graphToPrompt()).output;
return ids.map(nodeId=>{const node=app.graph.getNodeById(nodeId);const serialized=node.serialize();const index=node.widgets.findIndex(widget=>widget.name==='negative_text');
return{prompt:output[String(nodeId)]?.inputs.negative_text,serialized:serialized.widgets_values[index],text:node.widgets.find(widget=>widget.name==='text')?.value};});
},[id,twinId,linkedId]);
expect(all.slice(0,2)).toEqual([{prompt:negative,serialized:negative,text:positive},{prompt:negative,serialized:negative,text:positive}]);
expect(all[2].text).toBe(`Before all ${linkedId}`);
expect(await page.evaluate(()=>window.senderEffects.queue-window.senderSendBaseline.queue)).toBe(0);
await page.evaluate(async ids=>{const {app}=await import('/scripts/app.js');for(const nodeId of ids)app.graph.remove(app.graph.getNodeById(nodeId));},[twinId,linkedId]);
await page.evaluate(async twinId=>{const {app}=await import('/scripts/app.js');app.graph.remove(app.graph.getNodeById(twinId));},twinId);
// #535: the status line says why a window cannot receive.
const find=sender.getByRole('button',{name:'Find ComfyUI windows',exact:true});
const preview=sender.getByRole('button',{name:'Preview changes',exact:true});
-138
View File
@@ -225,141 +225,3 @@ test('acknowledgement cannot claim an extra role beyond approved changes',async(
const {createSendSession}=await source;const s=setup(createSendSession,{apply:async()=>({targetId:'one',values:{positive:'new',negative:'also changed'}})});
await s.session.discover();s.session.selectHost('host');s.session.selectTarget('one');await s.session.preview();await s.session.apply();assert.equal(s.session.state.phase,'error');assert.match(s.session.state.message,/unexpected acknowledgement/);s.session.dispose();
});
// #542: "All N eligible nodes" sends to every eligible node of one window, previewing every node first (Q542-2 C).
function allHost({nodes=['10','9','2'],revision,preview,apply,discover}={}) {
const calls=[];const sent=new Set();
const targets=nodes.map(id=>pm(id,['positive'],revision?{revision}:{}));
const client={
discover:discover ?? (async()=>[window1(targets)]),
preview:async(host,id,roles)=>{calls.push(['preview',id]);if(preview)return preview(id,calls,sent);return{ticket:`t-${id}-${calls.length}`,revision,target:{id,label:`Node ${id}`},fields:[{role:'positive',before:sent.has(id)?'new':'old',after:roles.positive}]};},
apply:async(host,ticket)=>{const id=ticket.split('-')[1];calls.push(['apply',id,ticket]);if(apply)return apply(id,calls);sent.add(id);return{targetId:id,values:{positive:'new'}};},
dispose:()=>calls.push(['dispose']),
};
return {client,calls,sent};
}
async function allSession(createSendSession,options) {
const host=allHost(options);const session=createSendSession({client:host.client,roles:{positive:'new'},onChange:()=>{}});
await session.discover();return {...host,session};
}
const order=calls=>calls.filter(call=>call[0]!=='dispose').map(call=>`${call[0]} ${call[1]}`);
test('All is offered only with two or more eligible nodes and is never selected for you',async()=>{
const {createSendSession,ALL_NODES}=await source;
const one=await allSession(createSendSession,{nodes:['1']});
one.session.selectTarget(ALL_NODES);assert.equal(one.session.state.targetId,'');assert.equal(one.calls.length,0);one.session.dispose();
const two=await allSession(createSendSession,{nodes:['1','2']});
assert.equal(two.session.state.targetId,'');
two.session.selectTarget(ALL_NODES);assert.equal(two.session.state.targetId,ALL_NODES);
assert.match(two.session.state.message,/all 2 eligible nodes in Flow - ComfyUI/);
await two.session.discover();assert.equal(two.session.state.targetId,'');two.session.dispose();
});
test('Apply to All previews every node first, then sends one after another in node id order',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.equal(s.session.state.phase,'success');assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,3);s.session.dispose();
});
test('a double click on Apply to All sends to each node once',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession,{nodes:['1','2']});
s.session.selectTarget(ALL_NODES);await Promise.all([s.session.apply(),s.session.apply()]);
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['1','2']);s.session.dispose();
});
test('a node refusing its preview stops All before anything is written and names the node',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{preview:id=>{if(id==='9')throw Error('positive is connected to an upstream input.');return{ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.equal(s.calls.filter(call=>call[0]==='apply').length,0);assert.equal(s.session.state.phase,'error');
assert.match(s.session.state.message,/^#9 Node 9: positive is connected to an upstream input\. Nothing was sent\./);
assert.equal(s.session.state.outcomeUnknown,false);s.session.dispose();
});
test('a refusal while sending to All is reported by node and the other nodes are still sent',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Workflow or prompt changed. Preview again.');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9','10']);
assert.equal(s.session.state.phase,'error');assert.equal(s.session.state.outcomeUnknown,false);
assert.equal(s.session.state.message,'Sent to 2 of 3 nodes in Flow - ComfyUI. #9 Node 9: Workflow or prompt changed. Preview again.');s.session.dispose();
});
test('a lost reply on one node of All leaves the later nodes unsent and that outcome unknown',async()=>{
const {createSendSession,ALL_NODES}=await source;
const s=await allSession(createSendSession,{apply:id=>{if(id==='9')throw Error('Bridge request timed out');return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.equal(s.session.state.outcomeUnknown,true);
assert.match(s.session.state.message,/^Sent to 1 of 3 nodes in Flow - ComfyUI\. No confirmation received from #9 Node 9; the update may have applied\. Not sent: #10 Node 10\. Check ComfyUI, then preview again\. Bridge request timed out$/);
await s.session.apply();assert.equal(s.calls.filter(call=>call[0]==='apply').length,2);s.session.dispose();
});
test('closing the dialog during All stops after the current node',async()=>{
const {createSendSession,ALL_NODES}=await source;let release;
const s=await allSession(createSendSession,{apply:id=>id==='2'?new Promise(done=>release=()=>done({targetId:id,values:{positive:'new'}})):{targetId:id,values:{positive:'new'}}});
s.session.selectTarget(ALL_NODES);const pending=s.session.apply();
for(let i=0;i<50 && !release;i++)await tick();assert.ok(release,'the first node was sent');
s.session.dispose();release();await pending;
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2']);
});
test('All accepts the revision our own write moved, but not a workflow changed before the run',async()=>{
const {createSendSession,ALL_NODES}=await source;
// The host moves the graph version on every write: later nodes preview with the new revision.
let version=4;
const bumped=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:`1:${version}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>{version++;return{targetId:id,values:{positive:'new'}};}});
bumped.session.selectTarget(ALL_NODES);await bumped.session.apply();
assert.equal(bumped.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');bumped.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:id=>({ticket:`t-${id}`,revision:'1:5',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.apply();
assert.match(moved.session.state.message,/workflow changed since this window was found/i);assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a node whose text changed after the preview pass is refused, not overwritten',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:id==='10'&&edited?'typed in ComfyUI':'old',after:'new'}]}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','9']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #10 Node 10: Its prompt changed since the preview\./);s.session.dispose();
});
test('Preview with All shows every node in order, and Apply sends fresh tickets that match the review',async()=>{
const {createSendSession,ALL_NODES}=await source;const s=await allSession(createSendSession);
s.session.selectTarget(ALL_NODES);await s.session.preview();
assert.equal(s.session.state.phase,'ready');assert.equal(s.session.state.preview,null);
assert.deepEqual(s.session.state.previews.map(item=>item.label),['#2 Node 2','#9 Node 9','#10 Node 10']);
assert.match(s.session.state.message,/Sending to all 3 eligible nodes in Flow - ComfyUI\. Review the complete changes, then apply\./);
const first=s.session.state.previews[0].preview.ticket;
await s.session.apply();
// A held review may have outlived the host's 30 s tickets, so even the first node takes a fresh one.
assert.deepEqual(order(s.calls),['preview 2','preview 9','preview 10','preview 2','apply 2','preview 9','apply 9','preview 10','apply 10']);
assert.notEqual(s.calls.find(call=>call[0]==='apply')[2],first);assert.equal(s.session.state.phase,'success');
s.session.selectTarget('2');assert.deepEqual(s.session.state.previews,[]);s.session.dispose();
});
test('the dialog offers All for two or more nodes and reviews each node under its name',async()=>{
const {createPromptSender,ALL_NODES}=await source;const {createScope}=await import('../../web/js/shared/toolkit/core.js');
const dom=fakeDOM();const scope=createScope();
const {client}=allHost({nodes:['1','2']});
try {
createPromptSender({scope,clientFactory:()=>client}).open({positive:'new'});await tick();
const dialog=dom.body.children.at(-1);const target=dom.find(dialog,node=>node.id==='prompt-sender-target');
assert.deepEqual(target.children.map(option=>[option.value,option.textContent]),[['','Choose a destination node'],['1','#1 Node 1'],['2','#2 Node 2'],[ALL_NODES,'All 2 eligible nodes']]);
assert.equal(target.value,'');target.value=ALL_NODES;target.dispatchEvent(new Event('change'));
dom.find(dialog,node=>node.textContent==='Preview changes').dispatchEvent(new Event('click'));for(let i=0;i<5;i++)await tick();
const sections=[];(function walk(node){if(node.className==='prompt-sender-node')sections.push(node);node.children.forEach(walk);})(dialog);
assert.deepEqual(sections.map(section=>section.children[0].textContent),['#1 Node 1','#2 Node 2']);
dom.find(dialog,node=>node.textContent==='Apply to ComfyUI').dispatchEvent(new Event('click'));for(let i=0;i<10;i++)await tick();
assert.ok(dom.find(dialog,node=>node.textContent==='Sent to 2 of 2 nodes in Flow - ComfyUI.'));
} finally {scope.dispose();dom.restore();}
});
test('an expired review still sends every node, and a held review checks the discovered revision first',async()=>{
const {createSendSession,ALL_NODES}=await source;let reviewing=true;
// The host refuses every ticket taken during the review, as after its 30 s expiry.
const s=await allSession(createSendSession,{preview:(id,calls)=>({ticket:`t-${id}-${reviewing?'old':calls.length}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}]}),apply:id=>({targetId:id,values:{positive:'new'}})});
const realApply=s.client.apply;s.client.apply=async(host,ticket,options)=>{if(ticket.endsWith('-old'))throw Error('Preview expired or unavailable; preview again');return realApply(host,ticket,options);};
s.session.selectTarget(ALL_NODES);await s.session.preview();reviewing=false;await s.session.apply();
assert.equal(s.session.state.message,'Sent to 3 of 3 nodes in Flow - ComfyUI.');s.session.dispose();
const moved=await allSession(createSendSession,{revision:'1:4',preview:(id,calls)=>({ticket:`t-${id}`,revision:calls.length>3?'1:5':'1:4',target:{id},fields:[{role:'positive',before:'old',after:'new'}]})});
moved.session.selectTarget(ALL_NODES);await moved.session.preview();assert.equal(moved.session.state.phase,'ready');await moved.session.apply();
assert.match(moved.session.state.message,/^Sent to 0 of 3 nodes in Flow - ComfyUI\. #2 Node 2: The workflow changed since this window was found/);
assert.equal(moved.calls.filter(call=>call[0]==='apply').length,0);moved.session.dispose();
});
test('a changed prepend or append after the review refuses that node',async()=>{
const {createSendSession,ALL_NODES}=await source;let edited=false;
const s=await allSession(createSendSession,{preview:id=>({ticket:`t-${id}`,target:{id},fields:[{role:'positive',before:'old',after:'new'}],composition:{prepend:id==='9'&&edited?'typed':'trigger',append:'',before:'trigger old',after:'trigger new'}}),apply:id=>{edited=true;return{targetId:id,values:{positive:'new'}};}});
s.session.selectTarget(ALL_NODES);await s.session.apply();
assert.deepEqual(s.calls.filter(call=>call[0]==='apply').map(call=>call[1]),['2','10']);
assert.match(s.session.state.message,/^Sent to 2 of 3 nodes in Flow - ComfyUI\. #9 Node 9: Its prompt changed since the preview\./);s.session.dispose();
});
-18
View File
@@ -128,24 +128,6 @@ test('invalid file shows the validator message; a refusal names its job; a lost
assert.deepEqual(await uploadRestore(file, { ApiError, fetchUrl: async () => response(200, PREVIEW) }), PREVIEW);
});
// #583: WebKit reads a FormData's file part by reference, possibly after a garbage collection; the page holds the form
// until the upload is answered, then lets it go, whether the server staged it, refused it or the request failed.
test('a review upload keeps its form referenced until the upload is answered', async () => {
const { uploadRestore, uploadsInFlight } = await backups;
const file = new File(['bytes'], 'b.db');
for (const answer of [async () => response(200, PREVIEW), async () => response(400, { success: false, error: 'x' }),
async () => { throw new TypeError('offline'); }]) {
let held;
const upload = uploadRestore(file, { ApiError, fetchUrl: async (url, init) => {
held = uploadsInFlight.has(init.body) && init.body.get('database_file').size === file.size;
return answer();
} }).catch(() => {});
await upload;
assert.equal(held, true);
assert.equal(uploadsInFlight.size, 0);
}
});
test('Cancel discards the stage through the api client and never throws', async () => {
const { discardRestore, PREVIEW_URL } = await backups;
const calls = [];
+2 -12
View File
@@ -16,22 +16,12 @@ const OVERVIEW = Object.freeze({
test('metrics are the server counts and Not checked before any health check', async () => {
const { metricValues } = await overviewModule;
assert.deepEqual(metricValues(OVERVIEW, 24), [
assert.deepEqual(metricValues(OVERVIEW), [
[(1234).toLocaleString(), 'Positive prompts'], ['7', 'Negative variants'], ['24', 'Images'],
['Not checked', 'Database integrity'],
]);
});
test('#555: the Images metric is the Images page count (the sidebar badge), not the indexed rows', async () => {
const { metricValues } = await overviewModule;
// The demo library: 110 indexed rows, 122 files on the Images page (unlinked ones included).
const overview = { ...OVERVIEW, images: { indexed: 110, rated: 0 } };
assert.deepEqual(metricValues(overview, 122)[2], [(122).toLocaleString(), 'Images']);
// Not read yet (undefined) or failed (null): a dash, as on the workspace Overview, never the row count.
assert.deepEqual(metricValues(overview, undefined)[2], ['–', 'Images']);
assert.deepEqual(metricValues(overview, null)[2], ['–', 'Images']);
});
test('metric 4 is the last health result', async () => {
const { integrityText } = await overviewModule;
assert.equal(integrityText(null), 'Not checked');
@@ -160,7 +150,7 @@ test('statistics rows are the real runs, then the library totals, without rating
rated_images: 20,
};
assert.deepEqual(categoryRows(statistics), [['Landscapes', (1200).toLocaleString()], ['Uncategorized', '3']]);
assert.deepEqual(totalsRows(statistics), [['Prompt records', '12'], ['Tags on prompts', '15'], ['Image records', '24']]);
assert.deepEqual(totalsRows(statistics), [['Prompts', '12'], ['Tags', '15'], ['Images', '24']]);
});
/** A fake api answering each `get`/`post` from a queue per URL, recording the calls. */
+5 -5
View File
@@ -908,20 +908,20 @@ class TestWorkspaceImages(ImageAPITestCase):
async def test_one_cards_parse_error_spares_the_page(self):
prompt = self._save_prompt("a fox")
# Cards are read on the image pool (#523), in no fixed order: the
# parser fails on bad.png's own record (seed 6), not on a call count.
for name, mtime, seed in (("bad.png", 200, 6), ("ok.png", 100, 5)):
for name, mtime in (("bad.png", 200), ("ok.png", 100)):
path = records_png(
self.output_dir / name, seeded_records("a fox", seed), mtime
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 '"seed": 6' in info["prompt"]:
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,
-12
View File
@@ -146,18 +146,6 @@ class TestPreviewQueueRefusedAtShutdown(unittest.TestCase):
class TestMapParallelRefusedAtShutdown(unittest.TestCase):
def setUp(self):
# The job pool is long-lived (#523): start each test without one, so
# the patched pool class is the one created.
self.assertTrue(parallel.shutdown_job_pool(30))
self.addCleanup(parallel.shutdown_job_pool, 30)
def test_the_refused_pool_is_not_reused(self):
with mock.patch.object(parallel, "ThreadPoolExecutor", _ShutPool):
parallel.map_parallel(lambda n: n, [1, 2], 2)
self.assertFalse(parallel.job_pool_started())
self.assertEqual(parallel.map_parallel(lambda n: n + 1, [1, 2], 2), [2, 3])
def test_a_shut_pool_runs_the_items_on_the_calling_thread(self):
records = _capture(self, "prompt_manager.parallel")
with mock.patch.object(parallel, "ThreadPoolExecutor", _ShutPool):
-60
View File
@@ -564,66 +564,6 @@ class TestUndecidedGraph(unittest.TestCase):
self.assertEqual(decision.review, "untraced")
def _search_list(text="fox", side="positive"):
"""f22: a PromptSearchList batch (no PromptManager node) feeds CLIPTextEncode."""
graph = parity.build("f22")["prompt"]
graph["10"]["inputs"]["text"] = text
if side == "negative":
# The search feeds the negative; the positive is typed text.
graph["6"]["inputs"]["text"] = parity.POS
graph["7"]["inputs"]["text"] = ["10", 0]
return graph
class TestSearchTextIsNeverAPrompt(unittest.TestCase):
"""#548: the PromptSearchList query is a search, never the prompt."""
def test_the_search_text_is_never_linked_or_stored(self):
for policy in Policy:
with self.subTest(policy=policy):
decision = _decide(
_graph(_search_list()), policy=policy, lookup=_Lookup()
)
self.assertEqual(decision.kind, "never")
self.assertIsNone(decision.text)
self.assertEqual(decision.unlinked_status, "unresolved")
self.assertTrue(decision.try_recorded_run)
def test_a_search_text_from_a_primitive_is_never_linked(self):
graph = _search_list()
graph["10"]["inputs"]["text"] = ["30", 0]
graph["30"] = {"class_type": "PrimitiveString", "inputs": {"value": "fox"}}
for policy in Policy:
with self.subTest(policy=policy):
decision = _decide(_graph(graph), policy=policy, lookup=_Lookup())
self.assertEqual(decision.kind, "never")
self.assertIsNone(decision.text)
def test_an_empty_search_text_is_unchanged(self):
"""Regression guard (passes on the base): nothing to exclude."""
for policy in Policy:
with self.subTest(policy=policy):
decision = _decide(
_graph(_search_list("")), policy=policy, lookup=_Lookup()
)
self.assertEqual((decision.kind, decision.text), ("never", None))
def test_without_a_sampler_any_search_list_query_is_excluded(self):
graph = _search_list()
del graph["3"]
self.assertTrue(file_prompt._search_text(graph, " fox "))
self.assertFalse(file_prompt._search_text(graph, "a fox"))
self.assertFalse(file_prompt._search_text(None, "fox"))
def test_a_search_on_the_negative_keeps_the_typed_positive(self):
"""Regression guard (passes on the base): only the positive's source
is excluded, so the legacy answer from typed text still links."""
graph = _search_list(side="negative")
decision = _decide(_graph(graph), lookup=_Lookup())
self.assertEqual((decision.kind, decision.text), ("positive", parity.POS))
self.assertEqual(decision.source, "legacy-parser")
class TestStepsWithoutAnUndecidedGraph(unittest.TestCase):
def test_step_9_ambiguous_parameters_only(self):
meta = {
+1 -78
View File
@@ -392,10 +392,7 @@ class TestStartStop(unittest.TestCase):
self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs,
):
self.assertFalse(self.monitor.stop_monitoring(timeout=5.0))
# The stop runs on its own thread and gets 0 s too: it may not have
# returned yet, and then nothing is joined at all.
joins = [call for call in fake.calls if call[0] == "join"]
self.assertTrue(all(call == ("join", 0.0) for call in joins), fake.calls)
self.assertEqual(fake.calls[-1], ("join", 0.0))
self.assertIn(
"watchdog observer still running after 0.0 s", "\n".join(logs.output)
)
@@ -404,80 +401,6 @@ class TestStartStop(unittest.TestCase):
def test_stop_monitoring_without_an_observer_returns_true(self):
self.assertTrue(self.monitor.stop_monitoring())
def test_threads_the_observer_starts_are_daemons(self):
# watchdog 7 on Windows starts a plain threading.Thread per watch in
# observer.start(); a non-daemon one holds the interpreter's exit
# before any atexit callback runs (#561).
fake = ReaderObserver()
self.addCleanup(fake.release.set)
self._start_with(fake)
self.assertIsNotNone(fake.reader)
self.assertTrue(fake.reader.daemon)
self.assertFalse(threading.current_thread().daemon)
def test_an_observer_that_fails_to_start_on_its_thread_is_reported(self):
fake = FakeObserver()
fake.start = MagicMock(side_effect=OSError("handle refused"))
with self.assertLogs("prompt_manager.image_monitor", "ERROR") as logs:
with patch.object(im_mod, "Observer", return_value=fake):
self.monitor.start_monitoring([self.tmp.name])
self.assertIn("handle refused", "\n".join(logs.output))
self.assertFalse(self.monitor.is_monitoring)
def test_an_observer_stop_that_never_returns_is_bounded(self):
# watchdog's observer.stop() joins its emitters (and, in 7.x on
# Windows, their reader threads) without a limit (#561).
fake = StuckStopObserver()
self.addCleanup(fake.release.set)
self._start_with(fake)
with self.assertLogs("prompt_manager.image_monitor", "WARNING") as logs:
self.assertFalse(self.monitor.stop_monitoring(timeout=0.05))
self.assertIn("watchdog observer still running", "\n".join(logs.output))
self.assertTrue(fake.entered.wait(30))
self.assertFalse(self.monitor.is_monitoring)
# A second stop waits for the stop already running; it never calls
# observer.stop() a second time.
fake.release.set()
self.assertTrue(self.monitor.stop_monitoring())
self.assertEqual(fake.stop_calls, 1)
self.assertFalse(self.monitor._unfinished)
class ReaderObserver(FakeObserver):
"""A FakeObserver whose start() starts a plain thread, as watchdog 7's
Windows emitter does for its ReadDirectoryChangesW reader."""
def __init__(self):
super().__init__()
self.release = threading.Event()
self.reader = None
def start(self):
self.reader = threading.Thread(target=self.release.wait)
self.reader.start()
super().start()
def stop(self):
self.release.set()
self.reader.join(30)
super().stop()
class StuckStopObserver(FakeObserver):
"""A FakeObserver whose stop() does not return until ``release``."""
def __init__(self):
super().__init__()
self.entered = threading.Event()
self.release = threading.Event()
self.stop_calls = 0
def stop(self):
self.stop_calls += 1
self.entered.set()
self.release.wait(30)
super().stop()
class FakeEvent:
def __init__(self, path, is_directory=False):
-241
View File
@@ -1,241 +0,0 @@
"""Image work runs on PromptManager's own pools, never the default executor (#523).
glibc keeps each thread's malloc arena at its high-water mark, so image work
spread over the default executor's threads (min(32, cpus + 4)) kept an
image-sized buffer per thread. Interactive image work (card facts, inspector
reads, view previews) runs on the fixed interactive image pool
(``pm-image``); jobs (scan metadata, thumbnail jobs, capture previews) on the
long-lived Worker threads pool (``pm-worker``). After a scan or thumbnail
job, glibc's free pages are released once (``malloc_trim``).
"""
import asyncio
import threading
from unittest.mock import patch
import py.api as api_package
import py.api.images as images_module
import py.api.inspection_service as inspection_service
import py.api.thumbnail_auto as auto
from utils import parallel
try:
from tests.test_api_images import make_png
from tests.test_thumbnail_auto import WAIT, AutoThumbnailCase, _entry
except ImportError: # discovered with tests/ as the top-level directory
from test_api_images import make_png
from test_thumbnail_auto import WAIT, AutoThumbnailCase, _entry
IMAGE_THREAD = auto.POOL_PREFIX + "_"
JOB_THREAD = parallel.JOB_THREAD_PREFIX + "_"
class ImagePoolCase(AutoThumbnailCase):
async def asyncSetUp(self):
await super().asyncSetUp()
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.addCleanup(parallel.shutdown_job_pool, WAIT)
self.threads = []
self.lock = threading.Lock()
def spy(self, owner, name):
"""Record the thread every call of ``owner.name`` runs on."""
original = getattr(owner, name)
def recording(*args, **kwargs):
with self.lock:
self.threads.append(threading.current_thread().name)
return original(*args, **kwargs)
patcher = patch.object(owner, name, recording)
patcher.start()
self.addCleanup(patcher.stop)
def assert_threads(self, prefix):
self.assertTrue(self.threads, "the spied call never ran")
self.assertTrue(
all(name.startswith(prefix) for name in self.threads), self.threads
)
async def run_job(self, path, body=None):
response = await self.client.post(path, json=body or {})
self.assertEqual(response.status, 202, await response.text())
job = self.api._jobs.get((await response.json())["job"]["id"])
await asyncio.wait_for(asyncio.shield(job.task), WAIT)
return job
class TestInteractiveImagePool(ImagePoolCase):
async def test_workspace_card_facts_are_read_on_the_image_pool(self):
for index in range(5):
make_png(self.output_dir / f"c{index}.png", size=(32, 32))
self.spy(images_module, "_card_header")
response = await self.client.get(
"/prompt_manager/images/output?mode=workspace&limit=100"
)
self.assertEqual(response.status, 200, await response.text())
images = (await response.json())["images"]
self.assertEqual([image["width"] for image in images], [32] * 5)
self.assert_threads(IMAGE_THREAD)
self.assertEqual(len(self.threads), 5)
async def test_cached_card_facts_need_no_image_pool_trip(self):
make_png(self.output_dir / "c.png", size=(32, 32))
url = "/prompt_manager/images/output?mode=workspace&limit=100"
await (await self.client.get(url)).read()
self.spy(images_module, "_card_header")
response = await self.client.get(url)
self.assertEqual((await response.json())["images"][0]["width"], 32)
self.assertEqual(self.threads, [])
async def test_the_inspector_reads_on_the_image_pool(self):
make_png(self.output_dir / "i.png", size=(32, 32))
self.spy(inspection_service, "read_inspection_file")
response = await self.client.get("/prompt_manager/images/inspect/i.png?root=0")
self.assertEqual(response.status, 200, await response.text())
self.assert_threads(IMAGE_THREAD)
async def test_view_previews_and_card_reads_share_three_threads(self):
names = [f"v{i}.png" for i in range(auto.ON_DEMAND_LIMIT + 3)]
for name in names:
make_png(self.output_dir / name, size=(64, 64))
calls = []
original = self.api._generate_one
def recording(*args, **kwargs):
calls.append(threading.current_thread().name)
return original(*args, **kwargs)
self.api._generate_one = recording
for name in names:
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
await (await self.client.get(url)).read()
self.assertEqual(len(calls), len(names))
self.assertTrue(all(name.startswith(IMAGE_THREAD) for name in calls), calls)
self.assertLessEqual(len(self.api._image_pool_threads()), auto.IMAGE_POOL_SIZE)
def test_map_image_keeps_order_and_runs_inline_on_a_pool_thread(self):
self.assertEqual(self.api._map_image(lambda n: n * 2, [3, 1, 2]), [6, 2, 4])
nested = self.api._image_pool().submit(
self.api._map_image, lambda _: threading.current_thread().name, [1]
)
(name,) = nested.result(WAIT)
self.assertTrue(name.startswith(IMAGE_THREAD), name)
def test_map_image_after_a_close_runs_on_the_calling_thread(self):
pool = self.api._image_pool()
pool.shutdown(wait=True)
here = threading.current_thread().name
with patch.object(self.api, "_image_pool", return_value=pool):
names = self.api._map_image(
lambda _: threading.current_thread().name, [1, 2]
)
self.assertEqual(names, [here, here])
async def test_after_the_final_close_no_image_thread_starts(self):
await asyncio.get_running_loop().run_in_executor(
None, lambda: self.api.close_auto_thumbnails(final=True)
)
self.assertIsNone(self.api._image_pool())
here = threading.current_thread().name
names = self.api._map_image(lambda _: threading.current_thread().name, [1])
self.assertEqual(names, [here])
name = await self.api._run_image(lambda: threading.current_thread().name)
self.assertFalse(name.startswith(IMAGE_THREAD), name)
self.assertEqual(self.api._image_pool_threads(), [])
def test_map_image_runs_items_a_shutdown_cancelled_here(self):
entered = threading.Barrier(auto.IMAGE_POOL_SIZE + 1)
release = threading.Event()
self.addCleanup(release.set)
pool = self.api._image_pool()
for _ in range(auto.IMAGE_POOL_SIZE): # occupy every pool thread
pool.submit(lambda: (entered.wait(WAIT), release.wait(WAIT)))
entered.wait(WAIT)
here = threading.current_thread().name
results, submitted = {}, threading.Event()
class Counting:
calls = 0
def submit(self, *args):
future = pool.submit(*args)
Counting.calls += 1
if Counting.calls == 2:
submitted.set()
return future
def call():
with patch.object(self.api, "_image_pool", return_value=Counting()):
results["names"] = self.api._map_image(
lambda _: threading.current_thread().name, [1, 2]
)
caller = threading.Thread(target=call, name="map-image-caller")
caller.start()
# The caller's two items are queued behind the busy threads; the
# shutdown cancels them and the caller runs them itself.
self.assertTrue(submitted.wait(WAIT))
pool.shutdown(wait=False, cancel_futures=True)
caller.join(WAIT)
release.set()
self.assertFalse(caller.is_alive())
self.assertEqual(results["names"], ["map-image-caller"] * 2)
self.assertNotEqual(here, "map-image-caller")
async def test_run_image_after_a_close_uses_the_default_executor(self):
pool = self.api._image_pool()
pool.shutdown(wait=True)
with patch.object(self.api, "_image_pool", return_value=pool):
name = await self.api._run_image(lambda: threading.current_thread().name)
self.assertFalse(name.startswith(IMAGE_THREAD), name)
class TestJobPool(ImagePoolCase):
def test_scan_metadata_is_read_on_the_job_pool(self):
paths = [
make_png(self.output_dir / f"s{i}.png", size=(16, 16)) for i in range(4)
]
self.spy(self.api, "_extract_comfyui_metadata")
results = self.api._extract_batch_metadata_sync(paths)
self.assertEqual([path for path, _ in results], paths)
self.assert_threads(JOB_THREAD)
async def test_thumbnail_job_writes_on_the_job_pool_and_trims_after(self):
for index in range(3):
make_png(self.output_dir / f"t{index}.png", size=(64, 64))
self.spy(self.api, "_generate_one")
with patch.object(api_package, "release_free_memory") as trim:
job = await self.run_job("/prompt_manager/jobs/thumbnails")
self.assertEqual(job.state, "finished")
self.assert_threads(JOB_THREAD)
trim.assert_called_once_with()
async def test_scan_job_trims_after_it_finishes(self):
make_png(self.output_dir / "s.png", size=(16, 16))
states = []
def trim():
states.append(self.api._jobs.active("scan"))
return True
with patch.object(api_package, "release_free_memory", side_effect=trim):
job = await self.run_job("/prompt_manager/jobs/scan")
self.assertEqual(job.state, "finished")
self.assertGreaterEqual(len(states), 1)
self.assertIsNone(states[0], "trimmed after the scan finished")
async def test_a_failing_trim_never_fails_the_job(self):
make_png(self.output_dir / "t.png", size=(64, 64))
with patch.object(
api_package, "release_free_memory", side_effect=RuntimeError("no")
):
job = await self.run_job("/prompt_manager/jobs/thumbnails")
self.assertEqual(job.state, "finished")
async def test_capture_previews_write_on_the_job_pool(self):
path = make_png(self.output_dir / "a.png", size=(64, 64))
self.spy(self.api, "_generate_one")
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assert_threads(JOB_THREAD)
+4 -83
View File
@@ -136,11 +136,6 @@ def cleanup_package(package, require_stopped=True):
if registration is not None:
assert registration.dispose(3), "package history worker leaked"
close_every_database()
# The package's exit actions outlive it otherwise: at interpreter exit
# they would import from a package no longer in sys.modules (#580).
exit_signal = getattr(package, "_exit_signal", None)
if exit_signal is not None:
exit_signal.reset()
sys.modules.pop(package.__name__, None)
@@ -395,39 +390,7 @@ class TestConsoleBanner(unittest.TestCase):
stream.flush()
banner = stream.buffer.getvalue().decode("cp1252")
lines = [
line
for line in banner.splitlines()
if "PromptManager" in line or "loaded" in line
]
self.assertEqual(len(lines), 1, banner)
self.assertRegex(
lines[0], r"\[ComfyUI-PromptManager\] Version:.* \S+ - \d+ tools loaded$"
)
self.assertNotIn("Loaded:", banner)
class TestFixtureExitActions(unittest.TestCase):
"""A disposed fixture package runs nothing at interpreter exit (#580)."""
def test_a_disposed_package_leaves_no_exit_action(self):
tmp = tempfile.TemporaryDirectory()
self.addCleanup(tmp.cleanup)
self.addCleanup(close_every_database)
output_dir = os.path.join(tmp.name, "output")
os.makedirs(output_dir)
name = "prompt_manager_pkg_exit_actions"
self.addCleanup(sys.modules.pop, name, None)
with contextlib.redirect_stdout(io.StringIO()):
with load_package(name, os.path.join(tmp.name, "p.db"), output_dir) as (
package,
_,
):
signal = package._exit_signal
self.assertTrue(signal._on_exit, "the package installs exit actions")
self.assertEqual(signal._on_exit, [])
self.assertIn("Loaded:", banner)
class PriorFileHandler(logging.FileHandler):
@@ -590,7 +553,7 @@ class TestPackageLoggerIsolation(unittest.TestCase):
original_print = print
def fail_banner(*args, **kwargs):
if any("tools loaded" in str(arg) for arg in args):
if any("Total:" in str(arg) for arg in args):
captured["package"] = sys.modules[name]
captured["handlers"] = [
h
@@ -624,16 +587,13 @@ PACKAGE_THREAD_NAMES = ("PromptManagerPromptCleanup", "PromptManagerImageWorker"
def package_threads():
"""Live threads this design owns: the tracker, the image worker, and the
watchdog observer and emitters (which have no name of their own).
watchdog 7's InotifyFD, one daemon reader per process that no observer
stop ends, is not the package's."""
watchdog observer and emitters (which have no name of their own)."""
from watchdog.utils import BaseThread
return {
thread
for thread in threading.enumerate()
if thread.name in PACKAGE_THREAD_NAMES
or (isinstance(thread, BaseThread) and type(thread).__name__ != "InotifyFD")
if thread.name in PACKAGE_THREAD_NAMES or isinstance(thread, BaseThread)
}
@@ -799,45 +759,6 @@ class TestPackageTeardown(PackageTeardownCase):
self.assertIsNone(seen["worker"])
self.assertTrue(all(result.values()), result)
def test_teardown_stops_the_job_pool_once_a_job_used_it(self):
"""#523: the long-lived job pool is a teardown part of its own."""
package = self.load()
parallel = sys.modules[package.__name__ + ".utils.parallel"]
self.assertEqual(parallel.map_parallel(lambda n: n * 2, [1, 2], 2), [2, 4])
# This package's own pool (the suite's top-level one may also run).
threads = list(parallel._pool[1]._threads)
self.assertTrue(threads)
result = package._stop_background_work()
self.assertEqual(list(result), ["monitor", "thumbnails", "workers", "tracker"])
self.assertTrue(all(result.values()), result)
self.assertFalse(parallel.job_pool_started())
self.assertFalse(any(t.is_alive() for t in threads))
def test_teardown_reports_a_job_thread_past_its_bound(self):
package = self.load(require_stopped=False)
parallel = sys.modules[package.__name__ + ".utils.parallel"]
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(30)
caller = threading.Thread(target=parallel.map_parallel, args=(hold, [1], 1))
caller.start()
self.addCleanup(caller.join, 30)
self.addCleanup(release.set)
self.assertTrue(entered.wait(30))
with (
mock.patch.object(parallel, "JOB_POOL_JOIN_SECONDS", 0.05),
self.assertLogs("prompt_manager.init", "WARNING") as logs,
):
result = package._stop_background_work()
self.assertFalse(result["workers"])
self.assertTrue(result["tracker"])
self.assertRegex("\n".join(logs.output), r"not stopped in time .*workers")
def test_teardown_kills_ffmpeg_and_stops_the_preview_worker(self):
package = self.load()
held = self.hold_a_preview(package, advance=4.0, kill_releases=True)
-27
View File
@@ -108,15 +108,6 @@ LEGACY_WRONG = {
"a cozy library with tall shelves",
"legacy shows branch 1; the file name names branch 4's saver",
),
# #548, the one shape where nothing is read on purpose: legacy shows a
# PromptSearchList's search text ("fox") as the positive, but that is the
# library query, not the prompt the batch ran. The scan and the monitor
# never store or link it either (utils.file_prompt._search_text).
("inspection_parity", "f22"): (
None,
"legacy shows the PromptSearchList search text; each item is decided "
"at run time, so no positive is read (#548)",
),
}
@@ -158,24 +149,6 @@ class LegacyParityGateTest(unittest.TestCase):
self.assertEqual(text, expected)
self.assertNotEqual(text, legacy)
def test_the_search_list_query_is_never_stored_either(self):
"""#548: the scan and the monitor depart from legacy for f22 too."""
from utils.file_prompt import Policy, decide_file_prompt
graph, workflow = corpora()["inspection_parity"]["f22"]
self.assertEqual(legacy_positive(graph, workflow), "fox")
for policy in Policy:
with self.subTest(policy=policy):
decision = decide_file_prompt(
{"prompt": graph, "workflow": workflow},
file_name="f22.png",
file_size=None,
in_output=True,
lookup=lambda: None,
policy=policy,
)
self.assertEqual((decision.kind, decision.text), ("never", None))
def test_no_graph_leaves_the_legacy_agreement(self):
recorded = json.loads(AGREEMENT_PATH.read_text(encoding="utf-8"))
current = {name: agreeing(graphs) for name, graphs in corpora().items()}
-82
View File
@@ -1,82 +0,0 @@
"""``release_free_memory``: glibc's malloc_trim(0), a no-op elsewhere (#523)."""
import sys
import unittest
from unittest import mock
from utils import native_memory
class ReleaseFreeMemoryTests(unittest.TestCase):
def setUp(self):
# Each test looks the symbol up again.
patcher = mock.patch.multiple(native_memory, _trim=None, _looked_up=False)
patcher.start()
self.addCleanup(patcher.stop)
@unittest.skipUnless(native_memory._glibc(), "glibc only")
def test_glibc_trims_once_per_call(self):
self.assertTrue(native_memory.release_free_memory())
self.assertTrue(native_memory.release_free_memory())
def test_other_platforms_do_nothing(self):
for platform in ("win32", "darwin", "cygwin"):
with self.subTest(platform), mock.patch.object(sys, "platform", platform):
native_memory._looked_up = False
with mock.patch.object(native_memory.ctypes, "CDLL") as cdll:
self.assertFalse(native_memory.release_free_memory())
cdll.assert_not_called()
def test_musl_does_nothing(self):
with (
mock.patch.object(sys, "platform", "linux"),
mock.patch.object(
native_memory.os, "confstr", side_effect=ValueError("unknown name")
),
mock.patch.object(native_memory.ctypes, "CDLL") as cdll,
):
self.assertFalse(native_memory.release_free_memory())
cdll.assert_not_called()
def test_a_missing_symbol_is_false(self):
library = mock.Mock(spec=[]) # no malloc_trim attribute
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", return_value=library),
):
self.assertFalse(native_memory.release_free_memory())
def test_an_unloadable_libc_is_false(self):
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", side_effect=OSError),
):
self.assertFalse(native_memory.release_free_memory())
def test_a_failing_trim_never_raises(self):
trim = mock.Mock(side_effect=OSError("boom"))
library = mock.Mock(malloc_trim=trim)
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(native_memory.ctypes, "CDLL", return_value=library),
):
self.assertFalse(native_memory.release_free_memory())
trim.assert_called_once_with(0)
def test_the_symbol_is_looked_up_once(self):
trim = mock.Mock(return_value=1)
library = mock.Mock(malloc_trim=trim)
with (
mock.patch.object(native_memory, "_glibc", return_value=True),
mock.patch.object(
native_memory.ctypes, "CDLL", return_value=library
) as cdll,
):
self.assertTrue(native_memory.release_free_memory())
self.assertTrue(native_memory.release_free_memory())
cdll.assert_called_once_with(None)
self.assertEqual(trim.call_count, 2)
if __name__ == "__main__":
unittest.main()
+44 -107
View File
@@ -31,65 +31,22 @@ CHILD = textwrap.dedent("""
import threading
root, tmp = sys.argv[1], sys.argv[2]
stuck = sys.argv[3:] == ["stuck-observer"]
sys.path.insert(0, root)
state = {}
# watchdog 7's Windows observer at its worst (#561): start() starts a
# plain reader thread, and stop() waits for it, which never ends.
class StuckObserver:
def __init__(self, *args, **kwargs):
self._never = threading.Event()
self._reader = None
def schedule(self, *args, **kwargs):
pass
def start(self):
self._reader = threading.Thread(
target=self._never.wait, name="StuckReader"
)
self._reader.start()
def stop(self):
self._reader.join()
def join(self, timeout=None):
self._reader.join(timeout)
def is_alive(self):
return self._reader.is_alive()
if stuck:
import watchdog.observers
watchdog.observers.Observer = StuckObserver
def report():
from watchdog.utils import BaseThread
package = state.get("package")
result = getattr(package, "_background_work_result", None)
# The image worker, the tracker, and watchdog's observer and emitters.
# watchdog 7's InotifyFD is one daemon reader per process that no
# observer stop ends, by design; it is not the package's.
alive = sorted(
t.name
for t in threading.enumerate()
if t.name in ("PromptManagerImageWorker", "PromptManagerPromptCleanup")
or (isinstance(t, BaseThread) and type(t).__name__ != "InotifyFD")
)
# Threads that would hold the exit before any atexit callback (#561).
holding = sorted(
t.name
for t in threading.enumerate()
if not t.daemon and t is not threading.main_thread()
)
print(
"PM_EXIT_REPORT "
+ json.dumps({"result": result, "alive": alive, "holding": holding})
or isinstance(t, BaseThread)
)
print("PM_EXIT_REPORT " + json.dumps({"result": result, "alive": alive}))
sys.stdout.flush()
# First registered, so it runs last: after the package's teardown and
@@ -107,10 +64,6 @@ CHILD = textwrap.dedent("""
fake_server_module(queue_task_done=True),
)
state["package"] = package
if stuck:
# The stop gives up after this long instead of the full 5 s.
monitor_module = sys.modules[package.__name__ + ".utils.image_monitor"]
monitor_module.OBSERVER_JOIN_SECONDS = 0.5
monitor = package._global_image_monitor
handler = monitor.handler if monitor is not None else None
if handler is None:
@@ -125,52 +78,47 @@ CHILD = textwrap.dedent("""
""")
def run_child(testcase, *args):
"""Run CHILD with ``args``; returns (completed, report, detail)."""
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(os.path.join(tmp, "output"))
script = os.path.join(tmp, "child.py")
with open(script, "w", encoding="utf-8") as handle:
handle.write(CHILD)
env = dict(os.environ)
env.pop("PROMPT_MANAGER_CONFIG_PATH", None)
# The child's library and log file live in its own folder, never
# in the checkout's logs/ (which this runner may hold open) or a
# data folder the caller set.
env["PROMPT_MANAGER_DATA_DIR"] = tmp
env["PYTHONIOENCODING"] = "utf-8"
try:
completed = subprocess.run(
[sys.executable, script, ROOT, tmp, *args],
cwd=ROOT,
env=env,
capture_output=True,
timeout=CHILD_CEILING_SECONDS,
encoding="utf-8",
errors="replace",
)
except subprocess.TimeoutExpired as hung:
testcase.fail(
f"the child did not exit within {CHILD_CEILING_SECONDS} s\n"
f"stdout:\n{hung.stdout}\nstderr:\n{hung.stderr}"
)
# Leaving the block removes the folder: on Windows that fails
# if the child left a handle open, which it cannot once it exited,
# so the check here is that the child exited at all.
detail = f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
testcase.assertEqual(completed.returncode, 0, detail)
reports = [
line[len(REPORT_PREFIX) :]
for line in completed.stdout.splitlines()
if line.startswith(REPORT_PREFIX)
]
testcase.assertEqual(len(reports), 1, detail)
return completed, json.loads(reports[0]), detail
class TestPackageExit(unittest.TestCase):
def test_teardown_runs_at_a_normal_exit_with_the_worker_alive(self):
completed, report, detail = run_child(self)
with tempfile.TemporaryDirectory() as tmp:
os.makedirs(os.path.join(tmp, "output"))
script = os.path.join(tmp, "child.py")
with open(script, "w", encoding="utf-8") as handle:
handle.write(CHILD)
env = dict(os.environ)
env.pop("PROMPT_MANAGER_CONFIG_PATH", None)
# The child's library and log file live in its own folder, never
# in the checkout's logs/ (which this runner may hold open) or a
# data folder the caller set.
env["PROMPT_MANAGER_DATA_DIR"] = tmp
env["PYTHONIOENCODING"] = "utf-8"
try:
completed = subprocess.run(
[sys.executable, script, ROOT, tmp],
cwd=ROOT,
env=env,
capture_output=True,
timeout=CHILD_CEILING_SECONDS,
encoding="utf-8",
errors="replace",
)
except subprocess.TimeoutExpired as hung:
self.fail(
f"the child did not exit within {CHILD_CEILING_SECONDS} s\n"
f"stdout:\n{hung.stdout}\nstderr:\n{hung.stderr}"
)
# Leaving the block removes the folder: on Windows that fails
# if the child left a handle open, which it cannot once it exited,
# so the check here is that the child exited at all.
detail = f"stdout:\n{completed.stdout}\nstderr:\n{completed.stderr}"
self.assertEqual(completed.returncode, 0, detail)
reports = [
line[len(REPORT_PREFIX) :]
for line in completed.stdout.splitlines()
if line.startswith(REPORT_PREFIX)
]
self.assertEqual(len(reports), 1, detail)
report = json.loads(reports[0])
# "thumbnails" is True here because no preview worker started; its
# close result is tested in test_thumbnail_auto and test_init_package.
self.assertEqual(
@@ -179,7 +127,6 @@ class TestPackageExit(unittest.TestCase):
detail,
)
self.assertEqual(report["alive"], [], detail)
self.assertEqual(report["holding"], [], detail)
# The teardown's own warnings. Import-time warnings (the API's
# output-folder search finds no ComfyUI here) are not about exit.
for text in (
@@ -190,16 +137,6 @@ class TestPackageExit(unittest.TestCase):
):
self.assertNotIn(text, completed.stderr, detail)
def test_an_observer_that_never_stops_does_not_hold_the_exit(self):
# Before #561 the reader thread kept the interpreter waiting before
# any atexit callback ran, so the teardown never started.
completed, report, detail = run_child(self, "stuck-observer")
self.assertEqual(
report["result"],
{"monitor": False, "thumbnails": True, "tracker": True},
detail,
)
# The stuck reader is still alive, but as a daemon it holds nothing.
self.assertEqual(report["holding"], [], detail)
self.assertIn("watchdog observer still running", completed.stderr, detail)
self.assertIn("Background work not stopped in time", completed.stderr, detail)
if __name__ == "__main__":
unittest.main()
-12
View File
@@ -90,18 +90,6 @@ class TestPairScan(AdminAPITestCase):
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
self.assertEqual(self._texts(), [])
def test_a_prompt_search_list_query_is_never_stored_as_a_prompt(self):
# #548: the search text was stored and every batch image linked to it.
g = sampled_graph()
g["10"] = {
"class_type": "PromptSearchList",
"inputs": {"text": "fox", "category": "", "tags": "", "limit": 50},
}
g["1"]["inputs"]["text"] = ["10", 0]
self.assertEqual(self._scanned("search.png", g), "unresolved")
self.assertEqual(self._texts(), [])
self.assertEqual(self.api.db.list_prompt_pairs()["total"], 0)
def test_numeric_widgets_are_never_stored_as_a_prompt(self):
# The pre-redesign scan stored "2048" from a resolution widget here.
g = {
-455
View File
@@ -1,455 +0,0 @@
"""PNG text without a pixel decode (#523), against the legacy reader floor.
Pillow's ``PngImageFile.text`` loads (decodes) every pixel to reach text
chunks written after IDAT. ``utils.png_text.png_text`` walks the remaining
chunks instead. The legacy reader (``Image.open(path).text``, every failure
read as ``{}``) is the floor: a file it read must read the same, and the
curated corpus below must read identically. The documented difference is
pixel data, which the walk never decodes: undecodable or cut-short pixel data
made the legacy reader fail, the walk still reads the text before it.
"""
import io
import random
import struct
import tempfile
import unittest
import zlib
from pathlib import Path
from unittest.mock import patch
from PIL import Image, ImageFile, PngImagePlugin
from utils.metadata_extractor import ComfyUIMetadataExtractor
from utils.png_text import png_text
FIXTURES = Path(__file__).resolve().parent / "fixtures"
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".gif", ".bmp"}
SIGNATURE = b"\x89PNG\r\n\x1a\n"
FUZZ_SEED = 523
FUZZ_CASES = 400
def legacy_text(path):
"""The legacy scan reader: Pillow's ``.text`` (a full decode)."""
try:
with Image.open(path) as image:
return dict(getattr(image, "text", None) or {})
except Exception:
return {}
def walked_text(path):
"""The new reader under the same failure rule."""
try:
with Image.open(path) as image:
return png_text(image)
except Exception:
return {}
def chunk(kind, data):
return (
struct.pack(">I", len(data))
+ kind
+ data
+ struct.pack(">I", zlib.crc32(kind + data) & 0xFFFFFFFF)
)
def chunks_of(raw):
"""``[(kind, data)]`` of a well-formed PNG."""
found, offset = [], 8
while offset < len(raw):
length, kind = struct.unpack(">I4s", raw[offset : offset + 8])
found.append((kind, raw[offset + 8 : offset + 8 + length]))
offset += 12 + length
return found
def assemble(parts):
return SIGNATURE + b"".join(
part if isinstance(part, bytes) else chunk(*part) for part in parts
)
def base_png(mode="RGB", size=(64, 64), text=None, noise=False):
"""A PNG from Pillow (text before IDAT), as ``[(kind, data)]``."""
image = Image.new(mode, size)
if noise:
rng = random.Random(7)
image = Image.frombytes(
"RGB", size, bytes(rng.randrange(256) for _ in range(size[0] * size[1] * 3))
)
info = PngImagePlugin.PngInfo()
for key, value in (text or {}).items():
info.add_text(key, value)
buffer = io.BytesIO()
image.save(buffer, format="PNG", pnginfo=info)
return chunks_of(buffer.getvalue())
def split_idat(parts, pieces=3):
"""Spread the IDAT payload over ``pieces`` consecutive IDAT chunks."""
data = b"".join(d for k, d in parts if k == b"IDAT")
step = max(1, len(data) // pieces)
idats = [(b"IDAT", data[i : i + step]) for i in range(0, len(data), step)]
first = next(i for i, (k, _) in enumerate(parts) if k == b"IDAT")
rest = [p for p in parts if p[0] != b"IDAT"]
return rest[:first] + idats + rest[first:]
def before_iend(parts, *extra):
return parts[:-1] + list(extra) + parts[-1:]
def text_chunk(key, value):
return (b"tEXt", key.encode("latin-1") + b"\0" + value.encode("latin-1"))
def ztxt_chunk(key, value):
return (b"zTXt", key.encode() + b"\0\0" + zlib.compress(value.encode("latin-1")))
def itxt_chunk(key, value, compressed=False):
body = value.encode("utf-8")
if compressed:
body = zlib.compress(body)
flag = b"\1\0" if compressed else b"\0\0"
return (b"iTXt", key.encode() + b"\0" + flag + b"en\0" + b"Title\0" + body)
def bad_crc(kind, data):
good = chunk(kind, data)
return good[:-4] + bytes(b ^ 0xFF for b in good[-4:])
PROMPT = '{"3": {"class_type": "KSampler", "inputs": {"seed": 1}}}'
WORKFLOW = '{"nodes": [], "links": []}'
def curated():
"""Name to PNG bytes; each must read the same with both readers."""
plain = base_png(text={"prompt": PROMPT})
trailing = before_iend(
base_png(), text_chunk("prompt", PROMPT), text_chunk("workflow", WORKFLOW)
)
cases = {
"no-text": assemble(base_png()),
"text-before-pixels": assemble(plain),
"text-after-pixels": assemble(trailing),
"ztxt-after-pixels": assemble(
before_iend(base_png(), ztxt_chunk("prompt", PROMPT))
),
"itxt-plain-and-compressed": assemble(
before_iend(
base_png(),
itxt_chunk("prompt", "café " + PROMPT),
itxt_chunk("workflow", WORKFLOW, compressed=True),
)
),
"xmp-after-pixels": assemble(
before_iend(
base_png(),
itxt_chunk("XML:com.adobe.xmp", "<x:xmpmeta/>"),
text_chunk("prompt", PROMPT),
)
),
"itxt-bad-utf8": assemble(
before_iend(
base_png(),
(b"iTXt", b"prompt\0\0\0\0\0\xff\xfe"),
text_chunk("workflow", WORKFLOW),
)
),
"later-copy-wins": assemble(
before_iend(plain, text_chunk("prompt", '{"later": 1}'))
),
"empty-key-and-no-separator": assemble(
before_iend(base_png(), (b"tEXt", b"\0value"), (b"tEXt", b"lonely"))
),
"split-pixels-text-between": assemble(
split_idat(before_iend(base_png(noise=True), text_chunk("prompt", PROMPT)))
),
"palette": assemble(
before_iend(base_png(mode="P"), text_chunk("prompt", PROMPT))
),
"sixteen-bit": assemble(
before_iend(base_png(mode="I;16"), text_chunk("prompt", PROMPT))
),
"bad-crc-before-pixels": assemble(
[base_png()[0], bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[1:]
),
"bad-crc-after-pixels": assemble(
base_png()[:-1] + [bad_crc(*text_chunk("prompt", PROMPT))] + base_png()[-1:]
),
"unknown-chunk-after-pixels": assemble(
before_iend(base_png(), (b"prVt", b"x" * 40), text_chunk("prompt", PROMPT))
),
"garbage-chunk-id-after-pixels": assemble(
base_png()[:-1]
+ [b"\0\0\0\4\x00\x01\x02\x03abcd\0\0\0\0"]
+ [chunk(*text_chunk("prompt", PROMPT))]
+ base_png()[-1:]
),
"oversized-ztxt-after-pixels": assemble(
before_iend(
base_png(),
(
b"zTXt",
b"prompt\0\0"
+ zlib.compress(b"a" * (PngImagePlugin.MAX_TEXT_CHUNK + 10)),
),
)
),
"pixels-length-past-end": assemble(
[p for p in base_png() if p[0] != b"IDAT" and p[0] != b"IEND"]
)
+ b"\xff\xff\x00\x00IDAT"
+ b"\0" * 64,
}
full = assemble(trailing)
idat_at = full.index(b"IDAT")
cases["truncated-in-pixels"] = full[: idat_at + 20]
cases["no-end-after-pixels"] = full[: full.rindex(b"IEND") - 4]
cases["truncated-in-trailing-text"] = full[: full.rindex(b"workflow") + 4]
cases["unknown-chunk-past-end"] = (
assemble(before_iend(base_png(), ztxt_chunk("prompt", PROMPT))[:-1])
+ chunk(b"prVt", b"x" * 40)[:-20]
)
cases["not-a-png"] = b"nope, not an image at all"
cases["signature-only"] = SIGNATURE
return cases
def corrupt_pixels():
"""Complete IDAT chunks with valid CRCs over data zlib cannot inflate."""
parts = before_iend(base_png(), text_chunk("prompt", PROMPT))
return assemble(
[(k, b"\x78\x9c" + b"\xff" * 30) if k == b"IDAT" else (k, d) for k, d in parts]
)
def apng():
frames = [Image.new("RGB", (16, 16), colour) for colour in ("red", "blue")]
info = PngImagePlugin.PngInfo()
info.add_text("prompt", PROMPT)
buffer = io.BytesIO()
frames[0].save(
buffer, format="PNG", save_all=True, append_images=frames[1:], pnginfo=info
)
return assemble(
before_iend(chunks_of(buffer.getvalue()), text_chunk("workflow", WORKFLOW))
)
def fixture_images():
return sorted(p for p in FIXTURES.rglob("*") if p.suffix.lower() in IMAGE_SUFFIXES)
class _TempFiles(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
def write(self, name, raw):
path = Path(self._tmp.name) / name
path.write_bytes(raw)
return path
class PngTextParityTests(_TempFiles):
def test_every_fixture_image_reads_like_the_legacy_reader(self):
images = fixture_images()
self.assertGreaterEqual(len(images), 25)
for path in images:
with self.subTest(path=path.relative_to(FIXTURES)):
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_reads_like_the_legacy_reader(self):
for name, raw in curated().items():
with self.subTest(name):
path = self.write(name + ".png", raw)
self.assertEqual(walked_text(path), legacy_text(path))
def test_curated_corpus_exercises_trailing_text(self):
"""The corpus is not vacuous: trailing text exists and is read."""
cases = curated()
path = self.write("t.png", cases["text-after-pixels"])
self.assertEqual(walked_text(path), {"prompt": PROMPT, "workflow": WORKFLOW})
with Image.open(path) as image:
self.assertNotIn("prompt", image.info)
self.assertEqual(
walked_text(self.write("x.png", cases["truncated-in-pixels"])), {}
)
def test_itxt_values_keep_their_language_and_type(self):
path = self.write("i.png", curated()["itxt-plain-and-compressed"])
new, old = walked_text(path), legacy_text(path)
self.assertIsInstance(new["prompt"], PngImagePlugin.iTXt)
self.assertEqual(new["prompt"].lang, old["prompt"].lang)
self.assertEqual(new["prompt"].tkey, old["prompt"].tkey)
def test_animated_png_falls_back_to_the_legacy_read(self):
path = self.write("a.png", apng())
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(walked_text(path)["workflow"], WORKFLOW)
def test_truncated_pixels_read_alike_when_truncated_images_load(self):
"""With LOAD_TRUNCATED_IMAGES set (a node pack may set it) both readers
accept a short file alike."""
path = self.write("t.png", curated()["truncated-in-pixels"])
with patch.object(ImageFile, "LOAD_TRUNCATED_IMAGES", True):
self.assertEqual(walked_text(path), legacy_text(path))
def test_a_text_decode_error_ends_the_walk_like_legacy(self):
"""Pillow's ``load_end`` stops at a chunk whose handler raises
``UnicodeDecodeError`` and keeps the text read so far."""
path = self.write(
"u.png",
assemble(
before_iend(
base_png(),
ztxt_chunk("prompt", PROMPT),
text_chunk("workflow", WORKFLOW),
ztxt_chunk("parameters", "steps 20"),
)
),
)
def undecodable(stream, position, length):
raise UnicodeDecodeError("latin-1", b"", 0, 1, "test")
with patch.object(PngImagePlugin.PngStream, "chunk_tEXt", undecodable):
old = legacy_text(path)
self.assertEqual(walked_text(path), old)
self.assertEqual(old, {"prompt": PROMPT})
def test_the_text_memory_budget_counts_text_before_the_pixels(self):
"""Pillow's MAX_TEXT_MEMORY spans the whole file: text before and
after the pixels, each under it, fail together in both readers."""
path = self.write(
"m.png",
assemble(
before_iend(
base_png(text={"prompt": PROMPT}), text_chunk("workflow", WORKFLOW)
)
),
)
budget = len(PROMPT) + len(WORKFLOW) - 1
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget):
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {})
with patch.object(PngImagePlugin, "MAX_TEXT_MEMORY", budget + 1):
self.assertEqual(walked_text(path), legacy_text(path))
self.assertEqual(len(walked_text(path)), 2)
def test_complete_but_undecodable_pixels_read_more_than_legacy(self):
path = self.write("c.png", corrupt_pixels())
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_cut_short_pixels_read_the_text_before_them(self):
full = assemble(base_png(text={"prompt": PROMPT}))
path = self.write("s.png", full[: full.index(b"IDAT") + 12])
self.assertEqual(legacy_text(path), {})
self.assertEqual(walked_text(path), {"prompt": PROMPT})
def test_mutated_files_never_read_less_than_legacy(self):
"""Seeded fuzz: truncations and byte flips of a file with text before,
between and after its pixel chunks. Wherever the legacy reader found
text, the walk finds exactly the same."""
base = assemble(
split_idat(
before_iend(
base_png(text={"workflow": WORKFLOW}),
ztxt_chunk("prompt", PROMPT),
itxt_chunk("parameters", "steps 20"),
)
)
)
rng = random.Random(FUZZ_SEED)
compared = 0
for case in range(FUZZ_CASES):
raw = bytearray(base)
if case % 2:
del raw[rng.randrange(8, len(raw)) :]
else:
for _ in range(rng.randrange(1, 4)):
raw[rng.randrange(8, len(raw))] = rng.randrange(256)
path = self.write(f"fuzz{case}.png", bytes(raw))
old = legacy_text(path)
if old:
compared += 1
with self.subTest(case=case):
self.assertEqual(walked_text(path), old)
self.assertGreater(compared, FUZZ_CASES // 5)
def test_non_png_images_have_no_text(self):
for name in ("tiny.jpg", "tiny.webp"):
with Image.open(FIXTURES / "local-inspect" / name) as image:
self.assertEqual(png_text(image), {})
class NoPixelDecodeTests(_TempFiles):
"""Metadata reads never decode pixels (the #523 memory finding)."""
def png_fixtures(self):
return sorted((FIXTURES / "inspection_parity").glob("*.png"))
def test_png_text_does_not_load_pixels_and_leaves_the_image_usable(self):
path = self.write("t.png", curated()["text-after-pixels"])
with Image.open(path) as image:
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
self.assertEqual(png_text(image)["workflow"], WORKFLOW)
load.assert_not_called()
image.load()
self.assertEqual(image.size, (64, 64))
self.assertEqual(image.text["workflow"], WORKFLOW)
def test_scan_reader_does_not_decode_pixels(self):
from py.api import PromptManagerAPI
api = PromptManagerAPI.__new__(PromptManagerAPI)
fixtures = self.png_fixtures() + [
self.write("t.png", curated()["text-after-pixels"])
]
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [api._extract_comfyui_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertEqual(found, [legacy_text(p) for p in fixtures])
self.assertTrue(all(found))
def test_monitor_extractor_does_not_decode_pixels(self):
extractor = ComfyUIMetadataExtractor()
fixtures = self.png_fixtures()
with patch.object(ImageFile.ImageFile, "load", autospec=True) as load:
found = [extractor.extract_metadata(str(p)) for p in fixtures]
load.assert_not_called()
self.assertTrue(all(metadata and len(metadata) > 1 for metadata in found))
class MonitorExtractorParityTests(_TempFiles):
"""``extract_metadata`` answers exactly as with the legacy ``.text``."""
def legacy_extract(self, path):
def old_text(image):
return dict(getattr(image, "text", None) or {})
with patch("utils.metadata_extractor.png_text", old_text):
return ComfyUIMetadataExtractor().extract_metadata(str(path))
def test_every_fixture_and_curated_file_extracts_like_legacy(self):
paths = fixture_images() + [
self.write(name + ".png", raw) for name, raw in curated().items()
]
extractor = ComfyUIMetadataExtractor()
for path in paths:
with self.subTest(path=path.name):
self.assertEqual(
extractor.extract_metadata(str(path)), self.legacy_extract(path)
)
if __name__ == "__main__":
unittest.main()
-457
View File
@@ -1,457 +0,0 @@
"""One resolver for where previews live on disk (#320 PR 1).
``py/api/preview_store.py`` owns every mapping between an image source, a
media file and its preview on disk. In source mode (the only mode so far)
it returns exactly the paths the code built by hand before
(``<source>/thumbnails/<rel>_thumb<ext>``), so nothing changes on disk. The
structural test keeps new ``thumbnails`` path joins out of the rest of the
backend.
"""
import asyncio
import os
import re
import sys
import tempfile
import threading
import unittest
from pathlib import Path
import unittest.mock
from unittest.mock import MagicMock
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.modules.setdefault("server", MagicMock())
from py.api import preview_store, thumbnail_settings # noqa: E402
from py.api.thumbnail_encode import ( # noqa: E402
PreviewWriteHeld,
replace_unless_held,
)
REPO = Path(__file__).resolve().parent.parent
WAIT = 10
class SourceModeCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name) / "output"
self.root.mkdir()
class TestSourceModePaths(SourceModeCase):
def test_default_location_is_inside_each_source(self):
location = preview_store.current()
self.assertEqual(location.mode, preview_store.SOURCE_MODE)
self.assertTrue(location.available)
self.assertIsNone(location.problem)
def test_store_of_a_source_is_its_thumbnails_folder(self):
self.assertEqual(preview_store.store_dir(self.root), self.root / "thumbnails")
def test_preview_path_matches_the_old_join(self):
rel = Path("sub", "deep", "a.png")
self.assertEqual(
preview_store.preview_path(self.root, rel, ".webp"),
self.root / "thumbnails/sub/deep/a_thumb.webp",
)
self.assertEqual(
preview_store.preview_path(self.root, "a.mp4", ".jpg"),
self.root / "thumbnails" / "a_thumb.jpg",
)
def test_preview_path_defaults_to_the_written_extension(self):
from py.api.thumbnail_encode import writer_settings
self.assertEqual(
preview_store.preview_path(self.root, "a.png").suffix,
writer_settings().extension,
)
def test_request_path_maps_url_parts_to_the_old_join(self):
parts = ("thumbnails", "sub", "a_thumb.webp")
self.assertEqual(
preview_store.request_path(self.root, parts), self.root.joinpath(*parts)
)
def test_request_path_is_none_for_anything_but_preview_parts(self):
self.assertIsNone(preview_store.request_path(self.root, ("sub", "a.png")))
self.assertIsNone(preview_store.request_path(self.root, ("Thumbnails", "a")))
self.assertIsNone(preview_store.request_path(self.root, ("thumbnails",)))
def test_ensure_store_is_the_store(self):
self.assertEqual(
preview_store.ensure_store(self.root), self.root / "thumbnails"
)
def test_owns_a_preview_inside_the_store(self):
inside = self.root / "thumbnails" / "sub" / "a_thumb.webp"
self.assertTrue(preview_store.owns(self.root, inside))
self.assertFalse(preview_store.owns(self.root, self.root / "a.png"))
self.assertFalse(
preview_store.owns(self.root, self.root.parent / "elsewhere.webp")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_owns_refuses_a_store_linked_out_of_the_source(self):
outside = Path(self._tmp.name) / "outside"
outside.mkdir()
try:
(self.root / "thumbnails").symlink_to(outside, target_is_directory=True)
except OSError:
self.skipTest("symlinks not permitted")
self.assertFalse(
preview_store.owns(self.root, self.root / "thumbnails" / "a_thumb.webp")
)
@unittest.skipUnless(hasattr(os, "symlink"), "needs symlinks")
def test_owns_refuses_a_preview_folder_linked_out_of_the_store(self):
outside = Path(self._tmp.name) / "outside"
outside.mkdir()
(self.root / "thumbnails").mkdir()
try:
(self.root / "thumbnails" / "sub").symlink_to(
outside, target_is_directory=True
)
except OSError:
self.skipTest("symlinks not permitted")
self.assertFalse(
preview_store.owns(
self.root, self.root / "thumbnails" / "sub" / "a_thumb.webp"
)
)
def test_store_container_is_the_source(self):
self.assertEqual(preview_store.store_container(self.root), self.root)
def test_given_store_argument_is_kept_unless_it_is_the_default(self):
default = self.root / "thumbnails"
other = Path(self._tmp.name) / "custom"
self.assertEqual(preview_store.given_store(self.root, default), default)
self.assertEqual(preview_store.given_store(self.root, other), other)
class TestPreviewText(unittest.TestCase):
def test_preview_text_is_an_exact_thumbnails_prefix_and_preview_name(self):
for text in (
"thumbnails/a_thumb.webp",
"thumbnails/sub/a_thumb.jxl",
"thumbnails/a_thumb.JPG",
"thumbnails/a_thumb.Png",
):
with self.subTest(text=text):
self.assertTrue(preview_store.is_preview_text(text))
def test_other_text_is_not_preview_text(self):
for text in (
"Thumbnails/a_thumb.webp",
"thumbnails/a.png",
"thumbnails/_thumb.webp",
"thumbnails/a_thumb.txt",
"sub/thumbnails/a_thumb.webp",
"thumbnails/../a_thumb.webp",
"thumbnails//a_thumb.webp",
"thumbnails/./a_thumb.webp",
"thumbnails\\a_thumb.webp",
"thumbnails/c:a_thumb.webp",
"thumbnails/sub/thumbnails/a_thumb.webp",
"thumbnails",
"",
):
with self.subTest(text=text):
self.assertFalse(preview_store.is_preview_text(text))
class TestWritesAndTheSwitch(SourceModeCase):
def _temp_and_dst(self):
folder = self.root / "thumbnails"
folder.mkdir()
tmp = folder / ".a_thumb.part-1-1.webp"
tmp.write_bytes(b"new")
return tmp, folder / "a_thumb.webp"
def test_a_write_from_the_current_generation_lands(self):
tmp, dst = self._temp_and_dst()
replace_unless_held(tmp, dst, preview_store.write_guard())
self.assertEqual(dst.read_bytes(), b"new")
def test_a_write_whose_generation_changed_is_dropped(self):
tmp, dst = self._temp_and_dst()
guard = preview_store.write_guard()
preview_store.bump_generation()
with self.assertRaises(PreviewWriteHeld):
replace_unless_held(tmp, dst, guard)
self.assertFalse(dst.exists())
def test_the_hold_check_still_applies(self):
tmp, dst = self._temp_and_dst()
with self.assertRaises(PreviewWriteHeld):
replace_unless_held(tmp, dst, preview_store.write_guard(lambda: False))
self.assertFalse(dst.exists())
def test_the_check_and_the_rename_run_under_the_write_lock(self):
"""The hold check and the rename hold ``write_lock``, the lock the
generation bump takes: a switch cannot fall between them."""
tmp, dst = self._temp_and_dst()
in_check = threading.Event()
release = threading.Event()
seen = {}
def slow_hold():
seen["locked"] = preview_store.write_lock.locked()
in_check.set()
release.wait(WAIT)
return True
guard = preview_store.write_guard(slow_hold)
writer = threading.Thread(target=replace_unless_held, args=(tmp, dst, guard))
writer.start()
try:
self.assertTrue(in_check.wait(WAIT))
self.assertTrue(seen["locked"])
self.assertTrue(preview_store.write_lock.locked())
finally:
release.set()
writer.join(WAIT)
self.assertFalse(preview_store.write_lock.locked())
self.assertEqual(dst.read_bytes(), b"new")
bumped = preview_store.bump_generation()
self.assertEqual(preview_store.generation(), bumped)
class TestOnlyTheResolverBuildsStorePaths(unittest.TestCase):
"""No backend module joins ``thumbnails`` onto a path by hand any more."""
ALLOWED = {
Path("py/api/preview_store.py"),
Path("py/api/thumbnail_settings.py"), # URL text
}
# A path join with a literal "thumbnails" operand.
JOIN = re.compile(
r"""/\s*["']thumbnails["']"""
r"""|["']thumbnails["']\s*/"""
r"""|joinpath\([^)]*["']thumbnails["']"""
r"""|os\.path\.join\([^)]*["']thumbnails["']"""
r"""|["']thumbnails/"""
r"""|/\s*PREVIEW_FOLDER|joinpath\([^)]*PREVIEW_FOLDER"""
r"""|Path\([^)]*,\s*["']thumbnails["']"""
)
def test_no_thumbnails_join_outside_the_resolver(self):
offenders = []
files = sorted(REPO.glob("*.py"))
for folder in ("py", "utils", "database"):
files += sorted((REPO / folder).rglob("*.py"))
for path in files:
rel = path.relative_to(REPO)
if rel in self.ALLOWED:
continue
for number, line in enumerate(
path.read_text(encoding="utf-8").splitlines(), 1
):
code = line.split("#", 1)[0]
if self.JOIN.search(code):
offenders.append(f"{rel}:{number}: {line.strip()}")
self.assertEqual(offenders, [])
class TestSettingsLookupsFollowTheStore(SourceModeCase):
def test_owned_thumbnails_reads_the_store(self):
folder = self.root / "thumbnails" / "sub"
folder.mkdir(parents=True)
(folder / "a_thumb.webp").write_bytes(b"x")
self.assertEqual(
thumbnail_settings.owned_thumbnails(self.root, Path("sub", "a.png")),
[folder / "a_thumb.webp"],
)
try:
from tests.test_thumbnail_auto import AutoThumbnailCase, _entry
from tests.test_api_images import make_png
except ImportError: # discovered with tests/ as the top-level directory
from test_thumbnail_auto import AutoThumbnailCase, _entry
from test_api_images import make_png
class RedirectedStoreCase(AutoThumbnailCase):
"""Every caller follows the resolver: with the store of the output root
moved to ``<tmp>/elsewhere/output-key`` (a stand-in for a later mode),
previews are read, written, listed and removed there and only there."""
async def asyncSetUp(self):
await super().asyncSetUp()
self.container = self.comfy_dir / "elsewhere"
self.store = self.container / "output-key"
self.store.mkdir(parents=True)
self.old_store = self.output_dir / "thumbnails"
root = self.output_dir
real_store = preview_store.store_dir
real_container = preview_store.store_container
def store_dir(r, location=None):
return self.store if Path(r) == root else real_store(r, location)
def store_container(r, location=None):
if Path(r) == root:
return self.container
return real_container(r, location)
for name, fake in (
("store_dir", store_dir),
("store_container", store_container),
):
patcher = unittest.mock.patch.object(preview_store, name, fake)
patcher.start()
self.addCleanup(patcher.stop)
def _preview(self, rel, ext=".webp"):
return self.store / (thumbnail_settings.preview_stem(rel) + ext)
class TestEveryCallerFollowsTheStore(RedirectedStoreCase):
async def test_an_existing_preview_is_served_from_the_store(self):
make_png(self.output_dir / "sub" / "a.png", size=(64, 64))
preview = self._preview("sub/a.png")
make_png(preview, size=(32, 32))
url = _entry(self.api, self.output_dir, "sub/a.png")["thumbnail_url"]
self.assertIn("v=", url) # the listing found it in the store
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), preview.read_bytes())
async def test_a_legacy_upper_case_preview_is_served_from_the_store(self):
make_png(self.output_dir / "k.JPG", size=(64, 64))
legacy = self.store / "k_thumb.JPG"
legacy.write_bytes(b"legacy preview")
resp = await self.client.get(
"/prompt_manager/images/serve/thumbnails/k_thumb.JPG?root=0"
)
self.assertEqual(resp.status, 200)
self.assertEqual(await resp.read(), b"legacy preview")
async def test_a_missing_preview_is_made_in_the_store_on_first_view(self):
make_png(self.output_dir / "sub" / "b.png", size=(640, 480))
url = _entry(self.api, self.output_dir, "sub/b.png")["thumbnail_url"]
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue(self._preview("sub/b.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_a_leftover_in_the_source_folder_is_not_served(self):
make_png(self.output_dir / "c.png", size=(640, 480))
leftover = self.old_store / "c_thumb.webp"
leftover.parent.mkdir()
leftover.write_bytes(b"old preview bytes")
resp = await self.client.get(
"/prompt_manager/images/serve/thumbnails/c_thumb.webp?root=0"
)
self.assertEqual(resp.status, 200)
self.assertNotEqual(await resp.read(), b"old preview bytes")
self.assertTrue(self._preview("c.png").is_file())
async def test_capture_writes_into_the_store(self):
path = make_png(self.output_dir / "run" / "d.png", size=(64, 64))
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
await self._idle()
self.assertTrue(self._preview("run/d.png").is_file())
self.assertFalse(self.old_store.exists())
async def test_generate_and_clear_follow_the_store(self):
make_png(self.output_dir / "e.png", size=(64, 64))
payload = await asyncio.get_running_loop().run_in_executor(
None, self.api._generate_gallery_thumbnails_sync, [self.output_dir]
)
self.assertTrue(payload["success"])
self.assertTrue(self._preview("e.png").is_file())
self.assertFalse(self.old_store.exists())
self.assertTrue(payload["thumbnails_path"].endswith("output-key"))
cleared, _size = self.api._clear_gallery_thumbnails_sync([self.output_dir])
self.assertEqual(cleared, 1)
self.assertFalse(self._preview("e.png").exists())
self.assertTrue(self.store.is_dir()) # the store itself stays
async def test_removing_a_file_removes_its_preview_in_the_store(self):
from py.api.admin import _remove_thumbnail
make_png(self.output_dir / "f.png", size=(64, 64))
preview = self._preview("f.png")
preview.write_bytes(b"x")
self.assertTrue(_remove_thumbnail(self.output_dir, Path("f.png")))
self.assertFalse(preview.exists())
async def test_the_temp_sweep_walks_the_store(self):
stray = self.store / ".g_thumb.part-1-1.webp"
stray.write_bytes(b"x")
old = os.path.getmtime(stray) - 10 * 24 * 3600
os.utime(stray, (old, old))
self.assertEqual(self.api.sweep_thumbnail_temps(), 1)
self.assertFalse(stray.exists())
async def test_history_cover_writes_into_the_store(self):
from py.api import history_current
self.assertTrue(
history_current._preview_writable(self.output_dir, self._preview("h.png"))
)
self.assertFalse(
history_current._preview_writable(
self.output_dir, self.old_store / "h_thumb.webp"
)
)
class TestCaptureResolvesAtWriteTime(RedirectedStoreCase):
def test_a_queued_capture_follows_a_store_that_moved_before_its_write(self):
from py.api import thumbnail_auto as auto
written = []
thumbnailer = auto.AutoThumbnailer(
lambda src, dst, *a, **k: written.append(dst) or {"action": "generated"},
lambda: True,
self.api.logger,
start_worker=False,
)
self.addCleanup(thumbnailer.close)
src = make_png(self.output_dir / "i.png", size=(32, 32))
deferred = auto.DeferredPreview(self.output_dir, Path("i.png"))
self.assertTrue(thumbnailer.enqueue(src, deferred, False))
moved = self.container / "moved"
moved.mkdir()
self.store = moved # the location changes before the worker runs
thumbnailer._write_queued(thumbnailer._queue.popleft())
self.assertEqual(written, [moved / "i_thumb.webp"])
def test_the_after_scan_queue_maps_each_target_to_its_own_root(self):
from py.api import thumbnail_auto as auto
nested = self.output_dir / "inner"
nested.mkdir()
src = nested / "j.png"
outer = auto._deferred(
[self.output_dir, nested], src, self._preview("inner/j.png")
)
inner = auto._deferred(
[self.output_dir, nested], src, nested / "thumbnails" / "j_thumb.webp"
)
self.assertEqual(
(outer.root, outer.rel), (self.output_dir, Path("inner/j.png"))
)
self.assertEqual((inner.root, inner.rel), (nested, Path("j.png")))
stray = self.comfy_dir / "x_thumb.webp"
self.assertEqual(auto._deferred([self.output_dir], src, stray), stray)
def test_serve_and_history_share_one_flight_key(self):
from py.api import thumbnail_auto as auto
parts = ("thumbnails", "sub", "a_thumb.webp")
self.assertEqual(
auto._request_key(self.output_dir, parts),
auto._dst_key(self._preview("sub/a.png")),
)
if __name__ == "__main__":
unittest.main()
+3 -4
View File
@@ -291,7 +291,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
await self._writers(state, auto.ON_DEMAND_LIMIT)
await self._waiting(len(names))
self.assertEqual(default_calls, [], "no view waits on a default thread")
self.assertTrue(all(name.startswith("pm-image") for name in state["threads"]))
self.assertTrue(all(name.startswith("pm-preview") for name in state["threads"]))
status = await self.client.get("/prompt_manager/images/thumbnails/status")
self.assertEqual(status.status, 200, "another route answers meanwhile")
await status.read()
@@ -678,7 +678,7 @@ class TestShutdown(AutoThumbnailCase):
_entry(self.api, self.output_dir, "viewed.png")["thumbnail_url"]
)
await resp.read()
pool_threads = list(self.api._image_pool_threads())
pool_threads = list(self.api._preview_pool_threads())
self.assertTrue(pool_threads)
loop = asyncio.get_running_loop()
await loop.run_in_executor(
@@ -904,8 +904,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
self.api.queue_capture_thumbnail(str(path))
release.set()
await self._idle()
# The job pool's size is the setting; each batch is at most that (#523).
self.assertEqual(batches, [(1, 2), (2, 2), (2, 2)])
self.assertEqual(batches, [(1, 1), (2, 2), (2, 2)])
self.assertLessEqual(state["peak"], 2)
for i in range(5):
self.assertTrue(
+4 -110
View File
@@ -1,8 +1,4 @@
"""Tests for utils/parallel.py: bounded thread-pool map used by long jobs.
The jobs share one long-lived pool of Worker threads (#523): its threads (and
their malloc arenas) are reused from job to job instead of a new set per call.
"""
"""Tests for utils/parallel.py: bounded thread-pool map used by long jobs."""
import os
import sys
@@ -12,22 +8,10 @@ import unittest
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from utils import parallel # noqa: E402
from utils.parallel import JOB_THREAD_PREFIX, map_parallel # noqa: E402
WAIT = 30
def _job_threads():
return {
t for t in threading.enumerate() if t.name.startswith(JOB_THREAD_PREFIX + "_")
}
from utils.parallel import map_parallel # noqa: E402
class TestMapParallel(unittest.TestCase):
def setUp(self):
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.addCleanup(parallel.shutdown_job_pool, WAIT)
def test_results_keep_input_order(self):
def slow_square(n):
@@ -38,102 +22,12 @@ class TestMapParallel(unittest.TestCase):
map_parallel(slow_square, [1, 2, 3, 4], workers=4), [1, 4, 9, 16]
)
def test_one_worker_runs_on_the_one_job_thread(self):
def test_one_worker_runs_on_the_calling_thread(self):
names = []
map_parallel(
lambda _: names.append(threading.current_thread().name), [1, 2, 3], 1
)
self.assertEqual(len(set(names)), 1)
self.assertTrue(names[0].startswith(JOB_THREAD_PREFIX + "_"), names)
def test_jobs_reuse_the_same_long_lived_threads(self):
"""Arenas are reused instead of a new set of threads per job."""
first, second = set(), set()
map_parallel(lambda _: first.add(threading.current_thread()), range(8), 2)
map_parallel(lambda _: second.add(threading.current_thread()), range(8), 2)
self.assertTrue(second <= first, (first, second))
self.assertLessEqual(len(_job_threads()), 2)
self.assertTrue(all(t.is_alive() for t in first | second))
def test_a_new_worker_count_replaces_the_pool(self):
map_parallel(lambda n: n, range(4), 2)
old = _job_threads()
map_parallel(lambda n: n, range(4), 3)
for thread in old:
thread.join(WAIT)
self.assertFalse(thread.is_alive(), thread.name)
self.assertLessEqual(len(_job_threads()), 3)
def test_shutdown_also_joins_a_pool_a_resize_retired(self):
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(WAIT)
caller = threading.Thread(target=map_parallel, args=(hold, [1], 1))
caller.start()
self.addCleanup(caller.join, WAIT)
self.addCleanup(release.set)
self.assertTrue(entered.wait(WAIT))
map_parallel(lambda n: n, [1, 2], 2) # a new size retires the busy pool
self.assertFalse(parallel.shutdown_job_pool(0.05))
release.set()
caller.join(WAIT)
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.assertFalse(parallel.job_pool_started())
def test_an_item_run_from_a_job_thread_runs_inline(self):
"""A nested map never waits for the threads it is running on."""
def outer(n):
return map_parallel(lambda m: (m, threading.current_thread()), [n], 1)
results = map_parallel(outer, [1, 2], 1)
self.assertEqual([r[0][0] for r in results], [1, 2])
def test_shutdown_stops_the_threads_and_a_later_job_starts_fresh(self):
map_parallel(lambda n: n, range(4), 2)
threads = _job_threads()
self.assertTrue(threads)
self.assertTrue(parallel.job_pool_started())
self.assertTrue(parallel.shutdown_job_pool(WAIT))
self.assertFalse(parallel.job_pool_started())
self.assertFalse(any(t.is_alive() for t in threads))
self.assertEqual(map_parallel(lambda n: n + 1, [1], 2), [2])
def test_shutdown_reports_a_thread_still_running(self):
entered, release = threading.Event(), threading.Event()
def hold(_):
entered.set()
release.wait(WAIT)
caller = threading.Thread(target=map_parallel, args=(hold, [1], 1))
caller.start()
self.assertTrue(entered.wait(WAIT))
self.assertFalse(parallel.shutdown_job_pool(0.05))
release.set()
caller.join(WAIT)
self.assertFalse(caller.is_alive())
def test_a_failing_item_waits_for_the_running_ones(self):
"""The caller sees the error only after its other items stopped."""
running = threading.Event()
finished = []
def item(n):
if n == 0:
self.assertTrue(running.wait(WAIT))
raise ValueError("zero")
running.set()
time.sleep(0.05)
finished.append(n)
return n
with self.assertRaises(ValueError):
map_parallel(item, [0, 1], 2)
self.assertEqual(finished, [1])
self.assertEqual(set(names), {threading.current_thread().name})
def test_several_workers_use_several_threads(self):
names = set()
-395
View File
@@ -1,395 +0,0 @@
"""Measure the server's resident memory under a busy Images/Library session (#523).
Manual only, Linux only (it reads ``/proc`` and glibc's ``malloc_info``); never
run in CI. It starts the production API inside the disposable browser test
server (``tests/browser/server.py``: its own temporary library, database and
output folder; never a live ComfyUI) and drives it for ``--minutes``:
- ``--clients`` threads loop over: a workspace grid page of 100 images, 40 of
its thumbnails, 6 inspector reads and 6 full-file reads, the Library list
with 3 entries' history and images, and the recent prompt and image lists;
- every minute, ``--batch`` new PNGs (832 to 1536 px, ComfyUI-shaped
metadata) are copied into the output folder, then a scan job and a
thumbnail job run to completion.
Every 5 s the server samples its RSS, Python heap (tracemalloc), threads and
glibc arena count. The test server pins glibc's mmap threshold (#516); this
driver turns that off so the production allocator behaviour is measured,
unless ``--mallopt`` asks for the pinned comparison run.
python tools/perf/memory_drive.py --label after --minutes 12
python tools/perf/memory_drive.py --repo /path/to/other/checkout --label before
The checkout under ``--repo`` (default: this one) is the code measured; the
driver itself only needs Pillow. The last line printed is a Markdown table row.
"""
import argparse
import ctypes
import json
import os
import random
import shutil
import subprocess # nosec B404 - starts this script's own server child
import sys
import tempfile
import threading
import time
import tracemalloc
import urllib.parse
import urllib.request
from concurrent.futures import ThreadPoolExecutor
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(os.path.dirname(HERE))
PORT_ANNOUNCEMENT = "PM_TEST_SERVER_PORT="
OUTPUT_ANNOUNCEMENT = "output="
SAMPLE_SECONDS = 5
MEDIA_FILES = 120
MEDIA_SIDES = (832, 1024, 1216, 1536)
REQUEST_TIMEOUT = 120
JOB_POLL_LIMIT = 600
FINAL_STATES = ("finished", "cancelled", "failed")
M_MMAP_THRESHOLD = -3
# ── the server child ────────────────────────────────────────────────────
def _arena_count(libc):
"""glibc's arena count (``<heap nr=`` entries of ``malloc_info``)."""
buffer, size = ctypes.c_char_p(), ctypes.c_size_t()
stream = libc.open_memstream(ctypes.byref(buffer), ctypes.byref(size))
libc.malloc_info(0, stream)
libc.fclose(stream)
text = ctypes.string_at(buffer, size.value).decode()
libc.free(ctypes.cast(buffer, ctypes.c_void_p))
return text.count("<heap nr=")
def _status():
with open("/proc/self/status", encoding="ascii") as status:
return {
line.split(":")[0]: int(line.split()[1])
for line in status
if line.startswith(("VmRSS", "RssAnon", "Threads"))
}
def _sample_forever(libc, samples_path):
start = time.monotonic()
announced = False
with open(samples_path, "a", buffering=1, encoding="utf-8") as out:
while True:
folder_paths = sys.modules.get("folder_paths")
if not announced and hasattr(folder_paths, "get_output_directory"):
out.write(OUTPUT_ANNOUNCEMENT + folder_paths.get_output_directory())
out.write("\n")
announced = True
status = _status()
out.write(
json.dumps(
{
"t": round(time.monotonic() - start),
"rss": status["VmRSS"] >> 10,
"anon": status["RssAnon"] >> 10,
"heap": tracemalloc.get_traced_memory()[0] >> 20,
"threads": status["Threads"],
"arenas": _arena_count(libc),
}
)
+ "\n"
)
time.sleep(SAMPLE_SECONDS if announced else 0.5)
def serve(repo, samples_path, mallopt):
"""Run the browser test server of *repo* in this process, sampled."""
sys.path.insert(0, repo)
os.chdir(repo)
tracemalloc.start(1)
libc = ctypes.CDLL(None)
libc.open_memstream.restype = ctypes.c_void_p
libc.open_memstream.argtypes = [
ctypes.POINTER(ctypes.c_char_p),
ctypes.POINTER(ctypes.c_size_t),
]
libc.fclose.argtypes = [ctypes.c_void_p]
libc.malloc_info.argtypes = [ctypes.c_int, ctypes.c_void_p]
libc.free.argtypes = [ctypes.c_void_p]
if mallopt:
libc.mallopt(M_MMAP_THRESHOLD, 128 * 1024)
threading.Thread(
target=_sample_forever, args=(libc, samples_path), daemon=True
).start()
from tests.browser import server
if not mallopt:
server.pin_mmap_threshold = lambda: False # production allocator
server.main(["--port", "0", "--exit-on-stdin-eof"])
# ── the driver ──────────────────────────────────────────────────────────
def make_media(folder, count=MEDIA_FILES, seed=523):
"""Synthetic ComfyUI-shaped PNGs (smooth noise, prompt and workflow)."""
from PIL import Image, PngImagePlugin
rng = random.Random(seed)
os.makedirs(folder, exist_ok=True)
for index in range(count):
side = MEDIA_SIDES[index % len(MEDIA_SIDES)]
small = Image.frombytes(
"RGB", (64, 64), bytes(rng.randrange(256) for _ in range(64 * 64 * 3))
)
image = small.resize((side, side), Image.BICUBIC)
text = f"synthetic scene {index}, soft light, detailed"
prompt = {
"3": {
"class_type": "KSampler",
"inputs": {
"seed": index,
"steps": 20,
"cfg": 7.0,
"sampler_name": "euler",
"scheduler": "normal",
"denoise": 1.0,
"model": ["4", 0],
"positive": ["6", 0],
"negative": ["7", 0],
"latent_image": ["5", 0],
},
},
"4": {
"class_type": "CheckpointLoaderSimple",
"inputs": {"ckpt_name": "model.safetensors"},
},
"5": {
"class_type": "EmptyLatentImage",
"inputs": {"width": side, "height": side, "batch_size": 1},
},
"6": {
"class_type": "CLIPTextEncode",
"inputs": {"text": text, "clip": ["4", 1]},
},
"7": {
"class_type": "CLIPTextEncode",
"inputs": {"text": "blurry", "clip": ["4", 1]},
},
"9": {
"class_type": "SaveImage",
"inputs": {"filename_prefix": "img", "images": ["8", 0]},
},
}
workflow = {
"nodes": [
{"id": int(k), "type": v["class_type"]} for k, v in prompt.items()
]
}
info = PngImagePlugin.PngInfo()
info.add_text("prompt", json.dumps(prompt))
info.add_text("workflow", json.dumps(workflow))
image.save(
os.path.join(folder, f"m{index:03d}.png"), pnginfo=info, compress_level=4
)
class Driver:
def __init__(self, base, output, media, batch):
self.base, self.output, self.media, self.batch = base, output, media, batch
self.errors, self.batches = [], 0
self.names = sorted(os.listdir(media))
def call(self, method, path, body=None, raw=False):
data = None if body is None else json.dumps(body).encode()
request = urllib.request.Request( # nosec B310 - loopback test server
self.base + path,
data=data,
method=method,
headers={"Content-Type": "application/json"},
)
try:
with urllib.request.urlopen( # nosec B310
request, timeout=REQUEST_TIMEOUT
) as response:
payload = response.read()
except Exception as error: # keep driving; errors are counted
self.errors.append(f"{method} {path.split('?')[0]}: {error}")
return None
return payload if raw else json.loads(payload or b"{}")
def add_batch(self):
self.batches += 1
folder = os.path.join(self.output, f"batch{self.batches:03d}")
os.makedirs(folder)
for index in range(self.batch):
name = self.names[(self.batches * self.batch + index) % len(self.names)]
shutil.copyfile(
os.path.join(self.media, name),
os.path.join(folder, f"img_{self.batches:03d}_{index:03d}.png"),
)
def wait_job(self, response):
job = ((response or {}).get("job") or {}).get("id")
for _ in range(JOB_POLL_LIMIT if job else 0):
state = self.call("GET", f"/prompt_manager/jobs/{job}")
if not state or (state.get("job") or {}).get("state") in FINAL_STATES:
return
time.sleep(1)
def scan_and_thumbnails(self):
self.add_batch()
self.wait_job(self.call("POST", "/prompt_manager/jobs/scan", {}))
self.wait_job(self.call("POST", "/prompt_manager/jobs/thumbnails", {}))
def browse(self, _index=None):
offset = random.randint(0, 3) * 100 # nosec B311 - load shape only
page = self.call(
"GET",
f"/prompt_manager/images/output?mode=workspace&limit=100&offset={offset}",
)
images = [i for i in (page or {}).get("images") or [] if isinstance(i, dict)]
urls = [i.get("thumbnail_url") or i.get("url") for i in images]
urls = [u for u in urls if u]
for url in random.sample(urls, min(40, len(urls))):
self.call("GET", url, raw=True)
for image in random.sample(images, min(6, len(images))):
self.inspect(image)
if image.get("url"):
self.call("GET", image["url"], raw=True)
library = self.call("GET", "/prompt_manager/library?limit=100") or {}
entries = [e for e in library.get("items") or [] if isinstance(e, dict)]
for entry in random.sample(entries, min(3, len(entries))):
prompt_id = entry.get("id") or entry.get("positive_id")
if prompt_id is not None:
self.call(
"GET", f"/prompt_manager/library/{prompt_id}/history", raw=True
)
self.call(
"GET", f"/prompt_manager/library/{prompt_id}/images", raw=True
)
self.call("GET", "/prompt_manager/recent?limit=50", raw=True)
self.call("GET", "/prompt_manager/images/recent?limit=50", raw=True)
def inspect(self, image):
relative, index = image.get("path"), image.get("root_index")
if not relative or not isinstance(index, int):
return
query = {"root": str(index)}
if image.get("root_scope"):
query["root_scope"] = image["root_scope"]
self.call(
"GET",
"/prompt_manager/images/inspect/"
+ urllib.parse.quote(relative)
+ "?"
+ urllib.parse.urlencode(query),
raw=True,
)
def _wait_for_output(samples_path, process):
while process.poll() is None:
with open(samples_path, encoding="utf-8") as samples:
for line in samples:
if line.startswith(OUTPUT_ANNOUNCEMENT):
return line[len(OUTPUT_ANNOUNCEMENT) :].strip()
time.sleep(0.2)
raise RuntimeError("the server exited before it started")
def summarize(samples_path, label, batches, errors):
rows = []
with open(samples_path, encoding="utf-8") as samples:
for line in samples:
if line.startswith("{"):
rows.append(json.loads(line))
if not rows:
raise RuntimeError("no samples were taken")
rss = [row["rss"] for row in rows]
late = [row for row in rows if row["t"] >= 120] or rows
end = rows[-1]
return (
f"| {label} | {rss[0]} → {end['rss']} | {min(r['rss'] for r in late)}–"
f"{max(r['rss'] for r in late)} | {max(rss)} | {rows[0]['heap']} → "
f"{end['heap']} | {max(r['threads'] for r in rows)} | "
f"{max(r['arenas'] for r in rows)} | {batches} | {len(errors)} |"
)
def drive(args):
work = args.out or tempfile.mkdtemp(prefix="pm-memory-")
media = os.path.join(work, "media")
if not os.path.isdir(media):
print(f"Writing {MEDIA_FILES} PNGs to {media}", flush=True)
make_media(media)
samples_path = os.path.join(work, f"{args.label}.samples")
open(samples_path, "w", encoding="utf-8").close()
command = [sys.executable, os.path.abspath(__file__), "--serve", samples_path]
command += ["--repo", os.path.abspath(args.repo)]
command += ["--mallopt"] if args.mallopt else []
with open(os.path.join(work, f"{args.label}.stderr"), "w") as stderr:
process = subprocess.Popen( # nosec B603
command, stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=stderr
)
try:
port = None
for line in process.stdout:
text = line.decode(errors="replace")
if text.startswith(PORT_ANNOUNCEMENT):
port = int(text[len(PORT_ANNOUNCEMENT) :])
break
if port is None:
raise RuntimeError("the server announced no port")
output = _wait_for_output(samples_path, process)
driver = Driver(f"http://127.0.0.1:{port}", output, media, args.batch)
run(driver, args)
finally:
process.stdin.close()
process.wait(60)
print(f"Samples: {samples_path}")
print(
"| run | RSS start → end (MB) | after 2 min | max | heap (MB) | threads "
"| arenas | batches | errors |"
)
print(summarize(samples_path, args.label, driver.batches, driver.errors))
def run(driver, args):
driver.call("POST", "/__test__/reset", {})
driver.scan_and_thumbnails()
deadline = time.monotonic() + args.minutes * 60
next_batch = time.monotonic() + 60
with ThreadPoolExecutor(args.clients) as pool:
while time.monotonic() < deadline:
list(pool.map(driver.browse, range(args.clients)))
if time.monotonic() > next_batch:
driver.scan_and_thumbnails()
next_batch = time.monotonic() + 60
time.sleep(SAMPLE_SECONDS + 1) # one more sample after the load stops
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
parser.add_argument("--repo", default=REPO, help="checkout to measure")
parser.add_argument("--label", default="run")
parser.add_argument("--minutes", type=float, default=12)
parser.add_argument("--clients", type=int, default=6)
parser.add_argument("--batch", type=int, default=60, help="new PNGs a minute")
parser.add_argument("--out", help="work folder (media, samples); default temp")
parser.add_argument(
"--mallopt", action="store_true", help="pin the mmap threshold (comparison)"
)
parser.add_argument("--serve", metavar="SAMPLES", help=argparse.SUPPRESS)
args = parser.parse_args(argv)
if not sys.platform.startswith("linux"):
parser.error("Linux only: it reads /proc and glibc's malloc_info")
if args.serve:
serve(os.path.abspath(args.repo), args.serve, args.mallopt)
else:
drive(args)
if __name__ == "__main__":
main()
+5 -48
View File
@@ -230,9 +230,6 @@ def _undecided_graph(found: _File) -> Decision:
if switched is not None:
# The legacy answer is a switched-off node's own text (#90)
return Decision(switched, canonical=found.canonical)
if text is not None and _search_text(found.canonical["prompt"], text):
# The query of a Search List batch is a search, never a prompt (#548)
return _never(found, _unlinked(found), try_recorded_run=True)
if text is not None and _joined_text(found.canonical["prompt"], text):
# The legacy parser joins prepend + text + append: the text a
# PromptManager node stores cannot be told from it (#343).
@@ -314,8 +311,6 @@ _NOT_TEXT_INPUTS = frozenset(
}
)
_MAX_SWITCH_VISITS = 5000
# The batch node whose ``text`` input is a library search query (#548).
SEARCH_LIST_TYPE = "PromptSearchList"
def _negative_name(key: Any) -> bool:
@@ -333,28 +328,10 @@ def _switched_off_upstream(graph: Any) -> Optional[str]:
"""
if not isinstance(graph, dict):
return None
upstream = _positive_upstream(graph)
if upstream is None:
ids = _prompt_manager_ids(graph)
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
found = [
node_id
for node_id in upstream
if graph[node_id].get("class_type") in PROMPT_MANAGER_TYPES
]
return _switch_kind(save_setting(graph, node_id) for node_id in found)
def _positive_upstream(graph: dict) -> Optional[set]:
"""The ids of the nodes upstream of a sampler's positive, or None when
the graph has no recognisable sampler.
Follows every input except negative and non-text ones (#90's walk follows
only prompt-like names).
"""
roots = _positive_roots(graph)
if not roots:
return None
ids = _prompt_manager_ids(graph)
return _switch_kind(save_setting(graph, node_id) for node_id in ids)
seen, found = set(), set()
stack = [str(link[0]) for link in roots]
while stack and len(seen) < _MAX_SWITCH_VISITS:
@@ -365,7 +342,8 @@ def _positive_upstream(graph: dict) -> Optional[set]:
node = graph.get(node_id)
if not isinstance(node, dict):
continue
found.add(node_id)
if node.get("class_type") in PROMPT_MANAGER_TYPES:
found.add(node_id)
inputs = node.get("inputs")
for key, value in (inputs.items() if isinstance(inputs, dict) else ()):
if key in _NOT_TEXT_INPUTS or _negative_name(key):
@@ -373,28 +351,7 @@ def _positive_upstream(graph: dict) -> Optional[set]:
if isinstance(value, (list, tuple)) and len(value) == 2:
if isinstance(value[0], (str, int)) and isinstance(value[1], int):
stack.append(str(value[0]))
return found
def _search_text(graph: Any, text: str) -> bool:
"""Whether ``text`` is the search query of a PromptSearchList node that
feeds a positive (any such node when no sampler is recognised).
The legacy parser traces a positive wired from a Search List to the
node's ``text`` input, which is the database search, never the prompt
the batch ran (#548). Each item is counted by the node at run time.
"""
if not isinstance(graph, dict):
return False
upstream = _positive_upstream(graph)
ids = graph if upstream is None else upstream
wanted = text.strip()
return any(
isinstance(graph[node_id], dict)
and graph[node_id].get("class_type") == SEARCH_LIST_TYPE
and resolve_text(graph, node_id) == wanted
for node_id in ids
)
return _switch_kind(save_setting(graph, node_id) for node_id in found)
def _off_text(graph: Any, text: str) -> Optional[str]:
+11 -87
View File
@@ -96,65 +96,6 @@ WORKER_JOIN_SECONDS = 5.0
OBSERVER_JOIN_SECONDS = 5.0
def _start_daemon_observer(observer) -> None:
"""``observer.start()`` on a short-lived daemon thread (#561).
A thread is a daemon when the thread that creates it is one. watchdog
7 on Windows starts a plain ``threading.Thread`` per watch inside
``start()``; created from ComfyUI's main thread it is not a daemon, and
the interpreter waits for every such thread before it runs any
``atexit`` callback, so the teardown that would stop it never runs.
Started from here, every thread the observer creates is a daemon.
Waits for the start without a limit (start-up, not exit); re-raises
what ``start()`` raised.
"""
raised: List[BaseException] = []
def start() -> None:
try:
observer.start()
except BaseException as e: # re-raised on the caller's thread
raised.append(e)
starter = threading.Thread(
target=start, name="PromptManagerObserverStart", daemon=True
)
starter.start()
starter.join()
if raised:
raise raised[0]
class _ObserverStop:
"""One ``observer.stop()`` running on a daemon thread (#561).
watchdog's ``stop()`` joins its emitters (and, in 7.x on Windows, their
reader threads) without a limit. Run here, a stop that never returns
holds neither the caller past its deadline nor the interpreter's exit.
"""
def __init__(self, observer) -> None:
self.error: Optional[Exception] = None
self._thread = threading.Thread(
target=self._run,
args=(observer,),
name="PromptManagerObserverStop",
daemon=True,
)
self._thread.start()
def _run(self, observer) -> None:
try:
observer.stop()
except Exception as e: # reported by the stop that waits for it
self.error = e
def wait(self, timeout: float) -> bool:
"""True once ``stop()`` has returned or raised."""
self._thread.join(timeout)
return not self._thread.is_alive()
class ImageGenerationHandler(FileSystemEventHandler):
"""Filesystem event handler for detecting new image generation.
@@ -1229,9 +1170,6 @@ class ImageMonitor:
# (observer, handler) pairs a stop left running: the next stop waits
# for them again instead of reporting True.
self._unfinished: List[Tuple[Any, Any]] = []
# Observer stops still running on their thread, by observer: a later
# stop waits for the same call instead of starting another (#561).
self._observer_stops: Dict[Any, _ObserverStop] = {}
self.logger = get_logger("prompt_manager.image_monitor")
self.review_recorder: Optional[Callable[[str], str]] = None
self.thumbnail_queue: Optional[Callable[[str], str]] = None
@@ -1335,7 +1273,7 @@ class ImageMonitor:
self.logger.warning("No directory could be watched")
return
try:
_start_daemon_observer(observer)
observer.start()
except Exception as e:
self.logger.error(f"Image monitoring could not start: {e}")
return
@@ -1365,12 +1303,12 @@ class ImageMonitor:
Closes the handler first (its worker is joined and its dispatch
returns at once), then stops the watchdog observer and joins it. The
worker and the observer share the one deadline: the observer's stop
and join get what the worker left, possibly 0 s. watchdog's
``observer.stop()`` joins its emitters without a limit, so it runs on
a daemon thread and is waited for only until the deadline (#561). A
part an earlier call left running is waited for again. Safe to call
when monitoring never started and more than once.
worker and the observer share the one deadline: the observer's join
gets what the worker left, possibly 0 s.
watchdog's ``observer.stop()`` joins its emitters without a limit;
it counts against the deadline but is not bounded here. A part an
earlier call left running is waited for again. Safe to call when
monitoring never started and more than once.
Returns:
True when every worker and observer it stopped has ended.
@@ -1411,27 +1349,13 @@ class ImageMonitor:
return False
def _stop_observer(self, observer, timeout: float) -> bool:
"""``observer.stop()`` on its own daemon thread, then a join, both
within ``timeout`` seconds; a stop or join still running then is
logged with the time it got. A stop still running from an earlier
call is waited for, never started again."""
"""``observer.stop()`` (safe to repeat), then a join of ``timeout``
seconds; a join that times out is logged with the time it got."""
if observer is None:
return True
deadline = time.monotonic() + timeout
stop = self._observer_stops.get(observer)
if stop is None:
stop = self._observer_stops[observer] = _ObserverStop(observer)
if not stop.wait(timeout):
self.logger.warning(
f"watchdog observer still running after {timeout:.1f} s"
)
return False
del self._observer_stops[observer]
if stop.error is not None:
self.logger.warning(f"Error while stopping image monitoring: {stop.error}")
return False
try:
observer.join(max(0.0, deadline - time.monotonic()))
observer.stop()
observer.join(timeout)
except Exception as e:
self.logger.warning(f"Error while stopping image monitoring: {e}")
return False
+9 -12
View File
@@ -37,7 +37,6 @@ from typing import Optional, Dict, Any
from PIL import Image
from .logging_config import get_logger
from .png_text import png_text
class ComfyUIMetadataExtractor:
@@ -89,14 +88,12 @@ class ComfyUIMetadataExtractor:
# Add basic file information
metadata["file_info"] = self.get_file_info(image_path, image)
# Extract ComfyUI-specific metadata from PNG text chunks,
# read without decoding the pixels (#523)
text = png_text(image)
if text:
# Extract ComfyUI-specific metadata from PNG text chunks
if hasattr(image, "text") and image.text:
# Look for ComfyUI workflow data
if "workflow" in text:
if "workflow" in image.text:
try:
workflow_data = json.loads(text["workflow"])
workflow_data = json.loads(image.text["workflow"])
metadata["workflow"] = workflow_data
# Extract text encoder nodes from workflow
@@ -110,9 +107,9 @@ class ComfyUIMetadataExtractor:
self.logger.warning(f"Failed to parse workflow JSON: {e}")
# Look for prompt data
if "prompt" in text:
if "prompt" in image.text:
try:
prompt_data = json.loads(text["prompt"])
prompt_data = json.loads(image.text["prompt"])
metadata["prompt"] = prompt_data
except json.JSONDecodeError as e:
self.logger.warning(f"Failed to parse prompt JSON: {e}")
@@ -132,13 +129,13 @@ class ComfyUIMetadataExtractor:
]
for field in metadata_fields:
if field in text:
if field in image.text:
try:
# Try to parse as JSON first
metadata[field] = json.loads(text[field])
metadata[field] = json.loads(image.text[field])
except (json.JSONDecodeError, TypeError):
# Store as string if not valid JSON
metadata[field] = text[field]
metadata[field] = image.text[field]
return (
metadata
-65
View File
@@ -1,65 +0,0 @@
"""Give glibc's free heap pages back to the OS after a long image job (#523).
Image work (decode, resize, encode) leaves image-sized free blocks in the
malloc arenas of the threads that did it; glibc keeps those pages resident.
``release_free_memory`` calls ``malloc_trim(0)`` once, which returns the free
pages of every arena and changes no allocator setting (no ``mallopt``, no
``MALLOC_ARENA_MAX``: ComfyUI owns this process's allocator).
glibc only: on Windows, macOS and musl (Alpine) it does nothing. It never
raises; a missing or failing ``malloc_trim`` is a quiet ``False``.
"""
import ctypes
import os
import sys
import threading
_lock = threading.Lock()
_trim = None
_looked_up = False
def _glibc() -> bool:
"""Whether this process runs on glibc (musl has no CS_GNU_LIBC_VERSION)."""
if not sys.platform.startswith("linux"):
return False
try:
version = os.confstr("CS_GNU_LIBC_VERSION")
except (AttributeError, ValueError, OSError):
return False
return bool(version) and version.startswith("glibc")
def _malloc_trim():
"""glibc's ``malloc_trim``, looked up once; None where there is none."""
global _trim, _looked_up
with _lock:
if not _looked_up:
_looked_up = True
_trim = _lookup()
return _trim
def _lookup():
if not _glibc():
return None
try:
trim = ctypes.CDLL(None).malloc_trim
except (OSError, AttributeError):
return None
trim.argtypes = [ctypes.c_size_t]
trim.restype = ctypes.c_int
return trim
def release_free_memory() -> bool:
"""Return free heap pages to the OS once (glibc); True when it ran."""
trim = _malloc_trim()
if trim is None:
return False
try:
trim(0)
except Exception: # a foreign libc symbol: never fail the caller's job
return False
return True
+21 -113
View File
@@ -15,36 +15,18 @@ create new thread at interpreter shutdown") only once the interpreter is
marked finalizing, after the ``atexit`` callbacks. ``refused_at_shutdown``
recognises both; ``note_refusal`` logs one DEBUG line per place, with no
traceback.
One long-lived pool (#523): every ``map_parallel`` call shares one pool of
``workers`` threads (``JOB_THREAD_PREFIX``), kept between jobs, instead of a
new pool per call. Image work then reuses the same few threads, and their
malloc arenas, rather than leaving each call's image-sized buffers in a new
set of arenas. A different ``workers`` count (the Worker threads setting
changed) replaces the pool; the old one finishes what it holds and exits.
``shutdown_job_pool`` stops it (the package teardown); the next call starts
a fresh one.
"""
import logging
import threading
import time
from concurrent.futures import ThreadPoolExecutor, wait
from concurrent.futures import ThreadPoolExecutor
from .exit_signal import checked
SHUTDOWN_REFUSAL = "interpreter shutdown"
# ``submit`` on a pool already shut down (this module's teardown or a resize).
SHUT_POOL_REFUSAL = "after shutdown"
JOB_THREAD_PREFIX = "pm-worker"
# The package teardown's bound for the job threads to finish their item.
JOB_POOL_JOIN_SECONDS = 5
_logger = logging.getLogger("prompt_manager.parallel")
_noted = set()
_noted_lock = threading.Lock()
_pool_lock = threading.Lock()
_pool = None # (workers, ThreadPoolExecutor)
_retired = [] # pools replaced by a new size, finishing what they hold
def refused_at_shutdown(error):
@@ -63,113 +45,39 @@ def note_refusal(logger, what):
logger.debug(f"{what}: refused, the interpreter is exiting")
def job_pool_started():
"""Whether a job pool exists (the teardown stops it)."""
return _pool is not None or bool(_retired)
def _job_pool(workers):
"""The shared pool of ``workers`` threads, replacing one of another size."""
global _pool
with _pool_lock:
if _pool is not None and _pool[0] == workers:
return _pool[1]
retired = _pool
_pool = (
workers,
ThreadPoolExecutor(workers, thread_name_prefix=JOB_THREAD_PREFIX),
)
current = _pool[1]
if retired is not None:
retired[1].shutdown(wait=False) # it still runs what it holds
with _pool_lock:
_retired[:] = [p for p in _retired if _alive(p)] + [retired[1]]
return current
def shutdown_job_pool(timeout=None):
"""Stop the job pool (and any pool a resize retired): queued items are
cancelled (their callers run them on their own thread), running ones are
joined up to ``timeout`` seconds. True when no job thread is left."""
global _pool
with _pool_lock:
current, _pool = _pool, None
executors = _retired[:] + ([] if current is None else [current[1]])
_retired.clear()
threads = []
for executor in executors:
# After shutdown: it takes the lock ``submit`` holds while it starts
# and records a thread, so every thread is listed and none starts.
executor.shutdown(wait=False, cancel_futures=True)
threads.extend(getattr(executor, "_threads", ()))
deadline = None if timeout is None else time.monotonic() + timeout
for thread in threads:
thread.join(None if deadline is None else max(0.0, deadline - time.monotonic()))
return not any(thread.is_alive() for thread in threads)
def _alive(executor):
return any(t.is_alive() for t in getattr(executor, "_threads", ()))
def _forget(executor):
"""Drop *executor* as the shared pool (a later call starts a new one)."""
global _pool
with _pool_lock:
if _pool is not None and _pool[1] is executor:
_pool = None
def _on_job_thread():
return threading.current_thread().name.startswith(JOB_THREAD_PREFIX + "_")
def map_parallel(func, items, workers):
"""Apply ``func`` to every item on the shared pool of ``workers``
threads, keeping order.
"""Apply ``func`` to every item on up to ``workers`` threads, keeping order.
``workers`` below 1 counts as 1. A call made from a job thread runs on
that thread, so nested work never waits for its own pool. An exception
in ``func`` propagates to the caller once the call's other items have
stopped (those not started are dropped), so callers that must survive
bad input wrap ``func`` themselves. A pool refused at interpreter exit
runs the items it did not start on the calling thread (#375). An item
``workers`` of 1 (or fewer) runs on the calling thread with no pool. An
exception in ``func`` propagates to the caller, so callers that must
survive bad input wrap ``func`` themselves. A pool refused at interpreter
exit runs the items it did not start on the calling thread (#375). An item
that has not started when the interpreter starts exiting raises
ExitRequested instead (#376).
"""
func = checked(func)
items = list(items)
if not items or _on_job_thread():
workers = max(1, int(workers or 1))
if workers == 1 or len(items) <= 1:
return [func(item) for item in items]
pool = _job_pool(max(1, int(workers or 1)))
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError as error:
if refused_at_shutdown(error):
max_workers = min(workers, len(items))
with ThreadPoolExecutor(max_workers, thread_name_prefix="pm-worker") as pool:
futures = []
try:
for item in items:
futures.append(pool.submit(func, item))
except RuntimeError as error:
if not refused_at_shutdown(error):
raise
note_refusal(_logger, "A worker pool")
# Drop what was queued but not started; it runs here instead.
# (A refused Thread.start leaves its item queued without a future
# here: it is cancelled too, unless a busy pool thread took it in
# between, the one case where an item can run twice.)
_forget(pool)
pool.shutdown(wait=False, cancel_futures=True)
elif SHUT_POOL_REFUSAL not in str(error):
raise
return _collect(func, items, futures)
def _collect(func, items, futures):
"""``_run_rest``; on an error, first drop the call's items not started
and wait for its running ones, as the old per-call pool's exit did."""
try:
return _run_rest(func, items, futures)
except BaseException:
for future in futures:
future.cancel()
wait(futures)
raise
pool.shutdown(wait=True, cancel_futures=True)
else:
return [future.result() for future in futures]
return _run_rest(func, items, futures)
def _run_rest(func, items, futures):
-116
View File
@@ -1,116 +0,0 @@
"""PNG text chunks without decoding pixels (#523).
Pillow's ``PngImageFile.text`` calls ``load()``, which decodes every pixel, to
reach text chunks written after the image data. On a scan or a busy image
monitor that is a full-size buffer per file for a few kilobytes of text.
``png_text`` returns the same dictionary as ``image.text`` for an opened,
single-frame PNG, by walking the chunks after the first IDAT with Pillow's own
chunk handlers (same decoding, limits and duplicate rule) and seeking over the
pixel data. It follows Pillow's ``load_end`` loop: chunk checksums after the
pixels are not checked, an unreadable chunk header ends the walk, and another
chunk that runs past the end of the file fails like ``ImageFile._safe_read``
(unless ``ImageFile.LOAD_TRUNCATED_IMAGES``).
The one difference is pixel data, which is never decoded: pixel data that is
undecodable, or cut short, no longer hides the text read before it. Pillow
itself accepts a file cut inside its last pixel chunk once the decoder has
all rows, and only a decode can tell those apart, so the walk never reads
less than ``.text`` did (the PR #170 "legacy parity floor").
Anything else (other formats, animated PNGs, an image already loaded) is
answered by Pillow itself.
"""
import os
import struct
from PIL import ImageFile
_PIXEL_CHUNKS = (b"IDAT", b"fdAT")
# Signature, IHDR chunk and IDAT chunk header: the earliest pixel data offset.
_FIRST_DATA_OFFSET = 8 + 25 + 8
def png_text(image) -> dict:
"""``dict(image.text)`` for an opened image, without a pixel decode.
The image stays usable: its file position and Pillow state are restored.
Non-PNG images answer ``{}`` (only PNG has text chunks in Pillow).
"""
if not _walkable(image):
return dict(getattr(image, "text", None) or {})
fp = image.fp
resume = fp.tell()
try:
return _walk_after_pixels(image.png, fp, image.tile[0][2] - 8)
finally:
fp.seek(resume)
def _walkable(image) -> bool:
"""A plain single-frame PNG whose header pass stopped at its first IDAT."""
tile = getattr(image, "tile", None)
return (
image.format == "PNG"
and getattr(image, "png", None) is not None
and getattr(image, "fp", None) is not None
and not getattr(image, "is_animated", False)
and isinstance(tile, list)
and len(tile) == 1
and tile[0][0] == "zip"
# The first IDAT's data offset; anything else is not a layout we know.
and isinstance(tile[0][2], int)
and tile[0][2] >= _FIRST_DATA_OFFSET
)
def _walk_after_pixels(stream, fp, offset: int) -> dict:
"""Pillow's ``load_end`` chunk loop from the first IDAT header, skipping pixels.
``stream`` is the image's own ``PngStream``: its text so far and its text
memory budget carry on exactly as a ``load()`` would carry them. The text
and info dictionaries are copied first, so ``image.text`` and
``image.info`` are unchanged. Like ``load_end``, a handler for a chunk
misplaced after the pixels (a second IHDR or PLTE) may still update the
stream's other header fields; Pillow ignores them once the image is open.
"""
end = fp.seek(0, os.SEEK_END)
saved = stream.im_text, stream.im_info, stream.text_memory
stream.im_text, stream.im_info = dict(saved[0]), dict(saved[1])
try:
fp.seek(offset)
while True:
try:
kind, position, length = stream.read()
except (struct.error, SyntaxError):
break
if kind == b"IEND":
break
try:
handled = kind not in _PIXEL_CHUNKS and _handle(
stream, kind, position, length
)
except UnicodeDecodeError:
break
if not handled:
if position + length > end:
if kind in _PIXEL_CHUNKS or ImageFile.LOAD_TRUNCATED_IMAGES:
break
raise OSError("Truncated File Read")
fp.seek(position + length + 4)
continue
fp.read(4) # checksum: not verified after the pixels, as in Pillow
return stream.im_text
finally:
stream.im_text, stream.im_info, stream.text_memory = saved
def _handle(stream, kind: bytes, position: int, length: int) -> bool:
"""Run Pillow's handler; ``False`` when the chunk's bytes must be skipped."""
try:
stream.call(kind, position, length)
except (EOFError, AttributeError):
# A chunk Pillow reads past (EOFError) or has no handler for.
return False
return True
+1 -7
View File
@@ -176,12 +176,6 @@ function mountWorkspace() {
function notifyCounts() { for (const listener of countListeners) listener(); }
/** Re-read the given counts unless a read is already in flight. */
function freshenCounts(ids) { refreshCounts(ids.filter(id => !countRequests.has(id))); }
/** #555: the counts as System › Overview reads them, so its Images metric and the Images badge cannot differ. */
const sharedCounts = Object.freeze({
get: id => knownCounts.get(id),
subscribe(listener) { countListeners.add(listener); return () => countListeners.delete(listener); },
freshen: freshenCounts,
});
/** A count a page has just read itself: it replaces the badge, and an older read in flight no longer lands. */
function setCount(id, count) {
countRequests.get(id)?.abort();
@@ -494,7 +488,7 @@ function mountWorkspace() {
if (route.id === 'system') {
// The concept's System (app.js renderSystem): its heading description, section nav and Save row.
description.textContent = route.description;
system = mountSystem(main, api, view, { section: route.section, activity, counts: sharedCounts });
system = mountSystem(main, api, view, { section: route.section, activity });
return;
}
if (route.id === 'inspect') {
+38 -150
View File
@@ -11,13 +11,6 @@ const linkedTo = (target,roles) => Object.keys(roles).filter(role=>Array.isArray
const nodeLabel = target => `#${target.id} ${target.label}`;
// A lost reply leaves the outcome unknown; anything else the host said is a refusal, and nothing was written.
const LOST_REPLY = /timed out|aborted|disposed/i;
const outcomeUnknown = (posted,error) => posted && (LOST_REPLY.test(error.message) || /unexpected acknowledgement/.test(error.message));
const UNKNOWN_ADVICE = 'Check ComfyUI, then preview again.';
/** The Destination node value that sends to every eligible node of the window (#542, D191). Node ids never take it. */
export const ALL_NODES = '*';
// "All" sends in node id order: 2 before 10, and a subgraph id "3:1" after "3".
const byNodeId = (a,b) => a.id.localeCompare(b.id,'en',{numeric:true});
const allMessage = (count,where) => `Sending to all ${count} eligible nodes in ${where}.`;
/** Why nothing in the discovered windows can receive these roles, most specific first (D173). */
function unavailableMessage(found,roles) {
@@ -47,90 +40,23 @@ function distinctLabels(items,suffix) {
export function createSendSession({client, roles:rawRoles, onChange}) {
const roles = Object.freeze(validateRoles(rawRoles));
// `previews` holds the reviewed nodes of "All", in send order; `preview` the one node otherwise.
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,previews:[],outcomeUnknown:false,message:''};
let state = {phase:'idle',hosts:[],hostId:'',targetId:'',preview:null,outcomeUnknown:false,message:''};
let operation, generation = 0, disposed = false;
const emit = patch => { if (!disposed) { state = Object.freeze({...state,...patch}); onChange(state); } };
function invalidate() { generation++; operation?.abort(); operation = null; }
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,previews:[],outcomeUnknown:false,message:''}); return {signal:operation.signal,version:generation}; }
function begin(phase) { invalidate(); operation = new AbortController(); emit({phase,preview:null,outcomeUnknown:false,message:''}); return {signal:operation.signal,version:generation}; }
const current = request => !disposed && generation === request.version;
const chosenHost = () => state.hosts.find(host=>host.hostId===state.hostId);
/** The nodes the current choice sends to: one, every eligible node of the window for "All", or none. */
function chosen() {
const host=chosenHost();
if (!host) return [];
if (state.targetId===ALL_NODES) return host.targets.length>1 ? host.targets.toSorted(byNodeId) : [];
const target=host.targets.find(item=>item.id===state.targetId);
return target ? [target] : [];
}
// checkRevision is off only for a later node of "All", whose revision our own earlier write may have moved.
async function fetchPreview(request,hostId,target,checkRevision=true) {
const preview=await client.preview(hostId,target.id,roles,{signal:request.signal});
const selected = () => state.hosts.find(host=>host.hostId===state.hostId)?.targets.find(target=>target.id===state.targetId);
async function fetchPreview(request) {
const preview=await client.preview(state.hostId,state.targetId,roles,{signal:request.signal});
if (!current(request)) return null;
const fields=preview?.fields;
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==target.id || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
if(typeof preview?.ticket!=='string' || !preview.ticket || preview.target?.id!==state.targetId || !Array.isArray(fields) || fields.length!==Object.keys(roles).length || new Set(fields.map(field=>field?.role)).size!==fields.length || Array.from(fields).some(field=>!field || !Object.hasOwn(roles,field.role) || typeof field.before!=='string' || field.after!==roles[field.role])) throw Error('ComfyUI returned an unexpected preview. Find the destination and preview again.');
// Undefined on a tab still running older code, which has nothing to compare.
const found=target.revision;
if(checkRevision && found!==undefined && preview.revision!==found) throw Error('The workflow changed since this window was found. Nothing was sent. Find windows again.');
const found=selected()?.revision;
if(found!==undefined && preview.revision!==found) throw Error('The workflow changed since this window was found. Nothing was sent. Find windows again.');
return preview;
}
function checkAcknowledgement(acknowledgement,targetId) {
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
}
const reviewedText = change => JSON.stringify([change.fields.map(field=>[field.role,field.before]).sort(),change.composition ?? null]);
const sameChanges = (reviewed,fresh) => reviewedText(reviewed)===reviewedText(fresh);
/** Q542-2 C: every node of "All" previews before anything is written; the first refusal names its node. */
async function previewEach(request,hostId,targets) {
const reviewed=[];
for (const target of targets) {
try {
const preview=await fetchPreview(request,hostId,target);
if (!preview) return null;
reviewed.push({target,label:target.label,preview});
} catch(error) {
if (!current(request)) return null;
throw Error(`${target.label}: ${error.message}${/Nothing was sent/.test(error.message) ? '' : ' Nothing was sent.'}`);
}
}
return reviewed;
}
/**
* Sends the reviewed nodes one after another, best effort past refusals. Only the first node of a pass taken in this
* click keeps its ticket: a held review may have expired at the host (30 s), and our own writes may make later
* tickets stale, so every other node takes a fresh ticket that must show exactly the reviewed text.
*/
async function applyEach(request,hostId,reviewed,where,held) {
const sent=[],refused=[];let written=false;
for (const [index,{target,preview}] of reviewed.entries()) {
let posted=false;
try {
let ticket=preview.ticket;
if (index>0 || held) {
// Until something was posted the revision is still the discovered one; after that our own write may move it.
const fresh=await fetchPreview(request,hostId,target,!written);
if (!fresh) return;
if (!sameChanges(preview,fresh)) throw Error('Its prompt changed since the preview. Preview again.');
ticket=fresh.ticket;
}
posted=written=true;
const acknowledgement=await client.apply(hostId,ticket,{signal:request.signal});
if (!current(request)) return;
checkAcknowledgement(acknowledgement,target.id);
sent.push(target);
} catch(error) {
if (!current(request)) return;
const summary=[`Sent to ${sent.length} of ${reviewed.length} nodes in ${where}.`,...refused];
if (outcomeUnknown(posted,error)) {
const unsent=reviewed.slice(index+1).map(item=>item.label);
emit({phase:'error',outcomeUnknown:true,message:[...summary,`No confirmation received from ${target.label}; the update may have applied.`,...(unsent.length ? [`Not sent: ${unsent.join(', ')}.`] : []),UNKNOWN_ADVICE,error.message].join(' ')});
return;
}
refused.push(`${target.label}: ${error.message}`);
}
}
const message=[`Sent to ${sent.length} of ${reviewed.length} nodes in ${where}.`,...refused].join(' ');
emit(refused.length ? {phase:'error',message} : {phase:'success',message});
}
function hostChoice(host) {
if (host.targets.length>1) return {hostId:host.hostId,targetId:'',message:`Choose a destination node in ${host.label}.`};
const [target] = host.targets;
@@ -153,61 +79,38 @@ export function createSendSession({client, roles:rawRoles, onChange}) {
else { const choice=hostChoice(hosts[0]); emit({phase:'select',hosts,...choice,message:choice.message+note}); }
} catch (error) { if (current(request)) emit({phase:'error',message:error.message}); }
},
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,previews:[],outcomeUnknown:false,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
selectTarget(targetId) {
if (disposed) return; invalidate(); const host=chosenHost();
// D173: "All" is a choice only where there are two or more nodes, and it is never made for you.
const all=targetId===ALL_NODES && host?.targets.length>1;
const chosenOne=all || Boolean(host?.targets.some(target=>target.id===targetId));
const message=all ? `${allMessage(host.targets.length,host.label)} Apply to send it, or preview the changes first.` : chosenOne ? 'Apply to send it, or preview the changes first.' : 'Choose a destination node, then preview changes.';
emit({phase:'select',outcomeUnknown:false,targetId:chosenOne?targetId:'',preview:null,previews:[],message});
},
selectHost(hostId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===hostId); emit({phase:'select',preview:null,outcomeUnknown:false,...(host ? hostChoice(host) : {hostId:'',targetId:'',message:'Choose a ComfyUI window.'})}); },
selectTarget(targetId) { if (disposed) return; invalidate(); const host=state.hosts.find(host=>host.hostId===state.hostId); const chosen=host?.targets.some(target=>target.id===targetId); emit({phase:'select',outcomeUnknown:false,targetId:chosen?targetId:'',preview:null,message:chosen?'Apply to send it, or preview the changes first.':'Choose a destination node, then preview changes.'}); },
async preview() {
const targets=chosen();
if (disposed || !targets.length || ['previewing','applying'].includes(state.phase)) return;
const all=state.targetId===ALL_NODES; const hostId=state.hostId; const windowLabel=chosenHost()?.label;
if (disposed || !selected() || ['previewing','applying'].includes(state.phase)) return;
const request=begin('previewing');
try {
if (all) {
const previews=await previewEach(request,hostId,targets);
if (previews) emit({phase:'ready',previews,message:`${allMessage(targets.length,windowLabel)} Review the complete changes, then apply.`});
return;
}
const preview=await fetchPreview(request,hostId,targets[0]);
const preview=await fetchPreview(request);
if (!preview) return;
const windowLabel=state.hosts.find(host=>host.hostId===state.hostId)?.label;
emit({phase:'ready',preview,message:`Sending to ${nodeLabel({id:preview.target.id,label:preview.target.label ?? ''})} in ${windowLabel}. Review the complete changes, then apply.`});
} catch(error) { if(current(request)) emit({phase:'error',preview:null,previews:[],message:error.message}); }
} catch(error) { if(current(request)) emit({phase:'error',preview:null,message:error.message}); }
},
async apply() {
// Success stays closed: the second click of a double click must not send again.
const targets=chosen();
if(disposed || !targets.length || !['select','ready','error'].includes(state.phase) || state.outcomeUnknown) return;
const all=state.targetId===ALL_NODES;
const held=state.phase==='ready' ? (all ? state.previews : state.preview) : null;
const hostId=state.hostId; const windowLabel=chosenHost()?.label;
if(disposed || !selected() || !['select','ready','error'].includes(state.phase) || state.outcomeUnknown) return;
const held=state.phase==='ready' ? state.preview : null;
const targetId=state.targetId; const hostId=state.hostId;
const request=begin('applying');
emit({message:'Waiting for ComfyUI confirmation. Closing this dialog does not undo a sent update.'});
if (all) {
let reviewed;
try { reviewed=held?.length ? held : await previewEach(request,hostId,targets); }
catch(error) { if(current(request)) emit({phase:'error',message:error.message}); return; }
if (reviewed) await applyEach(request,hostId,reviewed,windowLabel,Boolean(held?.length));
return;
}
const [target]=targets;
let posted=false;
try {
const preview=held ?? await fetchPreview(request,hostId,target);
const preview=held ?? await fetchPreview(request);
if(!preview || !current(request)) return;
posted=true;
const acknowledgement=await client.apply(hostId,preview.ticket,{signal:request.signal});
if(!current(request)) return;
checkAcknowledgement(acknowledgement,target.id);
if(acknowledgement?.targetId!==targetId || !acknowledgement.values || Object.keys(acknowledgement.values).length!==Object.keys(roles).length || Object.keys(roles).some(role=>!Object.hasOwn(acknowledgement.values,role) || acknowledgement.values[role]!==roles[role])) throw Error('ComfyUI returned an unexpected acknowledgement.');
emit({phase:'success',message:'Sent to ComfyUI.'});
} catch(error) {
if(!current(request)) return;
const unknown=outcomeUnknown(posted,error);
emit({phase:'error',preview:null,outcomeUnknown:unknown,message:unknown ? `No confirmation received. The update may have applied. ${UNKNOWN_ADVICE} ${error.message}` : error.message});
const unknown=posted && (LOST_REPLY.test(error.message) || /unexpected acknowledgement/.test(error.message));
emit({phase:'error',preview:null,outcomeUnknown:unknown,message:unknown ? `No confirmation received. The update may have applied. Check ComfyUI, then preview again. ${error.message}` : error.message});
}
},
dispose() { if(disposed) return; disposed=true; invalidate(); client.dispose(); },
@@ -256,7 +159,7 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
inspect.disabled=true;apply.disabled=true;
const actions=element('footer',undefined,'prompt-sender-actions');actions.append(find,inspect,apply);
dialog.append(header,description,controls,status,preview,actions);document.body.append(dialog);
let session,closed=false,headingIds=0;
let session,closed=false;
function dispose() {
if(closed) return;closed=true;local.dispose();session?.dispose();
if(dialog.open) dialog.close();dialog.remove();
@@ -273,8 +176,8 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
function options(select,placeholder,entries,value) {
select.replaceChildren(...[["",placeholder],...entries].map(([value,label])=>{const option=element('option',label);option.value=value;return option;}));select.value=value;
}
function textBlock(parent,label,value,level=4) {
parent.append(element(`h${Math.min(level,6)}`,label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
function textBlock(parent,label,value) {
parent.append(element('h4',label));const text=element('pre');text.tabIndex=0;text.append(element('code',value === '' ? '(empty)' : value));parent.append(text);
}
function render(state) {
if(closed) return;
@@ -284,41 +187,26 @@ export function createPromptSender({scope,baseUrl=window.ApiClient.resolveUrl('/
dialog.setAttribute('aria-busy',String(busy));
options(host,'Choose a ComfyUI window',state.hosts.map(item=>[item.hostId,item.label]),state.hostId);
const selected=state.hosts.find(item=>item.hostId===state.hostId);
const nodes=(selected?.targets || []).map(item=>[item.id,item.label]);
// Q542-4 A: the last option sends to every eligible node of this window.
if(nodes.length>1) nodes.push([ALL_NODES,`All ${nodes.length} eligible nodes`]);
options(target,'Choose a destination node',nodes,state.targetId);
options(target,'Choose a destination node',(selected?.targets || []).map(item=>[item.id,item.label]),state.targetId);
host.disabled=busy;target.disabled=busy || !selected;
find.disabled=busy;inspect.disabled=busy || !state.targetId;apply.disabled=busy || !state.targetId || state.phase==='success' || state.outcomeUnknown;
preview.replaceChildren();
if(state.preview) {
preview.append(element('h3','Review changes'));
changes(preview,state.preview,3);
}
if(state.previews?.length) {
preview.append(element('h3','Review changes'));
// One section per node of "All", in the order they will be sent, named by its heading.
for(const item of state.previews) {
const node=element('section',undefined,'prompt-sender-node');const heading=element('h4',item.label);
heading.id=`prompt-sender-node-${++headingIds}`;node.setAttribute('aria-labelledby',heading.id);
node.append(heading);changes(node,item.preview,5);preview.append(node);
for(const field of state.preview.fields || []) {
const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
section.append(element('h3',field.role==='positive'?'Positive prompt':'Negative prompt'));
textBlock(section,'Before',field.before);textBlock(section,'After',field.after);preview.append(section);
}
const composition=state.preview.composition;
if(composition) {
const section=element('section',undefined,'prompt-sender-composition');section.append(element('h3','Positive composition'));
textBlock(section,'Prepend (preserved)',composition.prepend || '');textBlock(section,'Append (preserved)',composition.append || '');
textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.');
textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.');
if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
preview.append(section);
}
}
}
function changes(parent,change,level) {
for(const field of change.fields || []) {
const section=element('section',undefined,`prompt-sender-field prompt-role-${field.role}`);
section.append(element(`h${level}`,field.role==='positive'?'Positive prompt':'Negative prompt'));
textBlock(section,'Before',field.before,level+1);textBlock(section,'After',field.after,level+1);parent.append(section);
}
const composition=change.composition;
if(composition) {
const section=element('section',undefined,'prompt-sender-composition');section.append(element(`h${level}`,'Positive composition'));
textBlock(section,'Prepend (preserved)',composition.prepend || '',level+1);textBlock(section,'Append (preserved)',composition.append || '',level+1);
textBlock(section,'Combined before',composition.before ?? 'Resolved at execution from connected inputs.',level+1);
textBlock(section,'Combined after',composition.after ?? 'Resolved at execution from connected inputs.',level+1);
if(composition.prependConnected || composition.appendConnected) section.append(element('p','Connected prepend and append inputs are preserved and resolved when the workflow runs.'));
parent.append(section);
}
}
dialog.showModal();
+2 -16
View File
@@ -179,27 +179,13 @@ export function reviewRefusal(error) {
return REVIEW_FAILED;
}
/**
* #583: the review uploads not answered yet. WebKit can read a FormData's file part after the fetch call returned (it
* did whenever a request interceptor, here Playwright's, was armed); a garbage collection in between dropped a form
* nothing referenced any more, and the server got an empty file. Each form is held here until its upload is answered.
* Exported for the unit tests only.
*/
export const uploadsInFlight = new Set();
/** Stage `file` for review; the server's answer, or an ApiError naming its refusal. */
export async function uploadRestore(file, { fetchUrl = window.ApiClient.fetchUrl, ApiError = window.ApiClient.ApiError,
signal } = {}) {
const form = new FormData();
form.append('database_file', file, file.name);
uploadsInFlight.add(form);
let response;
try {
response = await fetchUrl(PREVIEW_URL, { method: 'POST', body: form, cache: 'no-store', credentials: 'same-origin',
signal });
} finally {
uploadsInFlight.delete(form);
}
const response = await fetchUrl(PREVIEW_URL, { method: 'POST', body: form, cache: 'no-store', credentials: 'same-origin',
signal });
let data = null;
try {
data = await response.json();
+17 -48
View File
@@ -11,10 +11,8 @@
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
* - #555: metric 3, "Images", is the shell's Images count (the Images page's total, its sidebar badge), not the
* overview's indexed rows, which leave out files no row links: a dash until it is read or when its read failed.
* - D96: the Statistics dialog lists the real runs per category, then "Database records" (D153: no rating column or
* rated-images total; #555: the legacy row counts, labelled as records).
* - D96: the Statistics dialog lists the real runs per category, then "Library totals" (D153: no rating column or
* rated-images total).
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
* read job of the registry, tracked in the Activity store (its event stream); the report opens when it ends.
@@ -63,20 +61,12 @@ export function integrityText(health) {
return health.status === 'passed' ? 'Passed' : 'Incomplete';
}
/** #555: the Images metric's value from the shell's Images count; a dash while unread (undefined) or failed (null). */
export function imagesText(images) {
return Number.isSafeInteger(images) && images >= 0 ? number(images) : '–';
}
/**
* The concept's four metrics `[value, label]`, in its order. `images` is the shell's Images count (#555): the concept's
* `images.length`, the same number as the Images sidebar badge.
*/
export function metricValues(overview, images) {
/** The concept's four metrics `[value, label]`, in its order. */
export function metricValues(overview) {
return [
[number(overview.library.positive_prompts), 'Positive prompts'],
[number(overview.library.negative_variants), 'Negative variants'],
[imagesText(images), 'Images'],
[number(overview.images.indexed), 'Images'],
[integrityText(overview.health), 'Database integrity'],
];
}
@@ -184,15 +174,10 @@ export function categoryRows(statistics) {
return statistics.by_category.map(row => [row.category ?? 'Uncategorized', number(row.runs)]);
}
/**
* D96: the added "Database records" rows `[label, count]` (the legacy statistics; D153 drops the rated images). #555:
* they count raw rows (every prompt row, tags on a prompt, every image row), so they say so and never read as the
* Library or Images page counts.
*/
/** D96: the added "Library totals" rows `[label, count]` (the legacy statistics; D153 drops the rated images). */
export function totalsRows(statistics) {
const { totals } = statistics;
return [['Prompt records', totals.total_prompts], ['Tags on prompts', totals.total_tags],
['Image records', totals.total_images]]
return [['Prompts', totals.total_prompts], ['Tags', totals.total_tags], ['Images', totals.total_images]]
.map(([label, value]) => [label, number(value)]);
}
@@ -223,11 +208,11 @@ function table(caption, head, rows, empty) {
return node;
}
/** The Statistics dialog: "Runs by category" (the concept's table), then "Database records" (D96, #555). */
/** The Statistics dialog: "Runs by category" (the concept's table), then "Library totals" (D96). */
export function statisticsNodes(statistics) {
return [
table('Runs by category', ['Category', 'Runs'], categoryRows(statistics), 'No prompts in the library yet.'),
table('Database records', ['Total', 'Count'], totalsRows(statistics), ''),
table('Library totals', ['Total', 'Count'], totalsRows(statistics), ''),
];
}
@@ -347,9 +332,9 @@ export function healthNotice({ api, activity, scope, navigate = navigateTo, noti
return notice;
}
function metricsNode(overview, images) {
function metricsNode(overview) {
const metrics = el('div', undefined, 'summary-metrics');
for (const [value, label] of metricValues(overview, images)) {
for (const [value, label] of metricValues(overview)) {
const metric = el('div', undefined, 'metric');
metric.append(el('strong', value), el('span', label));
metrics.append(metric);
@@ -364,7 +349,7 @@ function surface(heading, rows) {
}
/** The Overview's nodes, and its thumbnail row (painted by its own read). */
function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails, images }) {
function overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics, openThumbnails }) {
const go = section => () => navigate(`#/system/${section}`);
const review = operationRow({ scope, title: ambiguousTitle(overview.needs_review),
description: Number.isSafeInteger(overview.needs_review?.pending)
@@ -389,36 +374,26 @@ function overviewNodes(overview, { api, activity, notify, scope, navigate, openS
operationRow({ scope, title: 'One display configuration', description: 'Grid and density settings are shared across views.',
label: 'Appearance', onClick: go('appearance') }),
operationRow({ scope, title: sourcesTitle, description: sourcesDescription, label: 'Manage sources', onClick: go('sources') }),
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with database record counts.', label: 'Explore',
operationRow({ scope, title: 'Statistics', description: 'Runs by category, with library totals.', label: 'Explore',
onClick: openStatistics }),
]);
const columns = el('div', undefined, 'two-col');
columns.append(health, preferences);
const metrics = metricsNode(overview, images);
return { nodes: [metrics, columns], thumbnails, backup: backupRow, metrics };
return { nodes: [metricsNode(overview), columns], thumbnails, backup: backupRow };
}
/**
* The Overview section's mount (`mountSystem`'s `sections.overview`): returns its loading line, which the server's
* answer replaces in place (the concept's metrics and columns sit directly in `.settings-layout`). A read that a newer
* one superseded, or one answering after the section left, paints nothing.
*
* `counts` is the shell's sidebar counts (`{get(id), subscribe(listener), freshen(ids)}`, index.mjs): the Images metric
* shows its `images` count and follows it, and a visit re-reads it as the workspace Overview does (#555).
*/
export function mountOverview(container, { api, activity, scope, counts = null, navigate = navigateTo,
notify = showWorkspaceToast, latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
export function mountOverview(container, { api, activity, scope, navigate = navigateTo, notify = showWorkspaceToast,
latestOnly = window.ApiClient.latestOnly, STALE = window.ApiClient.STALE }) {
const overviewRead = latestOnly({ lifetime: scope.signal });
const statisticsRead = latestOnly({ lifetime: scope.signal });
const thumbnailsRead = latestOnly({ lifetime: scope.signal });
const backupRead = latestOnly({ lifetime: scope.signal });
let shown = [];
// #555: the Images metric's value node, repainted whenever the shell's Images count changes.
let imagesValue = null;
const imagesCount = () => counts?.get('images');
const paintImages = () => {
if (imagesValue) imagesValue.textContent = imagesText(imagesCount());
};
// The backup status (undefined while read, null when it failed), painted into its row once both are there (S9).
let backupStatus;
let backupRow = null;
@@ -464,10 +439,8 @@ export function mountOverview(container, { api, activity, scope, counts = null,
try {
const overview = await overviewRead(signal => api.get(OVERVIEW_URL, { signal, cache: 'no-store' }));
if (overview === STALE || scope.signal.aborted) return;
const { nodes, thumbnails, backup, metrics } = overviewNodes(overview, { api, activity, notify, scope, navigate,
openStatistics, images: imagesCount(),
const { nodes, thumbnails, backup } = overviewNodes(overview, { api, activity, notify, scope, navigate, openStatistics,
openThumbnails: trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate }) });
imagesValue = metrics.children[2].querySelector('strong');
thumbnailRow = thumbnails;
backupRow = backup;
sources = overview.sources;
@@ -515,10 +488,6 @@ export function mountOverview(container, { api, activity, scope, counts = null,
const first = swap(loading());
// D129: a finished Move library changes where the library lives.
scope.cleanup(onMoveEnded(activity, load));
if (counts) {
scope.cleanup(counts.subscribe(paintImages));
counts.freshen(['images']);
}
load();
loadThumbnails();
loadBackup();
+3 -4
View File
@@ -135,7 +135,7 @@ export const DELIVERED_SECTIONS = Object.freeze({
/**
* Mount System under the heading `renderHeading` wrote in `main`. Returns `{show(section)}`, which shows another
* section in place. `scope` is the page's view scope; `sections` maps a section id to a delivered section's mount
* (`(container, {api, activity, scope, drafts, counts}) => nodes`; `counts` is the shell's sidebar counts, #555) for every section. `activity` is the page's Activity store, which follows the jobs a section starts.
* (`(container, {api, activity, scope, drafts}) => nodes`) for every section. `activity` is the page's Activity store, which follows the jobs a section starts.
*
* The host values are read on mount (and by the store again after a conflict), so a save carries the current revision;
* until a read returned one, a host save fails without a request (system-state.mjs). The browser values are read on
@@ -159,8 +159,7 @@ function legacyPagesLine() {
return line;
}
export function mountSystem(main, api, scope, { section, activity, counts = null, sections = DELIVERED_SECTIONS,
navigate = navigateTo }) {
export function mountSystem(main, api, scope, { section, activity, sections = DELIVERED_SECTIONS, navigate = navigateTo }) {
const store = systemDrafts(api);
jobs = activity;
store.reloadHost();
@@ -209,7 +208,7 @@ export function mountSystem(main, api, scope, { section, activity, counts = null
for (const button of nav.children) button.setAttribute('aria-pressed', String(button.dataset.value === next));
while (layout.firstChild !== saveRow) layout.firstChild.remove();
// Every section is delivered (S12), and the router only names known sections (state.mjs).
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store, counts });
const nodes = sections[next](layout, { api, activity, scope: sectionScope, drafts: store });
layout.prepend(...nodes);
if (next === 'overview') main.append(legacyPages);
else legacyPages.remove();
-3
View File
@@ -416,9 +416,6 @@ pre.prompt-text { font: inherit; font-size: 12px; line-height: 1.75; margin-bloc
.prompt-sender-preview { margin-block: 20px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field { border: 1px solid var(--ca-line); border-radius: 7px; padding: 14px; margin-block: 12px; min-inline-size: 0; /* D48 */ }
.prompt-sender-field :where(pre, textarea), .prompt-sender-composition { white-space: pre-wrap; overflow-wrap: anywhere; font: inherit; font-size: 12px; line-height: 1.65; max-inline-size: 100%; /* D48 */ }
/* D191 (#542): each node of "All eligible nodes" reviews under its own heading. */
.prompt-sender-node { border-block-start: 1px solid var(--ca-line); padding-block-start: 14px; margin-block: 18px; min-inline-size: 0; /* D191 */ }
.prompt-sender-node > h4 { font-size: 15px; /* D191 */ }
.prompt-sender-field textarea { inline-size: 100%; min-block-size: 100px; resize: vertical; /* D48 */ }
.prompt-sender-composition { padding: 12px; background: var(--ca-field); border-radius: 6px; /* D48 */ }
.prompt-sender-actions { justify-content: flex-end; margin-block-start: 18px; /* D48 */ }