Skip to content

Commit 267e02b

Browse files
committed
Fix 3-10s startup block from index enrichment
`enrich_entries_with_index` blocked the listing pipeline waiting on `GLOBAL_INDEX_STORE` mutex held by post-replay verification (hundreds of serial SQLite queries for affected dirs). - Switch `.lock()` to `.try_lock();` skip enrichment if busy - Subsequent get_file_range calls retry once lock is free - Fix loading message: "1 files" → "1 file", pluralize dynamically - Replace opaque "just a moment now" with "sorting your files, preparing view..."
1 parent dae9364 commit 267e02b

5 files changed

Lines changed: 37 additions & 15 deletions

File tree

apps/desktop/src-tauri/src/indexing/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,14 +99,14 @@ Key test files are alongside each module (test functions within `#[cfg(test)]` b
9999

100100
**Writer-side delete-with-propagation**: `DeleteEntry` and `DeleteSubtree` handlers in the writer automatically read old data before deleting and propagate accurate negative deltas. This means every deletion -- replay, live, verification -- gets correct dir_stats updates without callers needing to send separate `PropagateDelta` messages. `delete_subtree` and `propagate_delta` have no internal transactions, so they're safe inside the replay's `BEGIN IMMEDIATE` transaction.
101101

102-
**Post-replay verification is bidirectional**: `verify_affected_dirs` checks both directions: (1) stale entries in DB but not on disk (sends `DeleteEntry`/`DeleteSubtree`), and (2) missing entries on disk but not in DB (sends `UpsertEntry` + `PropagateDelta` for files, collects directory paths for `scan_subtree`). New directories are scanned and their subtree totals propagated up the ancestor chain. Uses a two-phase pattern to avoid blocking `enrich_entries_with_index`: Phase 1 holds the `GLOBAL_INDEX_STORE` lock briefly for bulk SQLite reads into a `HashMap`, Phase 2 does all disk I/O (hundreds of `readdir`/`exists`/`symlink_metadata` calls) without any lock.
102+
**Post-replay verification is bidirectional**: `verify_affected_dirs` checks both directions: (1) stale entries in DB but not on disk (sends `DeleteEntry`/`DeleteSubtree`), and (2) missing entries on disk but not in DB (sends `UpsertEntry` + `PropagateDelta` for files, collects directory paths for `scan_subtree`). New directories are scanned and their subtree totals propagated up the ancestor chain. Uses a two-phase pattern: Phase 1 holds the `GLOBAL_INDEX_STORE` lock for bulk SQLite reads into a `HashMap` (can take seconds with hundreds of affected dirs), Phase 2 does all disk I/O without any lock. `enrich_entries_with_index` uses `try_lock` to avoid blocking on Phase 1.
103103

104104
**Schema version mismatch drops the DB**: If `schema_version` in meta doesn't match what the code expects, the entire DB is deleted and rebuilt. No migration path (it's a cache, not user data).
105105

106106
**`verifier.rs` is a placeholder**: Per-navigation readdir diff is a future milestone. Currently just a TODO comment.
107107

108108
**Scan cancellation leaves partial data**: By design. `scan_completed_at` not set in meta, so next startup detects incomplete scan and runs fresh. No cleanup needed.
109109

110-
**Global read-only store uses `std::sync::Mutex`**: Not `RwLock`, because `rusqlite::Connection` is `Send` but not `Sync`. The mutex is held briefly for each batch read.
110+
**Global read-only store uses `std::sync::Mutex`**: Not `RwLock`, because `rusqlite::Connection` is `Send` but not `Sync`. `enrich_entries_with_index` uses `try_lock` to avoid blocking the listing pipeline when `verify_affected_dirs` Phase 1 holds the lock during startup (hundreds of serial SQLite queries for affected dirs). If the lock is busy, enrichment is skipped and retried on subsequent `get_file_range` calls.
111111

112112
**Progress events use `tauri::async_runtime::spawn`**: Not `tokio::spawn`, because indexing can start from Tauri's synchronous `setup()` hook where no Tokio runtime context exists.

apps/desktop/src-tauri/src/indexing/mod.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -68,13 +68,16 @@ fn clear_global_index_store() {
6868
/// Enrich directory entries with recursive size data from the index.
6969
///
7070
/// Called from `get_file_range` on every page fetch. Does nothing if
71-
/// indexing is not initialized. For directories (non-symlinks), populates
72-
/// `recursive_size`, `recursive_file_count`, and `recursive_dir_count`
73-
/// from the `dir_stats` table via a batch SQLite read.
71+
/// indexing is not initialized. Uses `try_lock` to avoid blocking the
72+
/// listing pipeline when background verification holds the lock at startup.
73+
/// Skipped enrichment is retried on subsequent fetches.
7474
pub fn enrich_entries_with_index(entries: &mut [FileEntry]) {
75-
let guard = match GLOBAL_INDEX_STORE.lock() {
75+
let guard = match GLOBAL_INDEX_STORE.try_lock() {
7676
Ok(g) => g,
77-
Err(_) => return,
77+
Err(_) => {
78+
log::debug!("Index enrichment skipped: store lock is held (background verification likely in progress)");
79+
return;
80+
}
7881
};
7982
let store = match guard.as_ref() {
8083
Some(s) => s,

apps/desktop/src/lib/ui/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,8 @@ Variants: `primary` | `secondary` (default) | `danger`. Sizes: `regular` (defaul
5353

5454
Progressive status text driven by props (mutually exclusive, evaluated top-down):
5555

56-
1. `finalizingCount` set → "All N files loaded, just a moment now."
57-
2. `loadedCount` set → "Loaded N files..."
56+
1. `finalizingCount` set → "All N file/files loaded. Sorting your files, preparing view..."
57+
2. `loadedCount` set → "Loaded N file/files..."
5858
3. `openingFolder` true → "Opening folder..."
5959
4. Default → "Loading..."
6060

apps/desktop/src/lib/ui/LoadingIcon.svelte

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,12 @@
1616
<div class="loading-container">
1717
<div class="spinner spinner-lg"></div>
1818
{#if finalizingCount !== undefined}
19-
<div class="loading-text">All {formatNumber(finalizingCount)} files loaded, just a moment now.</div>
19+
<div class="loading-text">
20+
All {formatNumber(finalizingCount)}
21+
{finalizingCount === 1 ? 'file' : 'files'} loaded. Sorting your files, preparing view...
22+
</div>
2023
{:else if loadedCount !== undefined}
21-
<div class="loading-text">Loaded {formatNumber(loadedCount)} files...</div>
24+
<div class="loading-text">Loaded {formatNumber(loadedCount)} {loadedCount === 1 ? 'file' : 'files'}...</div>
2225
{:else if openingFolder}
2326
<div class="loading-text">Opening folder...</div>
2427
{:else}

apps/desktop/src/lib/ui/streaming-loading.test.ts

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@ describe('LoadingIcon component', () => {
5151
expect(loadingText?.textContent).toBe('Loaded 1,500 files...')
5252
})
5353

54+
it('uses singular "file" when loadedCount is 1', async () => {
55+
mount(LoadingIcon, { target, props: { loadedCount: 1 } })
56+
await tick()
57+
58+
const loadingText = target.querySelector('.loading-text')
59+
expect(loadingText?.textContent).toBe('Loaded 1 file...')
60+
})
61+
5462
it('shows count of 0 when loadedCount is 0', async () => {
5563
mount(LoadingIcon, { target, props: { loadedCount: 0 } })
5664
await tick()
@@ -112,15 +120,23 @@ describe('LoadingIcon component', () => {
112120
await tick()
113121

114122
const loadingText = target.querySelector('.loading-text')
115-
expect(loadingText?.textContent).toBe('All 600 files loaded, just a moment now.')
123+
expect(loadingText?.textContent).toBe('All 600 files loaded. Sorting your files, preparing view...')
124+
})
125+
126+
it('uses singular "file" when finalizingCount is 1', async () => {
127+
mount(LoadingIcon, { target, props: { finalizingCount: 1 } })
128+
await tick()
129+
130+
const loadingText = target.querySelector('.loading-text')
131+
expect(loadingText?.textContent).toBe('All 1 file loaded. Sorting your files, preparing view...')
116132
})
117133

118134
it('finalizingCount takes precedence over loadedCount', async () => {
119135
mount(LoadingIcon, { target, props: { loadedCount: 500, finalizingCount: 600 } })
120136
await tick()
121137

122138
const loadingText = target.querySelector('.loading-text')
123-
expect(loadingText?.textContent).toBe('All 600 files loaded, just a moment now.')
139+
expect(loadingText?.textContent).toBe('All 600 files loaded. Sorting your files, preparing view...')
124140
})
125141

126142
it('shows finalizing message with cancel hint', async () => {
@@ -130,7 +146,7 @@ describe('LoadingIcon component', () => {
130146
const loadingText = target.querySelector('.loading-text')
131147
const cancelHint = target.querySelector('.cancel-hint')
132148

133-
expect(loadingText?.textContent).toBe('All 1,000 files loaded, just a moment now.')
149+
expect(loadingText?.textContent).toBe('All 1,000 files loaded. Sorting your files, preparing view...')
134150
expect(cancelHint?.textContent).toBe('Press ESC to cancel and go back')
135151
})
136152
})
@@ -157,7 +173,7 @@ describe('LoadingIcon component', () => {
157173
await tick()
158174

159175
const loadingText = target.querySelector('.loading-text')
160-
expect(loadingText?.textContent).toBe('All 500 files loaded, just a moment now.')
176+
expect(loadingText?.textContent).toBe('All 500 files loaded. Sorting your files, preparing view...')
161177
})
162178

163179
it('shows "Loading..." when openingFolder is false and no counts', async () => {

0 commit comments

Comments
 (0)