Skip to content
Merged
1 change: 1 addition & 0 deletions ChangeLog.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ Todo: More testing Linux / Mac. Create Windows .exe. Write better document
- Enhanced metadata display with camera-style shutter speed formatting.
- Added new thumbnail badges for Backups (Bk) and Developed (D) variants.
- Improved cache eviction handling and thread-safety for concurrent operations.
- Fixed a bug where deleting an image could mess up the batch selection ranges if the delete was cancelled, failed, or undone.

## 1.5.8 (2026-02-13)

Expand Down
Binary file removed faststack/all_verification_results.txt
Binary file not shown.
19 changes: 0 additions & 19 deletions faststack/all_verification_results_utf8.txt

This file was deleted.

245 changes: 214 additions & 31 deletions faststack/app.py

Large diffs are not rendered by default.

20 changes: 0 additions & 20 deletions faststack/check_daemon.py

This file was deleted.

6 changes: 0 additions & 6 deletions faststack/check_scipy.py

This file was deleted.

122 changes: 98 additions & 24 deletions faststack/imaging/cache.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""Byte-aware LRU cache for storing decoded image data (CPU and GPU)."""

import inspect
import logging
from collections import deque
from pathlib import Path
from typing import Any, Callable, Optional, Union
import time
Expand Down Expand Up @@ -51,9 +53,10 @@ def __init__(
self,
max_bytes: int,
size_of: Callable[[Any], int] = get_decoded_image_size,
on_evict: Optional[Callable[[Any, Any], None]] = None,
on_evict: Optional[Callable[..., None]] = None,
):
super().__init__(maxsize=max_bytes, getsizeof=size_of)
self._on_evict_arity = self._detect_arity(on_evict)
self.on_evict = on_evict
# RLock is required: __setitem__ holds _lock and calls super().__setitem__(),
# which may call our overridden popitem() for LRU eviction. A non-reentrant
Expand All @@ -66,6 +69,10 @@ def __init__(
self._tombstone_expiry: dict[str, float] = {}
self._pending_callbacks: Optional[list[Callable[[], None]]] = None
self._pending_callbacks_owner: Optional[int] = None
# Flag: True when __delitem__ is being called from __setitem__'s capacity
# eviction path (popitem), as opposed to targeted removal (pop_path, evict_paths).
self._pressure_eviction_active = False
self._pressure_eviction_owner: Optional[int] = None
log.info(
f"Initialized byte-aware LRU cache with {max_bytes / 1024**2:.2f} MB capacity."
)
Expand All @@ -82,6 +89,47 @@ def max_bytes(self, value: int) -> None:
self.maxsize = v
log.debug(f"Cache max_bytes updated to {v / 1024**2:.2f} MB")

@staticmethod
def _detect_arity(callback: Optional[Callable]) -> int:
"""Detect whether callback accepts 2 args (key, value) or 3 (key, value, info)."""
if callback is None:
return 2
try:
sig = inspect.signature(callback)
# Count parameters that can accept positional args
positional = sum(
1
for p in sig.parameters.values()
if p.kind
in (
inspect.Parameter.POSITIONAL_ONLY,
inspect.Parameter.POSITIONAL_OR_KEYWORD,
)
and p.default is inspect.Parameter.empty
)
return 3 if positional >= 3 else 2
Comment thread
coderabbitai[bot] marked this conversation as resolved.
except (ValueError, TypeError):
return 2

def _fire_evict(self, key: Any, value: Any, info: dict) -> None:
"""Invoke on_evict, dispatching by detected arity."""
if not self.on_evict:
return
if self._on_evict_arity >= 3:
self.on_evict(key, value, info)
else:
self.on_evict(key, value)

def _build_eviction_info(self, reason: str, pre_usage: int) -> dict:
"""Build eviction context dict captured at eviction time (inside lock)."""
return {
"reason": reason,
"usage_bytes": pre_usage,
"max_bytes": self.maxsize,
"entry_count": len(self),
"thread_id": threading.get_ident(),
}

def __setitem__(self, key, value):
pending_callbacks = []
with self._lock:
Expand Down Expand Up @@ -122,20 +170,26 @@ def __setitem__(self, key, value):
# callbacks triggered by popitem() -> __delitem__().
self._pending_callbacks = pending_callbacks
self._pending_callbacks_owner = threading.get_ident()
# Mark that any __delitem__ calls from here are capacity-pressure evictions
self._pressure_eviction_active = True
self._pressure_eviction_owner = threading.get_ident()
try:
super().__setitem__(key, value)

# If it was a replacement, we must ensure an eviction callback is added
# for the old value, because cachetools.__setitem__ for replacements
# does not call __delitem__ (it just overwrites the dict entry).
if old_value is not _MISSING and self.on_evict:
info = self._build_eviction_info("replace", self.currsize)
info["inserting_key"] = str(key)

def _replace_cb(k=key, v=old_value):
if self.on_evict:
self.on_evict(k, v)
def _replace_cb(k=key, v=old_value, _info=info):
self._fire_evict(k, v, _info)

pending_callbacks.append(_replace_cb)
finally:
self._pressure_eviction_active = False
self._pressure_eviction_owner = None
self._pending_callbacks = None
self._pending_callbacks_owner = None

Expand Down Expand Up @@ -173,16 +227,34 @@ def __delitem__(self, key):
except KeyError:
raise KeyError(key) from None

# Capture usage BEFORE deletion for accurate thrashing detection.
# After super().__delitem__, currsize will already be decremented.
pre_usage = self.currsize

# Determine eviction reason based on calling context.
# This is a heuristic: _pressure_eviction_active is only True when
# __setitem__ is executing super().__setitem__(), which calls
# popitem() when currsize + new_size > maxsize (cachetools LRU).
# Any other path into __delitem__ — pop_path(), direct del,
# popitem() from manual cache resize — is classified as "manual"
# by design, since those are intentional removals, not capacity
# pressure indicating the cache is too small.
is_pressure = (
self._pressure_eviction_active
and threading.get_ident() == self._pressure_eviction_owner
)
reason = "pressure" if is_pressure else "manual"

super().__delitem__(key)
log.debug(
f"Removed item '{key}'. Cache size: {self.currsize / 1024**2:.2f} MB"
)

if self.on_evict:
info = self._build_eviction_info(reason, pre_usage)

def _callback_func(k=key, v=value):
if self.on_evict:
self.on_evict(k, v)
def _callback_func(k=key, v=value, _info=info):
self._fire_evict(k, v, _info)

# If we are inside a call that defers callbacks (like __setitem__ or evict_paths),
# append to the shared list.
Expand All @@ -206,15 +278,21 @@ def get(self, key, default=None):
return super().get(key, default)

def clear(self):
"""Clear cache without triggering eviction callbacks."""
# Temporarily disable callback to prevent "thrashing" warnings during mass clear
"""Clear cache without triggering eviction callbacks.

Uses _pending_callbacks discard pattern (same as evict_paths) rather
than setting on_evict=None, which would race with closures that read
on_evict outside the lock on other threads.
"""
with self._lock:
saved_callback = self.on_evict
self.on_evict = None
_discard: list[Callable[[], None]] = []
self._pending_callbacks = _discard
self._pending_callbacks_owner = threading.get_ident()
try:
super().clear()
finally:
self.on_evict = saved_callback
self._pending_callbacks = None
self._pending_callbacks_owner = None

def pop_path(self, path: Union[Path, str]):
"""Targeted invalidation of all generations for a given path.
Expand Down Expand Up @@ -309,15 +387,17 @@ def evict_paths(self, paths: list[Union[Path, str]]):
if str(key).startswith(prefix_tuple):
keys_to_remove.append(key)

# 4. Remove keys
# 4. Remove keys — capture eviction callbacks but discard them,
# since these are intentional removals, not LRU pressure.
# We use _pending_callbacks to collect (and then drop) rather than
# setting on_evict=None, which would race with closures that read
# on_evict outside the lock.
removed_bytes = 0
pending_callbacks = []
self._pending_callbacks = pending_callbacks
_discard = []
self._pending_callbacks = _discard
self._pending_callbacks_owner = threading.get_ident()
try:
for k in keys_to_remove:
# Use self.pop (which calls __delitem__) to trigger eviction callbacks.
# It will re-acquire our RLock safely.
val = self.pop(k, None)
if val is not None:
try:
Expand All @@ -328,13 +408,7 @@ def evict_paths(self, paths: list[Union[Path, str]]):
finally:
self._pending_callbacks = None
self._pending_callbacks_owner = None

# Execute all captured eviction callbacks OUTSIDE the lock
for callback in pending_callbacks:
try:
callback()
except Exception:
log.exception("Error in eviction callback")
# _discard is intentionally not executed

if keys_to_remove:
log.info(
Expand Down
Binary file removed faststack/integration_results.txt
Binary file not shown.
Binary file removed faststack/integration_traceback.txt
Binary file not shown.
2 changes: 2 additions & 0 deletions faststack/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ class EntryMetadata:
restacked: bool = False
restacked_date: Optional[str] = None
favorite: bool = False
todo: bool = False
todo_date: Optional[str] = None


@dataclasses.dataclass
Expand Down
Binary file removed faststack/path_check.txt
Binary file not shown.
10 changes: 10 additions & 0 deletions faststack/qml/FilterDialog.qml
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,14 @@ Dialog {
Material.accent: "#ce93d8"
onCheckedChanged: _collectFlags()
}
CheckBox {
id: cbTodo
text: "Todo"
checked: false
Material.foreground: filterDialog.textColor
Material.accent: "#64B5F6"
onCheckedChanged: _collectFlags()
}
CheckBox {
id: cbFavorite
text: "Favorite"
Expand All @@ -136,6 +144,7 @@ Dialog {
if (cbStacked.checked) flags.push("stacked")
if (cbEdited.checked) flags.push("edited")
if (cbRestacked.checked) flags.push("restacked")
if (cbTodo.checked) flags.push("todo")
if (cbFavorite.checked) flags.push("favorite")
filterDialog.filterFlags = flags
}
Expand All @@ -156,6 +165,7 @@ Dialog {
cbStacked.checked = currentFlags.indexOf("stacked") >= 0
cbEdited.checked = currentFlags.indexOf("edited") >= 0
cbRestacked.checked = currentFlags.indexOf("restacked") >= 0
cbTodo.checked = currentFlags.indexOf("todo") >= 0
cbFavorite.checked = currentFlags.indexOf("favorite") >= 0

filterField.forceActiveFocus()
Expand Down
16 changes: 13 additions & 3 deletions faststack/qml/Main.qml
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ ApplicationWindow {
}
if (uiState && uiState.hasRecycleBinItems) {
close.accepted = false
uiState.refreshRecycleBinStats()
recycleBinCleanupDialog.open()
} else {
close.accepted = true
Expand Down Expand Up @@ -1015,6 +1016,11 @@ ApplicationWindow {
color: "lightgreen"
visible: uiState ? (uiState.imageCount > 0 && uiState.isUploaded) : false
}
Label {
text: uiState ? (uiState.todoDate ? ` Todo since ${uiState.todoDate}` : " Todo") : ""
color: "#64B5F6"
visible: uiState ? (uiState.imageCount > 0 && uiState.isTodo) : false
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Label {
text: uiState ? ` Edited on ${uiState.editedDate}` : ""
color: "lightgreen"
Expand Down Expand Up @@ -1374,6 +1380,7 @@ ApplicationWindow {
"&nbsp;&nbsp;}: End current batch<br>" +
"&nbsp;&nbsp;\\: Clear all batches<br><br>" +
"<b>Flag Toggles:</b><br>" +
"&nbsp;&nbsp;D: Toggle todo flag<br>" +
"&nbsp;&nbsp;F: Toggle favorite flag<br>" +
"&nbsp;&nbsp;U: Toggle uploaded flag<br>" +
"&nbsp;&nbsp;Ctrl+E: Toggle edited flag<br>" +
Expand Down Expand Up @@ -1620,13 +1627,14 @@ ApplicationWindow {
Behavior on height { NumberAnimation { duration: 250; easing.type: Easing.OutCubic } }

ScrollView {
id: detailsScrollView
anchors.fill: parent
anchors.margins: 8


TextArea {
id: detailsText
width: parent.width
width: detailsScrollView.availableWidth
text: uiState ? uiState.recycleBinDetailedText : ""
color: root.isDarkTheme ? "#efefef" : "#333333"
font.family: "Consolas, 'Courier New', monospace"
Expand All @@ -1635,7 +1643,9 @@ ApplicationWindow {
wrapMode: Text.WrapAnywhere
readOnly: true
selectByMouse: true
background: null
background: Rectangle {
color: "transparent"
}
}
}
}
Expand Down
1 change: 1 addition & 0 deletions faststack/qml/ThumbnailGridView.qml
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ Item {
tileIsEdited: isEdited || false
tileIsRestacked: isRestacked || false
tileIsFavorite: isFavorite || false
tileIsTodo: isTodo || false
tileIsInBatch: isInBatch || false
tileIsCurrent: isCurrent || false
tileThumbnailSource: thumbnailSource || ""
Expand Down
Loading