Skip to content

fix(commands): move four blocking filesystem commands off the UI thread - #502

Merged
PathGao merged 1 commit into
masterfrom
fix/blocking-fs-off-ui-thread
Aug 6, 2026
Merged

fix(commands): move four blocking filesystem commands off the UI thread#502
PathGao merged 1 commit into
masterfrom
fix/blocking-fs-off-ui-thread

Conversation

@PathGao

@PathGao PathGao commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

What this is

Four #[tauri::command] functions that touch the filesystem are declared
without async, which runs them inline on the IPC handler thread — the webview
UI thread on Windows. Each one becomes async fn + spawn_blocking, the same
shape the other ten filesystem commands in this file already use.

command what blocks
open_file_folder opener::reveal blocks its caller until the file manager answers
rename_file a round trip to whatever holds the path
watch_file arming the watcher opens the watched path
copy_file fs::copy streams the whole file

Noticed while reading #153, where the reporter describes a 30–60 second freeze
on Windows involving a \\wsl$\… path with the distro stopped. This does not
fix what they reported — their freeze is in the file picker, which runs on its
own thread — but it is the same defect class, on paths that reach the UI thread
for real.

Mechanism

#[tauri::command] on a non-async function compiles to
ExecutionContext::Blocking (tauri-macros/src/command/wrapper.rs, the
body_blocking branch) and runs the body inline on the thread that handles the
IPC message. An async fn is spawned on the async runtime instead. So the
async keyword here is not a style choice — it is what decides which thread
the syscall happens on.

An unreachable path is what turns that into a user-visible freeze: a UNC path
whose host is down costs the full share timeout before it fails, and on the UI
thread that is every window in the app, not just the operation.

The file already knew this. save_file_content carries the comment "on a
network or removable volume that is seconds of blocking I/O, and on the main
thread it would stall every window until the save completes", and
copy_file_to_img is split into an async command over a _blocking worker.
These four were simply not converted.

Two of them are worth spelling out:

  • opener::reveal looks like it might be fine on any thread. It is not:
    on Windows it spawns a COM worker for SHOpenFolderAndSelectItems and
    joins it, and on macOS it waits for an open -R child. Both block the
    caller. It also does its own CoInitializeEx on that worker, so moving the
    call off the main thread does not disturb COM.
  • watch_file loses its State<'_, WatcherState> parameter and resolves the
    state inside the closure via the AppHandle. State<'_, _> borrows from the
    app and cannot cross into a 'static blocking task. Tauri injects it either
    way, so the command's frontend-facing arguments are unchanged.

Scope

Deliberately left alone:

  • unwatch_file — dropping the watcher joins its thread, so it is not free,
    but the expensive half is arming the watch (a CreateFileW/open on the
    path); releasing a handle the OS already holds does not go back to the share.

  • The asset protocol, which is a worse instance of this same class and is
    not ours to fix. ![](relative.png) in a document on a slow share reaches
    tauri's asset protocol handler (markdown.ts rewrites image src through
    convertFileSrc), and that handler does its file I/O inline on the thread
    wry invokes it on — the one it records as main_thread_id. It needs no
    keystroke, just a rendered document.

    Upstream has had this open for three years as
    tauri-apps/tauri#7434
    ("load image file by asset protocol from smb will hang the entire
    application"), whose reporter guessed the cause correctly in 2023. On the
    pinned 2.10.2 the block is spelled safe_block_on; on today's dev that
    call is gone but the handler now uses std::fs directly and still runs
    inline, so the hang survives the rewrite. The shape of the fix is the one
    upstream already accepted for the sibling tauri:// protocol in
    #15220: hand the responder
    to another thread and answer from there.

  • selectFile() passing no defaultPath (the actual While in Preview Mode - Ctrl+O doesnt work on windows #153 symptom).
    Passing one is not sufficient on its own: the obvious value is the last-used
    directory, which in that report is the unreachable WSL path, and the dialog
    plugin's set_default_path stats it twice on the way in. That needs a
    reachability check that is itself off the UI thread.

Tests

None added, and I want to be explicit about why rather than quietly ship
without them.

Which thread a command runs on is decided by the #[tauri::command] macro at
compile time from the presence of async. There is no runtime seam to assert
on: the only test that could go red if this change were reverted is one that
matches the source text for the word async, which is exactly the kind of test
#433 deleted ("delete 14 source-text tests that no defect can fail"). A test
that would catch a regression here would have to run the app on Windows against
an unreachable UNC path and measure UI responsiveness, which nothing in this
repo can do today.

So this rests on the macro's documented behaviour and on the existing commands
that already follow it, not on a new test.

Verification

Everything CI runs, on macOS (arm64), all green:

npm audit      # found 0 vulnerabilities
npm run check  # 653 files, 0 errors, 0 warnings
npm test       # 778 pass, 0 fail
cargo test     # 125 pass, 0 fail

cargo check compiles clean. cargo fmt --check reports diffs in this repo
both before and after this change (47 of them, including files this branch does
not touch, so it is a rustfmt-version difference rather than something this
introduces); the regions changed here are not among them.

What I did not verify: any of this on Windows or Linux — no machine, no WSL
distro, no unreachable share. The freeze itself was never reproduced. The claim
that these four ran on the UI thread comes from reading the macro expansion and
wry's thread bookkeeping, not from a profiler, and opener::reveal's behaviour
comes from reading opener 0.7.2's source at the version in Cargo.lock, not
from running it. What is directly verified here is that the conversion compiles
and that nothing existing regressed.

🤖 Generated with Claude Code

open_file_folder, rename_file, watch_file and copy_file are declared
without `async`, so `#[tauri::command]` compiles them to
ExecutionContext::Blocking and runs the body inline on the IPC handler
thread — the webview UI thread on Windows. An unreachable path then
freezes every window for the length of the share timeout rather than
just failing the operation.

Each becomes `async fn` + spawn_blocking, the shape the other ten
filesystem commands in this file already use and that save_file_content
documents in a comment.

Two notes. `opener::reveal` blocks its caller on both platforms that
matter — on Windows it spawns a COM worker for
SHOpenFolderAndSelectItems and joins it, on macOS it waits for an
`open -R` child — and it does its own CoInitializeEx there, so moving
the call off the main thread is safe. watch_file loses its
State<'_, WatcherState> parameter and resolves the state inside the
closure from the AppHandle, because State borrows from the app and
cannot cross into a 'static task; Tauri injects it either way, so the
frontend-facing arguments are unchanged.

Noticed while reading #153. It does not fix what was reported there —
that freeze is in the file picker, which rfd runs on its own thread —
but it is the same defect class on paths that do reach the UI thread.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@PathGao
PathGao merged commit 674b886 into master Aug 6, 2026
4 checks passed
@PathGao
PathGao deleted the fix/blocking-fs-off-ui-thread branch August 6, 2026 15:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant