diff options
Diffstat (limited to 'src/pty')
| -rw-r--r-- | src/pty/actor.rs | 321 | ||||
| -rw-r--r-- | src/pty/actor/unix.rs | 607 |
2 files changed, 725 insertions, 203 deletions
diff --git a/src/pty/actor.rs b/src/pty/actor.rs index 6fde5051..39b1d29f 100644 --- a/src/pty/actor.rs +++ b/src/pty/actor.rs @@ -43,6 +43,24 @@ mod windows { pub on_reader_exit: Option<ReaderExitCallback>, } + enum PtyIoDataCommand { + WriteUserInput(Bytes), + SubmitUserInput { + text: Bytes, + enter: Bytes, + delay: Duration, + reply: std_mpsc::Sender<std::io::Result<()>>, + }, + } + + enum PtyIoWriteCommand { + Write(Bytes), + SubmissionPart { + bytes: Bytes, + reply: std_mpsc::Sender<std::io::Result<()>>, + }, + } + enum PtyIoControlCommand { Resize(PtyResizeRequest), Shutdown, @@ -50,40 +68,77 @@ mod windows { #[derive(Clone)] pub(crate) struct PtyIoActorHandle { - data_tx: mpsc::Sender<Bytes>, + data_tx: mpsc::Sender<PtyIoDataCommand>, control_tx: std_mpsc::Sender<PtyIoControlCommand>, - write_tx: std_mpsc::Sender<Bytes>, + write_tx: std_mpsc::Sender<PtyIoWriteCommand>, response_order: Arc<Mutex<()>>, accepting: Arc<Mutex<bool>>, } impl PtyIoActorHandle { - pub(crate) async fn write_user_input( + pub(crate) fn try_write_user_input( &self, bytes: Bytes, - ) -> Result<(), mpsc::error::SendError<Bytes>> { + ) -> Result<(), mpsc::error::TrySendError<Bytes>> { if !*self .accepting .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) { - return Err(mpsc::error::SendError(bytes)); + return Err(mpsc::error::TrySendError::Closed(bytes)); } - self.data_tx.send(bytes).await + self.data_tx + .try_send(PtyIoDataCommand::WriteUserInput(bytes)) + .map_err(|err| match err { + mpsc::error::TrySendError::Full(command) => { + let PtyIoDataCommand::WriteUserInput(bytes) = command else { + unreachable!("queued write returned another command") + }; + mpsc::error::TrySendError::Full(bytes) + } + mpsc::error::TrySendError::Closed(command) => { + let PtyIoDataCommand::WriteUserInput(bytes) = command else { + unreachable!("queued write returned another command") + }; + mpsc::error::TrySendError::Closed(bytes) + } + }) } - pub(crate) fn try_write_user_input( + pub(crate) fn queue_user_input_submission( &self, - bytes: Bytes, - ) -> Result<(), mpsc::error::TrySendError<Bytes>> { - if !*self + text: Bytes, + enter: Bytes, + delay: Duration, + ) -> std::io::Result<std_mpsc::Receiver<std::io::Result<()>>> { + let accepting = self .accepting .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()) - { - return Err(mpsc::error::TrySendError::Closed(bytes)); + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !*accepting { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "pty actor closed", + )); } - self.data_tx.try_send(bytes) + let (reply_tx, reply_rx) = std_mpsc::channel(); + self.data_tx + .try_send(PtyIoDataCommand::SubmitUserInput { + text, + enter, + delay, + reply: reply_tx, + }) + .map_err(|err| match err { + mpsc::error::TrySendError::Full(_) => std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "pty input queue is full", + ), + mpsc::error::TrySendError::Closed(_) => { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed") + } + })?; + Ok(reply_rx) } pub(crate) fn write_terminal_response(&self, response: impl FnOnce() -> Option<Bytes>) { @@ -92,7 +147,7 @@ mod windows { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); if let Some(bytes) = response().filter(|bytes| !bytes.is_empty()) { - let _ = self.write_tx.send(bytes); + let _ = self.write_tx.send(PtyIoWriteCommand::Write(bytes)); } } @@ -143,29 +198,22 @@ mod windows { let mut writer = master .take_writer() .map_err(|err| std::io::Error::other(err.to_string()))?; - let (data_tx, mut data_rx) = mpsc::channel::<Bytes>(1024); + let (data_tx, mut data_rx) = mpsc::channel::<PtyIoDataCommand>(1024); let (control_tx, control_rx) = std_mpsc::channel::<PtyIoControlCommand>(); - let (write_tx, write_rx) = std_mpsc::channel::<Bytes>(); + let (write_tx, write_rx) = std_mpsc::channel::<PtyIoWriteCommand>(); let response_order = Arc::new(Mutex::new(())); let accepting = Arc::new(Mutex::new(!initially_quiesced)); std::thread::spawn(move || { - for bytes in write_rx { - if writer.write_all(&bytes).is_err() || writer.flush().is_err() { - break; - } - } + run_writer(&mut writer, write_rx); debug!(pane_id, "windows pty writer thread exiting"); }); { let write_tx = write_tx.clone(); + let accepting = Arc::clone(&accepting); std::thread::spawn(move || { - while let Some(bytes) = data_rx.blocking_recv() { - if write_tx.send(bytes).is_err() { - break; - } - } + run_input_forwarder(&mut data_rx, write_tx, accepting); debug!(pane_id, "windows pty input thread exiting"); }); } @@ -183,11 +231,9 @@ mod windows { .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); let result = on_read(&buf[..n]); - if result - .terminal_responses - .into_iter() - .any(|response| write_tx.send(response).is_err()) - { + if result.terminal_responses.into_iter().any(|response| { + write_tx.send(PtyIoWriteCommand::Write(response)).is_err() + }) { break; } } @@ -219,11 +265,9 @@ mod windows { }) { warn!(pane_id, err = %err, "windows pty resize failed"); } - if request - .terminal_responses - .into_iter() - .any(|response| write_tx.send(response).is_err()) - { + if request.terminal_responses.into_iter().any(|response| { + write_tx.send(PtyIoWriteCommand::Write(response)).is_err() + }) { break; } } @@ -244,8 +288,207 @@ mod windows { } } - #[allow(dead_code)] - fn _assert_duration_send(_: Duration) {} + fn run_writer(writer: &mut impl Write, write_rx: std_mpsc::Receiver<PtyIoWriteCommand>) { + for command in write_rx { + let result = match command { + PtyIoWriteCommand::Write(bytes) => write_and_flush(writer, &bytes), + PtyIoWriteCommand::SubmissionPart { bytes, reply } => { + let result = write_and_flush(writer, &bytes); + let failed = result.is_err(); + let _ = reply.send(result); + if failed { + break; + } + continue; + } + }; + if result.is_err() { + break; + } + } + } + + fn run_input_forwarder( + data_rx: &mut mpsc::Receiver<PtyIoDataCommand>, + write_tx: std_mpsc::Sender<PtyIoWriteCommand>, + accepting: Arc<Mutex<bool>>, + ) { + while let Some(command) = data_rx.blocking_recv() { + match command { + PtyIoDataCommand::WriteUserInput(bytes) => { + if write_tx.send(PtyIoWriteCommand::Write(bytes)).is_err() { + break; + } + } + PtyIoDataCommand::SubmitUserInput { + text, + enter, + delay, + reply, + } => { + let result = write_submission_part(&write_tx, text).and_then(|()| { + std::thread::sleep(delay); + let accepting = accepting + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !*accepting { + return Err(pty_actor_closed()); + } + write_submission_part(&write_tx, enter) + }); + let failed = result.is_err(); + let _ = reply.send(result); + if failed { + break; + } + } + } + } + } + + fn write_submission_part( + write_tx: &std_mpsc::Sender<PtyIoWriteCommand>, + bytes: Bytes, + ) -> std::io::Result<()> { + let (reply, completion) = std_mpsc::channel(); + write_tx + .send(PtyIoWriteCommand::SubmissionPart { bytes, reply }) + .map_err(|_| pty_actor_closed())?; + completion + .recv() + .unwrap_or_else(|_| Err(pty_actor_closed())) + } + + fn pty_actor_closed() -> std::io::Error { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed") + } + + fn write_and_flush(writer: &mut impl Write, bytes: &[u8]) -> std::io::Result<()> { + writer.write_all(bytes)?; + writer.flush() + } + + #[cfg(test)] + mod tests { + use super::*; + use std::time::Instant; + + struct RecordingWriter { + writes: Vec<(Vec<u8>, Instant)>, + flushes: Vec<Instant>, + fail_after: Option<usize>, + flushed: std_mpsc::Sender<()>, + } + + impl Write for RecordingWriter { + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { + if self.fail_after == Some(self.writes.len()) { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "writer closed", + )); + } + self.writes.push((bytes.to_vec(), Instant::now())); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + self.flushes.push(Instant::now()); + let _ = self.flushed.send(()); + Ok(()) + } + } + + fn run_recorded_submission( + fail_after: Option<usize>, + delay: Duration, + during_delay: impl FnOnce(&std_mpsc::Sender<PtyIoWriteCommand>, &Arc<Mutex<bool>>), + ) -> (RecordingWriter, std::io::Result<()>) { + let (flushed_tx, flushed_rx) = std_mpsc::channel(); + let mut writer = RecordingWriter { + writes: Vec::new(), + flushes: Vec::new(), + fail_after, + flushed: flushed_tx, + }; + let (data_tx, mut data_rx) = mpsc::channel(2); + let (write_tx, write_rx) = std_mpsc::channel(); + let (reply_tx, reply_rx) = std_mpsc::channel(); + let accepting = Arc::new(Mutex::new(true)); + data_tx + .try_send(PtyIoDataCommand::SubmitUserInput { + text: Bytes::from_static(b"prompt"), + enter: Bytes::from_static(b"\r"), + delay, + reply: reply_tx, + }) + .unwrap(); + data_tx + .try_send(PtyIoDataCommand::WriteUserInput(Bytes::from_static( + b"user", + ))) + .unwrap(); + let writer_thread = std::thread::spawn(move || { + run_writer(&mut writer, write_rx); + writer + }); + let input_write_tx = write_tx.clone(); + let input_accepting = Arc::clone(&accepting); + let input_thread = std::thread::spawn(move || { + run_input_forwarder(&mut data_rx, input_write_tx, input_accepting) + }); + flushed_rx.recv().expect("prompt was flushed"); + during_delay(&write_tx, &accepting); + let result = reply_rx.recv().expect("writer reports submission"); + drop(data_tx); + input_thread.join().expect("input thread joins"); + drop(write_tx); + (writer_thread.join().expect("writer thread joins"), result) + } + + #[test] + fn submission_sequences_user_input_but_allows_terminal_responses() { + let delay = Duration::from_millis(30); + let (writer, result) = run_recorded_submission(None, delay, |write_tx, _| { + write_tx + .send(PtyIoWriteCommand::Write(Bytes::from_static(b"response"))) + .unwrap(); + }); + result.expect("submission succeeds"); + + assert_eq!(writer.writes[0].0, b"prompt"); + assert_eq!(writer.writes[1].0, b"response"); + assert_eq!(writer.writes[2].0, b"\r"); + assert_eq!(writer.writes[3].0, b"user"); + assert!(writer.writes[2].1.duration_since(writer.flushes[0]) >= delay); + } + + #[test] + fn submission_returns_enter_write_failure() { + let (_writer, result) = run_recorded_submission(Some(1), Duration::ZERO, |_, _| {}); + let err = result.expect_err("enter failure reaches caller"); + + assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe); + } + + #[test] + fn shutdown_during_submission_delay_cancels_enter() { + let (writer, result) = + run_recorded_submission(None, Duration::from_millis(30), |_, accepting| { + *accepting.lock().unwrap() = false; + }); + let err = result.expect_err("shutdown cancels enter"); + assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe); + assert_eq!( + writer + .writes + .iter() + .map(|write| write.0.as_slice()) + .collect::<Vec<_>>(), + vec![b"prompt"] + ); + } + } } #[cfg(windows)] diff --git a/src/pty/actor/unix.rs b/src/pty/actor/unix.rs index 599f02cb..d7a8bb78 100644 --- a/src/pty/actor/unix.rs +++ b/src/pty/actor/unix.rs @@ -73,6 +73,12 @@ pub(crate) struct PtyIoActorConfig { enum PtyIoDataCommand { WriteUserInput(Bytes), + SubmitUserInput { + text: Bytes, + enter: Bytes, + delay: Duration, + reply: std_mpsc::Sender<std::io::Result<()>>, + }, } enum PtyIoControlCommand { @@ -100,37 +106,6 @@ struct UserWriteGate { } 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)); - self.wake_actor(); - Ok(()) - } - pub(crate) fn try_write_user_input( &self, bytes: Bytes, @@ -150,15 +125,57 @@ impl PtyIoActorHandle { self.wake_actor(); Ok(()) } - Err(mpsc::error::TrySendError::Full(PtyIoDataCommand::WriteUserInput(bytes))) => { + Err(mpsc::error::TrySendError::Full(command)) => { + let PtyIoDataCommand::WriteUserInput(bytes) = command else { + unreachable!("queued write returned another command") + }; Err(mpsc::error::TrySendError::Full(bytes)) } - Err(mpsc::error::TrySendError::Closed(PtyIoDataCommand::WriteUserInput(bytes))) => { + Err(mpsc::error::TrySendError::Closed(command)) => { + let PtyIoDataCommand::WriteUserInput(bytes) = command else { + unreachable!("queued write returned another command") + }; Err(mpsc::error::TrySendError::Closed(bytes)) } } } + pub(crate) fn queue_user_input_submission( + &self, + text: Bytes, + enter: Bytes, + delay: Duration, + ) -> std::io::Result<std_mpsc::Receiver<std::io::Result<()>>> { + let user_writes = self + .user_writes + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !user_writes.accepting { + return Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "pty actor closed", + )); + } + let (reply_tx, reply_rx) = std_mpsc::channel(); + self.data_tx + .try_send(PtyIoDataCommand::SubmitUserInput { + text, + enter, + delay, + reply: reply_tx, + }) + .map_err(|err| match err { + mpsc::error::TrySendError::Full(_) => { + std::io::Error::new(std::io::ErrorKind::WouldBlock, "pty input queue is full") + } + mpsc::error::TrySendError::Closed(_) => { + std::io::Error::new(std::io::ErrorKind::BrokenPipe, "pty actor closed") + } + })?; + self.wake_actor(); + Ok(reply_rx) + } + pub(crate) fn write_terminal_response(&self, response: impl FnOnce() -> Option<Bytes>) { let _order = self .response_order @@ -232,6 +249,12 @@ impl PtyIoActorHandle { .user_writes .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); + if !user_writes.accepting { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "PTY handoff is already in progress", + )); + } user_writes.accepting = false; if self .control_tx @@ -391,6 +414,8 @@ impl PtyIoActor { }, pending_writes: VecDeque::new(), current_write_offset: 0, + active_submission: None, + pending_handoff: None, wake_read_fd: wake_pipe.read_fd, controls, response_order, @@ -421,8 +446,10 @@ struct PtyIoActorRunner { data_rx: mpsc::Receiver<PtyIoDataCommand>, control_rx: std_mpsc::Receiver<PtyIoControlCommand>, state: ActorState, - pending_writes: VecDeque<Bytes>, + pending_writes: VecDeque<PendingWrite>, current_write_offset: usize, + active_submission: Option<ActiveSubmission>, + pending_handoff: Option<std_mpsc::Sender<std::io::Result<()>>>, wake_read_fd: OwnedFd, controls: Arc<Mutex<SharedPtyControls>>, response_order: Arc<Mutex<()>>, @@ -431,10 +458,47 @@ struct PtyIoActorRunner { poll_observer: Option<std_mpsc::Sender<()>>, } +struct ActiveSubmission { + enter: Bytes, + delay: Duration, + phase: SubmissionPhase, + reply: std_mpsc::Sender<std::io::Result<()>>, +} + +#[derive(Debug, PartialEq, Eq)] +struct PendingWrite { + bytes: Bytes, + boundary: Option<SubmissionBoundary>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum SubmissionBoundary { + Text, + Enter, +} + +enum SubmissionPhase { + WritingText, + WaitingUntil(Instant), + WritingEnter, +} + impl PtyIoActorRunner { fn enqueue_write(&mut self, bytes: Bytes) { if !bytes.is_empty() { - self.pending_writes.push_back(bytes); + self.pending_writes.push_back(PendingWrite { + bytes, + boundary: None, + }); + } + } + + fn enqueue_submission_write(&mut self, bytes: Bytes, boundary: SubmissionBoundary) { + if !bytes.is_empty() { + self.pending_writes.push_back(PendingWrite { + bytes, + boundary: Some(boundary), + }); } } @@ -449,7 +513,18 @@ impl PtyIoActorRunner { self.apply_pending_controls(); if !self.pending_writes.is_empty() { - self.flush_pending_writes_once(); + match self.flush_pending_writes_once() { + Ok(Some(boundary)) => self.complete_submission_boundary(boundary), + Ok(None) => {} + Err(err) => { + self.fail_active_submission(err); + break; + } + } + } + self.schedule_submission_enter(); + if self.active_submission.is_none() && self.pending_handoff.is_some() { + continue; } if let Some(poll_observer) = &self.poll_observer { @@ -461,7 +536,7 @@ impl PtyIoActorRunner { self.wake_read_fd.as_raw_fd(), self.state == ActorState::Running, !self.pending_writes.is_empty(), - ACTOR_IDLE_POLL_MS, + self.poll_timeout_ms(), ) { Ok(readiness) => { if readiness.wake_ready { @@ -478,7 +553,14 @@ impl PtyIoActorRunner { break; } if readiness.pty_write_ready && !self.pending_writes.is_empty() { - self.flush_pending_writes_once(); + match self.flush_pending_writes_once() { + Ok(Some(boundary)) => self.complete_submission_boundary(boundary), + Ok(None) => {} + Err(err) => { + self.fail_active_submission(err); + break; + } + } } } Err(err) => { @@ -488,6 +570,10 @@ impl PtyIoActorRunner { } } + self.fail_active_submission(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "PTY actor closed during input submission", + )); if let Some(on_reader_exit) = self.on_reader_exit.take() { on_reader_exit(); } @@ -498,6 +584,13 @@ impl PtyIoActorRunner { if self.drain_control_commands() { return true; } + if self.active_submission.is_some() { + return false; + } + if let Some(reply) = self.pending_handoff.take() { + self.defer_or_begin_handoff(reply); + return false; + } self.drain_data_commands() } @@ -530,6 +623,9 @@ impl PtyIoActorRunner { should_exit = true; break; } + if self.active_submission.is_some() { + break; + } } Err(DataTryRecvError::Empty) => break, Err(DataTryRecvError::Disconnected) => { @@ -548,6 +644,32 @@ impl PtyIoActorRunner { self.enqueue_write(bytes); } } + PtyIoDataCommand::SubmitUserInput { + text, + enter, + delay, + reply, + } => { + if self.state == ActorState::Running { + let phase = if text.is_empty() { + SubmissionPhase::WaitingUntil(Instant::now() + delay) + } else { + self.enqueue_submission_write(text, SubmissionBoundary::Text); + SubmissionPhase::WritingText + }; + self.active_submission = Some(ActiveSubmission { + enter, + delay, + phase, + reply, + }); + } else { + let _ = reply.send(Err(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "pty actor is not accepting input", + ))); + } + } } false } @@ -555,8 +677,7 @@ impl PtyIoActorRunner { fn handle_control_command(&mut self, command: PtyIoControlCommand) -> bool { match command { PtyIoControlCommand::BeginHandoff(reply) => { - let result = self.begin_handoff(); - let _ = reply.send(result); + self.defer_or_begin_handoff(reply); } PtyIoControlCommand::DuplicateForHandoff(reply) => { let result = if self.state == ActorState::Quiesced { @@ -574,6 +695,7 @@ impl PtyIoActorRunner { let _ = reply.send(result); } PtyIoControlCommand::RollbackHandoff(reply) => { + self.pending_handoff.take(); let result = if self.state == ActorState::Released { Err(std::io::Error::new( std::io::ErrorKind::BrokenPipe, @@ -596,8 +718,26 @@ impl PtyIoActorRunner { false } + fn defer_or_begin_handoff(&mut self, reply: std_mpsc::Sender<std::io::Result<()>>) { + if self.active_submission.is_none() { + self.drain_pre_quiesce_commands(); + } + if self.active_submission.is_some() { + self.pending_handoff = Some(reply); + } else { + let result = self.begin_handoff(); + let _ = reply.send(result); + } + } + fn begin_handoff(&mut self) -> std::io::Result<()> { self.drain_pre_quiesce_commands(); + if self.active_submission.is_some() { + return Err(std::io::Error::new( + std::io::ErrorKind::WouldBlock, + "PTY input submission is still in progress", + )); + } self.apply_pending_controls(); if self.state == ActorState::Released { return Err(std::io::Error::new( @@ -606,7 +746,7 @@ impl PtyIoActorRunner { )); } let deadline = Instant::now() + HANDOFF_DRAIN_TIMEOUT; - self.flush_pending_writes_once(); + let _ = self.flush_pending_writes_once()?; while !self.pending_writes.is_empty() { let remaining = deadline.saturating_duration_since(Instant::now()); if remaining.is_zero() { @@ -633,7 +773,7 @@ impl PtyIoActorRunner { )); } if readiness.pty_write_ready { - self.flush_pending_writes_once(); + let _ = self.flush_pending_writes_once()?; } } self.state = ActorState::Quiesced; @@ -641,9 +781,12 @@ impl PtyIoActorRunner { } fn drain_pre_quiesce_commands(&mut self) { - while let Ok(PtyIoDataCommand::WriteUserInput(bytes)) = self.data_rx.try_recv() { - if self.state != ActorState::Released { - self.enqueue_write(bytes); + while let Ok(command) = self.data_rx.try_recv() { + if self.handle_data_command(command) { + break; + } + if self.active_submission.is_some() { + break; } } } @@ -717,32 +860,100 @@ impl PtyIoActorRunner { } } - fn flush_pending_writes_once(&mut self) { - while let Some(bytes) = self.pending_writes.front() { - let chunk = &bytes[self.current_write_offset..]; + fn complete_submission_boundary(&mut self, boundary: SubmissionBoundary) { + match boundary { + SubmissionBoundary::Text => { + let Some(submission) = self.active_submission.as_mut() else { + return; + }; + debug_assert!(matches!(submission.phase, SubmissionPhase::WritingText)); + submission.phase = SubmissionPhase::WaitingUntil(Instant::now() + submission.delay); + } + SubmissionBoundary::Enter => { + let Some(submission) = self.active_submission.take() else { + return; + }; + debug_assert!(matches!(submission.phase, SubmissionPhase::WritingEnter)); + let _ = submission.reply.send(Ok(())); + } + } + } + + fn schedule_submission_enter(&mut self) { + let Some(ActiveSubmission { + enter, + phase: SubmissionPhase::WaitingUntil(deadline), + .. + }) = self.active_submission.as_ref() + else { + return; + }; + if Instant::now() >= *deadline { + let enter = enter.clone(); + if enter.is_empty() { + let submission = self.active_submission.take().unwrap(); + let _ = submission.reply.send(Ok(())); + } else { + self.active_submission.as_mut().unwrap().phase = SubmissionPhase::WritingEnter; + self.enqueue_submission_write(enter, SubmissionBoundary::Enter); + } + } + } + + fn poll_timeout_ms(&self) -> i32 { + let Some(ActiveSubmission { + phase: SubmissionPhase::WaitingUntil(deadline), + .. + }) = self.active_submission.as_ref() + else { + return ACTOR_IDLE_POLL_MS; + }; + deadline + .saturating_duration_since(Instant::now()) + .as_millis() + .max(1) + .min(ACTOR_IDLE_POLL_MS as u128) as i32 + } + + fn fail_active_submission(&mut self, err: std::io::Error) { + if let Some(submission) = self.active_submission.take() { + let _ = submission.reply.send(Err(err)); + } + } + + fn flush_pending_writes_once(&mut self) -> std::io::Result<Option<SubmissionBoundary>> { + while let Some(write) = self.pending_writes.front() { + let chunk = &write.bytes[self.current_write_offset..]; match self.file.write(chunk) { Ok(0) => { - warn!(pane = self.pane_id, "PTY actor write returned zero bytes"); - return; + return Err(std::io::Error::new( + std::io::ErrorKind::WriteZero, + "PTY actor write returned zero bytes", + )); } Ok(written) => { self.current_write_offset += written; - if self.current_write_offset >= bytes.len() { - self.pending_writes.pop_front(); + if self.current_write_offset >= write.bytes.len() { + let completed = self.pending_writes.pop_front().unwrap(); self.current_write_offset = 0; + if let Some(boundary) = completed.boundary { + self.file.flush()?; + return Ok(Some(boundary)); + } } } - Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => return, - Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return, + Err(err) if err.kind() == std::io::ErrorKind::WouldBlock => return Ok(None), + Err(err) if err.kind() == std::io::ErrorKind::Interrupted => return Ok(None), Err(err) => { warn!(pane = self.pane_id, err = %err, "PTY actor write failed"); self.pending_writes.clear(); self.current_write_offset = 0; - return; + return Err(err); } } } - let _ = self.file.flush(); + self.file.flush()?; + Ok(None) } fn resize(&self, resize: PtyResize) { @@ -879,6 +1090,8 @@ mod tests { state: ActorState::Running, pending_writes: VecDeque::new(), current_write_offset: 0, + active_submission: None, + pending_handoff: None, wake_read_fd: wake_pipe.read_fd, controls: Arc::new(Mutex::new(SharedPtyControls::default())), response_order: Arc::new(Mutex::new(())), @@ -899,6 +1112,22 @@ mod tests { } #[test] + fn submission_boundary_does_not_wait_for_following_protocol_write() { + let (mut runner, _peer) = actor_runner_for_unit_test(); + runner.enqueue_submission_write(Bytes::from_static(b"prompt"), SubmissionBoundary::Text); + runner.enqueue_write(Bytes::from_static(b"response")); + + assert_eq!( + runner.flush_pending_writes_once().unwrap(), + Some(SubmissionBoundary::Text) + ); + assert_eq!( + runner.pending_writes[0].bytes, + Bytes::from_static(b"response") + ); + } + + #[test] fn actor_writes_user_input_to_owned_fd() { let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); @@ -913,6 +1142,127 @@ mod tests { } #[test] + fn actor_delays_enter_from_completed_prompt_write() { + let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); + let text = Bytes::from(vec![b'x'; 4 * 1024 * 1024]); + let text_len = text.len(); + let delay = Duration::from_millis(200); + let reader = std::thread::spawn(move || { + std::thread::sleep(delay); + let mut received = vec![0; text_len]; + peer.read_exact(&mut received) + .expect("peer receives prompt"); + let prompt_completed = Instant::now(); + let mut enter = [0; 1]; + peer.read_exact(&mut enter).expect("peer receives enter"); + let enter_received = Instant::now(); + let mut user = [0; 4]; + peer.read_exact(&mut user) + .expect("peer receives queued input"); + (prompt_completed, enter_received, enter, user) + }); + + let completion = handle + .queue_user_input_submission(text, Bytes::from_static(b"\r"), delay) + .expect("submission queues"); + handle + .try_write_user_input(Bytes::from_static(b"user")) + .expect("ordinary input queues behind submission"); + completion + .recv() + .expect("actor reports submission") + .expect("submission completes"); + let (prompt_completed, enter_received, enter, user) = reader.join().expect("reader joins"); + + assert_eq!(enter, *b"\r"); + assert_eq!(user, *b"user"); + assert!(enter_received.duration_since(prompt_completed) >= delay / 2); + + let err = match handle.queue_user_input_submission( + Bytes::from_static(b"prompt"), + Bytes::from_static(b"\r"), + Duration::ZERO, + ) { + Ok(completion) => completion + .recv() + .expect("actor reports submission") + .expect_err("closed PTY rejects submission"), + Err(err) => err, + }; + + assert!(matches!( + err.kind(), + std::io::ErrorKind::BrokenPipe + | std::io::ErrorKind::ConnectionReset + | std::io::ErrorKind::WriteZero + )); + } + + #[test] + fn actor_completes_empty_submission_parts() { + let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); + peer.set_read_timeout(Some(Duration::from_secs(1))) + .expect("peer timeout"); + + let completion = handle + .queue_user_input_submission(Bytes::new(), Bytes::from_static(b"\r"), Duration::ZERO) + .expect("empty prompt submission queues"); + let mut enter = [0; 1]; + peer.read_exact(&mut enter) + .expect("peer receives enter for empty prompt"); + assert_eq!(enter, *b"\r"); + completion + .recv_timeout(Duration::from_secs(1)) + .expect("actor reports empty prompt submission") + .expect("empty prompt submission completes"); + + let completion = handle + .queue_user_input_submission( + Bytes::from_static(b"prompt"), + Bytes::new(), + Duration::from_millis(40), + ) + .expect("empty enter submission queues"); + let handoff_handle = handle.clone(); + let handoff = + std::thread::spawn(move || handoff_handle.begin_handoff(Duration::from_millis(250))); + let mut prompt = [0; 6]; + peer.read_exact(&mut prompt) + .expect("peer receives prompt before empty enter"); + assert_eq!(&prompt, b"prompt"); + completion + .recv_timeout(Duration::from_secs(1)) + .expect("actor reports empty enter submission") + .expect("empty enter submission completes"); + handoff + .join() + .expect("handoff thread joins") + .expect("handoff resumes without an idle poll after submission"); + handle.shutdown(); + } + + #[test] + fn actor_reports_peer_closure_during_submission_delay() { + let (handle, mut peer, _read_rx) = actor_with_socket_pair(false); + let completion = handle + .queue_user_input_submission( + Bytes::from_static(b"prompt"), + Bytes::from_static(b"\r"), + Duration::from_secs(1), + ) + .expect("submission queues"); + let mut prompt = [0; 6]; + peer.read_exact(&mut prompt).expect("peer receives prompt"); + drop(peer); + + let err = completion + .recv_timeout(Duration::from_secs(1)) + .expect("actor reports peer closure") + .expect_err("peer closure fails the active submission"); + assert_eq!(err.kind(), std::io::ErrorKind::BrokenPipe); + } + + #[test] fn actor_wakes_idle_poll_for_user_input() { let (poll_tx, poll_rx) = std_mpsc::channel(); let (handle, mut peer, _read_rx) = @@ -976,9 +1326,9 @@ mod tests { .expect("actor spawn"); let marker = Bytes::from_static(b"queued-input"); - handle - .try_write_user_input(marker.clone()) - .expect("write command accepted"); + let completion = handle + .queue_user_input_submission(marker.clone(), Bytes::from_static(b"\r"), Duration::ZERO) + .expect("submission accepted"); const OUTPUT_LEN: usize = 128 * 1024; let mut peer_writer = peer.try_clone().expect("clone peer writer"); @@ -1004,11 +1354,27 @@ mod tests { assert_eq!(output_len, OUTPUT_LEN); output_writer.join().expect("output writer joins"); - let mut received_input = vec![0; prefilled + marker.len()]; + let handoff_handle = handle.clone(); + let handoff = + std::thread::spawn(move || handoff_handle.begin_handoff(Duration::from_secs(1))); + + let mut received_input = vec![0; prefilled + marker.len() + 1]; peer.read_exact(&mut received_input) .expect("peer receives prefill and queued input"); assert!(received_input[..prefilled].iter().all(|byte| *byte == 0xAA)); - assert_eq!(&received_input[prefilled..], marker.as_ref()); + assert_eq!( + &received_input[prefilled..prefilled + marker.len()], + marker.as_ref() + ); + assert_eq!(received_input.last(), Some(&b'\r')); + completion + .recv_timeout(Duration::from_secs(1)) + .expect("actor reports submission") + .expect("submission completes"); + handoff + .join() + .expect("handoff thread joins") + .expect("handoff waits for submission"); handle.shutdown(); } @@ -1080,6 +1446,10 @@ mod tests { handle .begin_handoff(Duration::from_secs(1)) .expect("handoff quiesced"); + let err = handle + .begin_handoff(Duration::from_secs(1)) + .expect_err("concurrent handoff rejected"); + assert_eq!(err.kind(), std::io::ErrorKind::WouldBlock); assert!(handle .try_write_user_input(Bytes::from_static(b"blocked")) .is_err()); @@ -1205,6 +1575,8 @@ mod tests { state: ActorState::Running, pending_writes: VecDeque::new(), current_write_offset: 0, + active_submission: None, + pending_handoff: None, wake_read_fd: wake_pipe.read_fd, controls: Arc::clone(&controls), response_order: Arc::clone(&response_order), @@ -1251,8 +1623,14 @@ mod tests { assert_eq!( runner.pending_writes, VecDeque::from([ - Bytes::from_static(b"live-light"), - Bytes::from_static(b"query-light"), + PendingWrite { + bytes: Bytes::from_static(b"live-light"), + boundary: None, + }, + PendingWrite { + bytes: Bytes::from_static(b"query-light"), + boundary: None, + }, ]) ); } @@ -1271,107 +1649,6 @@ mod tests { handle.shutdown(); } - #[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::WriteUserInput(Bytes::from_static( - b"fill", - ))) - .expect("fill data queue"); - let (wake, _wake_read_fd) = test_wake_pair(); - let handle = PtyIoActorHandle { - data_tx, - control_tx, - wake, - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), - controls: Arc::new(Mutex::new(SharedPtyControls::default())), - response_order: Arc::new(Mutex::new(())), - }; - - 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::WriteUserInput(_)) - )); - 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::WriteUserInput(Bytes::from_static( - b"fill", - ))) - .expect("fill data queue"); - let (wake, _wake_read_fd) = test_wake_pair(); - let handle = PtyIoActorHandle { - data_tx, - control_tx, - wake, - user_writes: Arc::new(Mutex::new(UserWriteGate { accepting: true })), - controls: Arc::new(Mutex::new(SharedPtyControls::default())), - response_order: Arc::new(Mutex::new(())), - }; - 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::WriteUserInput(_)) - )); - - 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); @@ -1431,6 +1708,8 @@ mod tests { state: ActorState::Running, pending_writes: VecDeque::new(), current_write_offset: 0, + active_submission: None, + pending_handoff: None, wake_read_fd: fd::create_wake_pipe().expect("wake pipe").read_fd, controls: Arc::new(Mutex::new(SharedPtyControls::default())), response_order: Arc::new(Mutex::new(())), |
