From 1cb8565a9c4fb4244e5d6a85de46074dd2f48cbe Mon Sep 17 00:00:00 2001 From: akbash-bot <300245827+akbash-bot@users.noreply.github.com> Date: Thu, 6 Aug 2026 23:11:49 +0000 Subject: [PATCH] fix(pty): process output alongside wake events refs #2449 --- docs/next/CHANGELOG.md | 1 + src/pty/actor/unix.rs | 79 +++++++++++++++++++++++++++++++++--------- 2 files changed, 63 insertions(+), 17 deletions(-) diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 5ec122d5c7..59028eb9cf 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -8,6 +8,7 @@ - The plugin marketplace now discovers valid manifests at repository roots and subdirectories, groups multiple plugins under each repository, and publishes their versions and exact default-branch commits. ### Fixed +- Fresh background workspaces now surface pane output reliably when PTY control activity coincides with process output. (#2449) - Configs containing the retired Herdr-written `ui.agent_panel_scope` setting no longer report it as an unknown key after upgrades. (#2292) - Claude Code confirmation prompts using `Enter to confirm ยท Esc to cancel` now report `blocked` instead of `idle`. (#2268) - Sidebar agent lists keep scrolling when differently sized clients are attached to the same session. (#2255, thanks @aiworkflowpro) diff --git a/src/pty/actor/unix.rs b/src/pty/actor/unix.rs index 599f02cb30..eb6ede2022 100644 --- a/src/pty/actor/unix.rs +++ b/src/pty/actor/unix.rs @@ -464,22 +464,9 @@ impl PtyIoActorRunner { ACTOR_IDLE_POLL_MS, ) { Ok(readiness) => { - if readiness.wake_ready { - if let Err(err) = fd::drain_wake_fd(self.wake_read_fd.as_raw_fd()) { - debug!(pane = self.pane_id, err = %err, "PTY actor wake drain failed"); - break; - } - continue; - } - if self.state == ActorState::Running - && readiness.pty_read_ready - && !self.read_once() - { + if !self.handle_poll_readiness(readiness) { break; } - if readiness.pty_write_ready && !self.pending_writes.is_empty() { - self.flush_pending_writes_once(); - } } Err(err) => { debug!(pane = self.pane_id, err = %err, "PTY actor poll failed"); @@ -494,6 +481,22 @@ impl PtyIoActorRunner { debug!(pane = self.pane_id, "PTY actor exiting"); } + fn handle_poll_readiness(&mut self, readiness: fd::PtyWakeReadiness) -> bool { + if readiness.wake_ready { + if let Err(err) = fd::drain_wake_fd(self.wake_read_fd.as_raw_fd()) { + debug!(pane = self.pane_id, err = %err, "PTY actor wake drain failed"); + return false; + } + } + if self.state == ActorState::Running && readiness.pty_read_ready && !self.read_once() { + return false; + } + if readiness.pty_write_ready && !self.pending_writes.is_empty() { + self.flush_pending_writes_once(); + } + true + } + fn drain_commands(&mut self) -> bool { if self.drain_control_commands() { return true; @@ -862,7 +865,7 @@ mod tests { (handle, peer, read_rx) } - fn actor_runner_for_unit_test() -> (PtyIoActorRunner, UnixStream) { + fn actor_runner_for_unit_test() -> (PtyIoActorRunner, UnixStream, fd::WakeWriter) { let (actor_socket, peer) = UnixStream::pair().expect("socket pair"); actor_socket .set_nonblocking(true) @@ -871,6 +874,7 @@ mod tests { let (_data_tx, data_rx) = mpsc::channel(ACTOR_COMMAND_BUFFER); let (_control_tx, control_rx) = std_mpsc::channel(); let wake_pipe = fd::create_wake_pipe().expect("wake pipe"); + let wake = wake_pipe.writer; let runner = PtyIoActorRunner { pane_id: 1, file: std::fs::File::from(owned), @@ -886,18 +890,59 @@ mod tests { on_reader_exit: None, poll_observer: None, }; - (runner, peer) + (runner, peer, wake) } #[test] fn actor_ignores_empty_user_input_write() { - let (mut runner, _peer) = actor_runner_for_unit_test(); + let (mut runner, _peer, _wake) = actor_runner_for_unit_test(); assert!(!runner.handle_data_command(PtyIoDataCommand::WriteUserInput(Bytes::new()))); assert!(runner.pending_writes.is_empty()); } + #[test] + fn actor_processes_pty_io_when_wake_is_also_ready() { + let (mut runner, mut peer, wake) = actor_runner_for_unit_test(); + peer.set_read_timeout(Some(Duration::from_secs(1))) + .expect("peer timeout"); + let (read_tx, read_rx) = std_mpsc::channel(); + runner.on_read = Box::new(move |bytes| { + read_tx + .send(Bytes::copy_from_slice(bytes)) + .expect("read callback receiver alive"); + PtyReadResult::empty() + }); + + peer.write_all(b"simultaneous-output") + .expect("peer writes output"); + runner.enqueue_write(Bytes::from_static(b"simultaneous-input")); + wake.wake().expect("wake actor"); + let readiness = fd::poll_pty_and_wake( + runner.file.as_raw_fd(), + runner.wake_read_fd.as_raw_fd(), + true, + true, + 100, + ) + .expect("poll succeeds"); + assert!(readiness.wake_ready); + assert!(readiness.pty_read_ready); + assert!(readiness.pty_write_ready); + + assert!(runner.handle_poll_readiness(readiness)); + assert_eq!( + read_rx + .recv_timeout(Duration::from_secs(1)) + .expect("PTY read"), + Bytes::from_static(b"simultaneous-output") + ); + let mut input = [0; 18]; + peer.read_exact(&mut input).expect("peer receives input"); + assert_eq!(&input, b"simultaneous-input"); + } + #[test] fn actor_writes_user_input_to_owned_fd() { let (handle, mut peer, _read_rx) = actor_with_socket_pair(false);