Skip to content

fix(commands): take the blocking Tauri commands off the main thread (and correct the count: 82, not 38) - #524

Merged
axpnet merged 2 commits into
mainfrom
fix/tauri-sync-commands-off-main-thread
Jul 30, 2026
Merged

fix(commands): take the blocking Tauri commands off the main thread (and correct the count: 82, not 38)#524
axpnet merged 2 commits into
mainfrom
fix/tauri-sync-commands-off-main-thread

Conversation

@axpnet

@axpnet axpnet commented Jul 30, 2026

Copy link
Copy Markdown
Member

Part of #517.

The defect

Tauri dispatches a synchronous #[tauri::command] on the main thread. That is not a documentation claim, it is in the macro: tauri-macros 2.6.3 starts WrapperAttributes at ExecutionContext::Blocking and only moves it to Async when the function is declared async or carries #[tauri::command(async)]; a Blocking command is invoked inline on the dispatching thread. On Linux that thread is the GTK thread, so any wait inside such a command freezes the entire window for its duration — no spinner, and nothing the user can tell apart from a crash. #515 already fixed one instance (portal_chooser::chooser_unavailable, up to two seconds on a wedged session bus).

The count was wrong, and that is the most useful thing in this PR

The work was scoped at "38 synchronous commands". That number came from a regex anchored on pub fn immediately after the attribute, which silently skips every command declared inside a nested mod — that is, most of lib.rs.

There are 82 synchronous commands out of 853. The 44 that the original count missed are not marginal: they are the sync index / journal / profile / snapshot / cloud-config / versioning family, which is JSON read and write against the config directory, all of it on the main thread.

Three other claims in the scoping notes did not survive reading the code, and are corrected here:

Claim What the code does
local_sync_cancel does File::open one AtomicBool::store. It stays synchronous, and that is now written down with the reason.
hash_text does File::open plus hashing, linear in the file it hashes a string that arrived over IPC. Worth moving, but for CPU duration, not I/O. hash_file is the one that opens a file, and it was already async.
native_rsync_enabled_get / mode_get / enabled_set / mode_set can stay synchronous all four reach native_rsync.toml: the getters stat and read it, the setters take a process-wide write lock and then write and rename. Moved.

What this PR moves, and why each one

Thirty commands become pub async fn with the blocking part inside tokio::task::spawn_blocking, the form already merged in portal_chooser.rs. Each carries the reason in a doc comment; the ones that are more than mechanical:

  • filesystem::list_subdirectories — the worst of them, and the reason it goes first. It takes its path straight from the frontend, stats it twice, read_dirs it, stats every entry, and read_dirs each subdirectory again for the expand chevron. That is O(entries) syscalls with no timeout anywhere in our code, so on a dead NFS/SMB/SSHFS mount it blocks in the kernel for as long as that mount was configured to wait. Synchronous, that is a frozen window; on the blocking pool it is one tree node stuck on "Loading…" while the app keeps working.
  • filesystem::volumes_changed (Linux)/proc/mounts always answers, but /run/user/N/gvfs is a FUSE mount served by gvfsd, and listing it blocks when a share behind it is unreachable. The frontend polls it every 30 seconds, so synchronously this is a periodic freeze with no user action to attribute it to. The Windows and fallback variants move too, so the command does not change asyncness with the target.
  • vault_remote::vault_v2_cleanup_temp — overwrites every byte of the temp vault in 1 MB chunks and then sync_all()s. Duration is linear in the file and ends in an fsync.
  • ai_tools::clipboard_read_image — arboard 3.6.1 gives the X11 selection exchange a 4000 ms budget (LONG_TIMEOUT_DUR), so a slow clipboard owner is up to four seconds of frozen GTK thread; plus base64 of a full-resolution image (~33 MB of RGBA for a 4K screenshot). Checked rather than assumed that this is safe off the main thread on all three platforms: arboard's macOS Clipboard is declared Send + Sync, its Windows one opens and closes the clipboard inside each call so the !Send handle never escapes, and the X11 backend runs its own connection.
  • totp::* (7), pty::* (4), local_panel_watcher::* (2) — these needed a small design change, not a rename: State<'_, T> borrows the app and cannot enter spawn_blocking, so TotpState and LocalPanelWatcherState now hold their mutex behind an Arc and the commands clone a 'static handle out. Converting only the one command that blocks would not have been enough: they all share one mutex, so leaving totp_status synchronous would keep the keystore-write freeze reachable through it, and leaving pty_resize synchronous would keep pty_write's blocked write reachable by resizing the terminal.
  • provider_commands::aerocrypt_profile_recovery_kit / aerocrypt_verify_recovery_kit — keystore reads, which on Linux are the Secret Service over D-Bus: another process, and one that can be wedged. Same shape as the freeze fix(linux): stop the file picker failing silently, in all 68 places #515 fixed.

Every guard is taken inside the closure. A std::sync::MutexGuard held across an .await is clippy::await_holding_lock, and that lint is the one that turned a claimed-green gate red on #515.

Two internal callers broke and are fixed rather than worked around: the CLI called settings::native_rsync_mode_get() from plain fns, so the synchronous body is now native_rsync_mode_str() and the CLI calls that (a CLI has no main thread to protect); and 24 test cases drove hash_text synchronously, so they now drive the named hash_text_blocking body.

The pins

Per command, at compile time. list_subdirectories and hash_text get a #[test] that block_ons them. block_on only accepts a future, so turning either back into a pub fn stops the test building (E0277) instead of failing an assertion someone can delete. Each also asserts the wrapper returns what the blocking body computed, so it is not only a type-level check.

Class level, src-tauri/src/sync_command_audit.rs. The per-command form defends only the commands that already have such a test; it does nothing about the failure mode that actually recurs, which is addition — someone writes a new command in six months, writes it synchronous because that is the shorter spelling, and the count climbs back. So this test reads the sources, collects every synchronous command, and asserts the set is exactly MAIN_THREAD_ALLOWED (justified, 8 entries, each with its reason) plus MAIN_THREAD_NOT_YET_MOVED (frozen, 74 entries). It reads text rather than reflecting over the crate on purpose: that way it sees commands behind a #[cfg] this build did not compile, which is exactly where a platform-specific one would otherwise hide.

It is a set equality in both directions, so it cannot rot: a new synchronous command is red, converting a pending one without deleting its line is red, and an allowlisted command that becomes async is red. The pending list can only shrink — adding to it is not a way to get green.

MAIN_THREAD_ALLOWED holds the eight that are justified, including two that must stay on the main thread and now say so: aeroshare_notify and (in the pending list until its entry is written) rebuild_menu marshal their work onto the main thread through run_on_main_thread, and tauri-runtime-wry's send_user_message runs the closure inline when the caller already is that thread — so making them async would add a trip through the event loop without moving any work.

What is left, and when

MAIN_THREAD_NOT_YET_MOVED lists the 74 remaining by name. They are mostly the lib.rs config/journal/snapshot family plus a handful that touch GTK and will end up justified instead once each has been read. That work follows in the next PR from this same session; the ratchet is what makes it not optional, and what makes this PR honest about not being the whole job.

Splitting is deliberate: those 74 live almost entirely in lib.rs, which several branches are editing right now, and holding the list_subdirectories fix behind a 3000-line mechanical diff helps nobody.

Verification

Local gate and its results go in a comment below rather than here, so the claim and the evidence arrive together.

Summary by CodeRabbit

  • Performance & Reliability

    • Improved responsiveness by moving intensive file, clipboard, security, terminal, synchronization, and authentication operations off the main application thread.
    • Added safer handling and clearer errors when background operations fail.
    • Preserved existing behaviors and fallback values across settings, volume detection, and clipboard operations.
  • File Monitoring

    • Improved local folder watching and stopping, including more reliable change notifications.
  • Tests

    • Added coverage confirming background execution and preserving existing results and error behavior.

Tauri dispatches a synchronous `#[tauri::command]` on the main thread. That is
not a documentation claim: `tauri-macros` 2.6.3 starts `WrapperAttributes` at
`ExecutionContext::Blocking` and only moves it to `Async` when the function is
declared `async` or carries `#[tauri::command(async)]`, and a `Blocking`
command is invoked inline on the dispatching thread. On Linux that thread is
the GTK thread, so any wait inside such a command freezes the whole window for
its duration, with no spinner and nothing the user can tell apart from a crash.
#515 already fixed one instance of this.

The count this work was scoped with was wrong, and that matters more than any
single conversion. It came from a regex anchored on `pub fn` immediately after
the attribute, which silently skips every command declared inside a nested
`mod` -- that is, most of lib.rs. It reported 38. There are 82, out of 853, and
the 44 it missed are the sync index / journal / profile / snapshot /
cloud-config family, which is JSON read and write on the main thread.

Thirty commands become `pub async fn` with the blocking part inside
`tokio::task::spawn_blocking`, the form already merged in portal_chooser.rs,
each with its reason in a doc comment. The one that led is
`filesystem::list_subdirectories`: it takes its path straight from the
frontend, stats it twice, `read_dir`s it, stats every entry and `read_dir`s
each subdirectory again for the expand chevron, with no timeout anywhere in our
code. On a dead NFS/SMB/SSHFS mount that is the window frozen for the mount's
own timeout; on the blocking pool it is one tree node stuck on "Loading...".

Three claims in the scoping notes did not survive reading the code and are
corrected here: `local_sync_cancel` does one atomic store rather than
`File::open` and stays synchronous; `hash_text` hashes a string that arrived
over IPC rather than opening a file, so it moves for CPU duration, not I/O;
and the four `native_rsync_*` accessors, listed as safe to leave, all reach
`native_rsync.toml` on disk.

`totp`, `pty` and `local_panel_watcher` needed a design change rather than a
rename: `State<'_, T>` borrows the app and cannot enter `spawn_blocking`, so
those states now hold their mutex behind an `Arc` and the commands clone a
`'static` handle out. Converting only the command that blocks would not have
been enough -- they share one mutex, so a synchronous `totp_status` would keep
the keystore-write freeze reachable, and a synchronous `pty_resize` would keep
`pty_write`'s blocked write reachable by resizing the terminal. Every guard is
taken inside the closure: a `MutexGuard` held across an `.await` is
`clippy::await_holding_lock`, which is what turned a claimed-green gate red on
#515.

Two internal callers are fixed rather than worked around: the CLI called
`settings::native_rsync_mode_get()` from plain `fn`s, so the synchronous body
is now `native_rsync_mode_str()` and the CLI calls that; and 24 test cases
drove `hash_text` synchronously, so they drive the named blocking body.
`load_secret_internal` lost its last caller and is deleted rather than kept
alive with an `#[allow(dead_code)]`.

Pins, all three verified by breaking them rather than by reading them:

  * per command, at compile time: `list_subdirectories` and `hash_text` get a
    test that `block_on`s them. Reverting `list_subdirectories` to `pub fn`
    produces E0277, "is not a future", instead of an assertion someone can
    delete;
  * class level, addition: a scratch file declaring a new synchronous command
    turns the audit red, naming it at file and line;
  * class level, drain: a name left in the pending list after its command went
    async turns the audit red, demanding the list shrink.

The class pin reads the sources rather than reflecting over the crate, so it
sees commands behind a `#[cfg]` this build did not compile. It asserts set
equality against MAIN_THREAD_ALLOWED (8, justified, each with its reason) plus
MAIN_THREAD_NOT_YET_MOVED (74, frozen). The pending list can only shrink;
adding to it is not a way to get green. Those 74 live almost entirely in
lib.rs and follow in the next PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@axpnet, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1aecc010-92ce-46b2-a7f5-e3be3ae7fec1

📥 Commits

Reviewing files that changed from the base of the PR and between 7a4c7e5 and 7f46444.

📒 Files selected for processing (1)
  • src-tauri/src/pty.rs
📝 Walkthrough

Walkthrough

Changes

The pull request converts numerous blocking Tauri commands to asynchronous wrappers using tokio::task::spawn_blocking, updates shared state for cross-thread execution, adds tests for off-main-thread behavior, and introduces an audit enforcing the synchronous-command policy.

Async Tauri command migration

Layer / File(s) Summary
Synchronous command audit
src-tauri/src/lib.rs, src-tauri/src/sync_command_audit.rs
Adds source-scanning tests that enforce allowed and pending synchronous-command lists.
Blocking command wrappers
src-tauri/src/aerocrypt_provider.rs, src-tauri/src/ai_tools.rs, src-tauri/src/cyber_tools.rs, src-tauri/src/peer_commands.rs, src-tauri/src/provider_commands.rs, src-tauri/src/vault_remote.rs
Moves emergency-kit, clipboard, hashing, inbox, recovery-kit, and vault operations into blocking tasks with join-error handling; hashing tests use the blocking helper and verify thread behavior.
Filesystem and shared-state commands
src-tauri/src/filesystem.rs, src-tauri/src/local_panel_watcher.rs, src-tauri/src/totp.rs
Moves filesystem, volume, watcher, and TOTP operations off the command thread, using Arc-backed state where required and adding wrapper behavior tests.
PTY command wrappers
src-tauri/src/pty.rs
Moves shell, write, resize, and close operations into blocking helpers while preserving session-management logic.
Rsync settings integration
src-tauri/src/settings.rs, src-tauri/src/bin/aeroftp_cli.rs
Moves rsync settings I/O and detection into blocking tasks and updates CLI reads to use the persisted mode-string helper.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related issues

  • Issue 517 — The changes implement the issue’s async spawn_blocking migration and synchronous-command audit objectives.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: moving blocking Tauri commands off the main thread, with the audit count correction noted as a secondary detail.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/tauri-sync-commands-off-main-thread

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@snyk-io

snyk-io Bot commented Jul 30, 2026

Copy link
Copy Markdown

Snyk checks have passed. No issues have been found so far.

Status Scan Engine Critical High Medium Low Total (0)
Open Source Security 0 0 0 0 0 issues

💻 Catch issues earlier using the plugins for VS Code, JetBrains IDEs, Visual Studio, and Eclipse.

@axpnet

axpnet commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

Local gate, with the exit codes read without a pipe

Every step redirected to a file and read rc=$? with nothing in between. ( cargo clippy … | tail -12 ); echo "rc=$?" reads the exit code of tail, which is how a red clippy was reported green on #515. cargo clean -p aeroftp first, because a warm target/ can skip re-linting an edited crate entirely — that was the second half of the same incident.

Step rc Result
cargo fmt --all -- --check 0
cargo clippy --all-targets -- -D warnings 0 after cargo clean -p aeroftp
cargo test --lib sync_command_audit 0 3 passed
cargo test --lib main_thread_tests 0 3 passed
cargo test --lib hash_forge_tests 0 11 passed
cargo test --lib totp:: 0 18 passed
cargo test --lib portal_chooser 0 5 passed
npm run i18n:validate 0

tsc and vitest were not run and are not claimed: this diff touches src-tauri/ only, no frontend file, and this worktree has no node_modules. Saying so rather than letting two blank rows read as green.

The first run of the gate was red, and it found something real: load_secret_internal lost its last caller when totp_load_secret started handing spawn_blocking an Arc handle instead of the State wrapper. Deleted rather than kept alive with an #[allow(dead_code)].

The pins, verified by breaking them

Reading a pin does not tell you it holds. All three were made to fail on purpose and then restored.

Per command, at compile time. Reverting list_subdirectories to pub fn:

error[E0277]: `Result<Vec<SubDirectory>, std::string::String>` is not a future
    --> src/filesystem.rs:2598:23
error[E0277]: `Result<Vec<SubDirectory>, std::string::String>` is not a future
    --> src/filesystem.rs:2623:23
error: could not compile `aeroftp` (lib test) due to 2 previous errors

Not a failing assertion someone can delete: the test stops building.

Class pin, the addition direction — the failure mode that actually recurs. A scratch file declaring one new synchronous command, deliberately not declared as a module so nothing recompiles, which is also what proves the scan reads sources rather than reflecting over the crate:

these #[tauri::command]s are synchronous, so they run on the main thread (the GTK
thread on Linux) and block the whole window for as long as they take:
  zz_probe_not_a_module.rs:5  zz_probe_new_sync_command

Class pin, the drain direction. A name left in the pending list after its command went async:

["zz_probe_already_converted"] are no longer synchronous, which is the point --
now delete them from MAIN_THREAD_NOT_YET_MOVED. The list has to shrink as the
work lands, otherwise it stops describing anything.

That second direction is what stops the list becoming a record of what used to be true. It can only shrink.

@axpnet

axpnet commented Jul 30, 2026

Copy link
Copy Markdown
Member Author

@coderabbitai full review

@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
✅ Action performed

Full review finished.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src-tauri/src/pty.rs`:
- Around line 78-80: Update the spawn_shell flow and spawn_shell_blocking setup
to reserve a PTY slot atomically under the manager lock before creating the
child process, so concurrent requests cannot exceed MAX_PTY_SESSIONS. Track the
reservation as pending, convert it to the active session on successful setup,
and release it on every setup failure or task error; do not rely on a late
capacity recheck after spawning.
- Around line 233-236: The PTY session-manager lock is held across blocking I/O,
preventing resize and close while a writer is stuck. In src-tauri/src/pty.rs
lines 233-236, refactor pty_write_blocking to use per-session synchronization or
a dedicated I/O worker; at lines 259-269, release the manager lock before
acquiring the per-session state needed for resize; at lines 302-307, let close
remove or cancel the session without waiting for an active writer.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1ed179c7-e111-482d-b0e0-f75441db1fa6

📥 Commits

Reviewing files that changed from the base of the PR and between 8952794 and 7a4c7e5.

📒 Files selected for processing (14)
  • src-tauri/src/aerocrypt_provider.rs
  • src-tauri/src/ai_tools.rs
  • src-tauri/src/bin/aeroftp_cli.rs
  • src-tauri/src/cyber_tools.rs
  • src-tauri/src/filesystem.rs
  • src-tauri/src/lib.rs
  • src-tauri/src/local_panel_watcher.rs
  • src-tauri/src/peer_commands.rs
  • src-tauri/src/provider_commands.rs
  • src-tauri/src/pty.rs
  • src-tauri/src/settings.rs
  • src-tauri/src/sync_command_audit.rs
  • src-tauri/src/totp.rs
  • src-tauri/src/vault_remote.rs

Comment thread src-tauri/src/pty.rs
Comment thread src-tauri/src/pty.rs
…anager across a write

Both from the CodeRabbit review on #524, and the first one is a regression this
PR introduces rather than a pre-existing defect, which is why it is fixed here
and not filed.

`spawn_shell` checked the session count, released the manager lock, opened the
PTY and forked the shell, and only then re-acquired the lock to insert. That is
a time-of-check-to-time-of-use gap: two calls both see 19 sessions and both
insert, and MAX_PTY_SESSIONS is quietly 21. It was unreachable while the
command was synchronous, because Tauri runs those on the main thread and the
main thread does one thing at a time. Taking the work off that thread is
exactly what makes it reachable, so the fix belongs with the move.

The check and the insert now share one acquisition, and the reserved slot is
held by a guard whose Drop hands it back, so a spawn that fails part way
through cannot leak a slot and shrink the cap for the rest of the session.

Second: `pty_write` held the single manager lock across `write_all`, which
blocks for as long as the child refuses to drain the master. On the main thread
that froze the window, which is the bug this PR is about; off it, it would have
parked every other session's write, every resize and every close behind one
wedged child. Sessions now sit behind their own locks and the manager is
released as soon as the handle is cloned out, so a wedged child costs only its
own session. `pty_close` deliberately takes only the manager lock, so closing
works while a write to that same session is stuck.

Three pins, and the first one had to be rewritten before it was one. Spawning
32 threads in a loop and asserting the cap passes against the racy shape,
because the early threads finish before the late ones start: one round of a
race proves nothing. With a barrier and 200 rounds it fails at round 1 with 23
sessions against a cap of 20, which is the defect reproduced, and passes on the
fix. The other two cover the dropped reservation returning its slot, and the
manager staying acquirable while a session is busy.

What this does not do, stated so nobody reads more into it: closing removes the
session from the map, so every later command reports "session not found", but
it does not interrupt a write already in the kernel, because the writer owns a
handle taken from the master and dropping our side does not unblock it. That
costs one parked blocking-pool thread per wedged child until it dies or drains.
Interrupting it means a non-blocking writer with its own cancellation, which is
a different change from this one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@axpnet
axpnet merged commit c2f7e48 into main Jul 30, 2026
21 checks passed
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