Silent freeze between hydrate dialog closing and grid populating on large directories #176
Labels
No labels
Kind/Bug
Kind/Feature
Priority/High
Priority/Medium
Reviewed/Confirmed
Compat/Breaking
Kind/Bug
Kind/Documentation
Kind/Enhancement
Kind/Feature
Kind/Security
Kind/Testing
Priority
Critical
Priority
High
Priority
Low
Priority
Medium
Reviewed
Confirmed
Reviewed
Duplicate
Reviewed
Invalid
Reviewed
Won't Fix
Status
Abandoned
Status
Blocked
Status
Need More Info
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
ai-collab/bulk-image-organizer#176
Loading…
Reference in a new issue
No description provided.
Delete branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
After the network-hydrate dialog (
NetworkHydrateDialog) finishes and closes, there is a visible gap/freeze before the directory grid populates and the scan's own progress reporting ("Discovering...") takes over. On large directories this can be a multi-second (or longer) apparent hang with no feedback, even thoughopen_directory()sets an indeterminate "Preparing workspace..." status message at the start of this block.Root cause
The entire block in
open_directory()fromhydrate_dialog.finish()throughself._launch_directory_scan(root)(src/bulk_image_organizer/ui/main_window.py:4466-4531) runs synchronously on the main thread with zero_pump_ui_events()calls anywhere in it — unlike the hydrate loop immediately before it, which pumps on every progress callback. Because Qt doesn't repaint until control returns to the event loop, the "Preparing workspace..." message set at the top of the block (main_window.py:4474-4479) never actually renders before the block's work is done — the app looks exactly as frozen as if that message didn't exist.Concretely, in order:
record_workspace_open(...)— cheap._prune_disabled_types_from_current_db()(main_window.py:3800-3832) — runs a fullget_all_images(include_thumbnails=False)table read, iterated row-by-row in Python to check each file extension against the enabled set._start_background_share_sync()/_stop_background_share_sync()— cheap.main_window.py:4494-4518):get_all_images(include_thumbnails=False)again — a second full-table read duplicating step 2's query, with no caching/reuse between them.TagManager.image_dicts_with_tags(existing)— one bulk tag query, but still O(N) Python work building a dict per image.self.model.set_images(plains)— resets the grid model.self._working_db.get_thumbnail_blob_dicts()(db/working_db.py:464-475) — likely the single biggest cost on a large directory: one query that loads thethumbnail_blobfor every image that has a cached thumbnail, all at once, into memory, with no chunking and no progress signal. On a large workspace this can be hundreds of MB of BLOB data deserialized synchronously.self.model.apply_thumbnail_batch(thumb_batch)— iterates every entry, then fires a singledataChangedspanning the entire row range, forcing the view to reconsider every visible thumbnail's paint at once._refresh_dupe_trash_counts()— a few aggregateCOUNTqueries, cheap._refresh_tags_ui()— tag panel refresh from DB, plus a redundant second call to_refresh_dupe_trash_counts().global_config.add_recent_dir(...)— cheap._launch_directory_scan(root)run, restoring visible progress ("Discovering...").This is the same class of bug fixed twice earlier on branch
0.8.0/dupe-compare-progress(the post-hydrate preload N+1, andget_sort_eligible_images_by_tag's N+1) — except here the cost is concentrated in a small number of large, unchunked queries/operations rather than a per-row loop, so the fix needs chunked reporting rather than just batching.Suggested fix
get_all_images()call (steps 2 and 4 can share one fetch).get_thumbnail_blob_dicts) and report progress through the existingcore/status_message.pystandard, the same way other long-running stages do._pump_ui_events()calls at appropriate points in this block so status text actually renders instead of being silently overwritten by the time the event loop next runs.Where found
Investigated on branch
0.8.0/dupe-compare-progresswhile working on dialog/status-message progress reporting across the app.Fixed on branch
0.9.1/issue-176-178-scan-freeze(commitaa080a3):_prune_disabled_types_from_current_db()now accepts a pre-fetched image list and returns the surviving rows, soopen_directory()fetchesget_all_images()once and reuses it for preload instead of querying twice back-to-back.WorkingDB.iter_thumbnail_blob_dicts()/count_images_with_thumbnails()chunk the cached-thumbnail load instead of one unchunked query pulling every BLOB into memory at once;open_directory()reports real per-chunk progress through the standardbuild_status_message()format._pump_ui_events()calls added after each stage of the post-hydrate block so the "Preparing workspace..." status actually renders between stages instead of being silently overwritten.Covered by new tests in
tests/db/test_working_db.py(chunked thumbnail load) plus the existingopen_directorypreload/hydrate test suite intests/test_pr13_config.py, all passing. Not closing yet — this is on a development branch, not yet merged tomain.Follow-up: the chunked thumbnail-hydrate loop from the initial fix (commit
aa080a3) was itself a regression on large cached workspaces, reported on Windows 11 with a 15,000-image already-cached directory — near-total UI unresponsiveness while loading thumbnails, and lingering sluggishness afterward. Root cause: the loop was chunked correctly, but it still ran the query and a forcedprocessEvents()pump on the main thread for every chunk (~75 chunks at the default chunk size) — each pump forced a synchronousdataChanged+ repaint/decode cycle, so the fix traded one silent-but-fast blocking call for dozens of forced synchronous repaint storms. Net effect was worse than the original bug.Fixed in commit
2076b5b: the hydrate loop now runs on a backgroundCachedThumbnailLoadWorker(QRunnable) that delivers chunks through the existingThumbnailSignalsbundle (same signal/slot infra as the thumbnail-generation worker). The main thread only applies a batch when Qt's event loop naturally processes the queued cross-thread signal — no explicitprocessEvents()forcing synchronous repaints — so Qt coalesces the paint work instead of doing it once per chunk.open_directory()kicks this off and proceeds straight to the scan without waiting on it.New tests cover the worker's chunking/cancel behavior directly and an integration test proving
open_directory()returns without blocking on it (deterministic via a blocked first DB call, not timing-dependent). Full suite (785 tests) passing.Closing — fixed via PR #187 (branch
0.9.1/issue-176-178-scan-freeze).