Skip to content

Commit bb5f5bb

Browse files
authored
Merge pull request #215 from ryanoneill/fix/clipboard-heap-corruption
Fix clipboard heap corruption on Windows
2 parents 80e4813 + 655fa1b commit bb5f5bb

File tree

5 files changed

+100
-41
lines changed

5 files changed

+100
-41
lines changed

src/clipboard.rs

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
//! System clipboard integration.
2+
//!
3+
//! Provides thread-safe clipboard access using a process-global singleton.
4+
//! On Windows, `arboard::Clipboard::new()` initializes COM. When multiple
5+
//! threads call `Clipboard::new()` concurrently (e.g., during parallel test
6+
//! execution), the concurrent COM initialization can corrupt the heap,
7+
//! causing `STATUS_HEAP_CORRUPTION` (0xc0000374).
8+
//!
9+
//! This module solves the problem by creating exactly one `Clipboard`
10+
//! instance for the entire process, serializing all access through a `Mutex`.
11+
//! Clipboard operations are infrequent (only on user copy/cut/paste), so
12+
//! the serialization has no practical performance impact.
13+
14+
use std::sync::{Mutex, OnceLock};
15+
16+
/// Process-global clipboard singleton.
17+
///
18+
/// `OnceLock` ensures `Clipboard::new()` is called exactly once.
19+
/// `Mutex` serializes all subsequent access.
20+
static CLIPBOARD: OnceLock<Mutex<Option<arboard::Clipboard>>> = OnceLock::new();
21+
22+
/// Runs a closure with access to the global clipboard context.
23+
///
24+
/// Returns `None` if the clipboard is unavailable (headless environments,
25+
/// CI, SSH sessions without a clipboard provider) or if the mutex is poisoned.
26+
fn with_clipboard<F, R>(f: F) -> Option<R>
27+
where
28+
F: FnOnce(&mut arboard::Clipboard) -> R,
29+
{
30+
let mutex = CLIPBOARD.get_or_init(|| Mutex::new(arboard::Clipboard::new().ok()));
31+
let mut guard = mutex.lock().ok()?;
32+
guard.as_mut().map(f)
33+
}
34+
35+
/// Attempt to write text to the system clipboard.
36+
///
37+
/// Errors are silently ignored — this is best-effort. Falls back gracefully
38+
/// in headless environments (CI, SSH) where no clipboard provider exists.
39+
pub(crate) fn system_clipboard_set(text: &str) {
40+
with_clipboard(|cb| {
41+
let _ = cb.set_text(text);
42+
});
43+
}
44+
45+
/// Attempt to read text from the system clipboard.
46+
///
47+
/// Returns `None` if the clipboard is unavailable or doesn't contain text.
48+
pub(crate) fn system_clipboard_get() -> Option<String> {
49+
with_clipboard(|cb| cb.get_text().ok())
50+
.flatten()
51+
.filter(|s| !s.is_empty())
52+
}
53+
54+
#[cfg(test)]
55+
mod tests {
56+
use super::*;
57+
58+
#[test]
59+
fn test_clipboard_set_does_not_panic() {
60+
// Should not panic even in headless CI environments
61+
system_clipboard_set("test");
62+
}
63+
64+
#[test]
65+
fn test_clipboard_get_does_not_panic() {
66+
// Should return None in headless CI, Some in desktop environments
67+
let _ = system_clipboard_get();
68+
}
69+
70+
#[test]
71+
fn test_clipboard_roundtrip() {
72+
// Set and get — may return None in headless environments.
73+
// Cannot assert exact content because the system clipboard is
74+
// global and other parallel tests may write to it.
75+
system_clipboard_set("roundtrip_test");
76+
let _ = system_clipboard_get();
77+
}
78+
79+
#[test]
80+
fn test_with_clipboard_returns_none_gracefully() {
81+
// Verify the with_clipboard wrapper handles unavailable clipboard
82+
let result = with_clipboard(|cb| cb.get_text().ok());
83+
// Result is either Some(Some/None) or None — both are fine
84+
let _ = result;
85+
}
86+
87+
#[test]
88+
fn test_repeated_access_same_thread() {
89+
// Verify that repeated access reuses the singleton without issues.
90+
for _ in 0..100 {
91+
system_clipboard_set("repeated");
92+
let _ = system_clipboard_get();
93+
}
94+
}
95+
}

src/component/input_field/mod.rs

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,8 @@ use crate::undo::{EditKind, UndoStack};
3737

3838
mod editing;
3939

40-
/// Attempt to write text to the system clipboard.
41-
///
42-
/// Errors are silently ignored — this is best-effort. Falls back gracefully
43-
/// in headless environments (CI, SSH) where no clipboard provider exists.
4440
#[cfg(feature = "clipboard")]
45-
fn system_clipboard_set(text: &str) {
46-
if let Ok(mut cb) = arboard::Clipboard::new() {
47-
let _ = cb.set_text(text);
48-
}
49-
}
50-
51-
/// Attempt to read text from the system clipboard.
52-
///
53-
/// Returns `None` if the clipboard is unavailable or doesn't contain text.
54-
#[cfg(feature = "clipboard")]
55-
fn system_clipboard_get() -> Option<String> {
56-
arboard::Clipboard::new()
57-
.ok()
58-
.and_then(|mut cb| cb.get_text().ok())
59-
.filter(|s| !s.is_empty())
60-
}
41+
use crate::clipboard::{system_clipboard_get, system_clipboard_set};
6142

6243
/// A snapshot of InputField state for undo/redo.
6344
#[derive(Debug, Clone)]

src/component/text_area/mod.rs

Lines changed: 1 addition & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -37,27 +37,8 @@ use crate::input::{Event, KeyCode, KeyModifiers};
3737
use crate::theme::Theme;
3838
use crate::undo::UndoStack;
3939

40-
/// Attempt to write text to the system clipboard.
41-
///
42-
/// Errors are silently ignored — this is best-effort. Falls back gracefully
43-
/// in headless environments (CI, SSH) where no clipboard provider exists.
4440
#[cfg(feature = "clipboard")]
45-
fn system_clipboard_set(text: &str) {
46-
if let Ok(mut cb) = arboard::Clipboard::new() {
47-
let _ = cb.set_text(text);
48-
}
49-
}
50-
51-
/// Attempt to read text from the system clipboard.
52-
///
53-
/// Returns `None` if the clipboard is unavailable or doesn't contain text.
54-
#[cfg(feature = "clipboard")]
55-
fn system_clipboard_get() -> Option<String> {
56-
arboard::Clipboard::new()
57-
.ok()
58-
.and_then(|mut cb| cb.get_text().ok())
59-
.filter(|s| !s.is_empty())
60-
}
41+
use crate::clipboard::system_clipboard_get;
6142

6243
mod cursor;
6344
mod selection;

src/component/text_area/update.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::undo::EditKind;
77
use super::{TextAreaMessage, TextAreaOutput, TextAreaState};
88

99
#[cfg(feature = "clipboard")]
10-
use super::system_clipboard_set;
10+
use crate::clipboard::system_clipboard_set;
1111

1212
impl TextAreaState {
1313
/// Applies a message to the textarea state, returning any output.

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,8 @@ pub mod adapter;
111111
pub mod annotation;
112112
pub mod app;
113113
pub mod backend;
114+
#[cfg(feature = "clipboard")]
115+
pub(crate) mod clipboard;
114116
pub mod component;
115117
pub mod error;
116118
pub mod harness;

0 commit comments

Comments
 (0)