fix(commands): move four blocking filesystem commands off the UI thread - #502
Merged
Conversation
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>
This was referenced Aug 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
Four
#[tauri::command]functions that touch the filesystem are declaredwithout
async, which runs them inline on the IPC handler thread — the webviewUI thread on Windows. Each one becomes
async fn+spawn_blocking, the sameshape the other ten filesystem commands in this file already use.
open_file_folderopener::revealblocks its caller until the file manager answersrename_filewatch_filecopy_filefs::copystreams the whole fileNoticed while reading #153, where the reporter describes a 30–60 second freeze
on Windows involving a
\\wsl$\…path with the distro stopped. This does notfix 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-asyncfunction compiles toExecutionContext::Blocking(tauri-macros/src/command/wrapper.rs, thebody_blockingbranch) and runs the body inline on the thread that handles theIPC message. An
async fnis spawned on the async runtime instead. So theasynckeyword here is not a style choice — it is what decides which threadthe 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_contentcarries the comment "on anetwork 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_imgis split into anasynccommand over a_blockingworker.These four were simply not converted.
Two of them are worth spelling out:
opener::reveallooks like it might be fine on any thread. It is not:on Windows it spawns a COM worker for
SHOpenFolderAndSelectItemsandjoins it, and on macOS it waits for anopen -Rchild. Both block thecaller. It also does its own
CoInitializeExon that worker, so moving thecall off the main thread does not disturb COM.
watch_fileloses itsState<'_, WatcherState>parameter and resolves thestate inside the closure via the
AppHandle.State<'_, _>borrows from theapp and cannot cross into a
'staticblocking task. Tauri injects it eitherway, 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/openon thepath); 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.
in a document on a slow share reachestauri's asset protocol handler (
markdown.tsrewrites imagesrcthroughconvertFileSrc), and that handler does its file I/O inline on the threadwry invokes it on — the one it records as
main_thread_id. It needs nokeystroke, 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'sdevthatcall is gone but the handler now uses
std::fsdirectly and still runsinline, 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 nodefaultPath(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_pathstats it twice on the way in. That needs areachability 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 atcompile time from the presence of
async. There is no runtime seam to asserton: 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:
cargo checkcompiles clean.cargo fmt --checkreports diffs in this repoboth 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 behaviourcomes from reading opener 0.7.2's source at the version in
Cargo.lock, notfrom running it. What is directly verified here is that the conversion compiles
and that nothing existing regressed.
🤖 Generated with Claude Code