mirror of
https://github.com/openai/codex.git
synced 2026-10-19 16:17:49 +00:00
Prefer the Windows clipboard and bound copy attempts on WSL (#53453)
## Why WSLg clipboard initialization can block indefinitely, and PowerShell clipboard writes can stall while receiving input or exiting. These stalls can prevent subsequent TUI copy requests from completing. ## What changed - Try the Windows clipboard first for `/copy` and `Ctrl+O` on WSL, falling back to the native clipboard and terminal delivery when needed. - Give Windows executable discovery and writing a shared five-second deadline, with a separate one-second deadline for native clipboard setup. Reuse pending setup tasks after timeouts to avoid accumulating blocked threads. - Launch PowerShell through a native shell and stop its process group on write failures or timeouts. Reap the writer before returning so an older copy cannot overwrite a newer one, and suppress stderr that could expose clipboard contents. ## Testing Add tests for command compatibility, large Unicode input, expired requests, and cleanup of stalled writers and subprocesses. Update routing tests to cover Windows-first copying and native fallback with HTML and clipboard leases. GitOrigin-RevId: 218c8541a169a16536db0370455e076a31a42b1f
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
//! Clipboard copy backend for the TUI's `/copy` command and `Ctrl+O` hotkey.
|
||||
//!
|
||||
//! Local copying uses the native clipboard, with WSL PowerShell as a fallback.
|
||||
//! Local copying uses the native clipboard, or the Windows clipboard directly on WSL.
|
||||
//! In tmux and Herdr, also forward to the attached terminal so clients attached after Codex
|
||||
//! started receive the copy. Over SSH without a multiplexer, send OSC 52 directly.
|
||||
//!
|
||||
@@ -22,6 +22,7 @@
|
||||
pub(crate) mod primary;
|
||||
mod tmux;
|
||||
pub(crate) mod worker;
|
||||
mod wsl;
|
||||
|
||||
use base64::Engine;
|
||||
use std::io::Write;
|
||||
@@ -98,10 +99,24 @@ fn copy_to_clipboard(
|
||||
begin_delivery: impl Fn() -> Result<(), String>,
|
||||
osc52: impl Fn(&str) -> Result<(), String>,
|
||||
) -> Result<CopyOutcome, String> {
|
||||
let wsl_deadline = std::time::Instant::now() + std::time::Duration::from_secs(/*secs*/ 5);
|
||||
let environment = CopyEnvironment {
|
||||
herdr_session: std::env::var("HERDR_ENV").is_ok_and(|value| value == "1"),
|
||||
ssh_session: is_ssh_session(),
|
||||
wsl_session: is_wsl_session(),
|
||||
tmux_session: is_tmux_session(),
|
||||
};
|
||||
#[cfg(not(target_os = "android"))]
|
||||
let native_copy = {
|
||||
let clipboard = arboard::Clipboard::new();
|
||||
move |text: &str, html: Option<&str>| arboard_copy(clipboard, text, html)
|
||||
// Never contact WSLg before trying Windows: even native setup can block forever.
|
||||
let clipboard = (!environment.wsl_session).then(arboard::Clipboard::new);
|
||||
move |text: &str, html: Option<&str>| {
|
||||
let clipboard = match clipboard {
|
||||
Some(clipboard) => clipboard.map_err(|e| format!("clipboard unavailable: {e}"))?,
|
||||
None => wsl::native_clipboard()?,
|
||||
};
|
||||
arboard_copy(clipboard, text, html)
|
||||
}
|
||||
};
|
||||
#[cfg(target_os = "android")]
|
||||
let native_copy = |_text: &str, _html: Option<&str>| {
|
||||
@@ -113,16 +128,11 @@ fn copy_to_clipboard(
|
||||
copy_to_clipboard_with(
|
||||
text,
|
||||
format,
|
||||
CopyEnvironment {
|
||||
herdr_session: std::env::var("HERDR_ENV").is_ok_and(|value| value == "1"),
|
||||
ssh_session: is_ssh_session(),
|
||||
wsl_session: is_wsl_session(),
|
||||
tmux_session: is_tmux_session(),
|
||||
},
|
||||
environment,
|
||||
tmux_clipboard_copy,
|
||||
osc52,
|
||||
native_copy,
|
||||
wsl_clipboard_copy,
|
||||
|text| wsl::copy(text, wsl_deadline),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -198,22 +208,25 @@ fn copy_to_clipboard_with(
|
||||
osc52_copy_fn(text)
|
||||
}
|
||||
};
|
||||
let html = match format {
|
||||
CopyFormat::PlainText => None,
|
||||
CopyFormat::Markdown => Some(crate::clipboard_html::render_markdown(text)),
|
||||
CopyFormat::MarkdownSelection(source) => {
|
||||
Some(crate::clipboard_html::render_markdown(&source))
|
||||
}
|
||||
let native_copy = || {
|
||||
let html = match format {
|
||||
CopyFormat::PlainText => None,
|
||||
CopyFormat::Markdown => Some(crate::clipboard_html::render_markdown(text)),
|
||||
CopyFormat::MarkdownSelection(source) => {
|
||||
Some(crate::clipboard_html::render_markdown(&source))
|
||||
}
|
||||
};
|
||||
arboard_copy_fn(text, html.as_deref()).map_err(|error| format!("native clipboard: {error}"))
|
||||
};
|
||||
let native_result = arboard_copy_fn(text, html.as_deref()).or_else(|native_error| {
|
||||
if environment.wsl_session {
|
||||
wsl_copy_fn(text).map(|()| None).map_err(|wsl_error| {
|
||||
format!("native clipboard: {native_error}; WSL fallback: {wsl_error}")
|
||||
let native_result = if environment.wsl_session {
|
||||
wsl_copy_fn(text).map(|()| None).or_else(|windows_error| {
|
||||
native_copy().map_err(|native_error| {
|
||||
format!("Windows clipboard: {windows_error}; {native_error}")
|
||||
})
|
||||
} else {
|
||||
Err(format!("native clipboard: {native_error}"))
|
||||
}
|
||||
});
|
||||
})
|
||||
} else {
|
||||
native_copy()
|
||||
};
|
||||
// Copy natively first: an X11 SelectionClear from a terminal write can otherwise
|
||||
// race with arboard reusing its ownership window and clear the new native data.
|
||||
// Persistent multiplexer sessions may gain remote clients after Codex starts, so still
|
||||
@@ -255,11 +268,10 @@ pub(crate) fn is_wsl_session() -> bool {
|
||||
/// Write to the native clipboard. The TUI owns any process-wide stderr redirection.
|
||||
#[cfg(not(target_os = "android"))]
|
||||
fn arboard_copy(
|
||||
clipboard: Result<arboard::Clipboard, arboard::Error>,
|
||||
mut clipboard: arboard::Clipboard,
|
||||
text: &str,
|
||||
html: Option<&str>,
|
||||
) -> Result<Option<ClipboardLease>, String> {
|
||||
let mut clipboard = clipboard.map_err(|e| format!("clipboard unavailable: {e}"))?;
|
||||
match html {
|
||||
Some(html) => clipboard
|
||||
.set_html(html, Some(text))
|
||||
@@ -278,62 +290,6 @@ fn arboard_copy(
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy text into the Windows clipboard from a WSL process.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn wsl_clipboard_copy(text: &str) -> Result<(), String> {
|
||||
let executable = codex_utils_path::system_executable("powershell.exe")
|
||||
.ok_or_else(|| "PowerShell is unavailable in the system PATH".to_string())?;
|
||||
let path = codex_utils_path::system_path()
|
||||
.map_err(|error| format!("failed to resolve system PATH: {error}"))?;
|
||||
let mut child = std::process::Command::new(executable)
|
||||
.env("PATH", path)
|
||||
.stdin(std::process::Stdio::piped())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::piped())
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; $ErrorActionPreference = 'Stop'; $text = [Console]::In.ReadToEnd(); Set-Clipboard -Value $text",
|
||||
])
|
||||
.spawn()
|
||||
.map_err(|e| format!("failed to spawn powershell.exe: {e}"))?;
|
||||
|
||||
let Some(mut stdin) = child.stdin.take() else {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err("failed to open powershell.exe stdin".to_string());
|
||||
};
|
||||
|
||||
if let Err(err) = stdin.write_all(text.as_bytes()) {
|
||||
let _ = child.kill();
|
||||
let _ = child.wait();
|
||||
return Err(format!("failed to write to powershell.exe: {err}"));
|
||||
}
|
||||
|
||||
drop(stdin);
|
||||
|
||||
let output = child
|
||||
.wait_with_output()
|
||||
.map_err(|e| format!("failed to wait for powershell.exe: {e}"))?;
|
||||
|
||||
if output.status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
|
||||
if stderr.is_empty() {
|
||||
let status = output.status;
|
||||
Err(format!("powershell.exe exited with status {status}"))
|
||||
} else {
|
||||
Err(format!("powershell.exe failed: {stderr}"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn wsl_clipboard_copy(_text: &str) -> Result<(), String> {
|
||||
Err("WSL clipboard fallback unavailable on this platform".to_string())
|
||||
}
|
||||
|
||||
/// Write text to the clipboard via the OSC 52 terminal escape sequence.
|
||||
fn osc52_copy(text: &str) -> Result<(), String> {
|
||||
let sequence = osc52_sequence(text, std::env::var_os("TMUX").is_some())?;
|
||||
@@ -468,7 +424,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_attempts_both_native_and_osc52() {
|
||||
fn ssh_on_wsl_attempts_windows_and_osc52() {
|
||||
let tmux_calls = Cell::new(/*value*/ 0_u8);
|
||||
let osc_calls = Cell::new(/*value*/ 0_u8);
|
||||
let native_calls = Cell::new(/*value*/ 0_u8);
|
||||
@@ -499,8 +455,8 @@ mod tests {
|
||||
assert!(matches!(result, Ok(CopyOutcome::Copied(None))));
|
||||
assert_eq!(tmux_calls.get(), 0);
|
||||
assert_eq!(osc_calls.get(), 1);
|
||||
assert_eq!(native_calls.get(), 1);
|
||||
assert_eq!(wsl_calls.get(), 0);
|
||||
assert_eq!(native_calls.get(), 0);
|
||||
assert_eq!(wsl_calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -536,7 +492,7 @@ mod tests {
|
||||
};
|
||||
assert_eq!(
|
||||
error,
|
||||
"native clipboard: native unavailable; WSL fallback: powershell unavailable; terminal clipboard: blocked"
|
||||
"Windows clipboard: powershell unavailable; native clipboard: native unavailable; terminal clipboard: blocked"
|
||||
);
|
||||
assert_eq!(tmux_calls.get(), 0);
|
||||
assert_eq!(osc_calls.get(), 1);
|
||||
@@ -545,7 +501,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_inside_tmux_attempts_both_native_and_tmux() {
|
||||
fn ssh_on_wsl_inside_tmux_attempts_windows_and_tmux() {
|
||||
let tmux_calls = Cell::new(/*value*/ 0_u8);
|
||||
let osc_calls = Cell::new(/*value*/ 0_u8);
|
||||
let native_calls = Cell::new(/*value*/ 0_u8);
|
||||
@@ -575,8 +531,8 @@ mod tests {
|
||||
assert!(matches!(result, Ok(CopyOutcome::Copied(None))));
|
||||
assert_eq!(tmux_calls.get(), 1);
|
||||
assert_eq!(osc_calls.get(), 0);
|
||||
assert_eq!(native_calls.get(), 1);
|
||||
assert_eq!(wsl_calls.get(), 0);
|
||||
assert_eq!(native_calls.get(), 0);
|
||||
assert_eq!(wsl_calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -610,8 +566,8 @@ mod tests {
|
||||
assert!(matches!(result, Ok(CopyOutcome::Copied(None))));
|
||||
assert_eq!(tmux_calls.get(), 1);
|
||||
assert_eq!(osc_calls.get(), 1);
|
||||
assert_eq!(native_calls.get(), 1);
|
||||
assert_eq!(wsl_calls.get(), 0);
|
||||
assert_eq!(native_calls.get(), 0);
|
||||
assert_eq!(wsl_calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -629,10 +585,6 @@ mod tests {
|
||||
let Err(error) = result else {
|
||||
panic!("expected tmux and OSC 52 errors");
|
||||
};
|
||||
assert_eq!(
|
||||
error,
|
||||
"native clipboard: native unavailable; WSL fallback: powershell unavailable; terminal clipboard: tmux clipboard: tmux unavailable; OSC 52 fallback: osc blocked"
|
||||
);
|
||||
insta::assert_snapshot!("all_copy_backends_fail", error);
|
||||
}
|
||||
|
||||
@@ -644,7 +596,7 @@ mod tests {
|
||||
let result = copy_to_clipboard_with(
|
||||
"hello",
|
||||
CopyFormat::PlainText,
|
||||
local_wsl_environment(),
|
||||
local_environment(),
|
||||
|_| Ok(()),
|
||||
|_| {
|
||||
osc_calls.set(osc_calls.get() + 1);
|
||||
@@ -681,19 +633,21 @@ mod tests {
|
||||
Some("<p><strong>hello</strong> /repo/my_notes!.txt</p>\n"),
|
||||
),
|
||||
] {
|
||||
let result = copy_to_clipboard_with(
|
||||
text,
|
||||
format,
|
||||
local_environment(),
|
||||
|_| panic!("native copy should succeed"),
|
||||
|_| panic!("native copy should succeed"),
|
||||
|actual_text, actual_html| {
|
||||
assert_eq!((actual_text, actual_html), (text, html));
|
||||
Ok(None)
|
||||
},
|
||||
|_| panic!("native copy should succeed"),
|
||||
);
|
||||
assert!(matches!(result, Ok(CopyOutcome::Copied(None))));
|
||||
for environment in [local_environment(), local_wsl_environment()] {
|
||||
let result = copy_to_clipboard_with(
|
||||
text,
|
||||
format.clone(),
|
||||
environment,
|
||||
|_| panic!("native copy should succeed"),
|
||||
|_| panic!("native copy should succeed"),
|
||||
|actual_text, actual_html| {
|
||||
assert_eq!((actual_text, actual_html), (text, html));
|
||||
Ok(Some(super::ClipboardLease::test()))
|
||||
},
|
||||
|_| Err("Windows clipboard unavailable".into()),
|
||||
);
|
||||
assert!(matches!(result, Ok(CopyOutcome::Copied(Some(_)))));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -767,7 +721,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_wsl_native_failure_uses_powershell_and_skips_osc52_on_success() {
|
||||
fn local_wsl_uses_windows_and_skips_osc52_on_success() {
|
||||
let osc_calls = Cell::new(/*value*/ 0_u8);
|
||||
let native_calls = Cell::new(/*value*/ 0_u8);
|
||||
let wsl_calls = Cell::new(/*value*/ 0_u8);
|
||||
@@ -792,12 +746,12 @@ mod tests {
|
||||
|
||||
assert!(matches!(result, Ok(CopyOutcome::Copied(None))));
|
||||
assert_eq!(osc_calls.get(), 0);
|
||||
assert_eq!(native_calls.get(), 1);
|
||||
assert_eq!(native_calls.get(), 0);
|
||||
assert_eq!(wsl_calls.get(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_wsl_falls_back_to_osc52_when_native_and_powershell_fail() {
|
||||
fn local_wsl_falls_back_to_osc52_when_powershell_fails() {
|
||||
let osc_calls = Cell::new(/*value*/ 0_u8);
|
||||
let native_calls = Cell::new(/*value*/ 0_u8);
|
||||
let wsl_calls = Cell::new(/*value*/ 0_u8);
|
||||
@@ -863,7 +817,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_wsl_reports_native_powershell_and_osc52_errors_when_all_fail() {
|
||||
fn local_wsl_reports_windows_and_osc52_errors_when_all_fail() {
|
||||
let osc_calls = Cell::new(/*value*/ 0_u8);
|
||||
let native_calls = Cell::new(/*value*/ 0_u8);
|
||||
let wsl_calls = Cell::new(/*value*/ 0_u8);
|
||||
@@ -887,11 +841,11 @@ mod tests {
|
||||
);
|
||||
|
||||
let Err(error) = result else {
|
||||
panic!("expected native, WSL, and OSC 52 errors");
|
||||
panic!("expected Windows and OSC 52 errors");
|
||||
};
|
||||
assert_eq!(
|
||||
error,
|
||||
"native clipboard: native unavailable; WSL fallback: powershell unavailable; terminal clipboard: osc blocked"
|
||||
"Windows clipboard: powershell unavailable; native clipboard: native unavailable; terminal clipboard: osc blocked"
|
||||
);
|
||||
assert_eq!(osc_calls.get(), 1);
|
||||
assert_eq!(native_calls.get(), 1);
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
//! WSL text copies with Windows interop use the Windows clipboard without contacting the display.
|
||||
//! Bound discovery, input, and exit; give fallback native setup its own short deadline.
|
||||
//! Never release the worker while a writer can still run.
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::ffi::OsString;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::path::PathBuf;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::Mutex;
|
||||
#[cfg(target_os = "linux")]
|
||||
use std::sync::mpsc;
|
||||
#[cfg(any(target_os = "linux", all(test, unix)))]
|
||||
use std::time::Instant;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
type Pending<T> = Mutex<Option<mpsc::Receiver<Result<T, String>>>>;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn copy(text: &str, deadline: Instant) -> Result<(), String> {
|
||||
static DISCOVERY: Pending<(PathBuf, OsString)> = Mutex::new(None);
|
||||
let (executable, path) = read_only_setup(&DISCOVERY, deadline, || {
|
||||
let executable = codex_utils_path::system_executable("powershell.exe")
|
||||
.ok_or("PowerShell is unavailable in the system PATH")?;
|
||||
let path = codex_utils_path::system_path()
|
||||
.map_err(|error| format!("failed to resolve system PATH: {error}"))?;
|
||||
Ok((executable, path))
|
||||
})?;
|
||||
let status = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|error| format!("could not start Windows clipboard writer: {error}"))?
|
||||
.block_on(write_command(command(&executable, &path), text, deadline))?;
|
||||
if status.success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("powershell.exe exited with status {status}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", all(test, unix)))]
|
||||
fn command(executable: &std::path::Path, path: &std::ffi::OsStr) -> tokio::process::Command {
|
||||
// Spawn only the native shell here: directly spawning a Windows exe can block on WSL.
|
||||
let mut command = tokio::process::Command::new("/bin/sh");
|
||||
command
|
||||
.env("PATH", path)
|
||||
.args(["-c", r#"exec "$@""#, "codex-wsl-copy"])
|
||||
.arg(executable)
|
||||
// Preserve the released command signature: trusted adapters may match it exactly.
|
||||
.args([
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; $ErrorActionPreference = 'Stop'; $text = [Console]::In.ReadToEnd(); Set-Clipboard -Value $text",
|
||||
]);
|
||||
command
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
pub(super) fn copy(_text: &str, _deadline: std::time::Instant) -> Result<(), String> {
|
||||
Err("Windows clipboard unavailable on this platform".into())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub(super) fn native_clipboard() -> Result<arboard::Clipboard, String> {
|
||||
static NATIVE: Pending<arboard::Clipboard> = Mutex::new(None);
|
||||
// Windows discovery may have exhausted its budget. Do not starve a healthy WSLg.
|
||||
let deadline = Instant::now() + std::time::Duration::from_secs(/*secs*/ 1);
|
||||
read_only_setup(&NATIVE, deadline, || {
|
||||
arboard::Clipboard::new().map_err(|error| format!("clipboard unavailable: {error}"))
|
||||
})
|
||||
}
|
||||
|
||||
// Discovery and WSLg setup may block forever. Keep at most one outstanding task of each
|
||||
// kind and reuse its result after a timeout. Neither task may capture text or write a clipboard.
|
||||
#[cfg(target_os = "linux")]
|
||||
fn read_only_setup<T: Send + 'static>(
|
||||
pending: &Pending<T>,
|
||||
deadline: Instant,
|
||||
setup: impl FnOnce() -> Result<T, String> + Send + 'static,
|
||||
) -> Result<T, String> {
|
||||
let remaining = deadline
|
||||
.checked_duration_since(Instant::now())
|
||||
.ok_or("clipboard setup timed out")?;
|
||||
let mut pending = pending.lock().map_err(|error| error.to_string())?;
|
||||
if pending.is_none() {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
std::thread::Builder::new()
|
||||
.name("wsl-clipboard-setup".into())
|
||||
.spawn(move || {
|
||||
let _ = tx.send(setup());
|
||||
})
|
||||
.map_err(|error| format!("could not start clipboard setup: {error}"))?;
|
||||
*pending = Some(rx);
|
||||
}
|
||||
let result = pending
|
||||
.as_ref()
|
||||
.ok_or("clipboard setup unavailable")?
|
||||
.recv_timeout(remaining);
|
||||
if !matches!(result, Err(mpsc::RecvTimeoutError::Timeout)) {
|
||||
*pending = None;
|
||||
}
|
||||
result.map_err(|error| format!("clipboard setup: {error}"))?
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "android")))]
|
||||
pub(super) fn native_clipboard() -> Result<arboard::Clipboard, String> {
|
||||
Err("WSL native clipboard unavailable on this platform".into())
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "linux", all(test, unix)))]
|
||||
async fn write_command(
|
||||
mut command: tokio::process::Command,
|
||||
text: &str,
|
||||
deadline: Instant,
|
||||
) -> Result<std::process::ExitStatus, String> {
|
||||
use std::process::Stdio;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
if Instant::now() >= deadline {
|
||||
return Err("Windows clipboard writer timed out".into());
|
||||
}
|
||||
let mut child = command
|
||||
.process_group(/*pgroup*/ 0)
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
// Do not expose clipboard contents from PowerShell errors or let its stderr block input.
|
||||
.stderr(Stdio::null())
|
||||
.kill_on_drop(/*kill_on_drop*/ true)
|
||||
.spawn()
|
||||
.map_err(|error| format!("failed to spawn powershell.exe: {error}"))?;
|
||||
// WSL process spawn itself may stall. Do not write after a late spawn.
|
||||
let result = tokio::time::timeout_at(deadline.into(), async {
|
||||
if Instant::now() >= deadline {
|
||||
return Err("Windows clipboard writer timed out".into());
|
||||
}
|
||||
child
|
||||
.stdin
|
||||
.as_mut()
|
||||
.ok_or("failed to open powershell.exe stdin")?
|
||||
.write_all(text.as_bytes())
|
||||
.await
|
||||
.map_err(|error| format!("failed to write to powershell.exe: {error}"))?;
|
||||
drop(child.stdin.take());
|
||||
child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|error| format!("failed to wait for powershell.exe: {error}"))
|
||||
})
|
||||
.await
|
||||
.unwrap_or_else(|_| Err("Windows clipboard writer timed out".into()));
|
||||
match result {
|
||||
Ok(status) => Ok(status),
|
||||
Err(error) => {
|
||||
// Keep partially written stdin open until kill: PowerShell copies on EOF.
|
||||
// If kill fails, still wait. Releasing the worker earlier could allow an old copy
|
||||
// to overwrite a newer one. kill_on_drop alone does not guarantee reaping.
|
||||
if let Some(pid) = child.id() {
|
||||
// SAFETY: process_group(0) assigned the owned, unreaped child its own group.
|
||||
// An installed adapter may have spawned its own writer. Stop the whole group.
|
||||
unsafe { libc::killpg(pid as libc::pid_t, libc::SIGKILL) };
|
||||
}
|
||||
let _ = child.start_kill();
|
||||
// wait() closes child.stdin itself, even if kill failed. Never give the
|
||||
// writer EOF with partial input while waiting for it to stop.
|
||||
let _stdin = child.stdin.take();
|
||||
child
|
||||
.wait()
|
||||
.await
|
||||
.map_err(|error| format!("failed to reap powershell.exe: {error}"))?;
|
||||
Err(error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
#[path = "wsl_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,99 @@
|
||||
use super::write_command;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
|
||||
#[tokio::test]
|
||||
async fn installed_adapter_receives_compatible_command_and_never_copies_expired_work() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().canonicalize().unwrap();
|
||||
let executable = root.join("powershell.exe");
|
||||
std::fs::write(
|
||||
&executable,
|
||||
r#"#!/bin/sh
|
||||
[ "$#" -eq 3 ] && [ "$1" = '-NoProfile' ] && [ "$2" = '-Command' ] || exit 46
|
||||
[ "$3" = "[Console]::InputEncoding = [System.Text.Encoding]::UTF8; \$ErrorActionPreference = 'Stop'; \$text = [Console]::In.ReadToEnd(); Set-Clipboard -Value \$text" ] || exit 47
|
||||
/bin/cat > "$COPY_OUTPUT"
|
||||
"#,
|
||||
).unwrap();
|
||||
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o755)).unwrap();
|
||||
let output = root.join("clipboard");
|
||||
let mut command = super::command(&executable, root.as_os_str());
|
||||
command.env("COPY_OUTPUT", &output);
|
||||
let text = " café 🦀\r\n\t".repeat(/*n*/ 32_768);
|
||||
let status = write_command(
|
||||
command,
|
||||
&text,
|
||||
Instant::now() + Duration::from_secs(/*secs*/ 5),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(status.success(), "{status}");
|
||||
assert_eq!(std::fs::read_to_string(&output).unwrap(), text);
|
||||
let mut command = super::command(&executable, root.as_os_str());
|
||||
command.env("COPY_OUTPUT", &output);
|
||||
assert_eq!(
|
||||
write_command(command, "late", Instant::now()).await,
|
||||
Err("Windows clipboard writer timed out".into())
|
||||
);
|
||||
assert_eq!(std::fs::read_to_string(output).unwrap(), text);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stalled_input_and_stalled_exit_are_reaped_before_returning() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
// Large input blocks on the pipe; small input fits, so the worker reaches the exit wait.
|
||||
for (stage, text, script) in [
|
||||
(
|
||||
"input",
|
||||
"x".repeat(/*n*/ 1_048_576),
|
||||
"echo $$ > \"$1\"; exec sleep 30",
|
||||
),
|
||||
("exit", "small".into(), "echo $$ > \"$1\"; exec sleep 30"),
|
||||
// An adapter waiting on a subprocess must stop that subprocess too.
|
||||
(
|
||||
"subprocess",
|
||||
String::new(),
|
||||
"sh -c 'echo $$ > \"$1\"; exec sleep 30' sh \"$1\"; wait",
|
||||
),
|
||||
] {
|
||||
let pid_path = dir.path().join(format!("{stage}.pid"));
|
||||
let mut command = tokio::process::Command::new("sh");
|
||||
command.args(["-c", script, "sh"]).arg(&pid_path);
|
||||
let started = Instant::now();
|
||||
assert_eq!(
|
||||
write_command(command, &text, started + Duration::from_secs(/*secs*/ 1)).await,
|
||||
Err("Windows clipboard writer timed out".into())
|
||||
);
|
||||
assert!(
|
||||
started.elapsed() < Duration::from_secs(/*secs*/ 10),
|
||||
"{stage}: cleanup exceeded its budget: {:?}",
|
||||
started.elapsed()
|
||||
);
|
||||
let pid: libc::pid_t = std::fs::read_to_string(&pid_path)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
if stage == "subprocess" {
|
||||
// The grandchild must be stopped; only init can reap it. A zombie is
|
||||
// acceptable here and still responds to kill(pid, 0) until reaped.
|
||||
let output = std::process::Command::new("ps")
|
||||
.args(["-o", "stat=", "-p", &pid.to_string()])
|
||||
.output()
|
||||
.unwrap();
|
||||
let state = String::from_utf8(output.stdout).unwrap();
|
||||
let state = state.trim();
|
||||
assert!(state.is_empty() || state.starts_with('Z'), "state: {state}");
|
||||
} else {
|
||||
// SAFETY: Signal 0 observes this disposable child without modifying it.
|
||||
assert_eq!(unsafe { libc::kill(pid, 0) }, -1);
|
||||
assert_eq!(
|
||||
std::io::Error::last_os_error().raw_os_error(),
|
||||
Some(libc::ESRCH)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
---
|
||||
source: codex-rs/tui/src/clipboard_copy.rs
|
||||
source: tui/src/clipboard_copy.rs
|
||||
expression: error
|
||||
---
|
||||
native clipboard: native unavailable; WSL fallback: powershell unavailable; terminal clipboard: tmux clipboard: tmux unavailable; OSC 52 fallback: osc blocked
|
||||
Windows clipboard: powershell unavailable; native clipboard: native unavailable; terminal clipboard: tmux clipboard: tmux unavailable; OSC 52 fallback: osc blocked
|
||||
|
||||
Reference in New Issue
Block a user