T04: pty through alacritty's event loop, OSC 7 tap on the reader
CI / platforms (windows-latest) (push) Failing after 9s
CI / macos (push) Successful in 10m6s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

This commit is contained in:
ZacharyZhang-NY committed 2026-10-14 11:51:51 -04:00
1 parent f122a88143
commit 62e50fe83a
7 files changed
+528

No files matched your search

+1
View File
@@ -1,2 +1,3 @@
pub mod settings;
pub mod term;
pub mod themes;
+4
View File
@@ -0,0 +1,4 @@
mod pty;
mod tap;
pub use pty::{Launch, Listener, Pty, PtyEvent, spawn, window_size};
+229
View File
@@ -0,0 +1,229 @@
use std::{borrow::Cow, collections::HashMap, path::PathBuf, sync::Arc};
use alacritty_terminal::{
event::{Event, EventListener, Notify, OnResize, WindowSize},
event_loop::{EventLoop, Msg, Notifier},
sync::FairMutex,
term::{Config, Term, test::TermSize},
tty::{self, Options, Shell},
};
use futures::channel::mpsc::{UnboundedReceiver, UnboundedSender, unbounded};
use super::tap::Tapped;
/// What a session hears from its pty: the grid's events and the folder the shell reports.
#[derive(Debug)]
pub enum PtyEvent {
Term(Event),
Folder(PathBuf),
}
/// Hands the grid's events to the session.
#[derive(Clone)]
pub struct Listener(UnboundedSender<PtyEvent>);
impl EventListener for Listener {
fn send_event(&self, event: Event) {
if self.0.unbounded_send(PtyEvent::Term(event)).is_err() {
log::debug!("pty: an event after its session closed");
}
}
}
/// A program, the folder it starts in, and variables over the app's own.
#[derive(Clone, Debug)]
pub struct Launch {
/// None runs the login shell.
pub program: Option<(String, Vec<String>)>,
pub cwd: PathBuf,
pub env: Vec<(String, String)>,
}
/// A running program on a pseudo-terminal and the grid it draws into.
pub struct Pty {
pub term: Arc<FairMutex<Term<Listener>>>,
pub events: UnboundedReceiver<PtyEvent>,
notifier: Notifier,
}
/// Starts `launch` on a pty of `size` cells, each `cell` pixels, keeping `scrollback` lines.
pub fn spawn(
launch: &Launch,
size: TermSize,
cell: (u16, u16),
scrollback: usize,
) -> anyhow::Result<Pty> {
anyhow::ensure!(
launch.cwd.is_dir(),
"pty: cannot start in {}: no such folder",
launch.cwd.display()
);
let (sender, events) = unbounded();
let listener = Listener(sender.clone());
let config = Config {
scrolling_history: scrollback,
kitty_keyboard: true,
..Config::default()
};
let term = Arc::new(FairMutex::new(Term::new(config, &size, listener.clone())));
let mut env = HashMap::from([
("TERM".to_string(), "xterm-256color".to_string()),
("COLORTERM".to_string(), "truecolor".to_string()),
]);
if std::env::var_os("LANG").is_none() {
env.insert("LANG".into(), "en_US.UTF-8".into());
}
env.extend(launch.env.iter().cloned());
let options = Options {
shell: launch
.program
.clone()
.map(|(program, args)| Shell::new(program, args)),
working_directory: Some(launch.cwd.clone()),
drain_on_exit: true,
env,
#[cfg(windows)]
escape_args: true,
};
let pty = tty::new(&options, window_size(&size, cell), 0)?;
log::info!(
"pty: started {:?} in {}, child {}",
launch.program,
launch.cwd.display(),
child_id(&pty)
);
let event_loop = EventLoop::new(
term.clone(),
listener,
Tapped::new(pty, sender),
true,
false,
)?;
let notifier = Notifier(event_loop.channel());
event_loop.spawn();
Ok(Pty {
term,
events,
notifier,
})
}
impl Pty {
/// Sends bytes to the program, as typing does.
pub fn write(&self, bytes: impl Into<Cow<'static, [u8]>>) {
self.notifier.notify(bytes);
}
/// Fits the grid and tells the program its new size.
pub fn resize(&mut self, size: TermSize, cell: (u16, u16)) {
self.notifier.on_resize(window_size(&size, cell));
self.term.lock().resize(size);
}
}
impl Drop for Pty {
fn drop(&mut self) {
if self.notifier.0.send(Msg::Shutdown).is_err() {
log::debug!("pty: the event loop had already stopped");
}
}
}
#[cfg(unix)]
fn child_id(pty: &tty::Pty) -> String {
pty.child().id().to_string()
}
#[cfg(windows)]
fn child_id(pty: &tty::Pty) -> String {
pty.child_watcher()
.pid()
.map_or_else(|| "without an id".into(), |pid| pid.to_string())
}
/// A pty's size; counts past what the kernel holds are cut and logged.
pub fn window_size(size: &TermSize, cell: (u16, u16)) -> WindowSize {
let cells = |count: usize, side: u16| {
let most = (i16::MAX as u16).min(u16::MAX / side.max(1));
let kept = u16::try_from(count).unwrap_or(u16::MAX).min(most);
if usize::from(kept) < count {
log::error!("pty: {count} cells of {side} px pass what a pty holds; {kept} kept");
}
kept
};
WindowSize {
num_lines: cells(size.screen_lines, cell.1),
num_cols: cells(size.columns, cell.0),
cell_width: cell.0,
cell_height: cell.1,
}
}
#[cfg(all(test, unix))]
mod tests {
use alacritty_terminal::index::{Column, Line};
use futures::StreamExt;
use super::*;
fn run(script: &str) -> (Vec<PathBuf>, String, bool) {
let launch = Launch {
program: Some(("/bin/sh".into(), vec!["-c".into(), script.into()])),
cwd: std::env::temp_dir(),
env: vec![("ELY".into(), "ok".into())],
};
let mut pty = spawn(&launch, TermSize::new(40, 4), (8, 16), 100).expect("sh starts");
let (folders, success) = futures::executor::block_on(async {
let (mut folders, mut success) = (Vec::new(), false);
while let Some(event) = pty.events.next().await {
match event {
PtyEvent::Folder(folder) => folders.push(folder),
PtyEvent::Term(Event::ChildExit(status)) => success = status.success(),
PtyEvent::Term(Event::Exit) => break,
PtyEvent::Term(_) => {}
}
}
(folders, success)
});
let term = pty.term.lock();
let row = &term.grid()[Line(0)];
let text: String = (0..20).map(|column| row[Column(column)].c).collect();
(folders, text.trim_end().to_string(), success)
}
#[test]
fn a_program_prints_into_the_grid_with_its_variables() {
let (folders, text, success) = run("printf 'ely %s' \"$ELY\"");
assert!(folders.is_empty());
assert_eq!(text, "ely ok");
assert!(success);
}
#[test]
fn osc_7_reaches_the_session_as_a_folder() {
let (folders, text, _) = run(
"printf '\\033]7;file:///tmp/a%%20b\\007x\\033]7;file://elsewhere.invalid/c\\033\\\\'",
);
assert_eq!(folders, [PathBuf::from("/tmp/a b")]);
assert_eq!(text, "x");
}
#[test]
fn a_missing_folder_stops_the_start() {
let launch = Launch {
program: None,
cwd: "/nonexistent/ely".into(),
env: Vec::new(),
};
let error = spawn(&launch, TermSize::new(20, 4), (8, 16), 100)
.err()
.expect("a missing folder is an error");
assert!(error.to_string().contains("/nonexistent/ely"));
}
#[test]
fn more_cells_than_a_pty_holds_cap_at_its_most() {
let size = window_size(&TermSize::new(70_000, 24), (8, 16));
assert_eq!((size.num_cols, size.num_lines), (u16::MAX / 8, 24));
}
}
+285
View File
@@ -0,0 +1,285 @@
use std::{io, path::PathBuf, sync::Arc};
use alacritty_terminal::{
event::{OnResize, WindowSize},
tty::{ChildEvent, EventedPty, EventedReadWrite},
};
use futures::channel::mpsc::UnboundedSender;
use polling::{Event, PollMode, Poller};
use super::pty::PtyEvent;
/// The longest OSC body kept; longer ones are dropped whole.
const MOST: usize = 4096;
/// A pty whose reader hands each byte to an OSC 7 scanner before the parser sees it.
pub(crate) struct Tapped<P> {
pub inner: P,
scanner: Osc7,
events: UnboundedSender<PtyEvent>,
}
impl<P> Tapped<P> {
pub fn new(inner: P, events: UnboundedSender<PtyEvent>) -> Self {
Self {
inner,
scanner: Osc7::default(),
events,
}
}
}
impl<P: EventedReadWrite> io::Read for Tapped<P> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
let count = self.inner.reader().read(buf)?;
let events = &self.events;
self.scanner.feed(&buf[..count], &mut |body| {
if let Some(folder) = folder(body)
&& events.unbounded_send(PtyEvent::Folder(folder)).is_err()
{
log::debug!("pty: a folder report after its session closed");
}
});
Ok(count)
}
}
impl<P: EventedReadWrite> EventedReadWrite for Tapped<P> {
type Reader = Self;
type Writer = P::Writer;
unsafe fn register(
&mut self,
poll: &Arc<Poller>,
event: Event,
mode: PollMode,
) -> io::Result<()> {
unsafe { self.inner.register(poll, event, mode) }
}
fn reregister(&mut self, poll: &Arc<Poller>, event: Event, mode: PollMode) -> io::Result<()> {
self.inner.reregister(poll, event, mode)
}
fn deregister(&mut self, poll: &Arc<Poller>) -> io::Result<()> {
self.inner.deregister(poll)
}
fn reader(&mut self) -> &mut Self {
self
}
fn writer(&mut self) -> &mut P::Writer {
self.inner.writer()
}
}
impl<P: EventedPty> EventedPty for Tapped<P> {
fn next_child_event(&mut self) -> Option<ChildEvent> {
self.inner.next_child_event()
}
}
impl<P: OnResize> OnResize for Tapped<P> {
fn on_resize(&mut self, size: WindowSize) {
self.inner.on_resize(size);
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
enum State {
#[default]
Ground,
Escape,
Osc,
OscEscape,
}
/// Finds `ESC ] 7 ; body` ended by BEL or `ESC \`, across reads.
#[derive(Default)]
pub(crate) struct Osc7 {
state: State,
body: Vec<u8>,
overflow: bool,
}
impl Osc7 {
pub fn feed(&mut self, bytes: &[u8], found: &mut impl FnMut(&[u8])) {
let mut rest = bytes;
while !rest.is_empty() {
if self.state == State::Ground {
match rest.iter().position(|&byte| byte == 0x1b) {
Some(at) => {
self.state = State::Escape;
rest = &rest[at + 1..];
}
None => return,
}
continue;
}
let byte = rest[0];
rest = &rest[1..];
self.state = match (self.state, byte) {
(State::Escape, b']') => {
self.body.clear();
self.overflow = false;
State::Osc
}
(State::Escape, 0x1b) => State::Escape,
(State::Escape, _) => State::Ground,
(State::Osc, 0x07) | (State::OscEscape, b'\\') => {
self.finish(found);
State::Ground
}
(State::Osc | State::OscEscape, 0x18 | 0x1a) => State::Ground,
(State::Osc, 0x1b) => State::OscEscape,
(State::OscEscape, b']') => {
self.body.clear();
self.overflow = false;
State::Osc
}
(State::OscEscape, 0x1b) => State::Escape,
(State::OscEscape, _) => State::Ground,
(State::Osc, byte) => {
if self.body.len() < MOST {
self.body.push(byte);
} else {
self.overflow = true;
}
State::Osc
}
(State::Ground, _) => unreachable!("ground scans by position"),
};
}
}
fn finish(&mut self, found: &mut impl FnMut(&[u8])) {
if self.overflow {
log::warn!("pty: an OSC over {MOST} bytes dropped");
} else if let Some(body) = self.body.strip_prefix(b"7;") {
found(body);
}
self.body.clear();
}
}
/// The local folder an OSC 7 `file://host/path` names; another host's folder is none.
pub(crate) fn folder(body: &[u8]) -> Option<PathBuf> {
let Some(rest) = body.strip_prefix(b"file://") else {
log::warn!(
"pty: OSC 7 without file:// ({})",
String::from_utf8_lossy(body)
);
return None;
};
let slash = rest.iter().position(|&byte| byte == b'/')?;
let host = String::from_utf8_lossy(&rest[..slash]);
let local = gethostname::gethostname();
if !(host.is_empty()
|| host.eq_ignore_ascii_case("localhost")
|| local
.to_str()
.is_some_and(|name| host.eq_ignore_ascii_case(name)))
{
log::debug!("pty: OSC 7 from host {host}, not this one");
return None;
}
let path = match decode(&rest[slash..]) {
Some(path) => path,
None => {
log::warn!("pty: OSC 7 path is not UTF-8 after decoding");
return None;
}
};
let drive = path.as_bytes().get(1..3).is_some_and(|two| two[1] == b':')
&& path.as_bytes()[1].is_ascii_alphabetic();
Some(PathBuf::from(if cfg!(windows) && drive {
&path[1..]
} else {
&path[..]
}))
}
fn decode(bytes: &[u8]) -> Option<String> {
let mut out = Vec::with_capacity(bytes.len());
let mut at = 0;
while at < bytes.len() {
let hex = bytes
.get(at + 1..at + 3)
.and_then(|two| std::str::from_utf8(two).ok())
.and_then(|two| u8::from_str_radix(two, 16).ok());
match (bytes[at], hex) {
(b'%', Some(value)) => {
out.push(value);
at += 3;
}
(byte, _) => {
out.push(byte);
at += 1;
}
}
}
String::from_utf8(out).ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn scan(chunks: &[&[u8]]) -> Vec<String> {
let mut scanner = Osc7::default();
let mut found = Vec::new();
for chunk in chunks {
scanner.feed(chunk, &mut |body| {
found.push(String::from_utf8(body.to_vec()).expect("utf-8"))
});
}
found
}
#[test]
fn finds_osc_7_ended_by_bel_or_st_across_reads() {
assert_eq!(scan(&[b"ab\x1b]7;file:///a\x07cd"]), ["file:///a"]);
assert_eq!(scan(&[b"\x1b]7;file:", b"///b\x1b", b"\\"]), ["file:///b"]);
assert_eq!(scan(&[b"\x1b", b"]7;x\x07\x1b]7;y\x1b\\"]), ["x", "y"]);
}
#[test]
fn other_sequences_and_cancels_pass() {
assert!(scan(&[b"\x1b]0;title\x07\x1b[31m"]).is_empty());
assert!(scan(&[b"\x1b]7;file:///a\x18\x07"]).is_empty());
assert_eq!(scan(&[b"\x1b]7;lost\x1b]7;kept\x07"]), ["kept"]);
}
#[test]
fn an_overlong_body_is_dropped() {
let mut long = b"\x1b]7;".to_vec();
long.extend(std::iter::repeat_n(b'a', MOST + 1));
long.push(0x07);
assert!(scan(&[&long]).is_empty());
assert_eq!(scan(&[&long, b"\x1b]7;b\x07"]), ["b"]);
}
#[test]
fn a_local_uri_gives_its_decoded_folder() {
assert_eq!(
folder(b"file:///tmp/ely%20dir"),
Some(PathBuf::from("/tmp/ely dir"))
);
assert_eq!(folder(b"file://localhost/x"), Some(PathBuf::from("/x")));
let host = gethostname::gethostname().into_string().expect("host");
let named = format!("file://{host}/y");
assert_eq!(folder(named.as_bytes()), Some(PathBuf::from("/y")));
assert_eq!(
folder("file:///%E4%B8%AD".as_bytes()),
Some(PathBuf::from("/中"))
);
}
#[test]
fn other_hosts_and_bad_uris_give_none() {
assert_eq!(folder(b"file://elsewhere.invalid/tmp"), None);
assert_eq!(folder(b"http://x/y"), None);
assert_eq!(folder(b"file:///%FF"), None);
}
}