aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-31 16:00:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-31 16:00:03 +0300
commitb778162a9f57b64ce9a067386b7de27d4cbcbafc (patch)
tree6bbce8729acba59aebbdaaa78eb2acb37a144b81 /src
parent77819797385c5538f624a8881208921342911db0 (diff)
refactor: centralize pty fd ownership
refs #357 refs #327
Diffstat (limited to 'src')
-rw-r--r--src/main.rs1
-rw-r--r--src/pane.rs894
-rw-r--r--src/pty/actor.rs996
-rw-r--r--src/pty/backend.rs94
-rw-r--r--src/pty/fd.rs109
-rw-r--r--src/pty/mod.rs6
6 files changed, 1470 insertions, 630 deletions
diff --git a/src/main.rs b/src/main.rs
index a3d19876..6493ae81 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -39,6 +39,7 @@ mod persist;
mod platform;
mod product_announcements;
mod protocol;
+mod pty;
mod raw_input;
mod release_notes;
mod remote;
diff --git a/src/pane.rs b/src/pane.rs
index ddf70b1b..86a6f5a5 100644
--- a/src/pane.rs
+++ b/src/pane.rs
@@ -1,5 +1,5 @@
use std::cell::Cell;
-use std::io::{self, Read, Write};
+use std::io;
use std::path::Path;
use std::sync::{
atomic::{AtomicBool, AtomicU16, AtomicU32, Ordering},
@@ -7,14 +7,20 @@ use std::sync::{
};
use bytes::Bytes;
-use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize};
+use portable_pty::CommandBuilder;
+#[cfg(test)]
+use portable_pty::{native_pty_system, PtySize};
use ratatui::{layout::Rect, Frame};
-use tokio::sync::{mpsc, watch, Notify};
+#[cfg(test)]
+use tokio::sync::watch;
+use tokio::sync::{mpsc, Notify};
use tracing::{debug, error, info, warn};
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::PaneId;
+#[cfg(unix)]
+use crate::pty::actor::{PtyIoActor, PtyIoActorConfig, PtyIoActorHandle, PtyReadResult};
mod input;
mod kitty_keyboard;
@@ -539,17 +545,9 @@ impl AgentDetectionPresence {
pub struct PaneRuntime {
pane_id: PaneId,
terminal: Arc<PaneTerminal>,
- sender: mpsc::Sender<Bytes>,
- resize_tx: watch::Sender<(u16, u16, u32, u32)>,
+ io: PaneRuntimeIo,
current_size: Cell<(u16, u16, u32, u32)>,
child_pid: Arc<AtomicU32>,
- pty_master: Option<Box<dyn MasterPty + Send>>,
- raw_master_fd: Option<std::os::fd::RawFd>,
- force_resize_fd: Option<std::os::fd::RawFd>,
- io_stop: Arc<AtomicBool>,
- reader_paused: Arc<AtomicBool>,
- reader_pause_ack: Arc<AtomicBool>,
- reader_stopped_rx: Option<std::sync::mpsc::Receiver<()>>,
kitty_keyboard_flags: Arc<AtomicU16>,
detect_reset_notify: Arc<Notify>,
pending_release: Arc<Mutex<Option<PendingAgentRelease>>>,
@@ -558,6 +556,128 @@ pub struct PaneRuntime {
detect_handle: tokio::task::AbortHandle,
}
+enum PaneRuntimeIo {
+ #[cfg(unix)]
+ Actor(PtyIoActorHandle),
+ #[cfg(test)]
+ TestChannel {
+ sender: mpsc::Sender<Bytes>,
+ resize_tx: watch::Sender<(u16, u16, u32, u32)>,
+ },
+}
+
+impl PaneRuntimeIo {
+ fn shutdown(&self) {
+ match self {
+ #[cfg(unix)]
+ PaneRuntimeIo::Actor(actor) => actor.shutdown(),
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { .. } => {}
+ }
+ }
+
+ #[cfg(unix)]
+ fn duplicate_handoff_fd(&self) -> std::io::Result<std::os::fd::RawFd> {
+ match self {
+ PaneRuntimeIo::Actor(actor) => actor.duplicate_for_handoff(),
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { .. } => {
+ Err(std::io::Error::other("test runtime has no PTY master fd"))
+ }
+ }
+ }
+
+ #[cfg(unix)]
+ fn foreground_process_group_id(&self) -> Option<u32> {
+ match self {
+ PaneRuntimeIo::Actor(actor) => actor.foreground_process_group_id(),
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { .. } => None,
+ }
+ }
+
+ #[cfg(unix)]
+ fn begin_handoff(&self, timeout: std::time::Duration) -> std::io::Result<()> {
+ match self {
+ PaneRuntimeIo::Actor(actor) => actor.begin_handoff(timeout),
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { .. } => Ok(()),
+ }
+ }
+
+ #[cfg(unix)]
+ fn set_handoff_paused(&self, paused: bool) -> std::io::Result<()> {
+ match self {
+ PaneRuntimeIo::Actor(actor) => {
+ if paused {
+ actor.begin_handoff(std::time::Duration::from_secs(1))
+ } else {
+ actor.rollback_handoff()
+ }
+ }
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { .. } => Ok(()),
+ }
+ }
+
+ #[cfg(unix)]
+ fn release_after_commit(&self) -> std::io::Result<()> {
+ match self {
+ PaneRuntimeIo::Actor(actor) => actor.release_after_commit(),
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { .. } => Ok(()),
+ }
+ }
+
+ fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
+ match self {
+ #[cfg(unix)]
+ PaneRuntimeIo::Actor(actor) => {
+ actor.resize(rows, cols, cell_width_px, cell_height_px);
+ }
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { resize_tx, .. } => {
+ let _ = resize_tx.send((rows, cols, cell_width_px, cell_height_px));
+ }
+ }
+ }
+
+ fn nudge_child_redraw_after_handoff(
+ &self,
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ ) {
+ match self {
+ #[cfg(unix)]
+ PaneRuntimeIo::Actor(actor) => {
+ actor.nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px);
+ }
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { .. } => {}
+ }
+ }
+
+ async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
+ match self {
+ #[cfg(unix)]
+ PaneRuntimeIo::Actor(actor) => actor.write_user_input(bytes).await,
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { sender, .. } => sender.send(bytes).await,
+ }
+ }
+
+ fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
+ match self {
+ #[cfg(unix)]
+ PaneRuntimeIo::Actor(actor) => actor.try_write_user_input(bytes),
+ #[cfg(test)]
+ PaneRuntimeIo::TestChannel { sender, .. } => sender.try_send(bytes),
+ }
+ }
+}
+
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WheelRouting {
HostScroll,
@@ -568,19 +688,12 @@ pub enum WheelRouting {
impl Drop for PaneRuntime {
fn drop(&mut self) {
// Abort detection task immediately and terminate the owned session.
- // Reader/writer/resize tasks shut down naturally via channel close
- // and PTY EOF when the rest of PaneRuntime is dropped.
+ // The PTY actor shuts down before the process/session policy runs.
self.detect_handle.abort();
- self.io_stop.store(true, Ordering::Release);
+ self.io.shutdown();
if !self.preserve_processes_on_drop {
shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire));
}
- if let Some(fd) = self.raw_master_fd.take() {
- let _ = unsafe { libc::close(fd) };
- }
- if let Some(fd) = self.force_resize_fd.take() {
- let _ = unsafe { libc::close(fd) };
- }
}
}
@@ -768,175 +881,7 @@ fn pane_shell_command_builder(shell_config: PaneShellConfig<'_>) -> io::Result<C
pane_shell_command_builder_for_target(shell_config, cfg!(target_os = "macos"))
}
-#[cfg(unix)]
-fn duplicate_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::os::fd::RawFd> {
- let duplicated = unsafe { libc::dup(fd) };
- if duplicated < 0 {
- return Err(std::io::Error::last_os_error());
- }
- Ok(duplicated)
-}
-
-#[cfg(unix)]
-fn set_cloexec(fd: std::os::fd::RawFd) -> std::io::Result<()> {
- let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
- if flags < 0 {
- return Err(std::io::Error::last_os_error());
- }
- if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 {
- return Err(std::io::Error::last_os_error());
- }
- Ok(())
-}
-
-#[cfg(unix)]
-fn set_nonblocking(fd: std::os::fd::RawFd) -> std::io::Result<()> {
- let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
- if flags < 0 {
- return Err(std::io::Error::last_os_error());
- }
- if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
- return Err(std::io::Error::last_os_error());
- }
- Ok(())
-}
-
-#[cfg(unix)]
-fn duplicate_cloexec_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::os::fd::RawFd> {
- let duplicated = duplicate_fd(fd)?;
- if let Err(err) = set_cloexec(duplicated) {
- let _ = unsafe { libc::close(duplicated) };
- return Err(err);
- }
- Ok(duplicated)
-}
-
-#[cfg(unix)]
-fn file_from_duplicated_fd(fd: std::os::fd::RawFd) -> std::io::Result<std::fs::File> {
- use std::os::fd::FromRawFd;
-
- let duplicated = duplicate_cloexec_fd(fd)?;
- Ok(unsafe { std::fs::File::from_raw_fd(duplicated) })
-}
-
-#[cfg(unix)]
-fn poll_read_ready(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result<bool> {
- let mut poll_fd = libc::pollfd {
- fd,
- events: libc::POLLIN,
- revents: 0,
- };
- loop {
- let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
- if result < 0 {
- let err = std::io::Error::last_os_error();
- if err.kind() == std::io::ErrorKind::Interrupted {
- continue;
- }
- return Err(err);
- }
- return Ok(result > 0 && (poll_fd.revents & (libc::POLLIN | libc::POLLHUP)) != 0);
- }
-}
-
-#[cfg(unix)]
-fn poll_write_ready(fd: std::os::fd::RawFd, timeout_ms: i32) -> std::io::Result<bool> {
- let mut poll_fd = libc::pollfd {
- fd,
- events: libc::POLLOUT,
- revents: 0,
- };
- loop {
- let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
- if result < 0 {
- let err = std::io::Error::last_os_error();
- if err.kind() == std::io::ErrorKind::Interrupted {
- continue;
- }
- return Err(err);
- }
- return Ok(result > 0 && (poll_fd.revents & (libc::POLLOUT | libc::POLLHUP)) != 0);
- }
-}
-
-#[cfg(unix)]
-fn write_all_nonblocking(
- writer: &mut std::fs::File,
- fd: std::os::fd::RawFd,
- mut bytes: &[u8],
- io_stop: &AtomicBool,
-) -> std::io::Result<()> {
- while !bytes.is_empty() {
- if io_stop.load(Ordering::Acquire) {
- return Ok(());
- }
- match writer.write(bytes) {
- Ok(0) => {
- return Err(std::io::Error::new(
- std::io::ErrorKind::WriteZero,
- "pty write returned zero bytes",
- ));
- }
- Ok(written) => bytes = &bytes[written..],
- Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
- let _ = poll_write_ready(fd, 50)?;
- }
- Err(err) if err.kind() == std::io::ErrorKind::Interrupted => {}
- Err(err) => return Err(err),
- }
- }
- Ok(())
-}
-
-#[cfg(unix)]
-fn write_pty_bytes_locked(
- writer: &mut std::fs::File,
- fd: std::os::fd::RawFd,
- bytes: &[u8],
- io_stop: &AtomicBool,
- pty_write_lock: &Mutex<()>,
-) -> std::io::Result<()> {
- let _guard = pty_write_lock
- .lock()
- .map_err(|_| std::io::Error::other("pty write lock poisoned"))?;
- write_all_nonblocking(writer, fd, bytes, io_stop)?;
- writer.flush()
-}
-
-#[cfg(unix)]
-fn resize_pty_fd(
- fd: std::os::fd::RawFd,
- rows: u16,
- cols: u16,
- cell_width_px: u32,
- cell_height_px: u32,
-) -> std::io::Result<()> {
- let size = libc::winsize {
- ws_row: rows,
- ws_col: cols,
- ws_xpixel: (cols as u32)
- .saturating_mul(cell_width_px)
- .min(u16::MAX as u32) as u16,
- ws_ypixel: (rows as u32)
- .saturating_mul(cell_height_px)
- .min(u16::MAX as u32) as u16,
- };
- if unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &size) } < 0 {
- return Err(std::io::Error::last_os_error());
- }
- Ok(())
-}
-
impl PaneRuntime {
- #[cfg(unix)]
- fn master_fd(&self) -> Option<std::os::fd::RawFd> {
- self.raw_master_fd.or_else(|| {
- self.pty_master
- .as_ref()
- .and_then(|master| master.as_raw_fd())
- })
- }
-
pub fn shutdown(self) {
self.detect_handle.abort();
shutdown_pane_processes(self.pane_id, self.child_pid.load(Ordering::Acquire));
@@ -944,17 +889,17 @@ impl PaneRuntime {
#[cfg(unix)]
pub fn duplicate_handoff_fd(&self) -> std::io::Result<std::os::fd::RawFd> {
- let master_fd = self
- .master_fd()
- .ok_or_else(|| std::io::Error::other("runtime has no PTY master fd"))?;
- duplicate_cloexec_fd(master_fd)
+ self.io.duplicate_handoff_fd()
}
#[cfg(unix)]
pub fn preserve_for_handoff(mut self) {
- self.io_stop.store(true, Ordering::Release);
- if let Some(reader_stopped_rx) = self.reader_stopped_rx.take() {
- let _ = reader_stopped_rx.recv_timeout(std::time::Duration::from_millis(500));
+ if let Err(err) = self.io.release_after_commit() {
+ warn!(
+ pane = self.pane_id.raw(),
+ err = %err,
+ "failed to release PTY actor after handoff commit; dropping runtime will still close the actor handle"
+ );
}
self.detect_handle.abort();
self.preserve_processes_on_drop = true;
@@ -967,28 +912,19 @@ impl PaneRuntime {
#[cfg(unix)]
pub fn set_handoff_reader_paused(&self, paused: bool) {
- self.reader_paused.store(paused, Ordering::Release);
- if !paused {
- self.reader_pause_ack.store(false, Ordering::Release);
+ if let Err(err) = self.io.set_handoff_paused(paused) {
+ warn!(
+ pane = self.pane_id.raw(),
+ err = %err,
+ paused,
+ "failed to update PTY actor handoff pause state"
+ );
}
}
#[cfg(unix)]
pub fn pause_handoff_reader(&self, timeout: std::time::Duration) -> std::io::Result<()> {
- self.reader_pause_ack.store(false, Ordering::Release);
- self.reader_paused.store(true, Ordering::Release);
- let deadline = std::time::Instant::now() + timeout;
- while std::time::Instant::now() < deadline {
- if self.reader_pause_ack.load(Ordering::Acquire) || self.io_stop.load(Ordering::Acquire)
- {
- return Ok(());
- }
- std::thread::sleep(std::time::Duration::from_millis(5));
- }
- Err(std::io::Error::new(
- std::io::ErrorKind::TimedOut,
- "timed out waiting for pane reader to pause for handoff",
- ))
+ self.io.begin_handoff(timeout)
}
#[cfg(unix)]
@@ -1243,24 +1179,11 @@ impl PaneRuntime {
initial_history_ansi,
} = state;
let pane_id = PaneId::from_raw(pane_id);
- use std::os::fd::{AsRawFd, FromRawFd, IntoRawFd};
+ use std::os::fd::FromRawFd;
let master_fd = unsafe { std::os::fd::OwnedFd::from_raw_fd(master_fd) };
- set_cloexec(master_fd.as_raw_fd())?;
- set_nonblocking(master_fd.as_raw_fd())?;
- let reader = file_from_duplicated_fd(master_fd.as_raw_fd())?;
- let writer = file_from_duplicated_fd(master_fd.as_raw_fd())?;
- let terminal_response_writer = file_from_duplicated_fd(master_fd.as_raw_fd())?;
- let force_resize_fd = duplicate_cloexec_fd(master_fd.as_raw_fd())?;
- let resize_fd = unsafe {
- std::os::fd::OwnedFd::from_raw_fd(duplicate_cloexec_fd(master_fd.as_raw_fd())?)
- };
- let io_stop = Arc::new(AtomicBool::new(false));
- let pty_write_lock = Arc::new(Mutex::new(()));
- let reader_paused = Arc::new(AtomicBool::new(true));
- let reader_pause_ack = Arc::new(AtomicBool::new(false));
- let (input_tx, mut input_rx) = mpsc::channel::<Bytes>(32);
+ let (response_tx, _response_rx) = mpsc::channel::<Bytes>(1);
let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes)
.map_err(|e| std::io::Error::other(e.to_string()))?;
if crate::kitty_graphics::is_enabled() {
@@ -1268,7 +1191,7 @@ impl PaneRuntime {
.enable_kitty_graphics()
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
- let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?;
+ let pane_terminal = GhosttyPaneTerminal::new(terminal, response_tx.clone())?;
pane_terminal.apply_host_terminal_theme(host_terminal_theme);
if let Some(input_state) = input_state {
pane_terminal.seed_handoff_input_state(input_state);
@@ -1284,154 +1207,59 @@ impl PaneRuntime {
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
let child_pid = Arc::new(AtomicU32::new(child_pid));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(keyboard_protocol_flags));
- let (reader_stopped_tx, reader_stopped_rx) = std::sync::mpsc::channel();
- {
- use std::os::fd::AsRawFd;
-
- let mut reader = reader;
- let reader_fd = reader.as_raw_fd();
- let mut terminal_response_writer = terminal_response_writer;
- let terminal_response_fd = terminal_response_writer.as_raw_fd();
+ let io = {
let terminal = terminal.clone();
- let response_writer = input_tx.clone();
+ let response_writer = response_tx.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let child_pid = child_pid.clone();
- let events = events.clone();
- let io_stop = io_stop.clone();
- let pty_write_lock = pty_write_lock.clone();
- let reader_paused = reader_paused.clone();
- let reader_pause_ack = reader_pause_ack.clone();
+ let read_events = events.clone();
let rt = tokio::runtime::Handle::current();
- tokio::task::spawn_blocking(move || {
- let mut buf = [0u8; 8192];
- loop {
- if io_stop.load(Ordering::Acquire) {
- break;
- }
- if reader_paused.load(Ordering::Acquire) {
- reader_pause_ack.store(true, Ordering::Release);
- std::thread::sleep(std::time::Duration::from_millis(5));
- continue;
- }
- reader_pause_ack.store(false, Ordering::Release);
- match poll_read_ready(reader_fd, 50) {
- Ok(true) => {}
- Ok(false) => continue,
- Err(e) => {
- debug!(pane = pane_id.raw(), err = %e, "handoff pty reader poll failed");
- break;
- }
- }
- match reader.read(&mut buf) {
- Ok(0) => break,
- Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
- Err(e) => {
- debug!(pane = pane_id.raw(), err = %e, "handoff pty reader closed");
- break;
- }
- Ok(n) => {
- let shell_pid = child_pid.load(Ordering::Acquire);
- let result = terminal.process_pty_bytes(
- pane_id,
- shell_pid,
- &buf[..n],
- &response_writer,
- );
- for response in result.terminal_responses {
- if let Err(err) = write_pty_bytes_locked(
- &mut terminal_response_writer,
- terminal_response_fd,
- &response,
- &io_stop,
- &pty_write_lock,
- ) {
- warn!(pane = pane_id.raw(), err = %err, "handoff terminal response write failed");
- break;
- }
- }
- if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
- render_notify.notify_one();
- }
- if let Some(delay) = result.render_delay {
- let render_notify = render_notify.clone();
- let render_dirty = render_dirty.clone();
- rt.spawn(async move {
- tokio::time::sleep(delay).await;
- if !render_dirty.swap(true, Ordering::AcqRel) {
- render_notify.notify_one();
- }
- });
- }
- for content in result.clipboard_writes {
- let _ =
- rt.block_on(events.send(AppEvent::ClipboardWrite { content }));
- }
+ let delay_rt = rt.clone();
+ let on_read = Box::new(move |bytes: &[u8]| {
+ let shell_pid = child_pid.load(Ordering::Acquire);
+ let result =
+ terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
+ if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
+ }
+ if let Some(delay) = result.render_delay {
+ let render_notify = render_notify.clone();
+ let render_dirty = render_dirty.clone();
+ delay_rt.spawn(async move {
+ tokio::time::sleep(delay).await;
+ if !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
}
- }
+ });
}
- let _ = reader_stopped_tx.send(());
- let _ = rt.block_on(events.send(AppEvent::PaneDied { pane_id }));
- debug!(pane = pane_id.raw(), "handoff reader task exiting");
- });
- }
-
- {
- use std::os::fd::AsRawFd;
-
- let mut writer = writer;
- let writer_fd = writer.as_raw_fd();
- let io_stop = io_stop.clone();
- let pty_write_lock = pty_write_lock.clone();
- tokio::task::spawn_blocking(move || {
- let rt = tokio::runtime::Handle::current();
- while let Some(bytes) = rt.block_on(input_rx.recv()) {
- if io_stop.load(Ordering::Acquire) {
- break;
- }
- if let Err(e) = write_pty_bytes_locked(
- &mut writer,
- writer_fd,
- &bytes,
- &io_stop,
- &pty_write_lock,
- ) {
- warn!(pane = pane_id.raw(), err = %e, "handoff pty write failed");
- break;
+ for content in result.clipboard_writes {
+ if let Err(err) = read_events.try_send(AppEvent::ClipboardWrite { content }) {
+ warn!(
+ pane = pane_id.raw(),
+ err = %err,
+ "failed to queue OSC 52 clipboard write"
+ );
}
}
- debug!(pane = pane_id.raw(), "handoff writer task exiting");
- });
- }
-
- let (resize_tx, mut resize_rx) =
- watch::channel::<(u16, u16, u32, u32)>((rows, cols, cell_width_px, cell_height_px));
- {
- let io_stop = io_stop.clone();
- let resize_fd = resize_fd.into_raw_fd();
- tokio::task::spawn_blocking(move || {
- let rt = tokio::runtime::Handle::current();
- let mut last_size = (rows, cols, cell_width_px, cell_height_px);
- while rt.block_on(resize_rx.changed()).is_ok() {
- if io_stop.load(Ordering::Acquire) {
- break;
- }
- let (rows, cols, cell_width_px, cell_height_px) =
- *resize_rx.borrow_and_update();
- if (rows, cols, cell_width_px, cell_height_px) == last_size {
- continue;
- }
- last_size = (rows, cols, cell_width_px, cell_height_px);
- if let Err(e) =
- resize_pty_fd(resize_fd, rows, cols, cell_width_px, cell_height_px)
- {
- warn!(pane = pane_id.raw(), err = %e, rows, cols, "handoff pty resize failed");
- }
+ PtyReadResult {
+ terminal_responses: result.terminal_responses,
}
- let _ = unsafe { libc::close(resize_fd) };
});
- }
+ let exit_events = events.clone();
+ let on_reader_exit = Box::new(move || {
+ let _ = rt.block_on(exit_events.send(AppEvent::PaneDied { pane_id }));
+ debug!(pane = pane_id.raw(), "handoff PTY actor exiting");
+ });
+ PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
+ pane_id: pane_id.raw(),
+ master_fd,
+ initially_quiesced: true,
+ on_read,
+ on_reader_exit: Some(on_reader_exit),
+ })?)
+ };
let (detect_handle, detect_reset_notify, pending_release) =
spawn_basic_detection_task(pane_id, child_pid.clone(), terminal.clone(), events);
@@ -1439,17 +1267,9 @@ impl PaneRuntime {
Ok(Self {
pane_id,
terminal,
- sender: input_tx,
- resize_tx,
+ io,
current_size: Cell::new((rows, cols, cell_width_px, cell_height_px)),
child_pid,
- pty_master: None,
- raw_master_fd: Some(master_fd.into_raw_fd()),
- force_resize_fd: Some(force_resize_fd),
- io_stop,
- reader_paused,
- reader_pause_ack,
- reader_stopped_rx: Some(reader_stopped_rx),
kitty_keyboard_flags,
detect_reset_notify,
pending_release,
@@ -1471,21 +1291,9 @@ impl PaneRuntime {
spawn_error_message: &'static str,
initial_state: SpawnInitialState<'_>,
) -> std::io::Result<Self> {
- let pty_system = native_pty_system();
- let pair = pty_system
- .openpty(PtySize {
- rows,
- cols,
- pixel_width: 0,
- pixel_height: 0,
- })
- .map_err(|e| std::io::Error::other(e.to_string()))?;
-
- // --- Writer channel ---
- let (input_tx, mut input_rx) = mpsc::channel::<Bytes>(32);
-
crate::logging::pane_spawn_started(pane_id.raw(), rows, cols, scrollback_limit_bytes);
+ let (response_tx, _response_rx) = mpsc::channel::<Bytes>(1);
let mut terminal = crate::ghostty::Terminal::new(cols, rows, scrollback_limit_bytes)
.map_err(|e| std::io::Error::other(e.to_string()))?;
if crate::kitty_graphics::is_enabled() {
@@ -1493,7 +1301,7 @@ impl PaneRuntime {
.enable_kitty_graphics()
.map_err(|e| std::io::Error::other(e.to_string()))?;
}
- let pane_terminal = GhosttyPaneTerminal::new(terminal, input_tx.clone())?;
+ let pane_terminal = GhosttyPaneTerminal::new(terminal, response_tx.clone())?;
pane_terminal.apply_host_terminal_theme(host_terminal_theme);
if let Some(ansi) = initial_state.history_ansi {
pane_terminal.seed_history_ansi(ansi);
@@ -1501,47 +1309,27 @@ impl PaneRuntime {
let terminal = Arc::new(PaneTerminal::new(pane_terminal));
let kitty_keyboard_flags = Arc::new(AtomicU16::new(0));
- let master_fd = pair
- .master
- .as_raw_fd()
- .ok_or_else(|| std::io::Error::other("pty master fd is unavailable"))?;
- set_nonblocking(master_fd)?;
- let reader = file_from_duplicated_fd(master_fd)?;
- let writer = file_from_duplicated_fd(master_fd)?;
- let terminal_response_writer = file_from_duplicated_fd(master_fd)?;
- let force_resize_fd = duplicate_cloexec_fd(master_fd)?;
- let resize_fd = duplicate_cloexec_fd(master_fd)?;
- let io_stop = Arc::new(AtomicBool::new(false));
- let pty_write_lock = Arc::new(Mutex::new(()));
- let reader_paused = Arc::new(AtomicBool::new(false));
- let reader_pause_ack = Arc::new(AtomicBool::new(false));
- let (reader_stopped_tx, reader_stopped_rx) = std::sync::mpsc::channel();
+ let spawned = crate::pty::backend::spawn_with_portable_pty(rows, cols, cmd)
+ .inspect_err(|err| error!(pane = pane_id.raw(), err = %err, "{spawn_error_message}"))?;
// --- Child watcher task ---
let child_pid = Arc::new(AtomicU32::new(0));
{
let child_pid = child_pid.clone();
- let slave = pair.slave;
let events = events.clone();
let rt = tokio::runtime::Handle::current();
+ let mut child = spawned.child;
+ if let Some(pid) = child.process_id() {
+ child_pid.store(pid, Ordering::Release);
+ crate::logging::pane_spawned(pane_id.raw(), pid);
+ }
tokio::task::spawn_blocking(move || {
- match slave.spawn_command(cmd) {
- Ok(mut child) => {
- if let Some(pid) = child.process_id() {
- child_pid.store(pid, Ordering::Release);
- crate::logging::pane_spawned(pane_id.raw(), pid);
- }
- match child.wait() {
- Ok(status) => {
- let status_text = format!("{status:?}");
- crate::logging::pane_exited(pane_id.raw(), &status_text);
- }
- Err(e) => {
- crate::logging::pane_exit_failed(pane_id.raw(), &e.to_string())
- }
- }
+ match child.wait() {
+ Ok(status) => {
+ let status_text = format!("{status:?}");
+ crate::logging::pane_exited(pane_id.raw(), &status_text);
}
- Err(e) => error!(pane = pane_id.raw(), err = %e, "{spawn_error_message}"),
+ Err(e) => crate::logging::pane_exit_failed(pane_id.raw(), &e.to_string()),
}
// Use blocking send — PaneDied is critical, must not be dropped
if let Err(e) = rt.block_on(events.send(AppEvent::PaneDied { pane_id })) {
@@ -1550,103 +1338,52 @@ impl PaneRuntime {
});
}
- // --- Reader task: PTY → terminal backend + screen snapshot + terminal query responses ---
- {
- use std::os::fd::AsRawFd;
-
- let mut reader = reader;
- let reader_fd = reader.as_raw_fd();
- let mut terminal_response_writer = terminal_response_writer;
- let terminal_response_fd = terminal_response_writer.as_raw_fd();
+ let io = {
let terminal = terminal.clone();
- let response_writer = input_tx.clone();
+ let response_writer = response_tx.clone();
let render_notify = render_notify.clone();
let render_dirty = render_dirty.clone();
let child_pid = child_pid.clone();
let events = events.clone();
- let io_stop = io_stop.clone();
- let pty_write_lock = pty_write_lock.clone();
- let reader_paused = reader_paused.clone();
- let reader_pause_ack = reader_pause_ack.clone();
let rt = tokio::runtime::Handle::current();
- tokio::task::spawn_blocking(move || {
- let mut buf = [0u8; 8192];
- loop {
- if io_stop.load(Ordering::Acquire) {
- break;
- }
- if reader_paused.load(Ordering::Acquire) {
- reader_pause_ack.store(true, Ordering::Release);
- std::thread::sleep(std::time::Duration::from_millis(5));
- continue;
- }
- reader_pause_ack.store(false, Ordering::Release);
- match poll_read_ready(reader_fd, 50) {
- Ok(true) => {}
- Ok(false) => continue,
- Err(e) => {
- debug!(pane = pane_id.raw(), err = %e, "pty reader poll failed");
- break;
- }
- }
- match reader.read(&mut buf) {
- Ok(0) => break,
- Err(e) if e.kind() == std::io::ErrorKind::WouldBlock => continue,
- Err(e) => {
- debug!(pane = pane_id.raw(), err = %e, "pty reader closed");
- break;
- }
- Ok(n) => {
- let shell_pid = child_pid.load(Ordering::Acquire);
- let result = terminal.process_pty_bytes(
- pane_id,
- shell_pid,
- &buf[..n],
- &response_writer,
- );
- for response in result.terminal_responses {
- if let Err(err) = write_pty_bytes_locked(
- &mut terminal_response_writer,
- terminal_response_fd,
- &response,
- &io_stop,
- &pty_write_lock,
- ) {
- warn!(pane = pane_id.raw(), err = %err, "terminal response write failed");
- break;
- }
- }
- if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
- render_notify.notify_one();
- }
- if let Some(delay) = result.render_delay {
- let render_notify = render_notify.clone();
- let render_dirty = render_dirty.clone();
- rt.spawn(async move {
- tokio::time::sleep(delay).await;
- if !render_dirty.swap(true, Ordering::AcqRel) {
- render_notify.notify_one();
- }
- });
- }
- for content in result.clipboard_writes {
- if let Err(err) =
- rt.block_on(events.send(AppEvent::ClipboardWrite { content }))
- {
- warn!(
- pane = pane_id.raw(),
- err = %err,
- "failed to send OSC 52 clipboard write"
- );
- }
- }
+ let on_read = Box::new(move |bytes: &[u8]| {
+ let shell_pid = child_pid.load(Ordering::Acquire);
+ let result =
+ terminal.process_pty_bytes(pane_id, shell_pid, bytes, &response_writer);
+ if result.request_render && !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
+ }
+ if let Some(delay) = result.render_delay {
+ let render_notify = render_notify.clone();
+ let render_dirty = render_dirty.clone();
+ rt.spawn(async move {
+ tokio::time::sleep(delay).await;
+ if !render_dirty.swap(true, Ordering::AcqRel) {
+ render_notify.notify_one();
}
+ });
+ }
+ for content in result.clipboard_writes {
+ if let Err(err) = events.try_send(AppEvent::ClipboardWrite { content }) {
+ warn!(
+ pane = pane_id.raw(),
+ err = %err,
+ "failed to send OSC 52 clipboard write"
+ );
}
}
- let _ = reader_stopped_tx.send(());
- debug!(pane = pane_id.raw(), "reader task exiting");
+ PtyReadResult {
+ terminal_responses: result.terminal_responses,
+ }
});
- }
+ PaneRuntimeIo::Actor(PtyIoActor::spawn(PtyIoActorConfig {
+ pane_id: pane_id.raw(),
+ master_fd: spawned.master_fd,
+ initially_quiesced: false,
+ on_read,
+ on_reader_exit: None,
+ })?)
+ };
// --- Detection task ---
let (detect_handle, detect_reset_notify, pending_release) = {
@@ -1934,76 +1671,12 @@ impl PaneRuntime {
(handle.abort_handle(), detect_reset_notify, pending_release)
};
- // --- Writer task: channel → PTY ---
- {
- use std::os::fd::AsRawFd;
-
- let mut writer = writer;
- let writer_fd = writer.as_raw_fd();
- let io_stop = io_stop.clone();
- let pty_write_lock = pty_write_lock.clone();
- tokio::task::spawn_blocking(move || {
- let rt = tokio::runtime::Handle::current();
- while let Some(bytes) = rt.block_on(input_rx.recv()) {
- if io_stop.load(Ordering::Acquire) {
- break;
- }
- if let Err(e) = write_pty_bytes_locked(
- &mut writer,
- writer_fd,
- &bytes,
- &io_stop,
- &pty_write_lock,
- ) {
- warn!(pane = pane_id.raw(), err = %e, "pty write failed");
- break;
- }
- }
- debug!(pane = pane_id.raw(), "writer task exiting");
- });
- }
-
- // --- Resize task ---
- let (resize_tx, mut resize_rx) = watch::channel::<(u16, u16, u32, u32)>((rows, cols, 0, 0));
- {
- let io_stop = io_stop.clone();
- tokio::task::spawn_blocking(move || {
- let rt = tokio::runtime::Handle::current();
- let mut last_size = (rows, cols, 0, 0);
- while rt.block_on(resize_rx.changed()).is_ok() {
- if io_stop.load(Ordering::Acquire) {
- break;
- }
- let (rows, cols, cell_width_px, cell_height_px) =
- *resize_rx.borrow_and_update();
- if (rows, cols, cell_width_px, cell_height_px) == last_size {
- continue;
- }
- last_size = (rows, cols, cell_width_px, cell_height_px);
- if let Err(e) =
- resize_pty_fd(resize_fd, rows, cols, cell_width_px, cell_height_px)
- {
- warn!(pane = pane_id.raw(), err = %e, rows, cols, "pty resize failed");
- }
- }
- let _ = unsafe { libc::close(resize_fd) };
- });
- }
-
Ok(Self {
pane_id,
terminal,
- sender: input_tx,
- resize_tx,
+ io,
current_size: Cell::new((rows, cols, 0, 0)),
child_pid,
- pty_master: Some(pair.master),
- raw_master_fd: None,
- force_resize_fd: Some(force_resize_fd),
- io_stop,
- reader_paused,
- reader_pause_ack,
- reader_stopped_rx: Some(reader_stopped_rx),
kitty_keyboard_flags,
detect_reset_notify,
pending_release,
@@ -2038,37 +1711,13 @@ impl PaneRuntime {
self.current_size.set(size);
self.terminal
.resize(rows, cols, cell_width_px, cell_height_px);
- let _ = self.resize_tx.send(size);
+ self.io.resize(rows, cols, cell_width_px, cell_height_px);
}
pub fn nudge_child_redraw_after_handoff(&self) {
- let Some(fd) = self.force_resize_fd else {
- return;
- };
let (rows, cols, cell_width_px, cell_height_px) = self.current_size.get();
- let nudge = if rows > 2 {
- (rows - 1, cols, cell_width_px, cell_height_px)
- } else {
- (
- rows,
- cols.saturating_sub(1).max(4),
- cell_width_px,
- cell_height_px,
- )
- };
- if nudge == (rows, cols, cell_width_px, cell_height_px) {
- return;
- }
-
- let Ok(fd) = duplicate_cloexec_fd(fd) else {
- return;
- };
- std::thread::spawn(move || {
- let _ = resize_pty_fd(fd, nudge.0, nudge.1, nudge.2, nudge.3);
- std::thread::sleep(std::time::Duration::from_millis(30));
- let _ = resize_pty_fd(fd, rows, cols, cell_width_px, cell_height_px);
- let _ = unsafe { libc::close(fd) };
- });
+ self.io
+ .nudge_child_redraw_after_handoff(rows, cols, cell_width_px, cell_height_px);
}
/// Scroll up by N lines (into scrollback history).
@@ -2180,11 +1829,11 @@ impl PaneRuntime {
}
pub async fn send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::SendError<Bytes>> {
- self.sender.send(bytes).await
+ self.io.send_bytes(bytes).await
}
pub fn try_send_bytes(&self, bytes: Bytes) -> Result<(), mpsc::error::TrySendError<Bytes>> {
- self.sender.try_send(bytes)
+ self.io.try_send_bytes(bytes)
}
pub async fn send_paste(&self, text: String) -> Result<(), mpsc::error::SendError<Bytes>> {
@@ -2289,8 +1938,8 @@ impl PaneRuntime {
let pid = self.child_pid.load(Ordering::Acquire);
let shell_cwd = usable_process_cwd(pid);
let foreground_pgid = self
- .master_fd()
- .and_then(crate::platform::foreground_process_group_id_for_tty_fd)
+ .io
+ .foreground_process_group_id()
.or_else(|| crate::platform::foreground_process_group_id(pid));
let leader_cwd = foreground_pgid.and_then(usable_process_cwd);
@@ -2355,17 +2004,12 @@ impl PaneRuntime {
terminal: Arc::new(PaneTerminal::new(
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
)),
- sender: tx,
- resize_tx,
+ io: PaneRuntimeIo::TestChannel {
+ sender: tx,
+ resize_tx,
+ },
current_size: Cell::new((rows, cols, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
- pty_master: None,
- raw_master_fd: None,
- force_resize_fd: None,
- io_stop: Arc::new(AtomicBool::new(false)),
- reader_paused: Arc::new(AtomicBool::new(false)),
- reader_pause_ack: Arc::new(AtomicBool::new(false)),
- reader_stopped_rx: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
@@ -2774,17 +2418,12 @@ mod tests {
terminal: Arc::new(PaneTerminal::new(
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
)),
- sender: tx,
- resize_tx,
+ io: PaneRuntimeIo::TestChannel {
+ sender: tx,
+ resize_tx,
+ },
current_size: Cell::new((80, 24, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
- pty_master: None,
- raw_master_fd: None,
- force_resize_fd: None,
- io_stop: Arc::new(AtomicBool::new(false)),
- reader_paused: Arc::new(AtomicBool::new(false)),
- reader_pause_ack: Arc::new(AtomicBool::new(false)),
- reader_stopped_rx: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
@@ -2806,17 +2445,12 @@ mod tests {
terminal: Arc::new(PaneTerminal::new(
GhosttyPaneTerminal::new(terminal, tx.clone()).unwrap(),
)),
- sender: tx,
- resize_tx,
+ io: PaneRuntimeIo::TestChannel {
+ sender: tx,
+ resize_tx,
+ },
current_size: Cell::new((80, 24, 0, 0)),
child_pid: Arc::new(AtomicU32::new(0)),
- pty_master: None,
- raw_master_fd: None,
- force_resize_fd: None,
- io_stop: Arc::new(AtomicBool::new(false)),
- reader_paused: Arc::new(AtomicBool::new(false)),
- reader_pause_ack: Arc::new(AtomicBool::new(false)),
- reader_stopped_rx: None,
kitty_keyboard_flags: Arc::new(AtomicU16::new(0)),
detect_reset_notify: Arc::new(Notify::new()),
pending_release: Arc::new(Mutex::new(None)),
diff --git a/src/pty/actor.rs b/src/pty/actor.rs
new file mode 100644
index 00000000..660a7d1c
--- /dev/null
+++ b/src/pty/actor.rs
@@ -0,0 +1,996 @@
+use std::{
+ collections::VecDeque,
+ io::{Read, Write},
+ os::fd::{AsRawFd, OwnedFd, RawFd},
+ sync::{mpsc as std_mpsc, Arc, Mutex},
+ time::{Duration, Instant},
+};
+
+use bytes::Bytes;
+use tokio::sync::mpsc::{self, error::TryRecvError as DataTryRecvError};
+use tracing::{debug, warn};
+
+use crate::pty::fd;
+
+const ACTOR_POLL_MS: i32 = 50;
+const ACTOR_COMMAND_BUFFER: usize = 1024;
+const HANDOFF_DRAIN_TIMEOUT: Duration = Duration::from_secs(2);
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum ActorState {
+ Running,
+ Quiesced,
+ Released,
+}
+
+pub(crate) struct PtyReadResult {
+ pub terminal_responses: Vec<Bytes>,
+}
+
+impl PtyReadResult {
+ #[cfg(test)]
+ pub(crate) fn empty() -> Self {
+ Self {
+ terminal_responses: Vec::new(),
+ }
+ }
+}
+
+type ReadCallback = Box<dyn FnMut(&[u8]) -> PtyReadResult + Send + 'static>;
+type ReaderExitCallback = Box<dyn FnOnce() + Send + 'static>;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+struct PtyResize {
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+}
+
+#[derive(Default)]
+struct SharedPtyControls {
+ resize: Option<PtyResize>,
+ nudge: Option<PtyResize>,
+}
+
+pub(crate) struct PtyIoActorConfig {
+ pub pane_id: u32,
+ pub master_fd: OwnedFd,
+ pub initially_quiesced: bool,
+ pub on_read: ReadCallback,
+ pub on_reader_exit: Option<ReaderExitCallback>,
+}
+
+enum PtyIoDataCommand {
+ Wake,
+ WriteUserInput(Bytes),
+}
+
+enum PtyIoControlCommand {
+ BeginHandoff(std_mpsc::Sender<std::io::Result<()>>),
+ DuplicateForHandoff(std_mpsc::Sender<std::io::Result<RawFd>>),
+ ForegroundProcessGroup(std_mpsc::Sender<Option<u32>>),
+ RollbackHandoff(std_mpsc::Sender<std::io::Result<()>>),
+ ReleaseAfterCommit(std_mpsc::Sender<std::io::Result<()>>),
+ Shutdown,
+}
+
+#[derive(Clone)]
+pub(crate) struct PtyIoActorHandle {
+ data_tx: mpsc::Sender<PtyIoDataCommand>,
+ control_tx: std_mpsc::Sender<PtyIoControlCommand>,
+ user_writes: Arc<Mutex<UserWriteGate>>,
+ controls: Arc<Mutex<SharedPtyControls>>,
+}
+
+#[derive(Debug)]
+struct UserWriteGate {
+ accepting: bool,
+}
+
+impl PtyIoActorHandle {
+ pub(crate) async fn write_user_input(
+ &self,
+ bytes: Bytes,
+ ) -> Result<(), mpsc::error::SendError<Bytes>> {
+ {
+ let user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if !user_writes.accepting {
+ return Err(mpsc::error::SendError(bytes));
+ }
+ }
+
+ let permit = match self.data_tx.reserve().await {
+ Ok(permit) => permit,
+ Err(_) => return Err(mpsc::error::SendError(bytes)),
+ };
+
+ let user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if !user_writes.accepting {
+ return Err(mpsc::error::SendError(bytes));
+ }
+ permit.send(PtyIoDataCommand::WriteUserInput(bytes));
+ Ok(())
+ }
+
+ pub(crate) fn try_write_user_input(
+ &self,
+ bytes: Bytes,
+ ) -> Result<(), mpsc::error::TrySendError<Bytes>> {
+ let user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if !user_writes.accepting {
+ return Err(mpsc::error::TrySendError::Closed(bytes));
+ }
+ self.data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(bytes))
+ .map_err(|err| match err {
+ mpsc::error::TrySendError::Full(PtyIoDataCommand::WriteUserInput(bytes)) => {
+ mpsc::error::TrySendError::Full(bytes)
+ }
+ mpsc::error::TrySendError::Closed(PtyIoDataCommand::WriteUserInput(bytes)) => {
+ mpsc::error::TrySendError::Closed(bytes)
+ }
+ mpsc::error::TrySendError::Full(PtyIoDataCommand::Wake) => {
+ mpsc::error::TrySendError::Full(Bytes::new())
+ }
+ mpsc::error::TrySendError::Closed(PtyIoDataCommand::Wake) => {
+ mpsc::error::TrySendError::Closed(Bytes::new())
+ }
+ })
+ }
+
+ pub(crate) fn resize(&self, rows: u16, cols: u16, cell_width_px: u32, cell_height_px: u32) {
+ {
+ let mut controls = self
+ .controls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ controls.resize = Some(PtyResize {
+ rows,
+ cols,
+ cell_width_px,
+ cell_height_px,
+ });
+ }
+ let _ = self.data_tx.try_send(PtyIoDataCommand::Wake);
+ }
+
+ pub(crate) fn nudge_child_redraw_after_handoff(
+ &self,
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+ ) {
+ {
+ let mut controls = self
+ .controls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ controls.nudge = Some(PtyResize {
+ rows,
+ cols,
+ cell_width_px,
+ cell_height_px,
+ });
+ }
+ let _ = self.data_tx.try_send(PtyIoDataCommand::Wake);
+ }
+
+ pub(crate) fn begin_handoff(&self, timeout: Duration) -> std::io::Result<()> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = false;
+ if self
+ .control_tx
+ .send(PtyIoControlCommand::BeginHandoff(reply_tx))
+ .is_err()
+ {
+ user_writes.accepting = true;
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::BrokenPipe,
+ "pty actor closed",
+ ));
+ }
+ }
+ match reply_rx.recv_timeout(timeout) {
+ Ok(Ok(())) => Ok(()),
+ Ok(Err(err)) => {
+ let _ = self.rollback_handoff();
+ Err(err)
+ }
+ Err(_) => {
+ let _ = self.rollback_handoff();
+ Err(std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY actor to quiesce",
+ ))
+ }
+ }
+ }
+
+ pub(crate) fn duplicate_for_handoff(&self) -> std::io::Result<RawFd> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::DuplicateForHandoff(reply_tx))
+ .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
+ reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY handoff duplicate",
+ )
+ })?
+ }
+
+ pub(crate) fn foreground_process_group_id(&self) -> Option<u32> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::ForegroundProcessGroup(reply_tx))
+ .ok()?;
+ reply_rx.recv_timeout(Duration::from_secs(1)).ok()?
+ }
+
+ pub(crate) fn rollback_handoff(&self) -> std::io::Result<()> {
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::RollbackHandoff(reply_tx))
+ .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
+ let result = reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY handoff rollback",
+ )
+ })?;
+ if result.is_ok() {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = true;
+ }
+ result
+ }
+
+ pub(crate) fn release_after_commit(&self) -> std::io::Result<()> {
+ {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = false;
+ }
+ let (reply_tx, reply_rx) = std_mpsc::channel();
+ self.control_tx
+ .send(PtyIoControlCommand::ReleaseAfterCommit(reply_tx))
+ .map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed"))?;
+ reply_rx.recv_timeout(Duration::from_secs(1)).map_err(|_| {
+ std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out waiting for PTY actor release",
+ )
+ })?
+ }
+
+ pub(crate) fn shutdown(&self) {
+ {
+ let mut user_writes = self
+ .user_writes
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ user_writes.accepting = false;
+ }
+ let _ = self.control_tx.send(PtyIoControlCommand::Shutdown);
+ }
+}
+
+pub(crate) struct PtyIoActor;
+
+impl PtyIoActor {
+ pub(crate) fn spawn(config: PtyIoActorConfig) -> std::io::Result<PtyIoActorHandle> {
+ fd::set_cloexec(config.master_fd.as_raw_fd())?;
+ fd::set_nonblocking(config.master_fd.as_raw_fd())?;
+
+ let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER);
+ let (control_tx, control_rx) = std_mpsc::channel();
+ let user_writes = Arc::new(Mutex::new(UserWriteGate {
+ accepting: !config.initially_quiesced,
+ }));
+ let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ user_writes,
+ controls: Arc::clone(&controls),
+ };
+
+ let mut runner = PtyIoActorRunner {
+ pane_id: config.pane_id,
+ file: std::fs::File::from(config.master_fd),
+ data_rx,
+ control_rx,
+ state: if config.initially_quiesced {
+ ActorState::Quiesced
+ } else {
+ ActorState::Running
+ },
+ pending_writes: VecDeque::new(),
+ current_write_offset: 0,
+ controls,
+ on_read: config.on_read,
+ on_reader_exit: config.on_reader_exit,
+ };
+ std::thread::Builder::new()
+ .name(format!("herdr-pty-{}", config.pane_id))
+ .spawn(move || runner.run())
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+
+ Ok(handle)
+ }
+}
+
+struct PtyIoActorRunner {
+ pane_id: u32,
+ file: std::fs::File,
+ data_rx: mpsc::Receiver<PtyIoDataCommand>,
+ control_rx: std_mpsc::Receiver<PtyIoControlCommand>,
+ state: ActorState,
+ pending_writes: VecDeque<Bytes>,
+ current_write_offset: usize,
+ controls: Arc<Mutex<SharedPtyControls>>,
+ on_read: ReadCallback,
+ on_reader_exit: Option<ReaderExitCallback>,
+}
+
+impl PtyIoActorRunner {
+ fn run(&mut self) {
+ let mut should_exit = false;
+ while !should_exit {
+ should_exit = self.drain_commands();
+ if should_exit || self.state == ActorState::Released {
+ break;
+ }
+
+ self.apply_pending_controls();
+
+ if !self.pending_writes.is_empty() {
+ self.flush_pending_writes_once();
+ }
+
+ if self.state == ActorState::Running {
+ match fd::poll_read_ready(self.file.as_raw_fd(), ACTOR_POLL_MS) {
+ Ok(true) => {
+ if !self.read_once() {
+ break;
+ }
+ }
+ Ok(false) => {}
+ Err(err) => {
+ debug!(pane = self.pane_id, err = %err, "PTY actor poll failed");
+ break;
+ }
+ }
+ } else {
+ std::thread::sleep(Duration::from_millis(ACTOR_POLL_MS as u64));
+ }
+ }
+
+ if let Some(on_reader_exit) = self.on_reader_exit.take() {
+ on_reader_exit();
+ }
+ debug!(pane = self.pane_id, "PTY actor exiting");
+ }
+
+ fn drain_commands(&mut self) -> bool {
+ if self.drain_control_commands() {
+ return true;
+ }
+ self.drain_data_commands()
+ }
+
+ fn drain_control_commands(&mut self) -> bool {
+ let mut should_exit = false;
+ loop {
+ match self.control_rx.try_recv() {
+ Ok(command) => {
+ if self.handle_control_command(command) {
+ should_exit = true;
+ break;
+ }
+ }
+ Err(std_mpsc::TryRecvError::Empty) => break,
+ Err(std_mpsc::TryRecvError::Disconnected) => {
+ should_exit = true;
+ break;
+ }
+ }
+ }
+ should_exit
+ }
+
+ fn drain_data_commands(&mut self) -> bool {
+ let mut should_exit = false;
+ loop {
+ match self.data_rx.try_recv() {
+ Ok(command) => {
+ if self.handle_data_command(command) {
+ should_exit = true;
+ break;
+ }
+ }
+ Err(DataTryRecvError::Empty) => break,
+ Err(DataTryRecvError::Disconnected) => {
+ should_exit = true;
+ break;
+ }
+ }
+ }
+ should_exit
+ }
+
+ fn handle_data_command(&mut self, command: PtyIoDataCommand) -> bool {
+ match command {
+ PtyIoDataCommand::Wake => {}
+ PtyIoDataCommand::WriteUserInput(bytes) => {
+ if self.state == ActorState::Running {
+ self.pending_writes.push_back(bytes);
+ }
+ }
+ }
+ false
+ }
+
+ fn handle_control_command(&mut self, command: PtyIoControlCommand) -> bool {
+ match command {
+ PtyIoControlCommand::BeginHandoff(reply) => {
+ let result = self.begin_handoff();
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::DuplicateForHandoff(reply) => {
+ let result = if self.state == ActorState::Quiesced {
+ fd::duplicate_cloexec_fd(self.file.as_raw_fd())
+ } else {
+ Err(std::io::Error::other(
+ "PTY actor must be quiesced before handoff duplication",
+ ))
+ };
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::ForegroundProcessGroup(reply) => {
+ let result =
+ crate::platform::foreground_process_group_id_for_tty_fd(self.file.as_raw_fd());
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::RollbackHandoff(reply) => {
+ let result = if self.state == ActorState::Released {
+ Err(std::io::Error::new(
+ std::io::ErrorKind::BrokenPipe,
+ "PTY actor was released before handoff rollback",
+ ))
+ } else {
+ self.state = ActorState::Running;
+ Ok(())
+ };
+ let _ = reply.send(result);
+ }
+ PtyIoControlCommand::ReleaseAfterCommit(reply) => {
+ self.state = ActorState::Released;
+ self.pending_writes.clear();
+ let _ = reply.send(Ok(()));
+ return true;
+ }
+ PtyIoControlCommand::Shutdown => return true,
+ }
+ false
+ }
+
+ fn begin_handoff(&mut self) -> std::io::Result<()> {
+ self.drain_pre_quiesce_commands();
+ self.apply_pending_controls();
+ if self.state == ActorState::Released {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::BrokenPipe,
+ "PTY actor was released before handoff quiesce",
+ ));
+ }
+ let deadline = Instant::now() + HANDOFF_DRAIN_TIMEOUT;
+ while !self.pending_writes.is_empty() {
+ if Instant::now() >= deadline {
+ return Err(std::io::Error::new(
+ std::io::ErrorKind::TimedOut,
+ "timed out draining PTY writes before handoff",
+ ));
+ }
+ self.flush_pending_writes_once();
+ }
+ self.state = ActorState::Quiesced;
+ Ok(())
+ }
+
+ fn drain_pre_quiesce_commands(&mut self) {
+ loop {
+ match self.data_rx.try_recv() {
+ Ok(PtyIoDataCommand::Wake) => {}
+ Ok(PtyIoDataCommand::WriteUserInput(bytes)) => {
+ if self.state != ActorState::Released {
+ self.pending_writes.push_back(bytes);
+ }
+ }
+ Err(DataTryRecvError::Empty | DataTryRecvError::Disconnected) => break,
+ }
+ }
+ }
+
+ fn apply_pending_controls(&mut self) {
+ let (resize, nudge) = {
+ let mut controls = self
+ .controls
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ (controls.resize.take(), controls.nudge.take())
+ };
+ if self.state == ActorState::Released {
+ return;
+ }
+ if let Some(resize) = resize {
+ self.resize(resize);
+ }
+ if let Some(nudge) = nudge {
+ self.nudge(nudge);
+ }
+ }
+
+ fn read_once(&mut self) -> bool {
+ let mut buf = [0u8; 8192];
+ match self.file.read(&mut buf) {
+ Ok(0) => false,
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => true,
+ Err(err) if err.kind() == std::io::ErrorKind::Interrupted => true,
+ Err(err) => {
+ debug!(pane = self.pane_id, err = %err, "PTY actor read failed");
+ false
+ }
+ Ok(n) => {
+ let result = (self.on_read)(&buf[..n]);
+ for response in result.terminal_responses {
+ if self.state != ActorState::Released {
+ self.pending_writes.push_back(response);
+ }
+ }
+ true
+ }
+ }
+ }
+
+ fn flush_pending_writes_once(&mut self) {
+ while let Some(bytes) = self.pending_writes.front() {
+ let chunk = &bytes[self.current_write_offset..];
+ match self.file.write(chunk) {
+ Ok(0) => {
+ warn!(pane = self.pane_id, "PTY actor write returned zero bytes");
+ return;
+ }
+ Ok(written) => {
+ self.current_write_offset += written;
+ if self.current_write_offset >= bytes.len() {
+ self.pending_writes.pop_front();
+ self.current_write_offset = 0;
+ }
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => {
+ let _ = fd::poll_write_ready(self.file.as_raw_fd(), ACTOR_POLL_MS);
+ return;
+ }
+ Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return,
+ Err(err) => {
+ warn!(pane = self.pane_id, err = %err, "PTY actor write failed");
+ self.pending_writes.clear();
+ self.current_write_offset = 0;
+ return;
+ }
+ }
+ }
+ let _ = self.file.flush();
+ }
+
+ fn resize(&self, resize: PtyResize) {
+ self.log_resize_result(fd::resize_pty_fd(
+ self.file.as_raw_fd(),
+ resize.rows,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ ));
+ }
+
+ fn nudge(&mut self, resize: PtyResize) {
+ if self.state == ActorState::Released {
+ return;
+ }
+ let nudge = if resize.rows > 2 {
+ (
+ resize.rows - 1,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ )
+ } else {
+ (
+ resize.rows,
+ resize.cols.saturating_sub(1).max(4),
+ resize.cell_width_px,
+ resize.cell_height_px,
+ )
+ };
+ if nudge
+ == (
+ resize.rows,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ )
+ {
+ return;
+ }
+ self.log_resize_result(fd::resize_pty_fd(
+ self.file.as_raw_fd(),
+ nudge.0,
+ nudge.1,
+ nudge.2,
+ nudge.3,
+ ));
+ std::thread::sleep(Duration::from_millis(30));
+ self.log_resize_result(fd::resize_pty_fd(
+ self.file.as_raw_fd(),
+ resize.rows,
+ resize.cols,
+ resize.cell_width_px,
+ resize.cell_height_px,
+ ));
+ }
+
+ fn log_resize_result(&self, result: std::io::Result<()>) {
+ if let Err(err) = result {
+ debug!(pane = self.pane_id, err = %err, "PTY resize failed");
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::{
+ io::{Read, Write},
+ os::fd::{FromRawFd, IntoRawFd},
+ os::unix::net::UnixStream,
+ };
+
+ fn actor_with_socket_pair(
+ initially_quiesced: bool,
+ ) -> (PtyIoActorHandle, UnixStream, std_mpsc::Receiver<Bytes>) {
+ let (actor_socket, peer) = UnixStream::pair().expect("socket pair");
+ actor_socket
+ .set_nonblocking(true)
+ .expect("actor socket nonblocking");
+ peer.set_read_timeout(Some(Duration::from_secs(1)))
+ .expect("peer timeout");
+ let owned = unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) };
+ let (read_tx, read_rx) = std_mpsc::channel();
+ let handle = PtyIoActor::spawn(PtyIoActorConfig {
+ pane_id: 1,
+ master_fd: owned,
+ initially_quiesced,
+ on_read: Box::new(move |bytes| {
+ read_tx
+ .send(Bytes::copy_from_slice(bytes))
+ .expect("read callback receiver alive");
+ PtyReadResult::empty()
+ }),
+ on_reader_exit: None,
+ })
+ .expect("actor spawn");
+ (handle, peer, read_rx)
+ }
+
+ #[test]
+ fn actor_writes_user_input_to_owned_fd() {
+ let (handle, mut peer, _read_rx) = actor_with_socket_pair(false);
+
+ handle
+ .try_write_user_input(Bytes::from_static(b"hello"))
+ .expect("write command accepted");
+
+ let mut buf = [0u8; 5];
+ peer.read_exact(&mut buf).expect("peer receives write");
+ assert_eq!(&buf, b"hello");
+ handle.shutdown();
+ }
+
+ #[test]
+ fn actor_delivers_fd_reads_to_callback() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ peer.write_all(b"from-peer").expect("peer write");
+
+ let read = read_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("actor read callback");
+ assert_eq!(read, Bytes::from_static(b"from-peer"));
+ handle.shutdown();
+ }
+
+ #[test]
+ fn begin_handoff_stops_reads_and_rejects_user_writes_until_rollback() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ handle
+ .begin_handoff(Duration::from_secs(1))
+ .expect("handoff quiesced");
+ assert!(handle
+ .try_write_user_input(Bytes::from_static(b"blocked"))
+ .is_err());
+
+ peer.write_all(b"held").expect("peer write during quiesce");
+ assert!(
+ read_rx.recv_timeout(Duration::from_millis(150)).is_err(),
+ "actor must not read while quiesced"
+ );
+
+ handle.rollback_handoff().expect("rollback resumes actor");
+ let read = read_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("actor reads held bytes after rollback");
+ assert_eq!(read, Bytes::from_static(b"held"));
+
+ handle
+ .try_write_user_input(Bytes::from_static(b"after"))
+ .expect("write accepted after rollback");
+ let mut buf = [0u8; 5];
+ peer.read_exact(&mut buf).expect("peer receives after");
+ assert_eq!(&buf, b"after");
+ handle.shutdown();
+ }
+
+ #[test]
+ fn duplicate_for_handoff_requires_quiesced_actor() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ assert!(handle.duplicate_for_handoff().is_err());
+ handle
+ .begin_handoff(Duration::from_secs(1))
+ .expect("handoff quiesced");
+ let duplicate = handle
+ .duplicate_for_handoff()
+ .expect("handoff duplicate created");
+ assert!(duplicate >= 0);
+ unsafe {
+ libc::close(duplicate);
+ }
+ handle.rollback_handoff().expect("rollback resumes actor");
+
+ peer.write_all(b"still-live").expect("peer write");
+ let read = read_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("actor still reads after duplicate closes");
+ assert_eq!(read, Bytes::from_static(b"still-live"));
+ handle.shutdown();
+ }
+
+ #[test]
+ fn resize_and_nudge_keep_latest_request_when_command_queue_is_full() {
+ let (data_tx, _data_rx) = mpsc::channel(1);
+ let (control_tx, _control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::Wake)
+ .expect("fill command queue");
+ let controls = Arc::new(Mutex::new(SharedPtyControls::default()));
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::clone(&controls),
+ };
+
+ handle.resize(20, 80, 8, 16);
+ handle.resize(40, 120, 9, 18);
+ handle.nudge_child_redraw_after_handoff(41, 121, 10, 20);
+
+ let controls = controls.lock().expect("controls lock");
+ assert_eq!(
+ controls.resize,
+ Some(PtyResize {
+ rows: 40,
+ cols: 120,
+ cell_width_px: 9,
+ cell_height_px: 18,
+ })
+ );
+ assert_eq!(
+ controls.nudge,
+ Some(PtyResize {
+ rows: 41,
+ cols: 121,
+ cell_width_px: 10,
+ cell_height_px: 20,
+ })
+ );
+ }
+
+ #[tokio::test]
+ async fn async_user_input_waits_for_queue_capacity() {
+ let (data_tx, mut data_rx) = mpsc::channel(1);
+ let (control_tx, _control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::Wake)
+ .expect("fill data queue");
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ };
+
+ let write = tokio::spawn(async move {
+ handle
+ .write_user_input(Bytes::from_static(b"wait-for-capacity"))
+ .await
+ });
+ tokio::time::sleep(Duration::from_millis(50)).await;
+ assert!(
+ !write.is_finished(),
+ "async input should wait for queue capacity"
+ );
+
+ assert!(matches!(data_rx.recv().await, Some(PtyIoDataCommand::Wake)));
+ write
+ .await
+ .expect("write task joins")
+ .expect("write succeeds after capacity opens");
+ match data_rx.recv().await {
+ Some(PtyIoDataCommand::WriteUserInput(bytes)) => {
+ assert_eq!(bytes, Bytes::from_static(b"wait-for-capacity"));
+ }
+ _ => panic!("expected queued user input"),
+ }
+ }
+
+ #[tokio::test]
+ async fn async_user_input_waiting_for_capacity_is_rejected_after_handoff_begins() {
+ let (data_tx, mut data_rx) = mpsc::channel(1);
+ let (control_tx, control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::Wake)
+ .expect("fill data queue");
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ };
+ let write_handle = handle.clone();
+ let write = tokio::spawn(async move {
+ write_handle
+ .write_user_input(Bytes::from_static(b"after-handoff-start"))
+ .await
+ });
+ tokio::time::sleep(Duration::from_millis(50)).await;
+
+ let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1)));
+ match control_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("handoff control command")
+ {
+ PtyIoControlCommand::BeginHandoff(reply) => {
+ reply.send(Ok(())).expect("handoff waiter alive");
+ }
+ _ => panic!("expected begin handoff command"),
+ }
+ handoff
+ .join()
+ .expect("handoff thread joins")
+ .expect("handoff succeeds");
+ assert!(matches!(data_rx.recv().await, Some(PtyIoDataCommand::Wake)));
+
+ let err = write.await.expect("write task joins").expect_err(
+ "write waiting for capacity must be rejected after handoff closes the input gate",
+ );
+ assert_eq!(err.0, Bytes::from_static(b"after-handoff-start"));
+ match tokio::time::timeout(Duration::from_millis(50), data_rx.recv()).await {
+ Err(_) | Ok(None) => {}
+ Ok(Some(_)) => panic!("rejected write must not be queued"),
+ }
+ }
+
+ #[test]
+ fn handoff_control_is_not_blocked_by_full_data_queue() {
+ let (data_tx, _data_rx) = mpsc::channel(1);
+ let (control_tx, control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::Wake)
+ .expect("fill data queue");
+ let handle = PtyIoActorHandle {
+ data_tx,
+ control_tx,
+ user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })),
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ };
+
+ let handoff = std::thread::spawn(move || handle.begin_handoff(Duration::from_secs(1)));
+ match control_rx
+ .recv_timeout(Duration::from_secs(1))
+ .expect("handoff control command")
+ {
+ PtyIoControlCommand::BeginHandoff(reply) => {
+ reply.send(Ok(())).expect("handoff waiter alive");
+ }
+ _ => panic!("expected begin handoff command"),
+ }
+
+ handoff
+ .join()
+ .expect("handoff thread joins")
+ .expect("handoff succeeds despite full data queue");
+ }
+
+ #[test]
+ fn begin_handoff_drains_user_writes_already_in_command_queue() {
+ let (actor_socket, mut peer) = UnixStream::pair().expect("socket pair");
+ actor_socket
+ .set_nonblocking(true)
+ .expect("actor socket nonblocking");
+ peer.set_read_timeout(Some(Duration::from_secs(1)))
+ .expect("peer timeout");
+ let (data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER);
+ let (_control_tx, control_rx) = std_mpsc::channel();
+ data_tx
+ .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static(
+ b"queued-before-ack",
+ )))
+ .expect("queued write");
+ let mut runner = PtyIoActorRunner {
+ pane_id: 1,
+ file: std::fs::File::from(unsafe { OwnedFd::from_raw_fd(actor_socket.into_raw_fd()) }),
+ data_rx,
+ control_rx,
+ state: ActorState::Running,
+ pending_writes: VecDeque::new(),
+ current_write_offset: 0,
+ controls: Arc::new(Mutex::new(SharedPtyControls::default())),
+ on_read: Box::new(|_| PtyReadResult::empty()),
+ on_reader_exit: None,
+ };
+
+ runner.begin_handoff().expect("handoff drains queued write");
+
+ let mut buf = [0u8; 17];
+ peer.read_exact(&mut buf)
+ .expect("queued write reaches peer before quiesce ack");
+ assert_eq!(&buf, b"queued-before-ack");
+ assert_eq!(runner.state, ActorState::Quiesced);
+ }
+
+ #[test]
+ fn release_after_commit_prevents_further_io() {
+ let (handle, mut peer, read_rx) = actor_with_socket_pair(false);
+
+ handle.release_after_commit().expect("actor released");
+ assert!(handle
+ .try_write_user_input(Bytes::from_static(b"blocked"))
+ .is_err());
+
+ let _ = peer.write_all(b"ignored");
+ assert!(read_rx.recv_timeout(Duration::from_millis(150)).is_err());
+ }
+}
diff --git a/src/pty/backend.rs b/src/pty/backend.rs
new file mode 100644
index 00000000..5871004a
--- /dev/null
+++ b/src/pty/backend.rs
@@ -0,0 +1,94 @@
+use std::os::fd::{FromRawFd, OwnedFd};
+
+use portable_pty::{native_pty_system, Child, CommandBuilder, PtySize};
+
+use crate::pty::fd;
+
+pub(crate) struct SpawnedPty {
+ pub master_fd: OwnedFd,
+ pub child: Box<dyn Child + Send + Sync>,
+}
+
+pub(crate) fn spawn_with_portable_pty(
+ rows: u16,
+ cols: u16,
+ cmd: CommandBuilder,
+) -> std::io::Result<SpawnedPty> {
+ let pty_system = native_pty_system();
+ let pair = pty_system
+ .openpty(PtySize {
+ rows,
+ cols,
+ pixel_width: 0,
+ pixel_height: 0,
+ })
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+ let master_fd = pair
+ .master
+ .as_raw_fd()
+ .ok_or_else(|| std::io::Error::other("pty master fd is unavailable"))?;
+ let actor_fd = fd::duplicate_cloexec_fd(master_fd)?;
+ let actor_fd = unsafe { OwnedFd::from_raw_fd(actor_fd) };
+ let child = pair
+ .slave
+ .spawn_command(cmd)
+ .map_err(|err| std::io::Error::other(err.to_string()))?;
+ drop(pair);
+
+ Ok(SpawnedPty {
+ master_fd: actor_fd,
+ child,
+ })
+}
+
+#[cfg(all(test, target_os = "linux"))]
+mod tests {
+ use super::*;
+ use std::sync::{Mutex, OnceLock};
+
+ fn pty_fd_test_lock() -> &'static Mutex<()> {
+ static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
+ LOCK.get_or_init(|| Mutex::new(()))
+ }
+
+ fn parent_pty_fd_targets() -> Vec<String> {
+ let Ok(entries) = std::fs::read_dir("/proc/self/fd") else {
+ return Vec::new();
+ };
+ let mut targets: Vec<String> = entries
+ .filter_map(Result::ok)
+ .filter_map(|entry| std::fs::read_link(entry.path()).ok())
+ .map(|target| target.to_string_lossy().into_owned())
+ .filter(|target| target.starts_with("/dev/pts/") || target == "/dev/ptmx")
+ .collect();
+ targets.sort();
+ targets
+ }
+
+ fn parent_pty_fd_count() -> usize {
+ parent_pty_fd_targets().len()
+ }
+
+ #[test]
+ fn portable_pty_setup_leaves_one_parent_pty_fd() {
+ let _guard = pty_fd_test_lock().lock().expect("pty fd test lock");
+ let before = parent_pty_fd_count();
+ let mut cmd = CommandBuilder::new("/bin/cat");
+ cmd.env(crate::HERDR_ENV_VAR, crate::HERDR_ENV_VALUE);
+
+ let mut spawned =
+ spawn_with_portable_pty(24, 80, cmd).expect("portable pty setup succeeds");
+ let after_spawn = parent_pty_fd_count();
+
+ assert_eq!(
+ after_spawn,
+ before + 1,
+ "portable-pty setup should leave only the Herdr-owned master fd in the parent: {:?}",
+ parent_pty_fd_targets()
+ );
+
+ let _ = spawned.child.kill();
+ let _ = spawned.child.wait();
+ drop(spawned.master_fd);
+ }
+}
diff --git a/src/pty/fd.rs b/src/pty/fd.rs
new file mode 100644
index 00000000..a8bf021e
--- /dev/null
+++ b/src/pty/fd.rs
@@ -0,0 +1,109 @@
+#[cfg(unix)]
+use std::os::fd::RawFd;
+
+#[cfg(unix)]
+pub(crate) fn duplicate_fd(fd: RawFd) -> std::io::Result<RawFd> {
+ let duplicated = unsafe { libc::dup(fd) };
+ if duplicated < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(duplicated)
+}
+
+#[cfg(unix)]
+pub(crate) fn set_cloexec(fd: RawFd) -> std::io::Result<()> {
+ let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) };
+ if flags < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ if unsafe { libc::fcntl(fd, libc::F_SETFD, flags | libc::FD_CLOEXEC) } < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+pub(crate) fn set_nonblocking(fd: RawFd) -> std::io::Result<()> {
+ let flags = unsafe { libc::fcntl(fd, libc::F_GETFL) };
+ if flags < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ if unsafe { libc::fcntl(fd, libc::F_SETFL, flags | libc::O_NONBLOCK) } < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+pub(crate) fn duplicate_cloexec_fd(fd: RawFd) -> std::io::Result<RawFd> {
+ let duplicated = duplicate_fd(fd)?;
+ if let Err(err) = set_cloexec(duplicated) {
+ let _ = unsafe { libc::close(duplicated) };
+ return Err(err);
+ }
+ Ok(duplicated)
+}
+
+#[cfg(unix)]
+pub(crate) fn poll_read_ready(fd: RawFd, timeout_ms: i32) -> std::io::Result<bool> {
+ let mut poll_fd = libc::pollfd {
+ fd,
+ events: libc::POLLIN,
+ revents: 0,
+ };
+ loop {
+ let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
+ if result < 0 {
+ let err = std::io::Error::last_os_error();
+ if err.kind() == std::io::ErrorKind::Interrupted {
+ continue;
+ }
+ return Err(err);
+ }
+ return Ok(result > 0 && (poll_fd.revents & (libc::POLLIN | libc::POLLHUP)) != 0);
+ }
+}
+
+#[cfg(unix)]
+pub(crate) fn poll_write_ready(fd: RawFd, timeout_ms: i32) -> std::io::Result<bool> {
+ let mut poll_fd = libc::pollfd {
+ fd,
+ events: libc::POLLOUT,
+ revents: 0,
+ };
+ loop {
+ let result = unsafe { libc::poll(&mut poll_fd, 1, timeout_ms) };
+ if result < 0 {
+ let err = std::io::Error::last_os_error();
+ if err.kind() == std::io::ErrorKind::Interrupted {
+ continue;
+ }
+ return Err(err);
+ }
+ return Ok(result > 0 && (poll_fd.revents & (libc::POLLOUT | libc::POLLHUP)) != 0);
+ }
+}
+
+#[cfg(unix)]
+pub(crate) fn resize_pty_fd(
+ fd: RawFd,
+ rows: u16,
+ cols: u16,
+ cell_width_px: u32,
+ cell_height_px: u32,
+) -> std::io::Result<()> {
+ let size = libc::winsize {
+ ws_row: rows,
+ ws_col: cols,
+ ws_xpixel: (cols as u32)
+ .saturating_mul(cell_width_px)
+ .min(u16::MAX as u32) as u16,
+ ws_ypixel: (rows as u32)
+ .saturating_mul(cell_height_px)
+ .min(u16::MAX as u32) as u16,
+ };
+ if unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &size) } < 0 {
+ return Err(std::io::Error::last_os_error());
+ }
+ Ok(())
+}
diff --git a/src/pty/mod.rs b/src/pty/mod.rs
new file mode 100644
index 00000000..74bbb124
--- /dev/null
+++ b/src/pty/mod.rs
@@ -0,0 +1,6 @@
+#[cfg(unix)]
+pub(crate) mod actor;
+#[cfg(unix)]
+pub(crate) mod backend;
+#[cfg(unix)]
+pub(crate) mod fd;