aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-25 02:59:57 +0300
committerGitHub <noreply@github.com>2026-08-25 02:59:57 +0300
commit6e8b138d0f7d7d695530657a6d8dc475bd3fba2b (patch)
tree52d379be063d66aa71041fea451e25ee0afcbf72
parent0e548bcf72671e6e6e661e90192bcb7f2f442032 (diff)
fix: keep wayland clipboard copies responsive (#3015)
* fix: keep wayland clipboard copies responsive refs #3014 * fix: preserve clipboard fallback after wl-copy failure refs #3014 --------- Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Co-authored-by: Ogulcan Celik <ogulcancelik@gmail.com>
-rw-r--r--docs/next/CHANGELOG.md1
-rw-r--r--src/platform/linux.rs283
2 files changed, 284 insertions, 0 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md
index c02f2677..48aa4a3a 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 fde57e57..f3ba0429 100644
--- a/src/platform/linux.rs
+++ b/src/platform/linux.rs
@@ -740,9 +740,64 @@ fn run_clipboard_command(command: &ClipboardCommand, bytes: &[u8]) -> bool {
}
drop(stdin);
+ if command.program == "wl-copy" {
+ 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));
+ 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<i32> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
let rest = stat.get(stat.rfind(')')? + 2..)?;
@@ -1014,6 +1069,234 @@ mod tests {
}
#[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<OsString>,
+ temp_dir: PathBuf,
+ owner_pid: Option<i32>,
+ }
+
+ 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 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<OsString>,
+ old_wayland_display: Option<OsString>,
+ old_display: Option<OsString>,
+ 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 {
+ 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();
unsafe {