Skip to content

Commit 0e6db16

Browse files
committed
fix: use permit-based gate to serialize entire PTY lifetime on musl
The previous approach (locking only spawn and drop) was insufficient because concurrent reads/writes on PTY FDs also trigger SIGSEGV in musl internals. Replace the per-operation PTY_LOCK with a gate that ensures only one Terminal can exist at a time on musl. The gate uses a Condvar + Arc<PtyPermit> pattern: spawn blocks until no other Terminal is active, then distributes Arc permits to reader, writer, and the background cleanup thread. When all permits are dropped, the gate reopens for the next Terminal.
1 parent 05f9323 commit 0e6db16

File tree

2 files changed

+53
-46
lines changed

2 files changed

+53
-46
lines changed

crates/pty_terminal/src/terminal.rs

Lines changed: 53 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
use std::{
22
collections::VecDeque,
33
io::{Read, Write},
4-
mem::ManuallyDrop,
54
sync::{Arc, Mutex, OnceLock},
65
thread,
76
};
@@ -15,18 +14,38 @@ type ChildWaitResult = Result<ExitStatus, Arc<std::io::Error>>;
1514

1615
// On musl libc (Alpine Linux), concurrent PTY operations trigger
1716
// SIGSEGV/SIGBUS in musl internals (sysconf, fcntl). This affects
18-
// openpty+fork, FD cleanup (close), and FD drops from any thread.
19-
// Serialize all PTY lifecycle operations that touch musl internals.
17+
// openpty+fork, FD operations (read/write/close), and FD drops.
18+
// Ensure only one Terminal exists at a time by blocking spawn until
19+
// all PTY FDs from the previous Terminal are closed.
2020
#[cfg(target_env = "musl")]
21-
static PTY_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
21+
static PTY_GATE: (std::sync::Mutex<bool>, std::sync::Condvar) =
22+
(std::sync::Mutex::new(false), std::sync::Condvar::new());
23+
24+
/// RAII guard that releases the PTY gate when dropped.
25+
/// Shared via `Arc` so the gate stays held while any part of the
26+
/// Terminal (reader, writer, background thread) is still alive.
27+
#[cfg(target_env = "musl")]
28+
struct PtyPermit;
29+
30+
#[cfg(target_env = "musl")]
31+
impl Drop for PtyPermit {
32+
fn drop(&mut self) {
33+
let (lock, cvar) = &PTY_GATE;
34+
*lock.lock().unwrap_or_else(|e| e.into_inner()) = false;
35+
cvar.notify_one();
36+
}
37+
}
2238

2339
/// The read half of a PTY connection. Implements [`Read`].
2440
///
2541
/// Reading feeds data through an internal vt100 parser (shared with [`PtyWriter`]),
2642
/// keeping `screen_contents()` up-to-date with parsed terminal output.
2743
pub struct PtyReader {
28-
reader: ManuallyDrop<Box<dyn Read + Send>>,
44+
reader: Box<dyn Read + Send>,
2945
parser: Arc<Mutex<vt100::Parser<Vt100Callbacks>>>,
46+
/// Prevent concurrent PTY sessions on musl; released when all parts are dropped.
47+
#[cfg(target_env = "musl")]
48+
_permit: Arc<PtyPermit>,
3049
}
3150

3251
/// The write half of a PTY connection. Implements [`Write`].
@@ -36,25 +55,10 @@ pub struct PtyReader {
3655
pub struct PtyWriter {
3756
writer: Arc<Mutex<Option<Box<dyn Write + Send>>>>,
3857
parser: Arc<Mutex<vt100::Parser<Vt100Callbacks>>>,
39-
master: ManuallyDrop<Box<dyn MasterPty + Send>>,
40-
}
41-
42-
impl Drop for PtyReader {
43-
fn drop(&mut self) {
44-
#[cfg(target_env = "musl")]
45-
let _guard = PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
46-
// SAFETY: called exactly once, from drop.
47-
unsafe { ManuallyDrop::drop(&mut self.reader) };
48-
}
49-
}
50-
51-
impl Drop for PtyWriter {
52-
fn drop(&mut self) {
53-
#[cfg(target_env = "musl")]
54-
let _guard = PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
55-
// SAFETY: called exactly once, from drop.
56-
unsafe { ManuallyDrop::drop(&mut self.master) };
57-
}
58+
master: Box<dyn MasterPty + Send>,
59+
/// Prevent concurrent PTY sessions on musl; released when all parts are dropped.
60+
#[cfg(target_env = "musl")]
61+
_permit: Arc<PtyPermit>,
5862
}
5963

6064
/// A cloneable handle to a child process spawned in a PTY.
@@ -282,8 +286,17 @@ impl Terminal {
282286
///
283287
/// Panics if the writer lock is poisoned when the background thread closes it.
284288
pub fn spawn(size: ScreenSize, cmd: CommandBuilder) -> anyhow::Result<Self> {
289+
// On musl, block until no other Terminal is alive.
285290
#[cfg(target_env = "musl")]
286-
let _spawn_guard = PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
291+
let permit = {
292+
let (lock, cvar) = &PTY_GATE;
293+
let mut busy = lock.lock().unwrap_or_else(|e| e.into_inner());
294+
while *busy {
295+
busy = cvar.wait(busy).unwrap_or_else(|e| e.into_inner());
296+
}
297+
*busy = true;
298+
Arc::new(PtyPermit)
299+
};
287300

288301
let pty_pair = portable_pty::native_pty_system().openpty(portable_pty::PtySize {
289302
rows: size.rows,
@@ -313,15 +326,16 @@ impl Terminal {
313326
let writer = Arc::clone(&writer);
314327
let exit_status = Arc::clone(&exit_status);
315328
let slave = pty_pair.slave;
329+
// Hold a permit clone so the gate stays held while FDs are being cleaned up.
330+
#[cfg(target_env = "musl")]
331+
let _permit = Arc::clone(&permit);
316332
move || {
317333
let _ = exit_status.set(child.wait().map_err(Arc::new));
318-
// On musl, serialize FD cleanup (close) with PTY spawn to
319-
// prevent racing on musl-internal state.
320-
#[cfg(target_env = "musl")]
321-
let _cleanup_guard = PTY_LOCK.lock().unwrap_or_else(|e| e.into_inner());
322334
// Close writer first, then drop slave to trigger EOF on the reader.
323335
*writer.lock().unwrap() = None;
324336
drop(slave);
337+
// _permit is dropped here (after FD cleanup), releasing the gate
338+
// once all other permit clones are also dropped.
325339
}
326340
});
327341

@@ -337,10 +351,18 @@ impl Terminal {
337351

338352
Ok(Self {
339353
pty_reader: PtyReader {
340-
reader: ManuallyDrop::new(reader),
354+
reader,
341355
parser: Arc::clone(&parser),
356+
#[cfg(target_env = "musl")]
357+
_permit: Arc::clone(&permit),
358+
},
359+
pty_writer: PtyWriter {
360+
writer,
361+
parser,
362+
master,
363+
#[cfg(target_env = "musl")]
364+
_permit: permit,
342365
},
343-
pty_writer: PtyWriter { writer, parser, master: ManuallyDrop::new(master) },
344366
child_handle: ChildHandle { child_killer, exit_status },
345367
})
346368
}

crates/pty_terminal_test/tests/milestone.rs

Lines changed: 0 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,14 @@
11
use std::io::Write;
2-
#[cfg(target_env = "musl")]
3-
use std::sync::Mutex;
42

53
use ntest::timeout;
64
use portable_pty::CommandBuilder;
75
use pty_terminal::geo::ScreenSize;
86
use pty_terminal_test::TestTerminal;
97
use subprocess_test::command_for_fn;
108

11-
// On musl, concurrent PTY spawns + FD operations in the same process cause
12-
// SIGSEGV in musl internals. The crate-level PTY_LOCK serializes spawn and
13-
// drop, but interleaved reads/writes between two live Terminals can still
14-
// trigger the race. Serialize entire test bodies as a workaround.
15-
#[cfg(target_env = "musl")]
16-
static TEST_MUTEX: Mutex<()> = Mutex::new(());
17-
189
#[test]
1910
#[timeout(5000)]
2011
fn milestone_raw_mode_keystrokes() {
21-
#[cfg(target_env = "musl")]
22-
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
23-
2412
let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| {
2513
use std::io::{Read, Write, stdout};
2614

@@ -89,9 +77,6 @@ fn milestone_raw_mode_keystrokes() {
8977
#[test]
9078
#[timeout(5000)]
9179
fn milestone_does_not_pollute_screen() {
92-
#[cfg(target_env = "musl")]
93-
let _guard = TEST_MUTEX.lock().unwrap_or_else(|e| e.into_inner());
94-
9580
let cmd = CommandBuilder::from(command_for_fn!((), |(): ()| {
9681
use std::io::{Read, Write, stdout};
9782

0 commit comments

Comments
 (0)