From 9307d3899141b42484dcb4489a3f6a7ed026a5dc Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 8 Feb 2026 22:45:58 -0500 Subject: [PATCH 1/2] Improve navigation responsiveness & harden shutdown; add cache timing logs --- faststack/app.py | 259 ++++++++++++++++++++----- faststack/config.py | 2 +- faststack/imaging/prefetch.py | 43 +++- faststack/thumbnail_view/prefetcher.py | 71 ++++++- faststack/ui/provider.py | 17 ++ 5 files changed, 330 insertions(+), 62 deletions(-) diff --git a/faststack/app.py b/faststack/app.py index 13e9e8d..9b729fe 100644 --- a/faststack/app.py +++ b/faststack/app.py @@ -170,10 +170,7 @@ def __init__( self.engine = engine self.debug_cache = debug_cache # New debug_cache flag - # Ensure clean shutdown of background threads - inst = QCoreApplication.instance() - if inst: - inst.aboutToQuit.connect(self._shutdown_executors) + # Shutdown is handled in main() via aboutToQuit connection self.display_width = 0 self.display_height = 0 @@ -322,6 +319,13 @@ def __init__( # This removes the extra 16ms delay in the fast-render case by chaining # immediately on completion. QML's 16ms slider timer remains the fps cap. + # Debounce timer for metadata/highlight signals during rapid navigation + # Only emits these signals once user stops navigating (16ms = 1 frame debounce) + self._metadata_debounce_timer = QTimer(self) + self._metadata_debounce_timer.setSingleShot(True) + self._metadata_debounce_timer.setInterval(16) # 16ms debounce (1 frame) + self._metadata_debounce_timer.timeout.connect(self._emit_debounced_metadata_signals) + # Track if any dialog is open to disable keybindings self._dialog_open = False @@ -715,6 +719,10 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: where users expect to see the correct image immediately. The prefetcher minimizes cache misses by decoding adjacent images in advance. """ + if self.debug_cache: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} get_decoded_image: START index={index}") + if not self.image_files or index < 0 or index >= len(self.image_files): log.warning( "get_decoded_image called with empty image_files or out of bounds index." @@ -759,6 +767,11 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: decoded = self.image_cache[cache_key] with self._last_image_lock: self.last_displayed_image = decoded + + if self.debug_cache: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} get_decoded_image: CACHE HIT index={index} total={(_t_end - _t_start)*1000:.2f}ms") + return decoded self.image_cache.misses += 1 # Increment miss counter @@ -771,6 +784,8 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: if key.startswith(prefix) ] cache_usage_gb = self.image_cache.currsize / (1024**3) + _t_miss = time.perf_counter() + print(f"[DBGCACHE] {_t_miss*1000:.3f} get_decoded_image: CACHE MISS index={index} gen={display_gen} (after {(_t_miss - _t_start)*1000:.2f}ms)") log.info( "Cache miss for %s (index=%d gen=%d). Cached gens: %s. Cache usage=%.2fGB entries=%d", image_path.name, @@ -836,6 +851,9 @@ def get_decoded_image(self, index: int) -> Optional[DecodedImage]: if _debug_mode: elapsed = time.perf_counter() - decode_start log.info("Decoded image %d in %.3fs", index, elapsed) + if self.debug_cache: + _t_decoded = time.perf_counter() + print(f"[DBGCACHE] {_t_decoded*1000:.3f} get_decoded_image: DECODED index={index} total={(_t_decoded - _t_start)*1000:.2f}ms") return decoded else: if _debug_mode: @@ -916,23 +934,50 @@ def _get_decoded_image_safe(self, index: int) -> Optional[DecodedImage]: return None def sync_ui_state(self): - """Forces the UI to update by emitting all state change signals.""" + """Forces the UI to update by emitting all state change signals. + + Essential signals (currentIndexChanged, currentImageSourceChanged) are emitted + immediately. Non-essential signals (highlightStateChanged, metadataChanged) are + debounced to reduce overhead during rapid navigation. + """ + if self.debug_cache: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} sync_ui_state: START gen={self.ui_refresh_generation + 1}") + self.ui_refresh_generation += 1 self._metadata_cache_index = (-1, -1) # Invalidate cache - # tell QML that index and image changed + # Essential signals - emit immediately for responsive image display self.ui_state.currentIndexChanged.emit() self.ui_state.currentImageSourceChanged.emit() - self.ui_state.highlightStateChanged.emit() # Notify UI of new highlight stats - # this is the one your footer needs - self.ui_state.metadataChanged.emit() + # Debounce non-essential signals during rapid navigation + # These will emit once after user stops navigating (16ms) + self._metadata_debounce_timer.start() + + if self.debug_cache: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} sync_ui_state: DONE signals emitted, total={(_t_end - _t_start)*1000:.2f}ms") log.debug( "UI State Synced: Index=%d, Count=%d", self.ui_state.currentIndex, self.ui_state.imageCount, ) + + def _emit_debounced_metadata_signals(self): + """Emit deferred metadata/highlight signals after navigation stops.""" + if self.debug_cache: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} _emit_debounced_metadata_signals: emitting deferred signals") + + self.ui_state.highlightStateChanged.emit() + self.ui_state.metadataChanged.emit() + + if self.debug_cache: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} _emit_debounced_metadata_signals: DONE total={(_t_end - _t_start)*1000:.2f}ms") + log.debug( "Metadata Synced: Filename=%s, Uploaded=%s, StackInfo='%s', BatchInfo='%s'", self.ui_state.currentFilename, @@ -1088,6 +1133,10 @@ def _set_current_index( self, index: int, direction: int = 0, is_navigation: bool = True ): """Centralized method to change current image index and reset state.""" + if self.debug_cache: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} _set_current_index: START index={index} dir={direction}") + if index < 0 or index >= len(self.image_files): return @@ -1110,23 +1159,53 @@ def _set_current_index( self.current_index = index # Set index first so signals pick up correct image self._reset_crop_settings() + + if self.debug_cache: + _t_prefetch = time.perf_counter() + print(f"[DBGCACHE] {_t_prefetch*1000:.3f} _set_current_index: calling _do_prefetch") + self._do_prefetch( self.current_index, is_navigation=is_navigation, direction=direction ) + + if self.debug_cache: + _t_sync = time.perf_counter() + print(f"[DBGCACHE] {_t_sync*1000:.3f} _set_current_index: calling sync_ui_state (prefetch took {(_t_sync - _t_prefetch)*1000:.2f}ms)") + self.sync_ui_state() # Update histogram if visible if self.ui_state.isHistogramVisible: self.update_histogram() + if self.debug_cache: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} _set_current_index: DONE total={(_t_end - _t_start)*1000:.2f}ms") + def next_image(self): + if self.debug_cache: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} next_image: START from index={self.current_index}") + if self.current_index < len(self.image_files) - 1: self._set_current_index(self.current_index + 1, direction=1) + if self.debug_cache: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} next_image: DONE total={(_t_end - _t_start)*1000:.2f}ms") + def prev_image(self): + if self.debug_cache: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} prev_image: START from index={self.current_index}") + if self.current_index > 0: self._set_current_index(self.current_index - 1, direction=-1) + if self.debug_cache: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} prev_image: DONE total={(_t_end - _t_start)*1000:.2f}ms") + @Slot(int) def jump_to_image(self, index: int): """Jump to a specific image by index (0-based).""" @@ -3409,19 +3488,32 @@ def undo_delete(self): if Path(backup_path).exists(): self.undo_history.append(("crop", action_data, timestamp)) - def shutdown(self): - log.info("Application shutting down.") + def shutdown_qt(self): + """Shutdown Qt objects only - MUST run on main/Qt thread.""" + self._shutting_down = True # set EARLY to make all slots no-op + log.info("Application shutting down (Qt cleanup).") + + # Stop Qt timers + try: + self._metadata_debounce_timer.stop() + except Exception: + pass - # Check tracked recycle bins plus explicit base bin check if it exists + # Stop QFileSystemWatcher if it's Qt-based + try: + self.watcher.stop() + except Exception: + pass + + # Check tracked recycle bins for logging bins_to_check = set(self.active_recycle_bins) - # Also check local "image recycle bin" in current/base folders just in case try: bins_to_check.add(self.image_dir / "image recycle bin") except Exception: pass total_files = 0 - bin_stats = {} # bin_path -> count + bin_stats = {} for bin_dir in bins_to_check: if bin_dir.exists() and bin_dir.is_dir(): @@ -3435,37 +3527,56 @@ def shutdown(self): pass if total_files > 0: - # In a GUI app, we can't easily show a blocking QMessageBox here because shutdown() - # might be called after loop exit or during cleanup. - # However, we implemented the QML close interception (Main.qml) which calls cleanup. - # This python-side check is a fallback or for CLI usage. - # Since we moved the logic to QML's onClosing, we mainly just log here. log.info( "Shutdown with %d files in recycle bins: %s", total_files, list(bin_stats.keys()), ) - # Clear QML context property to prevent TypeErrors during shutdown + # Clear QML engine reference (but don't delete - let Qt handle it) if self.engine: - log.info("Clearing uiState context property in QML.") - del self.engine # Explicitly delete the engine - - self.watcher.stop() - self.prefetcher.shutdown() - # Guard against partial init - if getattr(self, "_thumbnail_prefetcher", None): - self._thumbnail_prefetcher.shutdown() - self.sidecar.set_last_index(self.current_index) - self.sidecar.save() + log.info("Detaching QML engine.") + self.engine = None + + def shutdown_nonqt(self): + """Shutdown non-Qt resources - safe to run in background thread.""" + log.info("Shutting down background resources.") + + # Shutdown thread pool executors + try: + log.info("Shutting down background executors...") + self._hist_executor.shutdown(wait=False, cancel_futures=True) + self._preview_executor.shutdown(wait=False, cancel_futures=True) + self._save_executor.shutdown(wait=False, cancel_futures=True) + except Exception as e: + log.warning("Error shutting down executors: %s", e) + + # Shutdown prefetcher + try: + self.prefetcher.shutdown() + except Exception as e: + log.warning("Error shutting down prefetcher: %s", e) + + # Shutdown thumbnail prefetcher + try: + if getattr(self, "_thumbnail_prefetcher", None): + self._thumbnail_prefetcher.shutdown() + except Exception as e: + log.warning("Error shutting down thumbnail prefetcher: %s", e) + + # Save sidecar state + try: + self.sidecar.set_last_index(self.current_index) + self.sidecar.save() + except Exception as e: + log.warning("Error saving sidecar: %s", e) + + log.info("Background shutdown complete.") - def _shutdown_executors(self): - """Explicitly shuts down thread pools on app exit to prevent hanging.""" - self._shutting_down = True - log.info("Shutting down background executors...") - self._hist_executor.shutdown(wait=False, cancel_futures=True) - self._preview_executor.shutdown(wait=False, cancel_futures=True) - self._save_executor.shutdown(wait=False, cancel_futures=True) + def shutdown(self): + """Legacy shutdown method - calls both Qt and non-Qt shutdown.""" + self.shutdown_qt() + self.shutdown_nonqt() def empty_recycle_bin(self): """Permanently deletes all files in all tracked recycle bins.""" @@ -4764,9 +4875,6 @@ def execute_crop(self): if save_result: saved_path, backup_path = save_result - # Track for undo - import time - timestamp = time.time() self.undo_history.append( ("crop", (str(saved_path), str(backup_path)), timestamp) @@ -4945,9 +5053,6 @@ def quick_auto_levels(self): # Status message already set by auto_levels ("No changes made...") return - # Save - import time - try: save_result = self.image_editor.save_image() except RuntimeError as e: @@ -5019,8 +5124,6 @@ def quick_auto_white_balance(self): self.update_status_message("No image to adjust") return - import time - image_file = self.image_files[self.current_index] filepath = str(image_file.path) @@ -5398,6 +5501,15 @@ def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): app = QApplication( sys.argv ) # QApplication is correct for desktop apps with widgets + + # Enable Ctrl-C to terminate the application + import signal + signal.signal(signal.SIGINT, lambda *args: app.quit()) + # Ensure Python's signal handler runs (Qt blocks main thread) + timer = QTimer() + timer.start(500) # Check for signals every 500ms + timer.timeout.connect(lambda: None) + if debug: log.info("Startup: after QApplication: %.3fs", time.perf_counter() - t0) @@ -5467,8 +5579,61 @@ def main(image_dir: str = "", debug: bool = False, debug_cache: bool = False): if debug: log.info("Startup: after controller.load(): %.3fs", time.perf_counter() - t0) - # Graceful shutdown - app.aboutToQuit.connect(controller.shutdown) + # Graceful shutdown with timeout fallback + import threading + import faulthandler + + def _log_live_threads(tag: str): + """Log non-daemon threads for debugging shutdown hangs.""" + threads = threading.enumerate() + alive = [ + t for t in threads + if t.is_alive() + and not t.daemon + and t.name != "MainThread" + ] + if not alive: + return + + log.warning("%s: %d NON-DAEMON threads still alive:", tag, len(alive)) + for t in alive: + log.warning(" - name=%r ident=%r daemon=%r", t.name, t.ident, t.daemon) + + def _shutdown_with_timeout(): + """Graceful shutdown with Python timer fallback.""" + log.info("aboutToQuit fired") + + # Backstop MUST start first, or it won't run if shutdown blocks. + killer = threading.Timer(3.0, lambda: os._exit(1)) + killer.daemon = True + killer.start() + + # After 2s, dump stacks to stderr so we can see what's hung. + faulthandler.dump_traceback_later(2.0, repeat=False) + + try: + # Stop Qt timers on main thread + try: + timer.stop() + except Exception: + pass + + # Run Qt cleanup on main thread + controller.shutdown_qt() + + # Run non-Qt cleanup synchronously (should be fast with wait=False) + controller.shutdown_nonqt() + _log_live_threads("after shutdown_nonqt") + + finally: + faulthandler.cancel_dump_traceback_later() + killer.cancel() # if we got here, no need to force-kill + + app.aboutToQuit.connect(_shutdown_with_timeout) + + # Ensure closing last window actually quits the app + app.setQuitOnLastWindowClosed(True) + app.lastWindowClosed.connect(app.quit) sys.exit(app.exec()) diff --git a/faststack/config.py b/faststack/config.py index 2dbc6fc..ef043fe 100644 --- a/faststack/config.py +++ b/faststack/config.py @@ -63,7 +63,7 @@ def version_sort_key(path): DEFAULT_CONFIG = { "core": { "cache_size_gb": "1.5", - "prefetch_radius": "4", + "prefetch_radius": "6", "theme": "dark", "default_directory": "", "optimize_for": "speed", # "speed" or "quality" diff --git a/faststack/imaging/prefetch.py b/faststack/imaging/prefetch.py index c06c857..353ac7f 100644 --- a/faststack/imaging/prefetch.py +++ b/faststack/imaging/prefetch.py @@ -177,24 +177,28 @@ def __init__( self.debug = debug # Use CPU count for I/O-bound JPEG decoding # Rule of thumb: 2x CPU cores for I/O bound, 1x for CPU bound - optimal_workers = min((os.cpu_count() or 1) * 2, 4) # Cap at 4 + optimal_workers = min((os.cpu_count() or 1) * 2, 8) # Cap at 8 for fast navigation self.executor = ThreadPoolExecutor( - max_workers=optimal_workers, thread_name_prefix="Prefetcher" + max_workers=optimal_workers, + thread_name_prefix="Prefetcher", ) self._futures_lock = threading.RLock() self.futures: Dict[int, Future] = {} self.generation = 0 self._scheduled: Dict[int, set] = {} # generation -> set of scheduled indices + # Cooperative cancellation flag for shutdown + self._stop_event = threading.Event() + # Adaptive prefetch: start with smaller radius, expand after user navigates - self._initial_radius = 2 # Small radius at startup to reduce cache thrash + self._initial_radius = 4 # Increased for faster initial responsiveness self._navigation_count = 0 # Track how many times user has navigated self._radius_expanded = False # Directional prefetching self._last_navigation_direction: int = 1 # 1 = forward, -1 = backward - self._direction_bias: float = 0.7 # 70% of radius in travel direction + self._direction_bias: float = 0.85 # 85% of radius in travel direction def set_image_files(self, image_files: List[ImageFile]): if self.image_files != image_files: @@ -214,6 +218,11 @@ def update_prefetch( is_navigation: True if this is from user navigation (arrow keys, etc.) direction: 1 for forward, -1 for backward, None to use last direction """ + if self.debug: + import time + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} update_prefetch: START index={current_index} dir={direction}") + # NOTE: Generation is NOT incremented here. It only changes when display size, # zoom state, or color mode changes - events that actually invalidate cached images. # Navigation just shifts which indices to prefetch. @@ -270,6 +279,7 @@ def update_prefetch( ) # Cancel stale futures and remove from scheduled + tasks_submitted = 0 with self._futures_lock: # Clean up old generation entries to prevent memory leak # MOVED INSIDE LOCK to prevent race with cancel_all() @@ -305,6 +315,11 @@ def update_prefetch( if i not in scheduled and i not in self.futures: self.submit_task(i, self.generation) scheduled.add(i) + tasks_submitted += 1 + + if self.debug: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} update_prefetch: DONE submitted={tasks_submitted} total={(_t_end - _t_start)*1000:.2f}ms") def submit_task( self, index: int, generation: int, priority: bool = False @@ -316,6 +331,15 @@ def submit_task( generation: Generation number for cache invalidation priority: If True, cancels lower-priority pending tasks to free up workers """ + # Don't submit new work if shutdown is in progress + if self._stop_event.is_set(): + return None + + import time + if self.debug and priority: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} submit_task: PRIORITY index={index} gen={generation}") + with self._futures_lock: if index in self.futures and not self.futures[index].done(): return self.futures[index] # Already submitted @@ -392,6 +416,11 @@ def _decode_and_cache( ) return None + # Cooperative abort: check if shutdown is in progress + if self._stop_event.is_set(): + log.debug("Aborting decode for index %d - shutdown in progress", index) + return None + try: # Check for empty file to avoid mmap error if os.path.getsize(image_file.path) == 0: @@ -933,5 +962,9 @@ def cancel_all(self): def shutdown(self): """Shuts down the thread pool executor.""" log.info("Shutting down prefetcher thread pool.") + # Set stop event first to signal workers to abort + self._stop_event.set() self.cancel_all() - self.executor.shutdown(wait=False) + # cancel_futures=True cancels queued work immediately (Python 3.9+) + # wait=False so we don't block on slow decode tasks + self.executor.shutdown(wait=False, cancel_futures=True) diff --git a/faststack/thumbnail_view/prefetcher.py b/faststack/thumbnail_view/prefetcher.py index fcdca7e..5dd5fe5 100644 --- a/faststack/thumbnail_view/prefetcher.py +++ b/faststack/thumbnail_view/prefetcher.py @@ -6,6 +6,7 @@ from concurrent.futures import ThreadPoolExecutor, Future from pathlib import Path from threading import Lock +import threading from typing import Dict, Optional, Set, Tuple, Callable import numpy as np @@ -15,6 +16,19 @@ log = logging.getLogger(__name__) +# Optional Qt dispatch so callbacks always run on Qt thread when available +try: + from PySide6.QtCore import QObject, Signal, Qt, QCoreApplication + + class _ReadyEmitter(QObject): + ready = Signal(str) + + _HAS_QT = True +except Exception: + _ReadyEmitter = None + _HAS_QT = False + QCoreApplication = None + # Try to import turbojpeg for faster JPEG decoding try: from turbojpeg import TurboJPEG, TJPF_RGB, TJSAMP_444 @@ -63,6 +77,7 @@ def __init__( self._cache = cache self._on_ready = on_ready_callback self._target_size = target_size + self._stop_event = threading.Event() self._executor = ThreadPoolExecutor( max_workers=max_workers, thread_name_prefix="thumb" ) @@ -75,6 +90,17 @@ def __init__( # Track futures for potential cancellation self._futures: Dict[Tuple[int, str, int], Future] = {} + # If Qt is available AND a QApplication exists, forward ready notifications + # to Qt/main thread. This prevents Qt warnings/crashes from worker-thread callbacks. + self._ready_emitter = None + if _HAS_QT and self._on_ready: + try: + if QCoreApplication.instance() is not None: + self._ready_emitter = _ReadyEmitter() # created on constructing thread (should be Qt thread) + self._ready_emitter.ready.connect(self._on_ready, Qt.QueuedConnection) + except Exception: + self._ready_emitter = None + log.info( "ThumbnailPrefetcher initialized with %d workers, target size %dpx", max_workers, @@ -92,6 +118,10 @@ def submit(self, path: Path, mtime_ns: int, size: int = None) -> bool: Returns: True if job was submitted, False if already in-flight or cached """ + # Don't accept new work once shutdown begins + if self._stop_event.is_set(): + return False + if size is None: size = self._target_size @@ -118,13 +148,16 @@ def submit(self, path: Path, mtime_ns: int, size: int = None) -> bool: mtime_ns, size, ) - future.add_done_callback( - lambda f: self._on_decode_done(f, job_key, cache_key) - ) with self._inflight_lock: self._futures[job_key] = future + # Add callback *after* registering future. If already done, add_done_callback + # may invoke immediately in this thread, so we want state initialized first. + future.add_done_callback( + lambda f: self._on_decode_done(f, job_key, cache_key) + ) + return True except RuntimeError: # Executor shutdown @@ -245,12 +278,20 @@ def _on_decode_done( self, future: Future, job_key: Tuple[int, str, int], cache_key: str ): """Callback when decode completes.""" - # Remove from inflight + # Always remove bookkeeping first to avoid stranding entries with self._inflight_lock: self._inflight.discard(job_key) self._futures.pop(job_key, None) + # Then bail if shutting down + if self._stop_event.is_set(): + return + try: + # If cancelled, don't call result() + if future.cancelled(): + return + jpeg_bytes = future.result() if jpeg_bytes: # Store in cache @@ -258,24 +299,36 @@ def _on_decode_done( # Notify ready if self._on_ready: - # Extract thumbnail_id from cache_key (same format) - self._on_ready(cache_key) + # If Qt emitter exists, this will run callback on Qt thread. + if self._ready_emitter is not None: + self._ready_emitter.ready.emit(cache_key) + else: + self._on_ready(cache_key) except Exception as e: log.debug("Thumbnail decode failed: %s", e) def cancel_all(self): """Cancel all pending jobs.""" + # Snapshot under lock, cancel outside lock to avoid deadlock: + # Future.cancel() can synchronously run callbacks. with self._inflight_lock: - for future in self._futures.values(): - future.cancel() + futures = list(self._futures.values()) self._futures.clear() self._inflight.clear() + for f in futures: + try: + f.cancel() + except Exception: + pass + def shutdown(self): """Shutdown the executor.""" + self._stop_event.set() self.cancel_all() - self._executor.shutdown(wait=False) + # cancel_futures=True cancels queued tasks immediately (Py3.9+) + self._executor.shutdown(wait=False, cancel_futures=True) log.info("ThumbnailPrefetcher shutdown") diff --git a/faststack/ui/provider.py b/faststack/ui/provider.py index 258819d..f6a1c38 100644 --- a/faststack/ui/provider.py +++ b/faststack/ui/provider.py @@ -38,6 +38,12 @@ def __init__(self, app_controller): def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: """Handles image requests from QML.""" + import time + _debug = getattr(self.app_controller, 'debug_cache', False) + if _debug: + _t_start = time.perf_counter() + print(f"[DBGCACHE] {_t_start*1000:.3f} requestImage: START id={id}") + if not id: return self.placeholder @@ -66,12 +72,19 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: ) ) + if _debug: + _t_get = time.perf_counter() + image_data = ( self.app_controller._last_rendered_preview if use_editor_preview else self.app_controller.get_decoded_image(index) ) + if _debug: + _t_got = time.perf_counter() + print(f"[DBGCACHE] {_t_got*1000:.3f} requestImage: got image_data in {(_t_got - _t_get)*1000:.2f}ms") + if image_data: # Handle format being None (from prefetcher) or missing fmt = getattr(image_data, "format", None) @@ -118,6 +131,10 @@ def requestImage(self, id: str, size: object, requestedSize: object) -> QImage: "ICC mode: skipping Qt color space (pixels already in monitor space)" ) + if _debug: + _t_end = time.perf_counter() + print(f"[DBGCACHE] {_t_end*1000:.3f} requestImage: DONE id={id} total={(_t_end - _t_start)*1000:.2f}ms") + # Buffer is now safe to release (handled by copy), but original_buffer ref in Python object stays # We don't need to manually attach original_buffer to qimg anymore since we copied. return qimg From 5e59b924979083d013ec8872dfe53b67325ce657 Mon Sep 17 00:00:00 2001 From: AlanRockefeller Date: Sun, 8 Feb 2026 22:47:32 -0500 Subject: [PATCH 2/2] Changelog update --- ChangeLog.md | 14 +++++++++++++- pyproject.toml | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/ChangeLog.md b/ChangeLog.md index 07e8d4c..3c15cdb 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -2,7 +2,19 @@ Todo: Make it work on Linux / Mac. Create Windows .exe. Write better documentation / help. Add splash screen / icon. Fix raw image support. -# Changelog +## 1.5.6 (2026-02-08) + +#### Performance +- Debounced `metadataChanged` / `highlightStateChanged` emissions to reduce UI overhead during rapid navigation. +- Increased default prefetch radius to **6** and raised prefetch worker cap to **8** for smoother fast navigation. +- Added optional `[DBGCACHE]` timing logs for image request/decode and UI refresh paths when `debug_cache` is enabled. + +#### Stability +- Refactored shutdown into `shutdown_qt()` (main thread) and `shutdown_nonqt()` (background-safe), wired from `aboutToQuit` in `main()` with a timeout/stacks fallback to diagnose hangs. +- Added cooperative cancellation and `cancel_futures=True` shutdown behavior to both main image and thumbnail prefetchers. +- Ensured thumbnail “ready” callbacks run on the Qt thread when available; hardened cancellation/callback ordering to avoid deadlocks and worker-thread Qt warnings. +- Enabled Ctrl-C termination via SIGINT handling and a periodic Qt timer to allow Python signal processing. + ## 1.5.5 (2026-02-07) diff --git a/pyproject.toml b/pyproject.toml index b08fd09..007453b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta" [project] name = "faststack" -version = "1.5.5" +version = "1.5.6" authors = [ { name="Alan Rockefeller"}, ]