Skip to content

Commit c85c8c2

Browse files
committed
Improve perf: Non-blocking navi on slow/dead SMBs
- Rust: 2s timeout on path_exists via blocking_with_timeout helper - Frontend: optimistic volume switch (immediate UI, background resolve) - determineNavigationPath: parallel checks with 500ms timeouts - resolveValidPath: 1s timeout per parent-walk step - ESC cancel navigates to ~ instantly (no resolveValidPath) - Back/forward navigates immediately, error handler resolves - 3 Rust + 16 TypeScript unit tests
1 parent 87e3d6e commit c85c8c2

9 files changed

Lines changed: 764 additions & 80 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ immediately to business-logic modules. No significant logic lives here.
2626

2727
- **No business logic here.** If you find yourself adding branching or data transformation, move it to the relevant subsystem module.
2828
- **`spawn_blocking` for filesystem I/O.** All blocking operations in async commands are wrapped in `tokio::task::spawn_blocking`.
29+
- **`blocking_with_timeout` for potentially slow I/O.** `path_exists` uses `blocking_with_timeout(2s, false, ...)` to prevent hung network mounts from blocking the async runtime. The helper wraps `spawn_blocking` + `tokio::time::timeout` and returns a fallback value on timeout or `JoinError`.
2930
- **`expand_tilde`** is applied conditionally: for `list_directory` it's gated on `volume_id == "root"`, but for write operations (copy, move, delete, scan preview) it's always applied. MTP and network volume paths must never be tilde-expanded.
3031
- **AI commands** are registered directly from `ai::manager` and `ai::suggestions` — there is no `commands/ai.rs` file.
3132
- **Platform gates.** `mtp`, `network`, and `volumes` modules are macOS-only at the `mod.rs` level. Individual functions also use `#[cfg]` where behaviour differs (e.g., `sync_status`).

apps/desktop/src-tauri/src/commands/file_system.rs

Lines changed: 47 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ use std::path::{Path, PathBuf};
2525
use std::sync::mpsc::channel;
2626
#[cfg(target_os = "macos")]
2727
use tauri::Manager;
28+
use tokio::time::Duration;
29+
30+
const PATH_EXISTS_TIMEOUT: Duration = Duration::from_secs(2);
31+
32+
/// Runs a blocking closure on the blocking thread pool with a timeout.
33+
/// Returns the fallback value if the closure doesn't complete in time.
34+
async fn blocking_with_timeout<T: Send + 'static>(
35+
timeout_duration: Duration,
36+
fallback: T,
37+
f: impl FnOnce() -> T + Send + 'static,
38+
) -> T {
39+
match tokio::time::timeout(timeout_duration, tokio::task::spawn_blocking(f)).await {
40+
Ok(Ok(result)) => result,
41+
_ => fallback, // Timeout or JoinError
42+
}
43+
}
2844

2945
#[tauri::command]
3046
pub async fn path_exists(volume_id: Option<String>, path: String) -> bool {
@@ -36,15 +52,15 @@ pub async fn path_exists(volume_id: Option<String>, path: String) -> bool {
3652
// Try to use Volume abstraction
3753
if let Some(volume) = get_volume_manager().get(&volume_id) {
3854
let path_for_check = expanded_path.clone();
39-
// Use spawn_blocking for MTP volumes which need tokio runtime context
40-
return tokio::task::spawn_blocking(move || volume.exists(Path::new(&path_for_check)))
41-
.await
42-
.unwrap_or(false);
55+
return blocking_with_timeout(PATH_EXISTS_TIMEOUT, false, move || {
56+
volume.exists(Path::new(&path_for_check))
57+
})
58+
.await;
4359
}
4460

4561
// Fallback for unknown volumes (shouldn't happen in practice)
4662
let path_buf = PathBuf::from(expanded_path);
47-
path_buf.exists()
63+
blocking_with_timeout(PATH_EXISTS_TIMEOUT, false, move || path_buf.exists()).await
4864
}
4965

5066
#[tauri::command]
@@ -662,4 +678,30 @@ mod tests {
662678
let result = create_directory(None, "/nonexistent_path_12345".to_string(), "test".to_string()).await;
663679
assert!(result.is_err());
664680
}
681+
682+
#[tokio::test]
683+
async fn test_blocking_with_timeout_fast_closure_returns_value() {
684+
let result = blocking_with_timeout(Duration::from_secs(2), false, || true).await;
685+
assert!(result);
686+
}
687+
688+
#[tokio::test]
689+
async fn test_blocking_with_timeout_slow_closure_returns_fallback() {
690+
let result = blocking_with_timeout(Duration::from_millis(50), false, || {
691+
std::thread::sleep(Duration::from_secs(2));
692+
true
693+
})
694+
.await;
695+
assert!(!result);
696+
}
697+
698+
#[tokio::test]
699+
async fn test_blocking_with_timeout_returns_custom_fallback() {
700+
let result = blocking_with_timeout(Duration::from_millis(50), 42, || {
701+
std::thread::sleep(Duration::from_secs(2));
702+
99
703+
})
704+
.await;
705+
assert_eq!(result, 42);
706+
}
665707
}

apps/desktop/src/lib/file-explorer/navigation/CLAUDE.md

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ Browser-style back/forward history, path resolution, paged keyboard shortcuts, a
1111
| `keyboard-shortcuts.ts` | Home/End/PageUp/PageDown handling for file lists |
1212
| `VolumeBreadcrumb.svelte` | Clickable volume label + grouped dropdown |
1313
| `navigation-history.test.ts` | Full unit test coverage of history functions |
14+
| `path-navigation.test.ts` | Unit tests for path resolution and timeouts |
1415
| `keyboard-shortcuts.test.ts` | Unit tests for shortcut calculations |
1516

1617
## `navigation-history.ts`
@@ -34,15 +35,34 @@ Entries carry full `volumeId` — navigating back can cross volume boundaries (e
3435
## `path-navigation.ts`
3536

3637
`determineNavigationPath(volumeId, volumePath, targetPath, otherPane)` — picks best initial path when switching volumes.
37-
Priority:
38+
Runs checks **in parallel** with 500ms frontend timeouts per check. Priority:
3839

3940
1. Favorite path (when `targetPath !== volumePath`)
4041
2. Other pane's path (if same volume and path exists)
4142
3. Stored `lastUsedPath` for this volume
4243
4. Default: `~` for `DEFAULT_VOLUME_ID`, else volume root
4344

44-
`resolveValidPath(targetPath)` — walks parent tree until an existing directory is found. Fallback chain: parent dirs →
45-
`~``/``null` (volume unmounted).
45+
`resolveValidPath(targetPath)` — walks parent tree until an existing directory is found. Each step has a **1-second
46+
frontend timeout**. Fallback chain: parent dirs → `~``/``null` (volume unmounted).
47+
48+
`withTimeout(promise, ms, fallback)` — races a promise against a timeout, returning the fallback on expiry. Used by both
49+
functions above.
50+
51+
### Non-blocking navigation pattern
52+
53+
All `pathExists` calls are guarded by two timeout layers:
54+
55+
- **Rust-side**: `blocking_with_timeout` wraps filesystem syscalls in `tokio::time::timeout` (2 seconds). Prevents
56+
kernel syscalls on hung network mounts from blocking the Tauri async runtime.
57+
- **Frontend-side**: `withTimeout` races each `pathExists` IPC call (500ms for `determineNavigationPath`, 1s for
58+
`resolveValidPath`). The faster timeout wins.
59+
60+
`handleVolumeChange` in `DualPaneExplorer.svelte` uses **optimistic navigation**: it updates pane state immediately
61+
(showing the loading spinner), then resolves the "best" path in the background. A `volumeChangeGeneration` counter
62+
guards against stale corrections when the user navigates away before resolution completes.
63+
64+
`handleCancelLoading` navigates to `~` immediately on ESC (no `resolveValidPath` call). `handleNavigationAction`
65+
(back/forward) navigates immediately; FilePane's listing error handler resolves upward if the path is gone.
4666

4767
## `keyboard-shortcuts.ts`
4868

0 commit comments

Comments
 (0)