From d40e860d1a8395e6f1400dac38bdfeef98c809a0 Mon Sep 17 00:00:00 2001 From: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Date: Thu, 20 Aug 2026 03:12:50 +0000 Subject: [PATCH 1/2] fix: keep wayland clipboard copies responsive refs #3014 --- docs/next/CHANGELOG.md | 1 + src/platform/linux.rs | 177 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index c02f26779c..48aa4a3a48 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -18,6 +18,7 @@ - Windows panes now keep bare `cursor-agent` launches detected after Cursor hands off to its bundled Node process. (#3032) - Oversized Kitty images no longer prevent smaller images shown later in the same pane from rendering. (#3033) - `herdr agent explain --file` now reports fixture read failures as structured JSON instead of exposing Rust I/O debug output. (#3022) +- Wayland clipboard copies no longer freeze Herdr while `wl-copy` remains alive to serve the selection. (#3014) - Claude Code panes now use visible turn, background shell, and background agent activity as working-state fallbacks when OSC titles are unavailable or disabled. (#1630, #2241) - Claude Code panes now remain working while MCP tasks continue in the background after a turn ends. (#3090) - Tab bar status commands now remove ESC-prefixed terminal control sequences instead of displaying their sequence bodies as text. (#3001) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index fde57e57e3..7000f12731 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -740,9 +740,43 @@ fn run_clipboard_command(command: &ClipboardCommand, bytes: &[u8]) -> bool { } drop(stdin); + if command.program == "wl-copy" { + return detach_clipboard_owner(child); + } + child.wait().map(|status| status.success()).unwrap_or(false) } +fn detach_clipboard_owner(child: std::process::Child) -> bool { + let pid = child.id(); + let child = std::sync::Arc::new(std::sync::Mutex::new(child)); + let reaper_child = std::sync::Arc::clone(&child); + let reaper = std::thread::Builder::new() + .name("herdr-wl-copy-reaper".to_string()) + .spawn(move || { + let wait_result = match reaper_child.lock() { + Ok(mut child) => child.wait(), + Err(poisoned) => poisoned.into_inner().wait(), + }; + if let Err(err) = wait_result { + tracing::warn!(pid, %err, "failed to reap wl-copy clipboard owner"); + } + }); + + if let Err(err) = reaper { + tracing::warn!(pid, %err, "failed to start wl-copy clipboard owner reaper"); + let mut child = match child.lock() { + Ok(child) => child, + Err(poisoned) => poisoned.into_inner(), + }; + let _ = child.kill(); + let _ = child.wait(); + return false; + } + + true +} + fn process_session_id(pid: u32) -> Option { let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?; let rest = stat.get(stat.rfind(')')? + 2..)?; @@ -1013,6 +1047,149 @@ mod tests { assert_eq!(commands[0].program, "wl-copy"); } + #[test] + fn wl_copy_owner_does_not_block_clipboard_write() { + use std::ffi::OsString; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::sync::mpsc; + use std::time::{Duration, Instant, SystemTime}; + + struct Cleanup { + old_path: Option, + temp_dir: PathBuf, + owner_pid: Option, + } + + impl Drop for Cleanup { + fn drop(&mut self) { + if let Some(pid) = self.owner_pid { + unsafe { + libc::kill(pid, libc::SIGTERM); + } + } + unsafe { + match self.old_path.take() { + Some(path) => std::env::set_var("PATH", path), + None => std::env::remove_var("PATH"), + } + std::env::remove_var("HERDR_TEST_WL_COPY_MARKER"); + std::env::remove_var("HERDR_TEST_WL_COPY_PAYLOAD"); + std::env::remove_var("HERDR_TEST_WL_COPY_ARGS"); + } + let _ = std::fs::remove_dir_all(&self.temp_dir); + } + } + + let _guard = env_lock().lock().unwrap(); + let unique = SystemTime::now() + .duration_since(SystemTime::UNIX_EPOCH) + .expect("system time should follow unix epoch") + .as_nanos(); + let temp_dir = std::env::temp_dir().join(format!( + "herdr-fake-wl-copy-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let mut cleanup = Cleanup { + old_path: std::env::var_os("PATH"), + temp_dir: temp_dir.clone(), + owner_pid: None, + }; + let fake_wl_copy = temp_dir.join("wl-copy"); + let marker = temp_dir.join("owner-pid"); + let payload = temp_dir.join("payload"); + let args = temp_dir.join("args"); + std::fs::write( + &fake_wl_copy, + "#!/bin/sh\ncat > \"$HERDR_TEST_WL_COPY_PAYLOAD\"\nprintf '%s\\n' \"$@\" > \"$HERDR_TEST_WL_COPY_ARGS\"\nprintf '%s' \"$$\" > \"$HERDR_TEST_WL_COPY_MARKER\"\nexec sleep 30\n", + ) + .expect("fake wl-copy should be written"); + let mut permissions = std::fs::metadata(&fake_wl_copy) + .expect("fake wl-copy metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(&fake_wl_copy, permissions) + .expect("fake wl-copy should be executable"); + + let test_path = match cleanup.old_path.as_ref() { + Some(path) => { + let mut paths = vec![temp_dir.clone()]; + paths.extend(std::env::split_paths(path)); + std::env::join_paths(paths).expect("test path should be valid") + } + None => temp_dir.clone().into_os_string(), + }; + unsafe { + std::env::set_var("PATH", test_path); + std::env::set_var("HERDR_TEST_WL_COPY_MARKER", &marker); + std::env::set_var("HERDR_TEST_WL_COPY_PAYLOAD", &payload); + std::env::set_var("HERDR_TEST_WL_COPY_ARGS", &args); + } + + let (result_tx, result_rx) = mpsc::channel(); + let writer = std::thread::spawn(move || { + let command = ClipboardCommand { + program: "wl-copy", + args: &["--type", "text/plain;charset=utf-8"], + }; + let _ = result_tx.send(run_clipboard_command(&command, b"clipboard text")); + }); + + let marker_deadline = Instant::now() + Duration::from_secs(2); + while !marker.exists() && Instant::now() < marker_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let owner_pid: i32 = std::fs::read_to_string(&marker) + .expect("fake wl-copy should enter its clipboard-owner phase") + .parse() + .expect("owner pid should be numeric"); + cleanup.owner_pid = Some(owner_pid); + let returned_while_owner_running = result_rx + .recv_timeout(Duration::from_secs(2)) + .is_ok_and(|result| result); + let actual_payload = std::fs::read(&payload).expect("fake wl-copy should record stdin"); + let actual_args = std::fs::read_to_string(&args).expect("fake wl-copy should record args"); + + unsafe { + libc::kill(owner_pid, libc::SIGTERM); + } + let reap_deadline = Instant::now() + Duration::from_secs(2); + while process_exists(owner_pid as u32) && Instant::now() < reap_deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let owner_was_reaped = !process_exists(owner_pid as u32); + cleanup.owner_pid = None; + writer.join().expect("clipboard writer thread should join"); + drop(cleanup); + + assert!( + returned_while_owner_running, + "clipboard writes must return while wl-copy remains alive to own the selection" + ); + assert_eq!(actual_payload, b"clipboard text"); + assert_eq!(actual_args, "--type\ntext/plain;charset=utf-8\n"); + assert!( + owner_was_reaped, + "wl-copy owner should be reaped after exit" + ); + } + + #[test] + fn finite_clipboard_commands_report_exit_status() { + let success = ClipboardCommand { + program: "sh", + args: &["-c", "cat >/dev/null"], + }; + let failure = ClipboardCommand { + program: "sh", + args: &["-c", "cat >/dev/null; exit 7"], + }; + + assert!(run_clipboard_command(&success, b"clipboard text")); + assert!(!run_clipboard_command(&failure, b"clipboard text")); + } + #[test] fn clipboard_commands_include_x11_fallbacks() { let _guard = env_lock().lock().unwrap(); From 334b96508003472f64d7b263354883d596e64c4c Mon Sep 17 00:00:00 2001 From: Ogulcan Celik Date: Tue, 25 Aug 2026 02:51:23 +0300 Subject: [PATCH 2/2] fix: preserve clipboard fallback after wl-copy failure refs #3014 --- src/platform/linux.rs | 108 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 107 insertions(+), 1 deletion(-) diff --git a/src/platform/linux.rs b/src/platform/linux.rs index 7000f12731..f3ba0429c6 100644 --- a/src/platform/linux.rs +++ b/src/platform/linux.rs @@ -741,12 +741,33 @@ fn run_clipboard_command(command: &ClipboardCommand, bytes: &[u8]) -> bool { drop(stdin); if command.program == "wl-copy" { - return detach_clipboard_owner(child); + return wait_for_wl_copy_startup(child); } child.wait().map(|status| status.success()).unwrap_or(false) } +fn wait_for_wl_copy_startup(mut child: std::process::Child) -> bool { + const STARTUP_WAIT: std::time::Duration = std::time::Duration::from_millis(100); + const POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(5); + + let deadline = std::time::Instant::now() + STARTUP_WAIT; + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) if std::time::Instant::now() < deadline => { + std::thread::sleep(POLL_INTERVAL); + } + Ok(None) => return detach_clipboard_owner(child), + Err(_) => { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + } + } +} + fn detach_clipboard_owner(child: std::process::Child) -> bool { let pid = child.id(); let child = std::sync::Arc::new(std::sync::Mutex::new(child)); @@ -1175,6 +1196,91 @@ mod tests { ); } + #[test] + fn failed_wl_copy_uses_x11_fallback() { + use std::ffi::OsString; + use std::os::unix::fs::PermissionsExt; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct Cleanup { + old_path: Option, + old_wayland_display: Option, + old_display: Option, + temp_dir: PathBuf, + } + + impl Drop for Cleanup { + fn drop(&mut self) { + unsafe { + match self.old_path.take() { + Some(value) => std::env::set_var("PATH", value), + None => std::env::remove_var("PATH"), + } + match self.old_wayland_display.take() { + Some(value) => std::env::set_var("WAYLAND_DISPLAY", value), + None => std::env::remove_var("WAYLAND_DISPLAY"), + } + match self.old_display.take() { + Some(value) => std::env::set_var("DISPLAY", value), + None => std::env::remove_var("DISPLAY"), + } + std::env::remove_var("HERDR_TEST_XCLIP_PAYLOAD"); + } + let _ = std::fs::remove_dir_all(&self.temp_dir); + } + } + + let _guard = env_lock().lock().unwrap(); + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("system time should follow unix epoch") + .as_nanos(); + let temp_dir = std::env::temp_dir().join(format!( + "herdr-failed-wl-copy-{}-{unique}", + std::process::id() + )); + std::fs::create_dir_all(&temp_dir).expect("temp dir should be created"); + let cleanup = Cleanup { + old_path: std::env::var_os("PATH"), + old_wayland_display: std::env::var_os("WAYLAND_DISPLAY"), + old_display: std::env::var_os("DISPLAY"), + temp_dir: temp_dir.clone(), + }; + let payload = temp_dir.join("xclip-payload"); + let fake_wl_copy = temp_dir.join("wl-copy"); + let fake_xclip = temp_dir.join("xclip"); + std::fs::write(&fake_wl_copy, "#!/bin/sh\n/bin/cat >/dev/null\nexit 7\n") + .expect("fake wl-copy should be written"); + std::fs::write( + &fake_xclip, + "#!/bin/sh\n/bin/cat > \"$HERDR_TEST_XCLIP_PAYLOAD\"\n", + ) + .expect("fake xclip should be written"); + for command in [&fake_wl_copy, &fake_xclip] { + let mut permissions = std::fs::metadata(command) + .expect("fake clipboard command metadata") + .permissions(); + permissions.set_mode(0o700); + std::fs::set_permissions(command, permissions) + .expect("fake clipboard command should be executable"); + } + + unsafe { + std::env::set_var("PATH", &temp_dir); + std::env::set_var("WAYLAND_DISPLAY", "wayland-0"); + std::env::set_var("DISPLAY", ":0"); + std::env::set_var("HERDR_TEST_XCLIP_PAYLOAD", &payload); + } + + assert!(write_clipboard(b"clipboard fallback")); + assert_eq!( + std::fs::read(&payload).expect("xclip should record stdin"), + b"clipboard fallback" + ); + drop(cleanup); + } + #[test] fn finite_clipboard_commands_report_exit_status() { let success = ClipboardCommand {