Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/next/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
283 changes: 283 additions & 0 deletions src/platform/linux.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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..)?;
Expand Down Expand Up @@ -1013,6 +1068,234 @@ 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<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();
Expand Down
Loading