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.
This commit is contained in:
Vito Sansevero
2026-10-26 12:28:56 -07:00
parent 9ac471eb72
commit e84e56aa3b
10 changed files with 857 additions and 26 deletions
+38 -2
View File
@@ -106,6 +106,10 @@ class ImageGenerationHandler(FileSystemEventHandler):
# ``queue_capture_thumbnail``, wired at start-up; #249). It only
# queues: the preview is written on its own worker. None: no queue.
self.thumbnail_queue: Optional[Callable[[str], str]] = None
# Told about every settled output, with its resolved path (the API's
# ``notify_output_settled``, wired at start-up; #348): the Images
# listing walks that root again. None: nobody is told.
self.output_listener: Optional[Callable[[str], None]] = None
# Read from GalleryConfig if available, otherwise use defaults
try:
@@ -259,8 +263,12 @@ class ImageGenerationHandler(FileSystemEventHandler):
f"Image never settled or vanished, skipping: {image_path}"
)
return
self.process_new_image(image_path, prompt_snapshot=prompt_snapshot)
self._queue_thumbnail(image_path)
try:
self.process_new_image(image_path, prompt_snapshot=prompt_snapshot)
self._queue_thumbnail(image_path)
finally:
# Linked or not, even if linking raised: the file exists.
self._report_output(image_path)
finally:
with self._pending_lock:
self._pending.pop(image_path, None)
@@ -285,6 +293,25 @@ class ImageGenerationHandler(FileSystemEventHandler):
return
self.logger.debug(f"Preview {outcome}: {os.path.basename(image_path)}")
def _report_output(self, image_path: str) -> None:
"""Tell the output listener a settled output exists, best effort.
The path is resolved here, on the worker thread, so the listener's
event loop does no file I/O. A failure is logged, never raised: the
output is then listed by the listing's 30 s refresh.
"""
listener = getattr(self, "output_listener", None)
if listener is None:
return
try:
listener(os.path.realpath(image_path))
except Exception:
self.logger.warning(
f"{os.path.basename(image_path)} could not be reported to the "
"Images listing",
exc_info=True,
)
def wait_until_settled(self, image_path: str, stop=None) -> bool:
"""True once the file's size is non-zero and unchanged across two reads.
@@ -848,6 +875,14 @@ class ImageMonitor:
self.logger = get_logger("prompt_manager.image_monitor")
self.review_recorder: Optional[Callable[[str], str]] = None
self.thumbnail_queue: Optional[Callable[[str], str]] = None
self.output_listener: Optional[Callable[[str], None]] = None
def set_output_listener(self, listener: Optional[Callable[[str], None]]) -> None:
"""Report every settled output's resolved path to ``listener`` (None
stops it), on the running handler and every later one (#348)."""
self.output_listener = listener
if self.handler is not None:
self.handler.output_listener = listener
def set_thumbnail_queue(
self, queue_preview: Optional[Callable[[str], str]]
@@ -925,6 +960,7 @@ class ImageMonitor:
handler = ImageGenerationHandler(self.db_manager, self.prompt_tracker)
handler.review_recorder = self.review_recorder
handler.thumbnail_queue = self.thumbnail_queue
handler.output_listener = self.output_listener
observer = Observer()
scheduled = []
for output_dir in valid_directories: