Linux clipboard: stop the retry storm

The Flatpak on GNOME has no X11 (fallback-x11 on a Wayland session) and GNOME offers
no data-control protocol, so arboard::Clipboard::new() failed every time. The handle
was kept only on success, so the background Paste check reconnected to the compositor
and logged a warning every 250 ms: 267k warnings in 18 h, the 16 MB log full in 3 h.

- A clipboard that can't be opened is tried again after 1 s, doubling to a minute.
  A Copy or Paste of the user's retries at most once a second.
- The outage is logged once (WARN) and its end once (INFO), across the UI's and the
  background check's handles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
wjrichards
2026-10-30 02:19:33 +07:00
committed by bflatastic
co-authored by Claude Opus 5.5
parent f4d995647b
commit c988af83b5
4 changed files with 235 additions and 13 deletions
+2
View File
@@ -144,6 +144,7 @@ Ranked; detail in [docs/roadmap.md](docs/roadmap.md) and [docs/gaps.md](docs/gap
| Date | What landed |
|---|---|
| 2026-10-29 | Linux: a clipboard that can't be opened (the Flatpak on GNOME, where arboard has neither data-control nor X11) is retried after 1 s, doubling to a minute, and logged once, where the background Paste check used to reconnect and log a warning every 250 ms (267k warnings in 18 h). Scores and estimates unchanged. |
| 2026-10-21 | 3D lighting: four original lighting presets shared by Revolve, Extrude & Bevel, Inflate and Rotate, with command access and manual Custom settings; scores and estimates unchanged. |
| 2026-10-21 | Print: the Print dialog's Media list has the Japanese papers — JIS B4 (257 × 364 mm), JIS B5 (182 × 257 mm) and JIS B6 next to the ISO B4/B5 (which keep their ISO 216 dimensions for settings saved before them), はがき 100 × 148 mm, 往復はがき 148 × 200 mm, 封筒 長形3号 120 × 235 mm / 長形4号 90 × 225 mm / 角形2号 240 × 332 mm / 角形3号 216 × 277 mm and 四六判 127 × 188 mm. A paper is named by its JSON id or its dialog label, any case and spacing (`B5 (JIS)` or `b5jis`), and every added size prints as its own PDF page size. Headline numbers unchanged. |
| 2026-10-18 | Inch rulers divide the inch in half, not in ten. Zooming in stepped an inch ruler through 0.2, 0.1 and 0.05 of an inch, marks an inch ruler does not carry; below an inch the steps are binary fractions now, 1/2 down to 1/16, with the unlabelled ticks between them binary too (down to 1/128, so the 32nds, 64ths and 128ths appear as the report expects), while the metric units, points, pixels and picas keep their tenths. A label carries the decimals its step needs, so a sixteenth reads 0.0625 rather than the rounded 0.06, with trailing zeros dropped (#2827). Headline numbers unchanged. |
@@ -198,6 +199,7 @@ The full pre-standard record of what landed is kept in [docs/roadmap.md](docs/ro
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | Progress log row for the Linux clipboard retry backoff; headline numbers unchanged. |
| 2026-10-23 | minor | Progress log row for Pathfinder › Trap; Live vector effects hours 6–10 → 4–8; headline numbers unchanged. |
| 2026-10-23 | minor | Progress log row for OpenType SVG colour fonts; headline numbers unchanged. |
| 2026-10-23 | minor | Progress log row for COLR colour fonts; headline numbers unchanged. |
+228 -12
View File
@@ -9,7 +9,8 @@
//! and EPS as vector art (#1499) and its PNG or TIFF as they are, as screenshots copy them (on
//! Linux arboard reads no other formats). On Wayland, arboard's data-control
//! backend reads the compositor's clipboard (X11 through XWayland only sees what X11 apps
//! copied), falling back to X11 where the compositor lacks the protocol.
//! copied), falling back to X11 where the compositor lacks the protocol. A clipboard that can't
//! be opened is tried again after a wait that grows to a minute, not each time Paste asks.
//! - Everywhere: files copied in a file manager paste as the first one that is art (SVG, PDF, EPS,
//! a metafile or a bitmap), before the clipboard's other formats (the files' paths as text).
@@ -310,6 +311,8 @@ mod win {
#[cfg_attr(windows, allow(dead_code))]
mod portable {
use std::borrow::Cow;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
use super::{BITMAP, Flavour, PNG, SystemClipboard, TEXT, copied_file, decode_png, png_flavour, text_of};
@@ -319,19 +322,113 @@ mod portable {
Image(usize, usize),
}
#[derive(Default)]
/// Who asks for the clipboard: the background paste check waits out the whole wait after a
/// failure, a Copy or Paste of the user's [`USER_RETRY`] at most.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum Asker {
Probe,
User,
}
/// The wait after the first failure to open the clipboard; it doubles with each failed retry.
const FIRST_WAIT: Duration = Duration::from_secs(1);
/// The longest wait between retries.
const MAX_WAIT: Duration = Duration::from_secs(60);
/// A Copy or Paste of the user's retries at most this often.
const USER_RETRY: Duration = Duration::from_secs(1);
/// Whether "unavailable" has been logged and not yet answered by "available again": the
/// clipboard and its background check hold a handle each, and their failures are one outage.
static UNAVAILABLE_LOGGED: AtomicBool = AtomicBool::new(false);
/// A failure to open, and when to try again.
struct Failed {
at: Instant,
wait: Duration,
}
/// A handle opened on first use and kept, or after a failure opened again only once its wait is
/// over: where the clipboard can't be opened, asking a few times a second must not reconnect each time.
pub(super) struct Reopen<B> {
handle: Option<B>,
failed: Option<Failed>,
/// The outage flag shared with the other handles ([`UNAVAILABLE_LOGGED`]).
logged: &'static AtomicBool,
}
impl<B> Reopen<B> {
pub(super) fn new(logged: &'static AtomicBool) -> Self {
Self { handle: None, failed: None, logged }
}
/// The open handle, opened with `open` when there is none and `asker` may try at `now`.
/// `name` names an opened handle in the log.
pub(super) fn get(
&mut self,
asker: Asker,
now: Instant,
open: impl FnOnce() -> Result<B, String>,
name: impl Fn(&B) -> &'static str,
) -> Option<&mut B> {
if self.handle.is_none() {
if let Some(f) = &self.failed {
let wait = match asker {
Asker::Probe => f.wait,
Asker::User => f.wait.min(USER_RETRY),
};
if now.saturating_duration_since(f.at) < wait {
return None;
}
}
match open() {
Ok(handle) => {
if self.logged.swap(false, Ordering::Relaxed) {
log::info!("the system clipboard is available again, through {}", name(&handle));
} else {
log::debug!("system clipboard opened through {}", name(&handle));
}
self.failed = None;
self.handle = Some(handle);
}
Err(why) => {
let wait = match &self.failed {
Some(f) => (f.wait * 2).min(MAX_WAIT),
None => FIRST_WAIT,
};
if self.logged.swap(true, Ordering::Relaxed) {
log::debug!("the system clipboard is still unavailable ({why}); next try in {wait:?}");
} else {
log::warn!("the system clipboard is unavailable ({why}): Copy and Paste stay inside VectorCraft");
}
self.failed = Some(Failed { at: now, wait });
}
}
}
self.handle.as_mut()
}
/// The handle if one is open, without trying to open one.
pub(super) fn opened(&mut self) -> Option<&mut B> {
self.handle.as_mut()
}
}
pub struct Clipboard {
/// Kept open: on Linux the copied data lives as long as the handle does.
cb: Option<arboard::Clipboard>,
cb: Reopen<arboard::Clipboard>,
ours: Option<Ours>,
}
impl Default for Clipboard {
fn default() -> Self {
Self { cb: Reopen::new(&UNAVAILABLE_LOGGED), ours: None }
}
}
impl Clipboard {
fn cb(&mut self) -> Result<&mut arboard::Clipboard, String> {
if self.cb.is_none() {
self.cb = Some(arboard::Clipboard::new().map_err(|e| e.to_string())?);
}
self.cb.as_mut().ok_or_else(|| "no system clipboard".to_string())
fn cb(&mut self, asker: Asker) -> Result<&mut arboard::Clipboard, String> {
let open = || arboard::Clipboard::new().map_err(|e| e.to_string());
self.cb.get(asker, Instant::now(), open, |_| "arboard").ok_or_else(|| "no system clipboard".to_string())
}
}
@@ -342,7 +439,7 @@ mod portable {
Some(_) => None,
None => flavours.iter().find(|f| f.mime == PNG).and_then(|f| decode_png(&f.data)),
};
let cb = self.cb()?;
let cb = self.cb(Asker::User)?;
let ours = match (text, image) {
(Some(t), _) => {
cb.set_text(t.as_str()).map_err(|e| e.to_string())?;
@@ -363,7 +460,7 @@ mod portable {
}
fn holds_ours(&mut self) -> bool {
let Some(cb) = self.cb.as_mut() else { return false };
let Some(cb) = self.cb.opened() else { return false };
match &self.ours {
Some(Ours::Text(t)) => cb.get_text().is_ok_and(|now| now == *t),
Some(Ours::Image(w, h)) => cb.get_image().is_ok_and(|now| (now.width, now.height) == (*w, *h)),
@@ -372,7 +469,7 @@ mod portable {
}
fn read(&mut self, mimes: &[&'static str]) -> Option<Flavour> {
let cb = self.cb().ok()?;
let cb = self.cb(Asker::User).ok()?;
if let Some(f) = cb.get().file_list().ok().and_then(|paths| copied_file(paths, mimes)) {
return Some(f);
}
@@ -402,7 +499,126 @@ mod portable {
if super::mac::has(mimes) {
return true;
}
mimes.contains(&TEXT) && self.cb().is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
mimes.contains(&TEXT) && self.cb(Asker::Probe).is_ok_and(|cb| cb.get_text().is_ok_and(|t| !t.is_empty()))
}
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use super::*;
/// A clock that starts now and moves only when told.
struct Clock(Cell<Instant>);
impl Clock {
fn new() -> Self {
Self(Cell::new(Instant::now()))
}
fn now(&self) -> Instant {
self.0.get()
}
fn advance(&self, by: Duration) {
self.0.set(self.0.get() + by);
}
}
fn fail() -> Result<u8, String> {
Err("no compositor".into())
}
fn name(_: &u8) -> &'static str {
"fake"
}
#[test]
fn the_paste_check_retries_with_a_growing_wait() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
// Every 250 ms for a minute, as `clipboard_probe` asks.
for _ in 0..240 {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
assert!(cb.get(Asker::Probe, clock.now(), open, name).is_none());
clock.advance(Duration::from_millis(250));
}
// 0, 1, 3, 7, 15 and 31 s: not one connection a check.
assert_eq!(attempts.get(), 6);
assert!(LOGGED.load(Ordering::Relaxed), "the outage is logged");
}
#[test]
fn the_wait_stops_growing_at_a_minute() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
for _ in 0..(20 * 60) {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
let _ = cb.get(Asker::Probe, clock.now(), open, name);
clock.advance(Duration::from_secs(1));
}
// 0, 1, 3, 7, 15, 31, 63 s, then a minute apart until 20 minutes are up.
assert_eq!(attempts.get(), 7 + (20 * 60 - 63 - 1) / 60);
}
#[test]
fn a_copy_or_paste_retries_at_most_once_a_second() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let (clock, attempts) = (Clock::new(), Cell::new(0));
let mut cb = Reopen::new(&LOGGED);
let mut ask = |asker| {
let open = || {
attempts.set(attempts.get() + 1);
fail()
};
let _ = cb.get(asker, clock.now(), open, name);
};
// Long after the paste check gave up for a minute, a held Ctrl+V for 3 s at 30 a second.
for _ in 0..8 {
ask(Asker::Probe);
clock.advance(Duration::from_secs(70));
}
let before = attempts.get();
for _ in 0..90 {
ask(Asker::User);
clock.advance(Duration::from_millis(33));
}
assert_eq!(attempts.get() - before, 3);
}
#[test]
fn a_success_is_kept_and_ends_the_outage() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let clock = Clock::new();
let mut cb = Reopen::new(&LOGGED);
assert!(cb.get(Asker::Probe, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
clock.advance(FIRST_WAIT);
assert_eq!(cb.get(Asker::Probe, clock.now(), || Ok(7), name).copied(), Some(7));
assert!(!LOGGED.load(Ordering::Relaxed), "the recovery is logged");
// Kept: not opened again, even where opening would now fail.
assert_eq!(cb.get(Asker::Probe, clock.now(), fail, name).copied(), Some(7));
assert_eq!(cb.opened().copied(), Some(7));
}
#[test]
fn one_outage_is_logged_once_across_handles() {
static LOGGED: AtomicBool = AtomicBool::new(false);
let clock = Clock::new();
let (mut ui, mut probe) = (Reopen::new(&LOGGED), Reopen::new(&LOGGED));
assert!(probe.get(Asker::Probe, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
// The other handle's failure is the same outage: `swap` reports it already logged.
assert!(ui.get(Asker::User, clock.now(), fail, name).is_none());
assert!(LOGGED.load(Ordering::Relaxed));
assert!(probe.opened().is_none() && ui.opened().is_none());
}
}
}
+3 -1
View File
@@ -1,5 +1,6 @@
# Development
> **Last updated:** 2026-10-29 · **Change:** Linux clipboard: a clipboard that can't be opened is retried with a growing wait and logged once.
> **Last updated:** 2026-10-25 · **Change:** Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30).
> **Last updated:** 2026-10-23 · **Change:** Tests: cargo xtask test, one integration-test binary per crate, the dev profile's debug info.
> **Last updated:** 2026-10-21 · **Change:** the interface faces as vectorcraft-ui-egui's `interface-fonts` feature, and `theme::install_fonts` for an embedder that draws the panels itself.
@@ -113,7 +114,7 @@ On GNOME/Wayland, enable **Preferences › User Interface › System Title Bar**
The window runs natively on Wayland (eframe's `wayland` feature) and on X11. On machines with two GPUs it renders on the one the desktop runs on unless Preferences say otherwise; see [Desktop graphics processor](#desktop-graphics-processor) (#502). The system clipboard (`apps/vectorcraft/src/clipboard.rs`) is arboard with its `wayland-data-control` feature:
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. egui's own text paste into fields goes through smithay-clipboard and works either way.
- **Clipboard:** under Wayland arboard talks to the compositor through the data-control protocol (wlroots compositors such as Sway and Hyprland, KDE Plasma), so bitmaps (`image/png`), SVG markup and text copied in any app paste (#398). Where the compositor lacks the protocol (GNOME's Mutter, [arboard#223](https://github.com/1Password/arboard/issues/223)), arboard falls back to the X11 clipboard through XWayland, which only holds what X11 apps copied. A clipboard arboard can't open at all (the Flatpak on a Wayland session, where `--socket=fallback-x11` gives it no X11) is tried again after 1 s, doubling to a minute (a Copy or Paste of the user's at most once a second), and logged once when it goes and once when it comes back: before, the background Paste check reconnected and logged a warning every 250 ms. egui's own text paste into fields goes through smithay-clipboard and works either way.
- **Clipboard owners that never answer (#510):** reading the clipboard waits on the app that owns it: arboard gives up on an X11 owner after 4 s, and a Wayland read waits for the owner to send its data. So on Linux the check that enables the Paste menu items runs on a background thread with a second clipboard handle (`crates/ui-egui/src/clipboard_probe.rs`, installed as `Services::clipboard_probe`), only while nothing is copied in the app and a document is open, and the window keeps drawing. A paste itself still reads on the UI thread, so pasting from such an owner waits for it once. Windows and macOS keep the check on the UI thread: Windows only asks which formats the clipboard holds, macOS asks the pasteboard server.
- **Copied files:** files copied in a file manager (`text/uri-list`; `CF_HDROP` on Windows, file URLs on macOS) paste as the first one that is art (SVG, PDF, EPS, EMF/WMF or a bitmap; a `.ai` or `.ait` file from the editing data it holds, else as the PDF or PostScript it holds), before the clipboard's other formats (which include the files' paths as text).
- **Dropping files on the window does not work under Wayland:** winit 0.30, which eframe 0.37 runs on, has no Wayland drag and drop ([winit#1881](https://github.com/rust-windowing/winit/issues/1881), added in winit 0.31; [egui#1563](https://github.com/emilk/egui/issues/1563)), so no `dropped_files` arrive. Until eframe moves to winit 0.31: copy the files in the file manager and paste them, use File › Place or Open, or open the window through XWayland, where drops work: set **Preferences › Performance › Linux Display Server** to **X11 (XWayland)** and restart (#1151), or start the app with `WAYLAND_DISPLAY= vectorcraft`. The preference (`linuxDisplayServer`, read at startup in `apps/vectorcraft/src/main.rs`, shown on Linux only) has winit open the window on X11 (`EventLoopBuilderExtX11::with_x11`) when the session is Wayland, `DISPLAY` is set and the X11 libraries winit loads are installed (`apps/vectorcraft/src/xwayland.rs` reads `ldconfig -p` and the usual library directories); otherwise the window opens on Wayland and the log says why. Unlike clearing `WAYLAND_DISPLAY`, it leaves the clipboard on the compositor's own.
@@ -492,6 +493,7 @@ Scores and hour estimates are unchanged.
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | Linux clipboard: a clipboard arboard can't open is retried after 1 s doubling to 60 s and logged once, instead of every 250 ms. |
| 2026-10-28 | minor | Localisation: Catalan (`ca`) catalog and registry entry (every `ca-*` locale); complete under the catalog tests. |
| 2026-10-25 | minor | Linux Wayland drag-and-drop note names eframe 0.37 (still on winit 0.30); behavior unchanged. |
| 2026-10-23 | minor | Tests: `cargo xtask test` (nextest, then doctests), one integration-test binary per crate and the files that keep their own, the dev profile's debug info. |
+2
View File
@@ -178,6 +178,7 @@ from G14 and the budget run of G15: **~170–270 h** one agent, ~50–80 h wall
- **Unconfirmed (2026-10-14):** Tool cursors are painted into the window on Wayland (#1652; not run on Linux, #1645 stays open until confirmed). On Windows the custom title bar's window stays truly borderless, so the pointer lines up with what is drawn on systems where the hidden caption offset it (#1791, #1758; awaiting the reporter's confirmation).
- **Done:** RPM required dependencies use 64-bit library capabilities and declare glibc 2.35, libm and libgcc_s (#1275); generated metadata and isolated synthetic-provider checks cover x86_64/aarch64. Real-distribution install/start-up QA is still missing.
- **Done (2026-10-29):** the Linux Flatpak on GNOME no longer reconnects to the compositor and logs a warning every 250 ms when arboard has neither data-control nor X11 (267k warnings in 18 h filled the 16 MB log in 3 h): a clipboard that can't be opened is retried with a wait growing to a minute and logged once. Copy and Paste with other apps there are still missing.
- **Missing:** the app not starting or staying in the background on some Windows 11 machines (#858, #964 fixed
2026-10-10, #620, #818), slow pointer on a Linux AppImage (#575), macOS paste from other apps (#954), a
web-free crash/hang reporter, a field record.
@@ -646,6 +647,7 @@ trap, and the defaults (0.25 pt, 100%, 40%) are VectorCraft's own, the pages giv
| Date | Change | Summary |
|---|---|---|
| 2026-10-29 | minor | G10: the Linux Flatpak on GNOME stops the clipboard retry storm (retried with a growing wait, logged once). Estimate unchanged. |
| 2026-10-29 | minor | G12: Pathfinder: live compound shapes (#4522): the panel menu's Make, Release and Expand Compound Shape, Alt-clicking a Shape Mode, a selected member's Shape Mode and the Expand button act. No entry closed, estimates unchanged. |
| 2026-10-29 | minor | G8: a blend makes at most 100,000 steps in all, the steps of blends nested in its keys and steps included; past it each pair of keys keeps the same share of its steps (#3579). No entry closed, estimates unchanged. |
| 2026-10-26 | minor | G9: EPS save-back: an opened EPS saves back to its own path as EPS, keeping the chosen artboard's page frame and transparent margins and the complete editable VectorCraft document; Save's TIFF preview is capped at 1024 px (Export keeps 2048); partly read, preview-only or damaged-editing-data sources still need confirmation before they are overwritten. This narrows G9's save-back workflow only; its import-fidelity items (layer and group structure, guides, open speed, live objects) and estimate are unchanged, and writing Illustrator's own editing data stays out of scope (#868) |