Files
pebrel/nebula_terminal/src/event_loop.rs
T

1087 lines
43 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The main event loop which performs I/O on the pseudoterminal.
use std::borrow::Cow;
use std::collections::VecDeque;
use std::fmt::{self, Display, Formatter};
use std::fs::File;
use std::io::{self, ErrorKind, Read, Write};
use std::num::NonZeroUsize;
use std::sync::Arc;
use std::sync::OnceLock;
use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
use std::thread::JoinHandle;
use std::time::{Duration, Instant};
use log::error;
use polling::{Event as PollingEvent, Events, PollMode, Poller};
use crate::event::{self, Event, EventListener, WindowSize};
use crate::grid::Dimensions as _;
use crate::index::{Column, Line};
use crate::osc_cwd::OscEvent;
use crate::sync::FairMutex;
use crate::term::Term;
use crate::{thread, tty};
use vte::ansi;
/// Max bytes to read from the PTY before forced terminal synchronization.
pub(crate) const READ_BUFFER_SIZE: usize = 0x10_0000;
/// Max bytes to read from the PTY while the terminal is locked.
const MAX_LOCKED_READ: usize = u16::MAX as usize;
/// ConPTY 光标对账的静默期:最后一次 resize 提交后等这么久再探针,给
/// conhost 的内部整理(缓冲区塌缩/重锚)和 shell 的 resize 反应留时间。
const ALIGN_DELAY: Duration = Duration::from_millis(120);
/// Resize diagnostics are opt-in because PTY reads are a hot path. The trace
/// records the local grid state at the exact stream boundaries where bytes are
/// parsed or a resize is committed, so it can be aligned with
/// `NEBULA_PTY_RECORD=1` without changing terminal behavior.
fn resize_trace_enabled() -> bool {
static ENABLED: OnceLock<bool> = OnceLock::new();
*ENABLED.get_or_init(|| std::env::var_os("NEBULA_RESIZE_TRACE").is_some())
}
fn trace_terminal_state<U: EventListener>(stage: &str, terminal: &Term<U>) {
if !resize_trace_enabled() {
return;
}
// PowerShell's configured prompt uses U+276F. Recording every visible
// occurrence exposes stale prompt rows immediately after a reflow.
let mut prompt_rows = Vec::new();
for row in 0..terminal.screen_lines() {
if (0..terminal.columns())
.any(|column| terminal.grid()[Line(row as i32)][Column(column)].c == '❯')
{
prompt_rows.push(row);
}
}
let cursor = terminal.grid().cursor.point;
eprintln!(
"[nebula:resize-trace] {stage} grid={}x{} cursor={}:{} wrap={} history={} display_offset={} prompts={prompt_rows:?}",
terminal.columns(),
terminal.screen_lines(),
cursor.line.0,
cursor.column.0,
terminal.grid().cursor.input_needs_wrap,
terminal.history_size(),
terminal.grid().display_offset(),
);
}
/// One coalesced resize waiting at a stream boundary.
///
/// `notify_pty` records whether the child must see the resize: the geometry is
/// the same either way, only the child's awareness of it differs.
#[derive(Copy, Clone, Debug)]
struct PendingResize {
window_size: WindowSize,
notify_pty: bool,
}
/// conhost 的一次光标真值读数。
#[derive(Copy, Clone, Debug)]
struct ConhostCursor {
/// 视口相对行(0 基)。
row: usize,
/// conhost 自己的视口高度,用来判断新尺寸有没有落地。
rows: u16,
}
/// 向 conhost 要光标真值:临时 `AttachConsole` 到 ConPTY 子进程的控制台,
/// 读 `GetConsoleScreenBufferInfo`,换算成视口相对行(0 基)。
///
/// 进程同一时刻只能挂一个控制台,多 pane 并发对账用全局锁串行化;每次
/// 探针都 attach→读→detach,窗口只有微秒级。Nebula 主进程是 GUI 子系统
/// (自身无控制台),detach 后回到无控制台状态,不影响任何组件。失败
/// (子进程已退出、权限等)一律返回 None,对账静默放弃。
#[cfg(windows)]
fn conpty_cursor_probe(pid: u32) -> Option<ConhostCursor> {
use std::sync::Mutex;
use windows_sys::Win32::Foundation::{
CloseHandle, GENERIC_READ, GENERIC_WRITE, INVALID_HANDLE_VALUE,
};
use windows_sys::Win32::Storage::FileSystem::{
CreateFileW, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
};
use windows_sys::Win32::System::Console::{
AttachConsole, CONSOLE_SCREEN_BUFFER_INFO, FreeConsole, GetConsoleScreenBufferInfo,
};
static ATTACH_LOCK: Mutex<()> = Mutex::new(());
let _guard = ATTACH_LOCK.lock().ok()?;
// SAFETY: 纯 Win32 控制台句柄操作;FreeConsole 对无控制台进程是无害
// no-op,收尾的 FreeConsole 恢复 GUI 进程的无控制台状态。
unsafe {
FreeConsole();
if AttachConsole(pid) == 0 {
return None;
}
let mut conout: Vec<u16> = "CONOUT$\0".encode_utf16().collect();
let handle = CreateFileW(
conout.as_mut_ptr(),
GENERIC_READ | GENERIC_WRITE,
FILE_SHARE_READ | FILE_SHARE_WRITE,
std::ptr::null(),
OPEN_EXISTING,
0,
std::ptr::null_mut(),
);
if handle == INVALID_HANDLE_VALUE {
FreeConsole();
return None;
}
let mut info: CONSOLE_SCREEN_BUFFER_INFO = std::mem::zeroed();
let ok = GetConsoleScreenBufferInfo(handle, &mut info);
CloseHandle(handle);
FreeConsole();
if ok == 0 {
return None;
}
let row = i32::from(info.dwCursorPosition.Y) - i32::from(info.srWindow.Top);
let rows = i32::from(info.srWindow.Bottom) - i32::from(info.srWindow.Top) + 1;
Some(ConhostCursor {
row: usize::try_from(row).ok()?,
rows: u16::try_from(rows.max(0)).ok()?,
})
}
}
#[cfg(not(windows))]
fn conpty_cursor_probe(_pid: u32) -> Option<ConhostCursor> {
None
}
/// 本地 PTY 与远端传输共用的有状态终端字节流处理器。
/// OSC 提取必须紧贴 VT 解析,避免两类会话产生不同的目录、命令和图片状态。
#[derive(Default)]
pub struct StreamProcessor {
parser: ansi::Processor,
cwd_sniffer: crate::osc_cwd::CwdSniffer,
window_size: Option<WindowSize>,
remote_hook_token: Option<String>,
}
impl StreamProcessor {
pub fn resize(&mut self, window_size: WindowSize) {
self.window_size = Some(window_size);
}
pub fn set_remote_hook_token(&mut self, token: String) {
self.remote_hook_token = Some(token);
}
pub fn next_sync_timeout(&self) -> Option<Instant> {
self.parser.sync_timeout().sync_timeout()
}
pub fn sync_bytes_count(&self) -> usize {
self.parser.sync_bytes_count()
}
pub fn stop_sync<U: EventListener>(&mut self, terminal: &mut Term<U>) {
self.parser.stop_sync(terminal);
}
pub fn feed<U: EventListener>(
&mut self,
terminal: &mut Term<U>,
event_proxy: &U,
bytes: &[u8],
) {
let osc_events = self.cwd_sniffer.feed(bytes);
let mut advanced = 0;
for (offset, event) in osc_events {
// Titles and shell identity/cwd reports must retain wire order:
// coalescing a remote cwd past a parent prompt would attribute that
// directory to the parent shell's completion history.
self.parser.advance(terminal, &bytes[advanced..offset]);
advanced = offset;
match event {
OscEvent::Cwd(cwd) => event_proxy.send_event(Event::CwdReport(cwd)),
OscEvent::CommandStart => {
terminal.nebula_end_prompt();
event_proxy.send_event(Event::CommandStart);
},
OscEvent::CommandDone { exit_code } => {
terminal.nebula_end_prompt();
event_proxy.send_event(Event::CommandDone { exit_code })
},
OscEvent::UserVar { name, value } => {
event_proxy.send_event(Event::UserVar { name, value })
},
OscEvent::Notify(text) => event_proxy.send_event(Event::Notify(text)),
OscEvent::Progress { state, value } => {
event_proxy.send_event(Event::Progress { state, value })
},
OscEvent::RemoteHook { token, envelope } => {
if self.remote_hook_token.as_deref() == Some(token.as_str()) {
event_proxy.send_event(Event::AiHookEnvelope(envelope));
}
},
OscEvent::PromptMark => {
terminal.nebula_add_prompt_mark();
},
OscEvent::PromptInput => terminal.nebula_mark_prompt_input(),
OscEvent::InlineImage { data, width, height } => {
let (cell_w, cell_h) = self.window_size.map_or((9.0, 20.0), |ws| {
(f32::from(ws.cell_width), f32::from(ws.cell_height))
});
let max_w = terminal.columns() as f32 * cell_w;
let scale = (max_w / width as f32).min(1.0);
let disp_w = width as f32 * scale;
let disp_h = height as f32 * scale;
let rows = (disp_h / cell_h).ceil().max(1.0) as usize;
let abs_line = terminal.nebula_cursor_abs_line();
for _ in 0..=rows {
self.parser.advance(terminal, b"\r\n");
}
event_proxy.send_event(Event::InlineImage {
data: std::sync::Arc::new(data),
abs_line,
width: disp_w,
height: disp_h,
});
},
}
}
self.parser.advance(terminal, &bytes[advanced..]);
}
}
/// Messages that may be sent to the `EventLoop`.
#[derive(Debug)]
pub enum Msg {
/// Data that should be written to the PTY.
Input(Cow<'static, [u8]>),
/// Indicates that the `EventLoop` should shut down, as Nebula is shutting down.
Shutdown,
/// Instruction to resize the PTY.
Resize(WindowSize),
/// Reflow the local grid to a new geometry without telling the child.
///
/// Keep local grid reflow separate from notifying the child. The legacy shell
/// has always done this (`window_context/split.rs`: grids every drag tick,
/// PTYs on settle). The
/// two halves have opposite cost profiles: a client-side reflow is cheap and
/// reversible, while every `ResizePseudoConsole` makes conhost rewrap its
/// own buffer, and those rewraps accumulate cursor-row drift that nothing
/// can undo. So the grid follows the pointer frame by frame — the viewport
/// on screen is always a real reflow of the real geometry — and only the
/// child is debounced.
///
/// Goes through the same channel as `Resize` on purpose: the resize branch
/// drains everything readable against the old geometry first, so absolute
/// CUP sequences produced at the old width are never parsed into the new
/// grid. A UI thread reaching into `Term::resize` directly would skip that.
ResizeGrid(WindowSize),
}
/// The main event loop.
///
/// Handles all the PTY I/O and runs the PTY parser which updates terminal
/// state.
pub struct EventLoop<T: tty::EventedPty, U: EventListener> {
poll: Arc<Poller>,
pty: T,
rx: PeekableReceiver<Msg>,
tx: Sender<Msg>,
terminal: Arc<FairMutex<Term<U>>>,
event_proxy: U,
drain_on_exit: bool,
ref_test: bool,
remote_hook_token: Option<String>,
}
impl<T, U> EventLoop<T, U>
where
T: tty::EventedPty + event::OnResize + Send + 'static,
U: EventListener + Send + 'static,
{
/// Create a new event loop.
pub fn new(
terminal: Arc<FairMutex<Term<U>>>,
event_proxy: U,
pty: T,
drain_on_exit: bool,
ref_test: bool,
) -> io::Result<EventLoop<T, U>> {
let (tx, rx) = mpsc::channel();
let poll = Poller::new()?.into();
Ok(EventLoop {
poll,
pty,
tx,
rx: PeekableReceiver::new(rx),
terminal,
event_proxy,
drain_on_exit,
ref_test,
remote_hook_token: None,
})
}
pub fn channel(&self) -> EventLoopSender {
EventLoopSender { sender: self.tx.clone(), poller: self.poll.clone() }
}
/// Bind in-band hook delivery to this PTY before its reader starts.
pub fn set_remote_hook_token(&mut self, token: String) {
self.remote_hook_token = Some(token);
}
/// Drain the channel.
///
/// Returns `Err(())` when a shutdown message was received; otherwise the
/// last resize in this channel batch is returned for an ordered commit.
fn drain_recv_channel(&mut self, state: &mut State) -> Result<Option<PendingResize>, ()> {
// Resize storms (live window drags) queue faster than
// ResizePseudoConsole drains them — the console host performs a full
// viewport reflow per call. Within one drain only the newest size
// matters: intermediate sizes carry no information (the next message
// supersedes them), and the final size is never dropped because it is
// always the last one seen. The slower the host reflows, the more
// sizes pile up per drain and the harder the coalescing works.
//
// Grid-only and full resizes coalesce into the same slot, and a full
// resize never loses to a later grid-only one: the child must still be
// told about the geometry it is already producing output for. The
// reverse is fine — a full resize supersedes a pending grid-only one,
// because it reflows the grid too.
let mut resize: Option<PendingResize> = None;
while let Some(msg) = self.rx.recv() {
match msg {
Msg::Input(input) => state.write_list.push_back(input),
Msg::Resize(window_size) => {
resize = Some(PendingResize { window_size, notify_pty: true })
},
Msg::ResizeGrid(window_size) => {
let notify_pty = resize.is_some_and(|pending| pending.notify_pty);
resize = Some(PendingResize { window_size, notify_pty });
},
Msg::Shutdown => return Err(()),
}
}
Ok(resize)
}
/// 向 conhost 要光标真值并把本地网格滚到同一坐标系。
///
/// `expect_rows` 非 None 时只采信视口高度已经等于该值的读数:
/// `ResizePseudoConsole` 之后 conhost 若还停在旧几何,它报的行号属于上一
/// 个坐标系,照它对账会把网格滚到更错的位置。宁可放弃这一次——死线上的
/// 兜底探针会再来一遍。
fn realign_to_conpty(&mut self, stage: &str, expect_rows: Option<u16>) {
let Some(probe) = self.pty.child_pid().and_then(conpty_cursor_probe) else {
return;
};
if resize_trace_enabled() {
eprintln!(
"[nebula:resize-trace] {stage} conhost row={} rows={} expect_rows={expect_rows:?}",
probe.row, probe.rows,
);
}
if expect_rows.is_some_and(|rows| rows != probe.rows) {
return;
}
let mut terminal = self.terminal.lock();
trace_terminal_state(&format!("before-{stage}"), &terminal);
terminal.conpty_realign(probe.row);
trace_terminal_state(&format!("after-{stage}"), &terminal);
drop(terminal);
self.event_proxy.send_event(Event::Wakeup);
}
#[inline]
fn pty_read<X>(
&mut self,
state: &mut State,
buf: &mut [u8],
mut writer: Option<&mut X>,
) -> io::Result<usize>
where
X: Write,
{
let mut unprocessed = 0;
let mut processed = 0;
// Reserve the next terminal lock for PTY reading.
let _terminal_lease = Some(self.terminal.lease());
let mut terminal = None;
loop {
// Read from the PTY.
match self.pty.reader().read(&mut buf[unprocessed..]) {
// This is received on Windows/macOS when no more data is readable from the PTY.
Ok(0) if unprocessed == 0 => break,
Ok(got) => {
// Startup profiling: the process-wide first PTY output ≈
// the console host finished its bring-up handshake and
// the shell started talking. The first chunks are dumped
// escaped so a silent boot can be aligned with the host's
// handshake byte for byte.
{
use std::sync::atomic::{AtomicUsize, Ordering};
static CHUNKS: AtomicUsize = AtomicUsize::new(0);
let index = CHUNKS.fetch_add(1, Ordering::Relaxed);
if index == 0 {
crate::pty_trace("first conout bytes");
}
if index < 12 {
let shown = &buf[unprocessed..unprocessed + got.min(200)];
crate::pty_trace(&format!(
"conout chunk {index} ({got} bytes): {}",
shown.escape_ascii()
));
}
}
unprocessed += got;
},
Err(err) => match err.kind() {
ErrorKind::Interrupted | ErrorKind::WouldBlock => {
// Go back to mio if we're caught up on parsing and the PTY would block.
if unprocessed == 0 {
break;
}
},
_ => return Err(err),
},
}
// Attempt to lock the terminal.
let terminal = match &mut terminal {
Some(terminal) => terminal,
None => terminal.insert(match self.terminal.try_lock_unfair() {
// Force block if we are at the buffer size limit.
None if unprocessed >= READ_BUFFER_SIZE => self.terminal.lock_unfair(),
None => continue,
Some(terminal) => terminal,
}),
};
// Write a copy of the bytes to the ref test file.
if let Some(writer) = &mut writer {
writer.write_all(&buf[..unprocessed]).unwrap();
}
state.stream.feed(&mut **terminal, &self.event_proxy, &buf[..unprocessed]);
trace_terminal_state("after-pty-read", &terminal);
processed += unprocessed;
unprocessed = 0;
// Assure we're not blocking the terminal too long unnecessarily.
if processed >= MAX_LOCKED_READ {
break;
}
}
// Queue terminal redraw unless all processed bytes were synchronized.
if state.stream.sync_bytes_count() < processed && processed > 0 {
self.event_proxy.send_event(Event::Wakeup);
}
// Boot profiling: a readable wake that carried no bytes is the
// signature of a lost or spurious wakeup; report only the first few.
if processed == 0 {
use std::sync::atomic::{AtomicUsize, Ordering};
static EMPTY_READS: AtomicUsize = AtomicUsize::new(0);
if EMPTY_READS.fetch_add(1, Ordering::Relaxed) < 8 {
crate::pty_trace("pty_read: readable wake with no bytes");
}
}
Ok(processed)
}
#[inline]
fn pty_write(&mut self, state: &mut State) -> io::Result<()> {
state.ensure_next();
'write_many: while let Some(mut current) = state.take_current() {
'write_one: loop {
match self.pty.writer().write(current.remaining_bytes()) {
Ok(0) => {
state.set_current(Some(current));
break 'write_many;
},
Ok(n) => {
{
use std::sync::atomic::{AtomicUsize, Ordering};
static WRITES: AtomicUsize = AtomicUsize::new(0);
let index = WRITES.fetch_add(1, Ordering::Relaxed);
if index < 12 {
let shown = &current.remaining_bytes()[..n.min(200)];
crate::pty_trace(&format!(
"conin write {index} ({n} bytes): {}",
shown.escape_ascii()
));
}
}
current.advance(n);
if current.finished() {
state.goto_next();
break 'write_one;
}
},
Err(err) => {
state.set_current(Some(current));
match err.kind() {
ErrorKind::Interrupted | ErrorKind::WouldBlock => break 'write_many,
_ => return Err(err),
}
},
}
}
}
Ok(())
}
pub fn spawn(mut self) -> JoinHandle<(Self, State)> {
thread::spawn_named("PTY reader", move || {
let mut state = State::default();
if let Some(token) = self.remote_hook_token.take() {
state.stream.set_remote_hook_token(token);
}
let mut buf = [0u8; READ_BUFFER_SIZE];
let poll_opts = PollMode::Level;
let mut interest = PollingEvent::readable(0);
// Register TTY through EventedRW interface.
if let Err(err) = unsafe { self.pty.register(&self.poll, interest, poll_opts) } {
error!("Event loop registration error: {err}");
return (self, state);
}
let mut events = Events::with_capacity(NonZeroUsize::new(1024).unwrap());
let mut pipe = if self.ref_test {
Some(File::create("./nebula.recording").expect("create nebula recording"))
} else {
None
};
// Reason the transport died without a child exit, reported after
// the loop: without it the app never learns the session is gone
// and the tab turns into a zombie (unresponsive input, no notice).
let mut failure: Option<String> = None;
'event_loop: loop {
// Wakeup the event loop when a synchronized update timeout or
// the pending ConPTY align deadline was reached.
let deadline = match (state.stream.next_sync_timeout(), state.align_at) {
(Some(sync), Some(align)) => Some(sync.min(align)),
(sync, align) => sync.or(align),
};
let timeout = deadline.map(|at| at.saturating_duration_since(Instant::now()));
events.clear();
if let Err(err) = self.poll.wait(&mut events, timeout) {
match err.kind() {
ErrorKind::Interrupted => continue,
_ => {
error!("Event loop polling error: {err}");
failure = Some(format!("event loop polling error: {err}"));
break 'event_loop;
},
}
}
// ConPTY 光标对账到点:向 conhost 要真值并把本地网格滚到同
// 一坐标系(机理见 `Term::conpty_realign`)。这是兜底的一次
// ——resize 边界上已经同步对过一次账,这里只补 conhost 事后
// 才塌缩的情况,所以不校验视口高度。
if state.align_at.is_some_and(|at| Instant::now() >= at) {
state.align_at = None;
self.realign_to_conpty("align", None);
}
// Handle synchronized update timeout. The align deadline can
// wake the loop early with empty events — only a genuinely
// expired sync window may force-stop the synchronized update.
if events.is_empty() && self.rx.peek().is_none() {
if state.stream.next_sync_timeout().is_some_and(|st| Instant::now() >= st) {
state.stream.stop_sync(&mut *self.terminal.lock());
self.event_proxy.send_event(Event::Wakeup);
}
continue;
}
// Handle channel events, if there are any.
let pending_resize = match self.drain_recv_channel(&mut state) {
Ok(resize) => resize,
Err(()) => break,
};
if let Some(PendingResize { window_size, notify_pty }) = pending_resize {
// A resize is a stream boundary, not a UI-side property.
// Drain everything already readable while the grid still
// has the old geometry; otherwise old-width absolute CUP
// output can be parsed into a new-width grid. This is the
// Drain readable bytes against the old grid before the
// size change lands, so CUP sequences keep their geometry.
loop {
match self.pty_read(&mut state, &mut buf, pipe.as_mut()) {
Ok(processed) if processed >= MAX_LOCKED_READ => continue,
Ok(_) => break,
Err(err) => {
error!("PTY read before resize failed: {err}");
failure = Some(format!("PTY read before resize failed: {err}"));
break 'event_loop;
},
}
}
// Match ConPTY's order: reflow the client model
// first, then ask ConPTY to resize. ResizePseudoConsole is
// synchronous; repaint bytes it produces are consumed by
// the normal readable-event path below against this grid.
{
let mut terminal = self.terminal.lock();
trace_terminal_state("before-resize", &terminal);
terminal.resize(window_size);
trace_terminal_state(
if notify_pty { "after-resize" } else { "after-resize-grid" },
&terminal,
);
}
// Grid-only: the child keeps the geometry it is producing
// output for, so conhost's buffer is untouched and there is
// nothing to reconcile against — skip both the
// ResizePseudoConsole and the align probe. The reflow above
// is what keeps the viewport on screen honest while a drag
// is still in flight.
if !notify_pty {
self.event_proxy.send_event(Event::Wakeup);
} else {
self.pty.on_resize(window_size);
state.stream.resize(window_size);
// 对账的唯一可信时点就是这里:`ResizePseudoConsole` 是同步
// 的,返回时 conhost 已按新宽度 rewrap 完毕,而本地网格还是
// 纯 reflow 的结果——两侧都没有被重绘字节动过,行号差就是
// 两种折行语义的真实差值。等到下面 pty_read 把 PSReadLine
// 的绝对 CUP 解析进来,光标已经被搬到 conhost 的坐标上,
// 差值归零,判据就永久丢失了(字节取证:分屏后 after-resize
// 是 cursor=15/prompts=[15],120ms 后的 before-align 已经变成
// cursor=19/prompts=[15]——错位既成事实却测不出来)。
self.realign_to_conpty("align-sync", Some(window_size.num_lines));
// conhost 事后才做的塌缩/重锚探不到,留一次死线兜底;新
// resize 顺延死线,风暴天然合并成一次探针。
state.align_at = Some(Instant::now() + ALIGN_DELAY);
self.event_proxy.send_event(Event::Wakeup);
}
}
for event in events.iter() {
match event.key {
tty::PTY_CHILD_EVENT_TOKEN => {
if let Some(tty::ChildEvent::Exited(status)) =
self.pty.next_child_event()
{
if let Some(status) = status {
self.event_proxy.send_event(Event::ChildExit(status));
}
if self.drain_on_exit {
let _ = self.pty_read(&mut state, &mut buf, pipe.as_mut());
}
self.terminal.lock().exit();
self.event_proxy.send_event(Event::Wakeup);
break 'event_loop;
}
},
tty::PTY_READ_WRITE_TOKEN => {
if event.is_interrupt() {
// Don't try to do I/O on a dead PTY.
continue;
}
if event.readable {
if let Err(err) = self.pty_read(&mut state, &mut buf, pipe.as_mut())
{
// On Linux, a `read` on the master side of a PTY can fail
// with `EIO` if the client side hangs up. In that case,
// just loop back round for the inevitable `Exited` event.
// This sucks, but checking the process is either racy or
// blocking.
#[cfg(target_os = "linux")]
if err.raw_os_error() == Some(libc::EIO) {
continue;
}
error!("Error reading from PTY in event loop: {err}");
failure = Some(format!("PTY read failed: {err}"));
break 'event_loop;
}
}
if event.writable {
if let Err(err) = self.pty_write(&mut state) {
error!("Error writing to PTY in event loop: {err}");
failure = Some(format!("PTY write failed: {err}"));
break 'event_loop;
}
}
},
_ => (),
}
}
// Register write interest if necessary.
let needs_write = state.needs_write();
if needs_write != interest.writable {
interest.writable = needs_write;
// Re-register with new interest.
self.pty.reregister(&self.poll, interest, poll_opts).unwrap();
}
}
// Announce a transport death exactly like a child exit so the
// app's existing teardown runs (`exit()` triggers `Event::Exit`).
// The child-exit and shutdown paths leave `failure` unset.
if let Some(reason) = failure {
self.event_proxy.send_event(Event::PtyFailure(reason));
self.terminal.lock().exit();
self.event_proxy.send_event(Event::Wakeup);
}
// The evented instances are not dropped here so deregister them explicitly.
let _ = self.pty.deregister(&self.poll);
(self, state)
})
}
}
/// Helper type which tracks how much of a buffer has been written.
struct Writing {
source: Cow<'static, [u8]>,
written: usize,
}
pub struct Notifier(pub EventLoopSender);
impl event::Notify for Notifier {
fn notify<B>(&self, bytes: B)
where
B: Into<Cow<'static, [u8]>>,
{
let bytes = bytes.into();
// Terminal hangs if we send 0 bytes through.
if bytes.is_empty() {
return;
}
let _ = self.0.send(Msg::Input(bytes));
}
}
impl event::OnResize for Notifier {
fn on_resize(&mut self, window_size: WindowSize) {
let _ = self.0.send(Msg::Resize(window_size));
}
}
impl Notifier {
/// Reflow the grid to `window_size` and leave the child on its old size.
/// See [`Msg::ResizeGrid`] for why the two halves are split.
pub fn on_resize_grid(&mut self, window_size: WindowSize) {
let _ = self.0.send(Msg::ResizeGrid(window_size));
}
}
#[derive(Debug)]
pub enum EventLoopSendError {
/// Error polling the event loop.
Io(io::Error),
/// Error sending a message to the event loop.
Send(mpsc::SendError<Msg>),
}
impl Display for EventLoopSendError {
fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
match self {
EventLoopSendError::Io(err) => err.fmt(f),
EventLoopSendError::Send(err) => err.fmt(f),
}
}
}
impl std::error::Error for EventLoopSendError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
EventLoopSendError::Io(err) => err.source(),
EventLoopSendError::Send(err) => err.source(),
}
}
}
#[derive(Clone)]
pub struct EventLoopSender {
sender: Sender<Msg>,
poller: Arc<Poller>,
}
impl EventLoopSender {
/// 为非 PTY 传输创建消息通道,继续复用应用既有的输入、缩放和关闭协议。
pub fn standalone() -> io::Result<(Self, Receiver<Msg>)> {
let (sender, receiver) = mpsc::channel();
Ok((Self { sender, poller: Arc::new(Poller::new()?) }, receiver))
}
pub fn send(&self, msg: Msg) -> Result<(), EventLoopSendError> {
self.sender.send(msg).map_err(EventLoopSendError::Send)?;
self.poller.notify().map_err(EventLoopSendError::Io)
}
/// A sender wired to nothing: the receiving side is dropped on the spot,
/// so every `send` errors out and is discarded by the caller's `let _ =`.
/// For panes with no PTY behind them (e.g. document-viewer tabs), whose
/// input must be swallowed instead of reaching some other pane's shell.
pub fn sink() -> Self {
let (sender, _) = mpsc::channel();
Self { sender, poller: Arc::new(Poller::new().expect("create sink poller")) }
}
}
/// All of the mutable state needed to run the event loop.
///
/// Contains list of items to write, current write state, etc. Anything that
/// would otherwise be mutated on the `EventLoop` goes here.
#[derive(Default)]
pub struct State {
write_list: VecDeque<Cow<'static, [u8]>>,
writing: Option<Writing>,
stream: StreamProcessor,
/// ConPTY 光标对账的死线:resize 提交后 `ALIGN_DELAY` 触发,见
/// `conpty_cursor_probe`。
align_at: Option<Instant>,
}
impl State {
#[inline]
fn ensure_next(&mut self) {
if self.writing.is_none() {
self.goto_next();
}
}
#[inline]
fn goto_next(&mut self) {
self.writing = self.write_list.pop_front().map(Writing::new);
}
#[inline]
fn take_current(&mut self) -> Option<Writing> {
self.writing.take()
}
#[inline]
fn needs_write(&self) -> bool {
self.writing.is_some() || !self.write_list.is_empty()
}
#[inline]
fn set_current(&mut self, new: Option<Writing>) {
self.writing = new;
}
}
impl Writing {
#[inline]
fn new(c: Cow<'static, [u8]>) -> Writing {
Writing { source: c, written: 0 }
}
#[inline]
fn advance(&mut self, n: usize) {
self.written += n;
}
#[inline]
fn remaining_bytes(&self) -> &[u8] {
&self.source[self.written..]
}
#[inline]
fn finished(&self) -> bool {
self.written >= self.source.len()
}
}
struct PeekableReceiver<T> {
rx: Receiver<T>,
peeked: Option<T>,
}
impl<T> PeekableReceiver<T> {
fn new(rx: Receiver<T>) -> Self {
Self { rx, peeked: None }
}
fn peek(&mut self) -> Option<&T> {
if self.peeked.is_none() {
self.peeked = self.rx.try_recv().ok();
}
self.peeked.as_ref()
}
fn recv(&mut self) -> Option<T> {
if self.peeked.is_some() {
self.peeked.take()
} else {
match self.rx.try_recv() {
Err(TryRecvError::Disconnected) => panic!("event loop channel closed"),
res => res.ok(),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::event::VoidListener;
use crate::term::Config;
use crate::term::test::TermSize;
#[test]
fn authenticated_hook_frames_survive_every_chunk_boundary_and_reject_other_panes() {
#[derive(Clone, Default)]
struct Listener(std::sync::Arc<std::sync::Mutex<Vec<Vec<u8>>>>);
impl EventListener for Listener {
fn send_event(&self, event: Event) {
if let Event::AiHookEnvelope(envelope) = event {
self.0.lock().unwrap().push(envelope);
}
}
}
// Base64 for a small envelope; authentication is checked before delivery.
let frame = b"\x1b]777;nebula-hook;0123456789abcdef0123456789abcdef;bmVidWxhLWhvb2svMSBzb3VyY2U9Y29kZXgKe30=\x07";
for token in [
None,
Some("ffffffffffffffffffffffffffffffff"),
Some("0123456789abcdef0123456789abcdef"),
] {
for split in 0..=frame.len() {
let listener = Listener::default();
let mut terminal =
Term::new(Config::default(), &TermSize::new(80, 24), listener.clone());
let mut stream = StreamProcessor::default();
if let Some(token) = token {
stream.set_remote_hook_token(token.into());
}
stream.feed(&mut terminal, &listener, &frame[..split]);
stream.feed(&mut terminal, &listener, &frame[split..]);
let events = listener.0.lock().unwrap();
if token == Some("0123456789abcdef0123456789abcdef") {
assert_eq!(events.as_slice(), [b"nebula-hook/1 source=codex\n{}".to_vec()]);
} else {
assert!(events.is_empty());
}
}
}
}
#[test]
fn shell_identity_cwd_and_title_keep_wire_order_across_chunk_boundaries() {
#[derive(Clone, Default)]
struct Listener(std::sync::Arc<std::sync::Mutex<Vec<String>>>);
impl EventListener for Listener {
fn send_event(&self, event: Event) {
let label = match event {
Event::UserVar { value, .. } => format!("shell:{value}"),
Event::CwdReport(cwd) => format!("cwd:{cwd}"),
Event::Title(title) => format!("title:{title}"),
_ => return,
};
self.0.lock().unwrap().push(label);
}
}
let bytes = b"\x1b]1337;SetUserVar=pebrel_shell=cmVtb3Rl\x07\x1b]7;file://box/remote\x07\x1b]2;remote\x07\x1b]1337;SetUserVar=pebrel_shell=bG9jYWw=\x07\x1b]7;file://localhost/local\x07";
for split in 0..=bytes.len() {
let listener = Listener::default();
let size = TermSize::new(80, 24);
let mut terminal = Term::new(Config::default(), &size, listener.clone());
let mut stream = StreamProcessor::default();
stream.feed(&mut terminal, &listener, &bytes[..split]);
stream.feed(&mut terminal, &listener, &bytes[split..]);
assert_eq!(
*listener.0.lock().unwrap(),
["shell:remote", "cwd:/remote", "title:remote", "shell:local", "cwd:/local"],
"split at {split}"
);
}
}
#[test]
fn semantic_input_boundary_survives_chunking_and_scrolling() {
use crate::index::{Column, Line, Point};
let bytes = b"\x1b]133;A\x07[first]\r\n>\x1b]133;B\x07pause";
for split in 0..=bytes.len() {
let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener);
let mut stream = StreamProcessor::default();
stream.feed(&mut terminal, &VoidListener, &bytes[..split]);
stream.feed(&mut terminal, &VoidListener, &bytes[split..]);
assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(1), Column(1))));
stream.feed(&mut terminal, &VoidListener, b"\r\n");
assert_eq!(terminal.nebula_prompt_input_point(), Some(Point::new(Line(0), Column(1))));
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;C\x07");
assert_eq!(terminal.nebula_prompt_input_point(), None);
}
}
#[test]
fn input_boundaries_require_a_prompt_and_do_not_survive_reflow_or_reset() {
let mut terminal = Term::new(Config::default(), &TermSize::new(20, 2), VoidListener);
let mut stream = StreamProcessor::default();
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;B\x07");
assert_eq!(terminal.nebula_prompt_input_point(), None);
for ending in [b"\x1bc".as_slice(), b"\x1b]133;D;0\x07", b"\x1b]133;A\x07"] {
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07");
assert!(terminal.nebula_prompt_input_point().is_some());
stream.feed(&mut terminal, &VoidListener, ending);
assert_eq!(terminal.nebula_prompt_input_point(), None);
}
stream.feed(&mut terminal, &VoidListener, b"\x1b]133;A\x07\x1b]133;B\x07");
terminal.resize(TermSize::new(10, 2));
assert_eq!(terminal.nebula_prompt_input_point(), None);
}
#[test]
fn shell_semantic_events_track_the_active_prompt() {
let size = TermSize::new(80, 24);
let mut terminal = Term::new(Config::default(), &size, VoidListener);
let listener = VoidListener;
let mut stream = StreamProcessor::default();
stream.feed(&mut terminal, &listener, b"\x1b]133;A\x07custom prompt :: ");
assert!(terminal.nebula_prompt_active());
stream.feed(&mut terminal, &listener, b"\x1b]133;C\x07");
assert!(!terminal.nebula_prompt_active());
stream.feed(&mut terminal, &listener, b"\x1b]133;A\x07next prompt :: ");
assert!(terminal.nebula_prompt_active());
stream.feed(&mut terminal, &listener, b"\x1b]133;D;0\x07");
assert!(!terminal.nebula_prompt_active());
}
}