Merge pull request #7484 from dotCooCoo/fix/wayland-clipboard-stuck-pipe

Clipboard: a Wayland read or send ends when the other app moves no data for 2 s
This commit is contained in:
BFlat
2026-11-14 16:50:11 +07:00
committed by GitHub
4 changed files with 191 additions and 43 deletions
Generated
+1
View File
@@ -6386,6 +6386,7 @@ dependencies = [
"pollster 1.0.1",
"raw-window-handle",
"rfd",
"rustix 1.1.6",
"serde_json",
"socket2",
"tempfile",
+4
View File
@@ -110,6 +110,10 @@ wayland-protocols = { version = "0.32", features = ["client", "unstable"] }
# wayland-protocols' `unstable` set) made on the window's own connection. MIT, already in the build
# through winit and rfd.
wayland-backend = { version = "0.3", features = ["client_system"] }
# The Wayland clipboard's pipes (src/clipboard.rs): `poll` and the nonblocking flag give up a read
# or send when the other app moves no data for a while. Apache-2.0 WITH LLVM-exception OR
# Apache-2.0 OR MIT, already in the build through the workspace.
rustix = { workspace = true, features = ["std", "event", "fs"] }
# The save dialog's file type (src/portal_save.rs, #2679): the desktop portal over D-Bus, on the
# systems where rfd shows the portal's dialog. MIT, already in the build through accesskit. Pinned
+185 -43
View File
@@ -1790,15 +1790,17 @@ mod portable {
/// starts a thread for each paste of a Copy's data by another app ([`MAX_SENDS`] at once). Reads
/// run on another worker and take [`MAX_BYTES`] at most; text waits [`LOAD_WAIT`] at most, SVG and
/// PNG as long as a paste's bitmap from arboard. A read that gets no answer in its wait is kept for
/// a paste to wait for off the UI thread, as arboard's are.
/// a paste to wait for off the UI thread, as arboard's are. A read or send whose pipe moves no data
/// for [`IDLE_WAIT`] fails and closes the pipe, which frees its worker or thread.
#[cfg(target_os = "linux")]
mod wayland {
use std::io::{Read, Write};
use std::os::fd::AsFd as _;
use std::io::{ErrorKind, Read, Write};
use std::os::fd::{AsFd, BorrowedFd};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Arc, Mutex, MutexGuard, PoisonError, mpsc};
use std::time::{Duration, Instant};
use rustix::event::{PollFd, PollFlags, Timespec};
use vectorcraft_engine::cmd::clipboard::names_a_picture;
use vectorcraft_ui_egui::ClipboardRead;
use wayland_client::backend::ObjectData;
@@ -1827,6 +1829,11 @@ mod wayland {
const MAX_SENDS: usize = 8;
/// The most types of an offer that are kept (untrusted data).
const MAX_TYPES: usize = 256;
/// How long a read or send waits for the other app to move a byte through the pipe before it
/// fails, VectorCraft's own limit; the wait starts again after each byte moved.
const IDLE_WAIT: Duration = LOAD_WAIT;
/// The most bytes a read takes from the pipe at a time.
const CHUNK: usize = 64 << 10;
/// What a read gives: the bytes, or why there are none.
type Got = Result<Vec<u8>, String>;
@@ -1898,37 +1905,117 @@ mod wayland {
Some(first)
}
/// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read.
fn read_capped(from: impl Read, cap: usize) -> Got {
let mut data = vec![];
from.take(cap as u64 + 1).read_to_end(&mut data).map_err(|e| format!("couldn't read the clipboard's data: {e}"))?;
if data.len() > cap {
return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
/// The time `idle` from now; None when that is past the clock's range (no limit).
fn idle_until(idle: Duration) -> Option<Instant> {
Instant::now().checked_add(idle)
}
/// Makes `fd` nonblocking, its other flags kept.
fn nonblocking(fd: BorrowedFd<'_>) -> std::io::Result<()> {
let flags = rustix::fs::fcntl_getfl(fd)?;
rustix::fs::fcntl_setfl(fd, flags | rustix::fs::OFlags::NONBLOCK)?;
Ok(())
}
/// Waits until `fd` is ready for `events`, closed or failed (true), or `until` passes (false);
/// None waits without a limit. A wait a signal cuts short goes on until `until`.
fn ready(fd: BorrowedFd<'_>, events: PollFlags, until: Option<Instant>) -> std::io::Result<bool> {
loop {
let left = until.map(|t| t.saturating_duration_since(Instant::now()));
let timeout = left.and_then(|d| Timespec::try_from(d).ok());
let mut fds = [PollFd::new(&fd, events)];
match rustix::event::poll(&mut fds, timeout.as_ref()) {
Ok(n) => return Ok(n > 0),
Err(rustix::io::Errno::INTR) => {}
Err(e) => return Err(e.into()),
}
}
Ok(data)
}
/// `from` read to its end, `cap` bytes at most: more is an error, as is a failed read and a
/// pipe that is left open with no byte coming for `idle`. A read that fails gives none of the
/// bytes it took, and closes `from`.
fn read_capped(mut from: impl Read + AsFd, cap: usize, idle: Duration) -> Got {
let failed = |e: std::io::Error| format!("couldn't read the clipboard's data: {e}");
nonblocking(from.as_fd()).map_err(failed)?;
let (mut data, mut chunk) = (vec![], vec![0u8; CHUNK]);
let mut until = idle_until(idle);
loop {
match from.read(&mut chunk) {
Ok(0) => return Ok(data),
Ok(n) => {
let got = chunk.get(..n).ok_or_else(|| failed(ErrorKind::InvalidData.into()))?;
if data.len() + got.len() > cap {
return Err(format!("the clipboard's data is larger than {} MiB", cap >> 20));
}
data.extend_from_slice(got);
until = idle_until(idle);
}
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
if !ready(from.as_fd(), PollFlags::IN, until).map_err(failed)? {
return Err("the app that copied stopped sending the clipboard's data".to_string());
}
}
Err(e) => return Err(failed(e)),
}
}
}
/// `data` written to `to`: an error when the app that pastes closes its end (the process
/// ignores `SIGPIPE`) or takes no byte for `idle`.
fn send(mut to: impl Write + AsFd, data: &[u8], idle: Duration) -> std::io::Result<()> {
nonblocking(to.as_fd())?;
let (mut sent, mut until) = (0, idle_until(idle));
while let Some(rest) = data.get(sent..).filter(|rest| !rest.is_empty()) {
match to.write(rest) {
Ok(0) => return Err(ErrorKind::WriteZero.into()),
Ok(n) => {
sent += n;
until = idle_until(idle);
}
Err(e) if e.kind() == ErrorKind::Interrupted => {}
Err(e) if e.kind() == ErrorKind::WouldBlock => {
if !ready(to.as_fd(), PollFlags::OUT, until)? {
return Err(ErrorKind::TimedOut.into());
}
}
Err(e) => return Err(e),
}
}
Ok(())
}
/// The threads that send a Copy's data to the apps that paste it, counted.
#[derive(Clone, Default)]
struct Sends(Arc<AtomicUsize>);
#[derive(Clone)]
struct Sends {
running: Arc<AtomicUsize>,
/// How long a send waits for the app that pastes to take a byte.
idle: Duration,
}
impl Default for Sends {
fn default() -> Self {
Self { running: Arc::default(), idle: IDLE_WAIT }
}
}
impl Sends {
/// `data` written to `to` on a thread of its own, which then closes `to`; an app that
/// closes its end early ends the write (the process ignores `SIGPIPE`). While
/// [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
fn start(&self, mut to: impl Write + Send + 'static, data: Arc<[u8]>) -> bool {
if self.0.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
self.0.fetch_sub(1, Ordering::SeqCst);
/// `data` written to `to` on a thread of its own ([`send`]), which then closes `to`; an app
/// that closes its end early or takes no byte for the send's idle wait ends the write.
/// While [`MAX_SENDS`] are being written `to` is closed at once with nothing written → false.
fn start(&self, to: impl Write + AsFd + Send + 'static, data: Arc<[u8]>) -> bool {
if self.running.fetch_add(1, Ordering::SeqCst) >= MAX_SENDS {
self.running.fetch_sub(1, Ordering::SeqCst);
return false;
}
let count = self.0.clone();
let send = move || {
let _ = to.write_all(&data);
drop(to);
let (count, idle) = (self.running.clone(), self.idle);
let work = move || {
let _ = send(to, &data, idle);
count.fetch_sub(1, Ordering::SeqCst);
};
if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(send).is_err() {
self.0.fetch_sub(1, Ordering::SeqCst);
if std::thread::Builder::new().name("wayland-clipboard-send".into()).spawn(work).is_err() {
self.running.fetch_sub(1, Ordering::SeqCst);
return false;
}
true
@@ -1937,7 +2024,7 @@ mod wayland {
/// How many are being written.
#[cfg(test)]
fn running(&self) -> usize {
self.0.load(Ordering::SeqCst)
self.running.load(Ordering::SeqCst)
}
}
@@ -2298,7 +2385,7 @@ mod wayland {
impl Receiver {
/// The clipboard's data of type `wire`, read until the app that copied closes the pipe,
/// [`MAX_BYTES`] at most.
/// [`MAX_BYTES`] at most; an error when that app sends no byte for [`IDLE_WAIT`].
fn receive(&self, wire: String) -> Got {
let offer = lock(&self.objects).selection.clone().ok_or("the clipboard is empty")?;
let (from, to) = std::io::pipe().map_err(|e| format!("couldn't open a pipe for the clipboard's data: {e}"))?;
@@ -2307,7 +2394,7 @@ mod wayland {
// holds the only one, and the read ends when that app closes it.
drop(to);
self.conn.flush().map_err(|e| format!("couldn't reach the compositor: {e}"))?;
read_capped(from, MAX_BYTES)
read_capped(from, MAX_BYTES, IDLE_WAIT)
}
}
@@ -2557,31 +2644,86 @@ mod wayland {
assert_eq!(keyboard_serial(&modifiers(9), None), None);
}
/// A wait no test reaches: the other end of each pipe below either moves data or closes.
const LONG: Duration = Duration::from_secs(600);
/// A pipe holding `data`, its write end closed when `close`.
fn pipe_with(data: &[u8], close: bool) -> (std::io::PipeReader, Option<std::io::PipeWriter>) {
let (from, mut to) = std::io::pipe().unwrap();
to.write_all(data).unwrap();
(from, (!close).then_some(to))
}
#[test]
fn a_read_stops_at_its_limit() {
assert_eq!(MAX_BYTES, 64 * 1024 * 1024);
assert_eq!(read_capped(&b"12345678"[..], 8), Ok(b"12345678".to_vec()));
assert_eq!(read_capped(&b""[..], 8), Ok(vec![]));
assert_eq!(read_capped(&[0u8; 9][..], 8), Err("the clipboard's data is larger than 0 MiB".to_string()));
assert_eq!(read_capped(&vec![0u8; (2 << 20) + 1][..], 2 << 20), Err("the clipboard's data is larger than 2 MiB".to_string()));
/// A pipe that breaks.
struct Broken;
impl Read for Broken {
fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> {
Err(std::io::ErrorKind::BrokenPipe.into())
}
assert_eq!(IDLE_WAIT, LOAD_WAIT);
assert_eq!(read_capped(pipe_with(b"12345678", true).0, 8, Duration::ZERO), Ok(b"12345678".to_vec()));
assert_eq!(read_capped(pipe_with(b"", true).0, 8, Duration::ZERO), Ok(vec![]));
assert_eq!(read_capped(pipe_with(&[0u8; 9], true).0, 8, Duration::ZERO), Err("the clipboard's data is larger than 0 MiB".to_string()));
// More than a pipe holds, from an app that is still sending.
let (from, to) = std::io::pipe().unwrap();
assert!(Sends::default().start(to, vec![0u8; (2 << 20) + 1].into()));
assert_eq!(read_capped(from, 2 << 20, LONG), Err("the clipboard's data is larger than 2 MiB".to_string()));
// A pipe that can't be read: the write end.
let (_, to) = std::io::pipe().unwrap();
let unreadable = std::fs::File::from(std::os::fd::OwnedFd::from(to));
assert!(read_capped(unreadable, 8, LONG).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
}
#[test]
fn a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe() {
let stopped = Err("the app that copied stopped sending the clipboard's data".to_string());
// Some bytes and then nothing, the pipe left open: none of them are pasted.
let (from, to) = pipe_with(b"part", false);
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped);
// The read end is closed, so that app's next write fails.
let mut to = to.unwrap();
assert_eq!(to.write(b"x").map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe));
// Nothing at all, and data that fills the limit exactly with more never coming.
let (from, _held) = pipe_with(b"", false);
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), stopped);
let (from, _held) = pipe_with(b"12345678", false);
assert_eq!(read_capped(from, 8, Duration::ZERO), stopped);
// The next read gets its data.
assert_eq!(read_capped(pipe_with(b"words", true).0, MAX_BYTES, Duration::ZERO), Ok(b"words".to_vec()));
}
#[test]
fn a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread() {
let data: Arc<[u8]> = vec![3u8; 300_000].into();
// More than a pipe holds, to an app that never reads.
let (_held, to) = std::io::pipe().unwrap();
assert_eq!(send(to, &data, Duration::ZERO).map_err(|e| e.kind()), Err(ErrorKind::TimedOut));
// An app that closes its end before reading.
let (from, to) = std::io::pipe().unwrap();
drop(from);
assert_eq!(send(to, &data, LONG).map_err(|e| e.kind()), Err(ErrorKind::BrokenPipe));
// A send thread to an app that does not read until the send gives up, its end still open.
let sends = Sends { idle: Duration::ZERO, ..Sends::default() };
let (from, to) = std::io::pipe().unwrap();
assert!(sends.start(to, data.clone()));
while sends.running() > 0 {
std::thread::yield_now();
}
assert!(read_capped(Broken, 8).is_err_and(|e| e.starts_with("couldn't read the clipboard's data")));
// What the pipe held when the send gave up, then the end of the data: the send closed it.
let got = read_capped(from, MAX_BYTES, Duration::ZERO).map(|d| d.len());
assert!(got.as_ref().is_ok_and(|&n| n > 0 && n < data.len()), "{got:?}");
// The thread is free for the next paste.
let (from, to) = std::io::pipe().unwrap();
assert!(sends.start(to, b"words"[..].into()));
assert_eq!(read_capped(from, MAX_BYTES, LONG), Ok(b"words".to_vec()));
}
#[test]
fn a_copys_data_is_sent_through_the_pipe_and_closed() {
let sends = Sends::default();
assert_eq!(sends.idle, IDLE_WAIT);
let (from, to) = std::io::pipe().unwrap();
let data: Arc<[u8]> = vec![7u8; 300_000].into();
assert!(sends.start(to, data.clone()));
// More than a pipe holds: the write ends once all of it is read, then the pipe closes.
assert_eq!(read_capped(from, MAX_BYTES).as_deref(), Ok(&data[..]));
assert_eq!(read_capped(from, MAX_BYTES, LONG).as_deref(), Ok(&data[..]));
// An app that closes its end before reading ends the send.
let (from, to) = std::io::pipe().unwrap();
drop(from);
@@ -2593,9 +2735,9 @@ mod wayland {
#[test]
fn pastes_that_read_nothing_hold_a_few_sends_at_most() {
let sends = Sends::default();
let sends = Sends { idle: LONG, ..Sends::default() };
let data: Arc<[u8]> = vec![1u8; 300_000].into();
// Apps that ask for the data and never read it.
// Apps that ask for the data and never read it, within the sends' idle wait.
let stalled: Vec<_> = (0..MAX_SENDS)
.map(|_| {
let (from, to) = std::io::pipe().unwrap();
@@ -2606,7 +2748,7 @@ mod wayland {
// The next paste gets its pipe closed at once, with nothing written.
let (from, to) = std::io::pipe().unwrap();
assert!(!sends.start(to, data.clone()));
assert_eq!(read_capped(from, MAX_BYTES), Ok(vec![]));
assert_eq!(read_capped(from, MAX_BYTES, Duration::ZERO), Ok(vec![]));
// Once those apps close their pipes, the sends end and others are sent again.
drop(stalled);
while sends.running() > 0 {
@@ -2614,7 +2756,7 @@ mod wayland {
}
let (from, to) = std::io::pipe().unwrap();
assert!(sends.start(to, data.clone()));
assert_eq!(read_capped(from, MAX_BYTES).map(|d| d.len()), Ok(data.len()));
assert_eq!(read_capped(from, MAX_BYTES, LONG).map(|d| d.len()), Ok(data.len()));
}
/// What a clipboard holds, by type.
+1
View File
@@ -1780,6 +1780,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur
- File › Place and a file dropped on the canvas refuse a PNG, JPEG, GIF or WebP file whose pixels can't be read, such as one cut short by a download or a sync still running, and the status bar shows "Couldn't place <name>: the image's pixels can't be read". Paste and Relink refuse such an image too, and Update Links, or Open when it reads the file again, counts it as missing. A JPEG cut short still places when its pixels can be decoded (`file.place`, `crates/engine/src/tests_place.rs`).
- On the desktop, Copy and Cut make the system clipboard's formats (text, SVG, PDF, PNG) from a copy of the clipboard on the thread that writes background saves and exports, and write them when they are ready; while that thread has a save, an export, a print or a recovery copy to finish, they are made and written at once. Until they are written, Paste in VectorCraft takes the internal clipboard, and while a text field or the Type tool has the keyboard the app waits for them. Another Copy or Cut, or a copy of text in VectorCraft, drops them.
- In the Linux Flatpak on GNOME, where the clipboard goes through the compositor's core Wayland protocol, one Copy offers the PNG (`image/png`), the SVG (`image/svg+xml`, with Include SVG Code on) and the text, and Paste takes another app's SVG, text or PNG in that order, a picture over blank text or text that only names it; the Paste menu item is enabled by the types offered (`clipboard::wayland` tests in `apps/vectorcraft/src/clipboard.rs`; not yet run in the Flatpak on a GNOME session).
- On that core Wayland clipboard, a paste from an app that leaves its pipe open and sends no data for 2 s fails and pastes nothing, and a paste of VectorCraft's Copy by an app that takes no data for 2 s is stopped and its pipe closed, so later pastes and copies are read and sent again; the 2 s limit is VectorCraft's own and starts again after each byte that moves (`clipboard::wayland::tests::a_read_from_an_app_that_stops_sending_fails_and_closes_the_pipe` and `a_send_to_an_app_that_stops_reading_fails_and_frees_its_thread`).
- On the desktop with Enable Background Export on (Preferences › File Handling), File › Package… collects the linked files and fonts and writes the package from the document as it was at OK on the thread that writes background saves and exports, with "Packaging" in the status bar and the busy spinner until it is written; a save of unsaved changes that Package starts is written first. `file.package` over MCP and scripts replies as before.
- On the desktop with Enable Background Export on, Export for Screens and the Asset Export panel's Export encode and write their files (into the folder, or one ZIP in it) from the document as it was at OK on the thread that writes background saves and exports, with "Exporting" in the status bar and the export progress dialog, which counts the files and whose Cancel stops before the next file. `document.exportForScreens` and `assets.export` over MCP and scripts reply as before.
- With Enable Background Export on, an Export for Screens with no folder of its own also writes in the background into the folder its `exportPreset` keeps, with the same progress dialog and Cancel, and the status bar names that folder. When that folder is outside an MCP client's read roots, the reply, its errors and the status bar name it by its own name, as for an export made at once (`dialogs::tests_screens::an_export_into_a_presets_folder_writes_in_the_background`, `fileio::tests_screens::a_prepared_export_into_a_presets_folder_outside_the_read_root_leaves_it_out`). Behavior reference: the help page, https://helpx.adobe.com/illustrator/using/exporting-artwork.html (exports run in the background by default).