perf(images): refresh the listing when the monitor sees a new output (#348, PR 2) (#353)

* perf(images): refresh the listing when the monitor sees a new output (#348, PR 2)

The image monitor now reports every settled output (linked or not, also
when linking raised) with its resolved path through set_output_listener.
The API's notify_output_settled hops from the monitor's worker thread onto
the server's loop (call_soon_threadsafe) and does nothing before the loop is
known or after cleanup. mark_root_dirty matches the path against each
walk's resolved_root (normcase(realpath), computed in the executor by
walk_root, now its fourth value) and marks the root dirty.

A dirty root gets one background walk, no sooner than
rewalk_interval = min(15, max(5, 5 x last walk's duration)) after the
previous walk started (last_started_at / last_duration are recorded on
every walk). _schedule_walk is shared with PR 1's delayed stale refresh:
one deferred walk per root, never armed while a walk runs (the walk's end
re-checks dirty: the trailing walk), at least 0.5 s away, the earlier
deadline wins, and every walk start cancels it. A failed walk clears dirty.
Rescan semantics are unchanged.

* fix(images): keep removed roots and cancelled walks from walking again (#348, PR 2)

Review findings on the monitor-triggered walks:

- reset_gallery_roots_cache now retires every listing record: its pending
  timer is cancelled, dirty is cleared, and no mark, timer or trailing walk
  starts a walk of it until a reader or the warm-up asks for that root
  again. Before, a timer or trailing walk pending when the roots changed
  stored a listing for the removed root, and the monitor (which watches the
  old roots until restart) kept it walking on every new output.
- A walk task cancelled outside cleanup_gallery_listings no longer starts a
  trailing walk while the loop shuts down.
- root_contains ignores a Windows extended-length prefix (\\?\ and
  \\?\UNC\) on either side, which realpath can keep on a file past MAX_PATH.
- The REWALK comment states the ~20% GIL share only for walks up to 3 s.

* test(images): prove a cancelled walk starts no trailing walk (#348, PR 2)

The previous assertions held even with the trailing walk, because that
walk finished before they ran; assert the walk number and the scan count.

* perf(images): add a settled output to the listing without a walk (#348, PR 2)

Live test: each landing in a 20,000-file folder cost a full walk (~0.4 s of
GIL) plus a full workspace rebuild, so Images reads during a run took up to
2.1 s. The monitor's report now patches the stored listing instead.

- output_settled (the monitor hop's target) slices the file's part below
  the root in its own case (relative_part; Windows compares folded), and
  _run_adds places one root's pending files in one executor batch:
  _settled_entry applies the walk's filters (suffix, thumbnails, depth,
  symlink by lstat; macOS on-disk spelling), insert_entry puts the
  (Path, mtime) entry where the newest-first sort does, add_folder copies
  the folder sets. Stored only when no walk started and no drop happened
  meanwhile; walked_at is untouched, so the 30 s refresh still walks.
- Anything inexact marks the root dirty and walks as before: a running
  walk, an unsliceable path, a failed lstat, a tie in mtime, the cap, an
  unexpected error. Rescan, the refresh, the warm-up and invalidation stay
  full walks; a walk start forgets the pending adds.
- The workspace caches follow a patched listing by entry identity
  (listing_delta): only added values are built, the newest order places
  them by bisection, the favorites lookup is copied and extended.

Synthetic 20,000 files: per landing 252 ms (walk + full build) -> 18 ms
(3 ms add + 15 ms warm build).

* fix(images): review fixes for adds without a walk (#348, PR 2)

- _newest_step steps the cached newest order only when the per-file cache
  still holds exactly the values this call built. A stale page read racing
  a newer warm build (both run in executor threads) stored an order
  labelled with older values than it held, and the next read inserted the
  new file a second time. It also sorts in full when a roots reset dropped
  the per-file values meanwhile.
- The stored spelling is checked on Windows too (nt._getfinalpathname:
  realpath keeps the given case when it cannot open the file), and before
  the name filters, which compare the walk's stored spelling. macOS opens
  non-blocking, so a FIFO named like an image never holds the executor.
- An output in a folder the walk cannot list (no read permission) is not
  added, as the walk skips it.
- Windows-portable unit tests: POSIX paths in the insert tests, and a
  str.lower fold for the length guard (Windows' normcase keeps lengths).

* test(images): reproduce the newest-order race and fix a Windows clock flake (#348, PR 2)

- The race test now has the newer build step from the values the cached
  order was built from, which is the interleaving that inserted a file
  twice; it fails without the parts check in _newest_step.
- test_a_failed_walk_records_its_timing_and_clears_dirty compared two
  monotonic readings that Windows' ~15.6 ms clock can make equal; the
  previous start is moved back instead.
- TESTING.md: os.access ignores ACLs on Windows.
This commit is contained in:
Vito
2026-10-26 16:14:50 -07:00
committed by GitHub
parent 9ac471eb72
commit c7ba481d3c
10 changed files with 2294 additions and 66 deletions
+41 -6
View File
@@ -362,8 +362,11 @@ class PromptManagerAPI(
# background walks refreshing them (py/api/gallery_listing.py, #348).
self._root_listings: dict[str, RootListing] = {}
self._listing_tasks: set = set()
# The loop the walks run on, set by the first walk.
# The loop the walks run on, set by the startup warm-up or the first
# walk; the monitor's thread hops onto it (notify_output_settled).
self._listing_loop = None
# Set by cleanup_gallery_listings: no background walk starts after it.
self._listings_closed = False
# The workspace caches rebuilt behind a stored walk that changed the
# listing, once the host's startup warm-up has run (#348).
self._workspace_warm_enabled = False
@@ -492,7 +495,7 @@ class PromptManagerAPI(
"""The shared listing record of one output root (created empty)."""
return self._root_listings.setdefault(str(root), RootListing())
def invalidate_gallery_cache(self):
def invalidate_gallery_cache(self, retire=False):
"""Drop every root's listing, so the next read waits for a new walk.
Called after files are deleted through the app (duplicate cleanup and
@@ -503,6 +506,9 @@ class PromptManagerAPI(
_workspace_listed and _card_facts); its sort orders are dropped, as
the walk replaces them anyway, and so is the favorites lookup, which
holds the old list.
``retire`` (the roots changed): no background walk of a root starts
again until a reader or the warm-up asks for it.
"""
loop = self._listing_loop
if loop is not None and loop.is_running() and _running_loop() is not loop:
@@ -513,7 +519,7 @@ class PromptManagerAPI(
def drop():
try:
self._drop_listings()
self._drop_listings(retire)
finally:
dropped.set()
@@ -521,11 +527,19 @@ class PromptManagerAPI(
if not dropped.wait(_OFF_LOOP_DROP_WAIT):
self.logger.warning("The Images listing drop is still queued")
return
self._drop_listings()
self._drop_listings(retire)
def _drop_listings(self):
def _drop_listings(self, retire=False):
for record in list(self._root_listings.values()):
record.drop()
if retire:
# The roots changed: a removed root must not come back through
# a pending timer, a trailing walk or a monitor mark.
record.retired = True
record.dirty = False
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel()
self._workspace_order_cache = {}
self._favorite_listing_cache = {}
@@ -539,15 +553,36 @@ class PromptManagerAPI(
# Each stored walk that changes a listing then rebuilds the workspace's
# per-file values, orders and favorites behind it (_warm_workspace).
self._workspace_warm_enabled = True
self._listing_loop = asyncio.get_running_loop()
self._listings_closed = False
try:
for root in self._get_all_output_dirs():
self._root_listing(root).retired = False
self._schedule_walk(root)
except Exception as e:
self.logger.warning(f"Could not warm the Images listing: {e}")
def notify_output_settled(self, path):
"""The image monitor settled an output at *path* (any thread).
Called on the monitor's worker thread with the resolved path. It only
hands the path to the event loop (``output_settled``) and never
touches a listing from this thread; before the loop is known, or once
the listings are cleaned up, it does nothing.
"""
loop = self._listing_loop
if loop is None or self._listings_closed or not loop.is_running():
return
try:
loop.call_soon_threadsafe(self.output_settled, path)
except RuntimeError:
pass # the loop closed meanwhile (shutdown)
async def cleanup_gallery_listings(self, app=None):
"""Stop the background listing walks (aiohttp on_cleanup)."""
self._workspace_warm_enabled = False
# No mark, deferred walk or trailing walk starts a walk from now on.
self._listings_closed = True
# Before any await, so no delayed refresh can start a walk meanwhile.
for record in self._root_listings.values():
handle, record.rewalk_handle = record.rewalk_handle, None
@@ -571,7 +606,7 @@ class PromptManagerAPI(
def reset_gallery_roots_cache(self):
"""Drop every cached listing and derived workspace value (roots changed)."""
self.invalidate_gallery_cache()
self.invalidate_gallery_cache(retire=True)
self._workspace_listed_cache = {}
self._workspace_card_cache = {}
+211 -20
View File
@@ -6,13 +6,20 @@ loop; the walk itself, the comparison with the previous listing and both
folder sets run in the executor through ``walk_root``. Routes live in
``py/api/images.py`` (``_listing``); this module has no aiohttp import so
tests can exercise it without a server.
A settled output the image monitor reports is added to its root's stored
listing without a walk (``output_settled``, ``insert_entry``), in the place
a walk would list it. When that cannot be exact, the root is marked dirty
(``mark_root_dirty``) instead; a dirty root gets one background walk, no
sooner than ``rewalk_interval`` after its previous walk started (PR 2).
"""
from __future__ import annotations
import asyncio
import os
from dataclasses import dataclass
from bisect import bisect_left
from dataclasses import dataclass, field
from pathlib import PurePath
from typing import Callable, NamedTuple
@@ -26,6 +33,19 @@ REFRESH_AFTER = 30
# (#348 live test: reads during the walk took 2.2 s instead of 0.07 s).
REFRESH_DELAY = 1.0
# No background walk of a root starts sooner than rewalk_interval(last walk's
# duration) after its previous walk started: FACTOR times that duration, at
# least FLOOR and at most CAP seconds. Walks up to CAP / FACTOR (3 s) then
# hold the GIL at most ~20% of the time; the cap keeps a new output listed
# within ~20 s, at the cost of a higher share (back to back from 15 s) on
# slower walks. Read through this module so tests can patch them.
REWALK_FLOOR = 5
REWALK_FACTOR = 5
REWALK_CAP = 15
# A deferred walk is never armed sooner than this: no zero-delay loop.
MIN_REWALK_DELAY = 0.5
# A reader with no listing gives up (500) after this many walks in a row were
# discarded by invalidations made while they ran.
MAX_DISCARDED_WALKS = 3
@@ -80,14 +100,35 @@ class RootListing:
flight: asyncio.Future | None = None
# True while walks keep failing: one warning per streak.
failing: bool = False
# The delayed refresh not started yet (loop.call_later), or None.
# The one deferred background walk not started yet (loop.call_later), or
# None: a stale refresh or a dirty root's rewalk, whichever is due first.
rewalk_handle: asyncio.TimerHandle | None = None
# normcase(realpath(root)), computed by the walk in the executor: monitor
# paths are matched against it without file I/O on the loop.
resolved_root: str | None = None
# Monotonic start and duration of the last walk (rewalk_interval).
last_started_at: float | None = None
last_duration: float | None = None
# A settled output was reported under this root; cleared when a walk starts.
dirty: bool = False
# Dropped because the output roots changed: no background walk until a
# reader (or the warm-up) asks for this root again. The monitor keeps
# watching a removed root until ComfyUI restarts.
retired: bool = False
# Relative paths of settled outputs waiting to be added to ``files``
# without a walk, and the one task adding them (``output_settled``).
pending_adds: list = field(default_factory=list)
add_task: asyncio.Task | None = None
# Settled outputs added to the stored listing since the last walk stored.
patched: int = 0
def drop(self) -> None:
self.files = None
self.folders = None
self.folders_with_ancestors = None
self.walked_at = None
self.pending_adds = []
self.patched = 0
self.generation += 1
@@ -99,26 +140,174 @@ def folder_sets(root, files) -> tuple[frozenset, frozenset]:
files, plus every ancestor joined by ``/``.
"""
# From the path strings: relative_to per file was half of a large walk.
sep, prefix = _root_prefix(root)
folders = set()
for path, _mtime in files:
rel_dir = _folder_of(root, path, sep, prefix)
if rel_dir:
folders.add(rel_dir)
with_ancestors = set(folders)
for folder in folders:
with_ancestors.update(_ancestors(folder))
return frozenset(folders), frozenset(with_ancestors)
def add_folder(root, path, folders, with_ancestors) -> tuple[frozenset, frozenset]:
"""Both folder sets once *path* is listed too (the same sets when it adds none)."""
sep, prefix = _root_prefix(root)
rel_dir = _folder_of(root, path, sep, prefix)
if not rel_dir or rel_dir in folders:
return folders, with_ancestors
return folders | {rel_dir}, with_ancestors | {rel_dir, *_ancestors(rel_dir)}
def _root_prefix(root) -> tuple[str, str]:
# The separator is the path flavour's own (tests use PureWindowsPath).
sep = str(type(root)("a", "b"))[1]
prefix = str(root)
if not prefix.endswith(sep):
prefix += sep
folders = set()
for path, _mtime in files:
text = str(path)
if text.startswith(prefix):
rel_dir = text[len(prefix) :].rpartition(sep)[0]
else:
rel_dir = str(path.relative_to(root).parent)
if rel_dir and rel_dir != ".":
folders.add(rel_dir)
with_ancestors = set(folders)
for folder in folders:
parts = folder.replace("\\", "/").split("/")
for i in range(1, len(parts)):
with_ancestors.add("/".join(parts[:i]))
return frozenset(folders), frozenset(with_ancestors)
return sep, prefix
def _folder_of(root, path, sep, prefix) -> str:
"""The folder ``/gallery/subfolders`` names for *path*; "" for the root."""
text = str(path)
if text.startswith(prefix):
rel_dir = text[len(prefix) :].rpartition(sep)[0]
else:
rel_dir = str(path.relative_to(root).parent)
return "" if rel_dir == "." else rel_dir
def _ancestors(folder: str) -> list[str]:
parts = folder.replace("\\", "/").split("/")
return ["/".join(parts[:i]) for i in range(1, len(parts))]
def relative_part(resolved_root: str, path, pathmod=os.path) -> str | None:
"""The part of the resolved *path* below *resolved_root*, as a walk spells it.
Kept in *path*'s own case (``realpath`` gives a file's on-disk case on
Windows) with the native separator. None when *path* is not below the
root, or when the case folding changed the length so the slice would
not be exact.
"""
sep = pathmod.sep
text = str(path)
if sep == "\\":
text = _plain(text.replace("/", sep), pathmod)
folded = pathmod.normcase(text)
prefix = _plain(resolved_root, pathmod)
if not prefix.endswith(sep):
prefix += sep
if len(folded) != len(text) or not folded.startswith(prefix):
return None
relative = text[len(prefix) :]
if any(part in ("", ".", "..") for part in relative.split(sep)):
return None
return relative
def insert_entry(files: list, entry: tuple, max_files: int) -> list | None:
"""A new listing with *entry* ``(path, mtime)`` where a walk would list it.
*files* is newest first; an entry for the same path is replaced. Returns
*files* itself when it already holds *entry*, and None when the place is
not exact: another file has the same mtime (a walk orders those by its
own visit order), or the listing is at the walk's *max_files* cap.
Blocking (one pass over *files*): run it in the executor.
"""
path, mtime = entry
text = str(path)
kept = files
for index, (listed, listed_mtime) in enumerate(files):
if str(listed) == text:
if listed_mtime == mtime:
return files
kept = files[:index] + files[index + 1 :]
break
else:
if len(files) >= max_files:
return None
index = bisect_left(kept, -mtime, key=lambda item: -item[1])
if index < len(kept) and kept[index][1] == mtime:
return None
return [*kept[:index], entry, *kept[index:]]
# A listing that differs from the cached one by more entries than this is
# rebuilt in full by the workspace caches (listing_delta).
MAX_DELTA = 256
def listing_delta(old: list, new: list, limit: int = MAX_DELTA):
"""``(added, removed)`` entries from *old* to *new*, or None past *limit*.
A listing patched by ``insert_entry`` shares every unchanged entry object
with the one it came from, so entries are told apart by identity: a walk
builds new tuples, shares none and is rebuilt in full. *old* must still
be alive (the
caller's cache holds it), so no identity was reused.
"""
if abs(len(new) - len(old)) > limit:
return None
old_ids = set(map(id, old))
added = []
for entry in new:
if id(entry) not in old_ids:
added.append(entry)
if len(added) > limit:
return None
if new and len(added) == len(new):
return None # nothing shared: a walk
if len(old) + len(added) == len(new):
return added, []
new_ids = set(map(id, new))
return added, [entry for entry in old if id(entry) not in new_ids]
def rewalk_interval(last_duration) -> float:
"""Seconds from a walk's start to the next background walk of its root."""
scaled = REWALK_FACTOR * (last_duration or 0.0)
return min(REWALK_CAP, max(REWALK_FLOOR, scaled))
def root_contains(resolved_root: str, path, pathmod=os.path) -> bool:
"""True when the resolved *path* lies under *resolved_root* (no file I/O).
*resolved_root* is already ``normcase``d; *path* is folded the same way,
so Windows matches case-insensitively and with either separator. The
match ends at a separator: ``/out2/x.png`` is not under ``/out``.
"""
target = _plain(pathmod.normcase(str(path)), pathmod)
prefix = _plain(resolved_root, pathmod)
if not prefix.endswith(pathmod.sep):
prefix += pathmod.sep
return target.startswith(prefix)
def _plain(path: str, pathmod) -> str:
"""*path* (normcased or not) without a Windows extended-length prefix.
``realpath`` may keep ``\\\\?\\`` on a file past MAX_PATH while its shorter
root resolves without it.
"""
if pathmod.sep != "\\":
return path
if path[:8].lower() == "\\\\?\\unc\\":
return "\\\\" + path[8:]
if path.startswith("\\\\?\\"):
return path[4:]
return path
def resolve_root(root) -> str:
"""``normcase(realpath(root))``, the spelling monitor paths are matched in.
File I/O (realpath follows links): call it in the executor.
"""
return os.path.normcase(os.path.realpath(str(root)))
def same_listing(old, new) -> bool:
@@ -130,8 +319,10 @@ def same_listing(old, new) -> bool:
def walk_root(
scan_fn: Callable[[PurePath], list], root, previous_files
) -> tuple[list, frozenset, frozenset]:
"""The single executor job of a walk: ``(files, folders, with_ancestors)``.
) -> tuple[list, frozenset, frozenset, str]:
"""The single executor job of a walk.
Returns ``(files, folders, with_ancestors, resolved_root)``.
Opens the root first, so a missing or unreadable root raises ``OSError``
instead of listing nothing. Returns *previous_files* itself when nothing
@@ -143,4 +334,4 @@ def walk_root(
if same_listing(previous_files, files):
files = previous_files
folders, with_ancestors = folder_sets(root, files)
return files, folders, with_ancestors
return files, folders, with_ancestors, resolve_root(root)
+392 -33
View File
@@ -10,10 +10,12 @@ import io
import json
import os
import re
import stat
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
import sys
import time as _time
import urllib.parse
from bisect import bisect_left, bisect_right
from bisect import bisect_left, bisect_right, insort
from collections.abc import Mapping
from pathlib import Path
from typing import Callable, NamedTuple
@@ -589,6 +591,10 @@ def _workspace_key(entry: tuple, order: str) -> tuple:
return (mtime, entry.name, entry.tie, entry.index)
def _newest_key(entry: _Listed) -> tuple:
return _workspace_key(entry, "newest")
def _rated_key(entry: _Listed, rank: int) -> tuple:
"""Highest rated: rating 5..1, linked unrated (0), unlinked (-1); then newest."""
return (-rank, *_workspace_key(entry, "rating"))
@@ -764,6 +770,34 @@ def _favorite_relative_key(relative: str) -> str:
return _path_flavor.normcase(relative).replace(_path_flavor.sep, "/")
def _listing_posix(root, prefix, path) -> str:
"""*path*'s POSIX spelling below *root* (*prefix*: ``join(root, "")``)."""
text = str(path)
if text.startswith(prefix):
relative = text[len(prefix) :]
return relative if os.sep == "/" else relative.replace(os.sep, "/")
return path.relative_to(root).as_posix()
def _favorite_listing_step(root, listing, added, removed) -> dict | None:
"""*listing* after a patched listing's change, or None to rebuild it.
Only when every removed file is added again under the same spelling (a
rewritten output) and no added file's key is already taken by another
spelling: the full build keeps the first spelling in listing order.
"""
prefix = os.path.join(str(root), "")
spellings = {_listing_posix(root, prefix, path) for path, _mtime in added}
if any(_listing_posix(root, prefix, path) not in spellings for path, _ in removed):
return None
listing = dict(listing)
for posix in spellings:
known = listing.setdefault(_favorite_relative_key(posix), posix)
if known != posix:
return None
return listing
def _favorite_root_key(root) -> str:
"""Server-side identity of a gallery root: its real, case-normalized path.
@@ -827,6 +861,91 @@ def _walk_media(output_path, max_files):
stack.append((entry.path, rel_parts + (entry.name,)))
def _settled_entry(root, relative):
"""``(Path, mtime)`` of a settled output as a walk would list it.
*relative* is its part below *root* (``gallery_listing.relative_part``).
None when a walk would not list it (``_walk_media``'s filters, and every
folder on the way must be listable); raises ``OSError`` when that cannot
be told (the file is gone or unreadable, or its on-disk spelling differs
or cannot be read). Blocking: run it in the executor.
"""
path = Path(os.path.join(os.fspath(root), relative))
info = os.lstat(path)
if stat.S_ISLNK(info.st_mode) or stat.S_ISDIR(info.st_mode):
return None
# Before the name filters: they compare the walk's (stored) spelling.
on_disk = _on_disk_path(path)
if on_disk is not None and not on_disk.endswith(os.sep + relative):
raise OSError(f"{relative} is spelled differently on disk")
*folders, name = relative.split(os.sep)
if _name_suffix(name).lower() not in MEDIA_EXTENSIONS:
return None
if "thumbnails" in folders or len(folders) > MAX_SCAN_DEPTH:
return None
folder = os.fspath(root)
for part in ("", *folders):
folder = os.path.join(folder, part) if part else folder
if not os.access(folder, os.R_OK | os.X_OK):
return None # the walk cannot list this folder
return path, info.st_mtime
def _on_disk_path(path, platform=None):
"""The file's path as its folders store the names, where that can differ.
Windows and macOS file systems ignore case. Windows' ``realpath`` gives
the stored case, but keeps the given one when it cannot open the file;
macOS' ``realpath`` always keeps the given one. A walk lists the names as
stored, so an add checks them: Windows through ``GetFinalPathNameByHandle``
(``nt._getfinalpathname``), macOS through ``F_GETPATH``. Raises OSError
when it cannot be read; None elsewhere (case-sensitive file systems).
"""
platform = platform or sys.platform
if platform == "win32":
import nt
return nt._getfinalpathname(os.fspath(path))
if platform != "darwin":
return None
import fcntl
# Non-blocking: a FIFO named like an image must not hold the executor.
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
try:
raw = fcntl.fcntl(descriptor, fcntl.F_GETPATH, bytes(1024))
finally:
os.close(descriptor)
return os.fsdecode(raw.split(b"\0", 1)[0])
def _patch_listing(root, files, folders, with_ancestors, relatives):
"""The listing with each settled output in *relatives* added (blocking).
Returns ``(files, folders, with_ancestors, unresolved)``; *files* is the
same list when nothing changed. ``unresolved`` is True when an output
could not be placed exactly, so the root needs a walk.
"""
unresolved = False
for relative in relatives:
try:
entry = _settled_entry(root, relative)
except OSError:
unresolved = True
continue
if entry is None:
continue # a walk would not list it either
patched = gallery_listing.insert_entry(files, entry, MAX_GALLERY_FILES)
if patched is None:
unresolved = True
elif patched is not files:
files = patched
folders, with_ancestors = gallery_listing.add_folder(
root, entry[0], folders, with_ancestors
)
return files, folders, with_ancestors, unresolved
def _iter_media_files(output_path, max_files):
"""Yield the ``Path`` of each media file under *output_path* (see _walk_media)."""
for entry in _walk_media(output_path, max_files):
@@ -1408,6 +1527,7 @@ class ImageRoutesMixin:
even if a previous listing exists.
"""
record = self._root_listing(output_path)
record.retired = False # a configured root: read, so listened to
needed = record.walk_seq + 1 if fresh else 0
discarded = 0
while True:
@@ -1433,28 +1553,172 @@ class ImageRoutesMixin:
)
def _refresh_if_stale(self, output_path, record):
"""Walk a stale listing again, REFRESH_DELAY after this read (on the loop).
"""Ask for a background walk when the listing is stale or dirty.
At most one pending refresh per root, none while a walk runs.
A stale listing (older than REFRESH_AFTER) is walked REFRESH_DELAY
after this read, so the walk does not share the GIL with the page's
own requests; a dirty one as soon as its interval allows.
"""
age = _time.monotonic() - (record.walked_at or 0.0)
if age < gallery_listing.REFRESH_AFTER:
if age >= gallery_listing.REFRESH_AFTER:
self._schedule_walk(output_path, gallery_listing.REFRESH_DELAY)
elif record.dirty:
self._schedule_walk(output_path)
def output_settled(self, path):
"""The monitor settled an output at *path*: list it (on the loop).
*path* is resolved by the monitor's thread (``notify_output_settled``).
A root with a stored listing and no walk running gets the file added
in the executor (``_run_adds``), without a walk. Every other root
holding it is marked dirty (``mark_root_dirty``): one with a walk
running gets the trailing walk, as that walk may have passed the
file's folder already.
"""
for key, record in self._settled_records(path):
relative = None
if (
record.files is not None
and record.flight is None
and record.resolved_root is not None
):
relative = gallery_listing.relative_part(record.resolved_root, path)
if relative is None:
self._mark_dirty(key, record)
continue
record.pending_adds.append(relative)
if record.add_task is None:
task = asyncio.get_running_loop().create_task(
self._run_adds(Path(key), record)
)
record.add_task = task
self._listing_tasks.add(task)
task.add_done_callback(self._listing_tasks.discard)
def mark_root_dirty(self, path):
"""A settled output at *path*: walk every root holding it again (on the loop).
*path* is resolved by the monitor's thread (``notify_output_settled``),
so this does no file I/O: it compares ``normcase`` strings with each
walk's ``resolved_root``. Only roots with a listing, or a walk running,
are marked; a root without either is walked by its next read anyway.
"""
for key, record in self._settled_records(path):
self._mark_dirty(key, record)
def _settled_records(self, path):
"""``(key, record)`` of every listed or walking root holding *path*."""
if self._listings_closed:
return []
found = []
for key, record in list(self._root_listings.items()):
if record.retired or (record.files is None and record.flight is None):
continue
resolved = record.resolved_root or os.path.normcase(key)
if gallery_listing.root_contains(resolved, path):
found.append((key, record))
return found
def _mark_dirty(self, key, record):
if self._listings_closed or record.retired:
return
if record.flight is not None or record.rewalk_handle is not None:
if record.files is None and record.flight is None:
return # walked by its next read anyway
record.dirty = True
self._schedule_walk(Path(key))
async def _run_adds(self, root, record):
"""Add the root's pending settled outputs to its listing (on the loop).
Each batch is placed in the executor against the stored listing, and
stored only when no walk started and no drop happened meanwhile: a
walk started after the outputs settled lists them itself, and a
dropped listing is walked by its next read. An output that cannot be
placed exactly marks the root dirty instead.
"""
key = str(root)
try:
while record.pending_adds:
relatives, record.pending_adds = record.pending_adds, []
if record.files is None or record.flight is not None:
continue # dropped, or a walk started after they settled
base, generation, seq = record.files, record.generation, record.walk_seq
files, folders, with_ancestors, unresolved = (
await self._run_in_executor(
_patch_listing,
root,
base,
record.folders,
record.folders_with_ancestors,
relatives,
)
)
if self._listings_closed or record.retired:
return
if record.generation != generation or record.walk_seq != seq:
continue
if files is not base:
record.files = files
record.folders = folders
record.folders_with_ancestors = with_ancestors
record.patched += 1
self._schedule_workspace_warm()
if unresolved:
self._mark_dirty(key, record)
except asyncio.CancelledError:
raise
except Exception as exc:
self.logger.warning(f"Could not add a new output to the listing: {exc}")
record.pending_adds = []
self._mark_dirty(key, record)
finally:
record.add_task = None
def _schedule_walk(self, output_path, delay=0.0):
"""Start or defer a background walk of *output_path* (on the loop).
- A walk is running: nothing; its end re-checks ``dirty``.
- Otherwise the walk is due *delay* from now, and never sooner than
``rewalk_interval`` after the previous walk started. Due now: start
it. Later: one deferred walk per root, at least MIN_REWALK_DELAY
away; when one is already pending, the earlier deadline wins.
Every walk start cancels the pending one (``_start_walk``).
"""
if self._listings_closed:
return
record.rewalk_handle = asyncio.get_running_loop().call_later(
gallery_listing.REFRESH_DELAY, self._delayed_walk, output_path, record
record = self._root_listing(output_path)
if record.flight is not None or record.retired:
return
now = _time.monotonic()
due = now + delay
if record.last_started_at is not None:
interval = gallery_listing.rewalk_interval(record.last_duration)
due = max(due, record.last_started_at + interval)
if due <= now:
self._start_walk(output_path, record)
return
wait = max(gallery_listing.MIN_REWALK_DELAY, due - now)
loop = asyncio.get_running_loop()
handle = record.rewalk_handle
if handle is not None:
if handle.when() - loop.time() <= wait:
return # the pending walk comes first
handle.cancel()
record.rewalk_handle = loop.call_later(
wait, self._delayed_walk, output_path, record
)
def _delayed_walk(self, output_path, record):
record.rewalk_handle = None
self._schedule_walk(output_path)
"""The deferred walk's timer: clear the handle first, then walk.
def _schedule_walk(self, output_path):
"""Start a background walk of *output_path* unless one is running."""
record = self._root_listing(output_path)
if record.flight is None:
self._start_walk(output_path, record)
It was armed for a stale or dirty listing at the earliest moment the
interval allowed. Only a walk start clears either reason, and every
start cancels this timer, so when it fires the walk is still due.
"""
record.rewalk_handle = None
if self._listings_closed or record.flight is not None or record.retired:
return
self._start_walk(output_path, record)
def _start_walk(self, output_path, record):
"""Start the root's one walk; returns its flight future (on the loop)."""
@@ -1462,7 +1726,11 @@ class ImageRoutesMixin:
self._listing_loop = loop
handle, record.rewalk_handle = record.rewalk_handle, None
if handle is not None:
handle.cancel() # this walk is the refresh it was waiting to start
handle.cancel() # this walk is the one it was waiting to start
# This walk lists every output reported so far.
record.dirty = False
record.pending_adds = []
record.last_started_at = _time.monotonic()
record.walk_seq += 1
flight = loop.create_future()
record.flight = flight
@@ -1482,11 +1750,14 @@ class ImageRoutesMixin:
error never reaches a future nobody awaits.
"""
outcome = None
cancelled = False
started = record.last_started_at
try:
files, folders, with_ancestors = await self._run_in_executor(
files, folders, with_ancestors, resolved = await self._run_in_executor(
walk_root, self._scan_gallery_files_sync, output_path, record.files
)
except asyncio.CancelledError:
cancelled = True
outcome = WalkOutcome.failed(RuntimeError(WALK_STOPPED))
raise
except Exception as exc:
@@ -1496,11 +1767,15 @@ class ImageRoutesMixin:
f"Could not list output folder {output_path}: {exc}"
)
record.failing = True
# Retried by the 30 s rule, not by a trailing walk.
record.dirty = False
outcome = WalkOutcome.failed(exc)
else:
record.failing = False
record.resolved_root = resolved
if record.generation == generation:
changed = files is not record.files
record.patched = 0
record.files = files
record.folders = folders
record.folders_with_ancestors = with_ancestors
@@ -1512,10 +1787,14 @@ class ImageRoutesMixin:
else:
outcome = WalkOutcome.discarded()
finally:
record.last_duration = _time.monotonic() - started
if record.flight is flight:
record.flight = None
if not flight.done():
flight.set_result(outcome)
if record.dirty and not cancelled:
# An output settled during this walk: the trailing walk.
self._schedule_walk(output_path)
async def get_output_images(self, request):
"""Get all images from ComfyUI output folder(s)."""
@@ -1644,6 +1923,10 @@ class ImageRoutesMixin:
cached = self._workspace_listed_cache.get(key)
if cached is not None and cached[0] is files:
return cached[1]
if cached is not None:
delta = gallery_listing.listing_delta(cached[0], files)
if delta is not None:
return self._workspace_listed_step(key, cached, files, delta)
previous = cached[2] if cached is not None else {}
listed = []
for path, mtime in files:
@@ -1663,7 +1946,42 @@ class ImageRoutesMixin:
self._workspace_card_cache[key] = {
file: facts for file, facts in cards.items() if file in lookup
}
self._workspace_listed_cache[key] = (files, listed, lookup)
self._workspace_listed_cache[key] = (files, listed, lookup, None)
return listed
def _workspace_listed_step(self, key, cached, files, delta) -> list:
"""The _Listed values after a few files were added or replaced.
A listing patched by a settled output (``output_settled``) shares
every other entry with the cached one, so only the added files are
built; the order of the values does not matter (every sort is a total
order). The step ``(previous values, added, removed)`` lets the sort
orders follow it the same way (``_workspace_orders``).
"""
index, root = key[0], Path(key[1])
_files, old_listed, old_lookup, _step = cached
added, removed = delta
if not added and not removed:
self._workspace_listed_cache[key] = (files, old_listed, old_lookup, None)
return old_listed
lookup = dict(old_lookup)
gone = [lookup.pop(item) for item in removed if item in lookup]
fresh = [_listed(path, mtime, root, index) for path, mtime in added]
for entry in fresh:
lookup[(entry.path, entry.mtime)] = entry
if gone:
gone_ids = {id(entry) for entry in gone}
listed = [entry for entry in old_listed if id(entry) not in gone_ids]
cards = self._workspace_card_cache.get(key)
if cards:
self._workspace_card_cache[key] = {
file: facts for file, facts in cards.items() if file in lookup
}
else:
listed = list(old_listed)
listed.extend(fresh)
step = (old_listed, fresh, gone)
self._workspace_listed_cache[key] = (files, listed, lookup, step)
return listed
def _workspace_orders(self, listings: list[tuple]) -> dict:
@@ -1678,14 +1996,54 @@ class ImageRoutesMixin:
old is new for old, new in zip(cached[0], parts, strict=True)
):
return cached[1]
newest = sorted(
(entry for part in parts for entry in part),
key=lambda entry: _workspace_key(entry, "newest"),
)
newest = None
if cached is not None:
newest = self._newest_step(listings, parts, cached)
if newest is None:
newest = sorted(
(entry for part in parts for entry in part),
key=_newest_key,
)
# The other orders, the wanted paths and the locators follow lazily.
orders = {"newest": newest}
self._workspace_order_cache[key] = (parts, orders)
return orders
def _newest_step(self, listings, parts, cached) -> list | None:
"""The cached newest order with each part's last step applied, or None.
Only when every changed part is exactly the value list this call
built (*parts*) and one step (``_workspace_listed_step``) past the
part the cached order was built from: the added values are placed by
bisection instead of sorting the whole listing again. Page reads and
the warm build run in executor threads at once, so the per-file cache
may already hold a newer step than *parts*; then this sorts in full.
"""
old_parts, orders = cached
added, gone = [], []
for (index, root, _files), part, old in zip(
listings, parts, old_parts, strict=True
):
if part is old:
continue
listed = self._workspace_listed_cache.get((index, str(root)))
if listed is None or listed[1] is not part:
return None
step = listed[3]
if step is None or step[0] is not old:
return None
added.extend(step[1])
gone.extend(step[2])
newest = orders["newest"]
if gone:
gone_ids = {id(entry) for entry in gone}
newest = [entry for entry in newest if id(entry) not in gone_ids]
else:
newest = list(newest)
for entry in added:
insort(newest, entry, key=_newest_key)
return newest
def _workspace_wanted(self, listings: list[tuple]) -> frozenset:
"""The selected listing's normalized paths (cached with its orders)."""
orders = self._workspace_orders(listings)
@@ -2967,18 +3325,19 @@ class ImageRoutesMixin:
cached = self._favorite_listing_cache.get(cache_key)
if cached is not None and cached[0] is files:
return cached[1]
# From the path strings, as _listed does: relative_to per file was the
# largest cost of a cold first open (#348).
prefix = os.path.join(str(root), "")
listing = {}
for path, _mtime in files:
text = str(path)
if text.startswith(prefix):
relative = text[len(prefix) :]
posix = relative if os.sep == "/" else relative.replace(os.sep, "/")
else:
posix = path.relative_to(root).as_posix()
listing.setdefault(_favorite_relative_key(posix), posix)
listing = None
if cached is not None:
delta = gallery_listing.listing_delta(cached[0], files)
if delta is not None:
listing = _favorite_listing_step(root, cached[1], *delta)
if listing is None:
# From the path strings, as _listed does: relative_to per file was
# the largest cost of a cold first open (#348).
prefix = os.path.join(str(root), "")
listing = {}
for path, _mtime in files:
posix = _listing_posix(root, prefix, path)
listing.setdefault(_favorite_relative_key(posix), posix)
self._favorite_listing_cache[cache_key] = (files, listing)
return listing