From 21352d9c8f77caee2301307302f0894669917a06 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:15:49 +0000 Subject: [PATCH 1/2] fix(standalone): run clipboard-read commands async to avoid main-thread freeze Three clipboard reads (read_clipboard_file_paths, read_clipboard_image_as_file_path, read_clipboard_text) were left as plain sync #[tauri::command] while still calling request_from_sidecar_timeout on the non-Windows path, violating the invariant documented above request_from_sidecar_timeout. On macOS/Linux they ran on the main thread and froze the webview for the sidecar round trip (up to 10s for image paste). Declare them #[tauri::command(async)] to match every other sidecar-reaching command, and add a source-scanning guard test (sidecar_commands_are_async) enforcing the invariant so it can't silently regress. --- standalone/src-tauri/src/lib.rs | 85 +++++++++++++++++++++++++++++++-- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 1d844a2c..4d4ea170 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -652,7 +652,7 @@ fn agent_browser_screenshot( // Clipboard reads run natively on Windows (see clipboard_win) to avoid the // console-window flicker of shelling out to PowerShell; other platforms keep the // sidecar path (pbpaste/xclip never pop a console window). -#[tauri::command] +#[tauri::command(async)] fn read_clipboard_file_paths( state: tauri::State<'_, SidecarState>, ) -> Result, String> { @@ -672,7 +672,7 @@ fn read_clipboard_file_paths( } } -#[tauri::command] +#[tauri::command(async)] fn read_clipboard_image_as_file_path( state: tauri::State<'_, SidecarState>, ) -> Result, String> { @@ -691,7 +691,7 @@ fn read_clipboard_image_as_file_path( } } -#[tauri::command] +#[tauri::command(async)] fn read_clipboard_text( state: tauri::State<'_, SidecarState>, ) -> Result { @@ -1687,4 +1687,83 @@ mod tests { assert_eq!(session_file_name("main"), "main.json"); assert_eq!(session_file_name("a/b"), "a_b.json"); } + + // Enforces the INVARIANT documented above `request_from_sidecar_timeout`: + // every `#[tauri::command]` whose body reaches the blocking sidecar helpers + // must be `#[tauri::command(async)]` (or an `async fn`). A plain-sync command + // runs on the main thread, where `recv_timeout` freezes the webview for the + // whole round trip — up to 10s on a clipboard image paste. Three clipboard + // commands once slipped through the async port; this scans the source so the + // omission can't silently recur. + #[test] + fn sidecar_commands_are_async() { + let src = include_str!("lib.rs"); + let lines: Vec<&str> = src.lines().collect(); + let mut offenders: Vec = Vec::new(); + + for (i, line) in lines.iter().enumerate() { + let trimmed = line.trim_start(); + if !trimmed.starts_with("#[tauri::command") { + continue; + } + let is_async_attr = trimmed.contains("(async)"); + + // Skip any further attribute lines / blanks down to the fn signature. + let mut j = i + 1; + while j < lines.len() { + let t = lines[j].trim_start(); + if t.starts_with("#[") || t.is_empty() { + j += 1; + } else { + break; + } + } + if j >= lines.len() { + continue; + } + let sig = lines[j].trim_start(); + let is_async_fn = sig.starts_with("async fn") || sig.starts_with("pub async fn"); + let name = sig + .trim_start_matches("pub ") + .trim_start_matches("async ") + .trim_start_matches("fn ") + .split('(') + .next() + .unwrap_or("") + .trim(); + + // Extract the fn body by brace-counting from the signature onward. + // Rust requires balanced (and `{{`/`}}`-escaped) braces, so a plain + // char count over valid source returns to depth 0 exactly at the + // fn's closing brace. + let mut depth = 0i32; + let mut started = false; + let mut body = String::new(); + for l in &lines[j..] { + for ch in l.chars() { + if ch == '{' { + depth += 1; + started = true; + } else if ch == '}' { + depth -= 1; + } + } + body.push_str(l); + if started && depth == 0 { + break; + } + } + + if body.contains("request_from_sidecar") && !(is_async_attr || is_async_fn) { + offenders.push(name.to_string()); + } + } + + assert!( + offenders.is_empty(), + "these #[tauri::command] fns reach the blocking sidecar helpers but are \ + not declared #[tauri::command(async)] (see the INVARIANT above \ + request_from_sidecar_timeout): {offenders:?}", + ); + } } From 1c594f40b05a126479b8f6ce5e3fa0888fc43980 Mon Sep 17 00:00:00 2001 From: dormouse-bot <287024035+dormouse-bot@users.noreply.github.com> Date: Tue, 28 Jul 2026 09:23:46 +0000 Subject: [PATCH 2/2] test(standalone): extend sidecar-async guard to agent_browser_forward wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidecar_commands_are_async guard only matched direct callers of request_from_sidecar. The 8 agent-browser commands reach the blocking helper transitively through agent_browser_forward, so a plain-sync one would have slipped past — and that family carries the longest timeout (AGENT_BROWSER_TIMEOUT = 30s). Extend the reachability check to the wrapper, and soften the brace-counting comment which oversold the balanced-braces assumption (a lone brace in a string/char literal would miscount). --- standalone/src-tauri/src/lib.rs | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/standalone/src-tauri/src/lib.rs b/standalone/src-tauri/src/lib.rs index 4d4ea170..97ed58e4 100644 --- a/standalone/src-tauri/src/lib.rs +++ b/standalone/src-tauri/src/lib.rs @@ -1733,9 +1733,12 @@ mod tests { .trim(); // Extract the fn body by brace-counting from the signature onward. - // Rust requires balanced (and `{{`/`}}`-escaped) braces, so a plain - // char count over valid source returns to depth 0 exactly at the - // fn's closing brace. + // This is a naive char count, not a lexer: a lone `{`/`}` inside a + // string or char literal (e.g. `'{'`, or `"missing }"`) would throw + // off the depth. It holds across the command bodies scanned here + // because none of them contain such a literal; a future command that + // did would need a real tokenizer. Good enough to enforce the + // async-attribute invariant, not a general Rust brace matcher. let mut depth = 0i32; let mut started = false; let mut body = String::new(); @@ -1754,7 +1757,15 @@ mod tests { } } - if body.contains("request_from_sidecar") && !(is_async_attr || is_async_fn) { + // Match direct callers of the blocking helper *and* the + // agent-browser commands, which reach it transitively through the + // `agent_browser_forward` wrapper (their bodies never name + // `request_from_sidecar` directly). That family carries the longest + // timeout (AGENT_BROWSER_TIMEOUT = 30s), so it's the worst case to + // let slip plain-sync. + let reaches_sidecar = + body.contains("request_from_sidecar") || body.contains("agent_browser_forward"); + if reaches_sidecar && !(is_async_attr || is_async_fn) { offenders.push(name.to_string()); } }