diff --git a/CLAUDE.md b/CLAUDE.md index af05800..4b0fe38 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,6 +259,7 @@ cargo test -- --ignored # 会读写真实钥匙串的测试 - 通知在解析线程上:按 `\n` 切开输出,在 LF **之前**读光标所在的逻辑行(这时 `\r` 和颜色都已生效),锁外匹配。备用屏和同步更新进行中不读。 - 规则由 `settings::apply` 编译成全局量 `TerminalHighlights`,引擎 `observe_global` 后换进与解析线程共用的槽;重启运行时也要把槽传过去。 - `settings.json` 里读不了的规则单条丢掉(`readable_rules`),否则整个设置文件会回退成默认值。 +- **同步更新(DEC 2026)要解析线程自己收尾**:vte 只管攒住 `\e[?2026h` 之后的输出,超时(150 ms)后调 `stop_sync` 是调用方的事,alacritty 自己的事件循环就是这么做的。解析线程等输出时带着截止时间(`receive`,用 `async_io` 的定时器),到点就画出来;输出一直不停时在 `advance` 前补查;退出、出错追加提示前也先 `stop_sync`。否则程序死在一帧中间、连接断在一帧中间时,屏幕会一直冻住。 - **OSC 52 只写不读**(`Osc52::OnlyCopy`),只认剪贴板 `c`,主选择区忽略。 - **通知**:OSC 9 / 777 由 `terminal/notices.rs` 的扫描器在解析线程上认(alacritty 会丢掉),OSC 9 里「数字;」开头的是 ConEmu 的命令,不算通知。投递在 `workspace/notices.rs`:窗口不在前台走 GPUI 的 `show_system_notification`(tag 是 `terminal:local:` / `terminal:remote:`,点击回调按 tag 切标签),在前台走应用内通知。`shellrs::init` 里的 `set_app_identity` 用 bundle id,测试平台没有它就不发系统通知;macOS 上 `cargo run` 没有 bundle,看不到系统通知。 diff --git a/Cargo.lock b/Cargo.lock index caa2d1a..392f2cb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7707,6 +7707,7 @@ dependencies = [ "alacritty_terminal", "anyhow", "async-channel", + "async-io", "base64 0.22.1", "chrono", "clap", diff --git a/Cargo.toml b/Cargo.toml index 282b7da..8ae21f4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,6 +44,9 @@ gpui-kit = { version = "0.6.1", features = [ alacritty_terminal = "0.26" anyhow = "1" async-channel = "2" +# A terminal's parser thread waits for output only until a synchronized +# update has to be drawn. +async-io = "2" base64 = "0.22" clap = { version = "4", features = ["derive"] } dirs = "6" diff --git a/src/terminal/engine.rs b/src/terminal/engine.rs index 4bff7f2..389642e 100644 --- a/src/terminal/engine.rs +++ b/src/terminal/engine.rs @@ -14,7 +14,10 @@ use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::color::COUNT; use alacritty_terminal::term::search::RegexSearch; use alacritty_terminal::term::{ClipboardType, Config, Osc52, TermMode}; -use alacritty_terminal::vte::ansi::{Color, CursorShape, CursorStyle, Processor, Rgb}; +use alacritty_terminal::vte::ansi::{ + Color, CursorShape, CursorStyle, Processor, Rgb, Timeout as _, +}; +use futures::future::{Either, select}; use crate::connection::{ConnectionPrompt, ConnectionPromptReply, Latency}; use crate::host::HostOs; @@ -654,11 +657,26 @@ impl TerminalRuntime { thread::Builder::new() .name("shellrs-terminal-parser".into()) .spawn(move || { - let mut processor = Processor::new(); + let mut processor: Processor = Processor::new(); // Alacritty drops the notifications programs ask for. let mut notices = NoticeScanner::default(); let mut watcher = LineWatcher::new(current(&highlights)); - while let Ok(event) = transport_receiver.recv_blocking() { + loop { + // Output inside a synchronized update is held back until + // the program ends it. Alacritty's own event loop draws + // it anyway once the update's time is up, so a program + // that never ends it (killed mid-frame, or its connection + // lost) does not freeze the screen; this loop has to too. + let deadline = processor.sync_timeout().sync_timeout(); + let event = match receive(&transport_receiver, deadline) { + Received::Event(event) => event, + Received::Closed => break, + Received::Overdue => { + processor.stop_sync(&mut *parser_term.lock()); + parser_proxy.wakeup(); + continue; + } + }; match event { TerminalTransportEvent::Started => { parser_proxy.send(TerminalUiEventKind::Started); @@ -667,12 +685,18 @@ impl TerminalRuntime { watcher.sync(current(&highlights)); // The lines are only read under the lock; the // rules match them once it is let go. - let lines = advance_watching( - &mut processor, - &mut parser_term.lock(), - &bytes, - watcher.is_watching(), - ); + let lines = { + let mut term = parser_term.lock(); + // Output that never pauses gives the wait no + // chance to see the time is up. + end_overdue_sync(&mut processor, &mut term); + advance_watching( + &mut processor, + &mut term, + &bytes, + watcher.is_watching(), + ) + }; if let Some(hit) = watcher.check(lines, Instant::now()) { parser_proxy.send(TerminalUiEventKind::Keyword(hit)); } @@ -895,7 +919,55 @@ fn append_message_with_processor( message: &str, ) { let line = format!("\r\n\x1b[2m[{message}]\x1b[0m\r\n"); - processor.advance(&mut *term.lock(), line.as_bytes()); + let mut term = term.lock(); + // A program that ended mid-frame left its synchronized update open: + // neither that frame nor this message would show. + if processor.sync_timeout().pending_timeout() { + processor.stop_sync(&mut *term); + } + processor.advance(&mut *term, line.as_bytes()); +} + +/// What waiting for the transport came to. +enum Received { + Event(T), + /// The open synchronized update's time is up before anything came. + Overdue, + /// The transport is gone. + Closed, +} + +/// The transport's next event; with a synchronized update open, waiting +/// only until its `deadline`. `recv_blocking` cannot stop at a time, hence +/// the timer. +fn receive(events: &async_channel::Receiver, deadline: Option) -> Received { + let Some(deadline) = deadline else { + return events + .recv_blocking() + .map_or(Received::Closed, Received::Event); + }; + async_io::block_on(async { + let next = std::pin::pin!(events.recv()); + // What has come already wins over a deadline that has passed. + match select(next, async_io::Timer::at(deadline)).await { + Either::Left((Ok(event), _)) => Received::Event(event), + Either::Left((Err(_), _)) => Received::Closed, + Either::Right(_) => Received::Overdue, + } + }) +} + +/// Draw what an open synchronized update has held back, if its time is up. +/// Says whether it was. +fn end_overdue_sync(processor: &mut Processor, term: &mut AlacrittyTerm) -> bool { + let overdue = processor + .sync_timeout() + .sync_timeout() + .is_some_and(|deadline| deadline <= Instant::now()); + if overdue { + processor.stop_sync(term); + } + overdue } fn sanitize_title(title: &str) -> Option { @@ -1521,4 +1593,140 @@ mod tests { // vim and less have the keys to themselves. assert!(encode_command("ls", true, true, TermMode::ALT_SCREEN).is_err()); } + + /// Sends its events, then stays quiet until shut down, as a shell does + /// when nothing is typed. + struct ScriptedTransport(Vec); + + impl super::super::TerminalTransport for ScriptedTransport { + fn run( + self: Box, + _: TerminalSize, + commands: mpsc::Receiver, + events: async_channel::Sender, + ) -> anyhow::Result<()> { + for event in self.0 { + events.send_blocking(event)?; + } + while let Ok(command) = commands.recv() { + if matches!(command, TerminalTransportCommand::Shutdown) { + break; + } + } + Ok(()) + } + } + + /// A terminal whose transport plays `script`. + fn run_script(script: Vec) -> TerminalRuntime { + let (ui_events, _) = mpsc::channel(); + TerminalRuntime::start( + 1, + TerminalSize::new(40, 4, 8, 16), + Box::new(ScriptedTransport(script)), + ui_events, + Arc::default(), + ) + } + + /// The screen's text once it holds `text`, or what it holds after two + /// seconds of waiting. + fn screen_once_it_shows(runtime: &TerminalRuntime, text: &str) -> String { + let started = Instant::now(); + loop { + let term = runtime.term.lock(); + let screen = (0..4) + .map(|line| { + term.grid()[Line(line)] + .into_iter() + .filter(|cell| !cell.flags.contains(Flags::WIDE_CHAR_SPACER)) + .map(|cell| cell.c) + .collect::() + .trim_end() + .to_owned() + }) + .collect::>() + .join("\n"); + drop(term); + if screen.contains(text) || started.elapsed() > Duration::from_secs(2) { + return screen; + } + thread::sleep(Duration::from_millis(5)); + } + } + + #[test] + fn an_update_left_open_is_drawn_once_its_time_is_up() { + // `printf '\e[?2026h'`, or a program killed between beginning a + // frame and ending it: nothing ends the update. + let started = Instant::now(); + let runtime = run_script(vec![ + TerminalTransportEvent::Started, + TerminalTransportEvent::Output(b"before \x1b[?2026hframe".to_vec()), + ]); + assert!(screen_once_it_shows(&runtime, "before frame").contains("before frame")); + // Held back meanwhile, as the update asked. + assert!(started.elapsed() >= Duration::from_millis(150)); + } + + #[test] + fn an_update_ended_in_time_is_drawn_whole() { + let runtime = run_script(vec![ + TerminalTransportEvent::Output(b"\x1b[?2026hone ".to_vec()), + TerminalTransportEvent::Output(b"two\x1b[?2026l".to_vec()), + ]); + assert!(screen_once_it_shows(&runtime, "one two").contains("one two")); + } + + #[test] + fn a_program_that_ends_mid_frame_still_shows_the_frame_and_its_exit() { + let runtime = run_script(vec![ + TerminalTransportEvent::Output(b"\x1b[?2026hframe".to_vec()), + TerminalTransportEvent::Exited { + code: 137, + signal: None, + }, + ]); + let screen = screen_once_it_shows(&runtime, "退出码 137"); + assert!(screen.contains("frame"), "{screen}"); + assert!(screen.contains("[进程已退出,退出码 137]"), "{screen}"); + } + + #[test] + fn output_that_never_pauses_does_not_hold_an_update_open() { + let mut term = test_term(40, 4); + let mut processor: Processor = Processor::new(); + processor.advance(&mut term, b"\x1b[?2026hframe"); + assert!(!end_overdue_sync(&mut processor, &mut term)); + assert_eq!(line_text(&term, 0), ""); + thread::sleep(Duration::from_millis(160)); + assert!(end_overdue_sync(&mut processor, &mut term)); + assert_eq!(line_text(&term, 0), "frame"); + // Done with, the next output is drawn as it comes. + processor.advance(&mut term, b" more"); + assert_eq!(line_text(&term, 0), "frame more"); + } + + #[test] + fn waiting_stops_at_the_deadline_but_not_before_what_has_come() { + // Something comes in the end, so a wait past the deadline fails the + // test rather than hangs it. + let (late, receiver) = async_channel::unbounded(); + thread::spawn(move || { + thread::sleep(Duration::from_secs(1)); + let _ = late.send_blocking(0); + }); + let soon = Instant::now() + Duration::from_millis(20); + assert!(matches!(receive(&receiver, Some(soon)), Received::Overdue)); + assert!(Instant::now() >= soon); + + let (sender, receiver) = async_channel::unbounded(); + sender.send_blocking(1).unwrap(); + let past = Instant::now(); + assert!(matches!(receive(&receiver, Some(past)), Received::Event(1))); + sender.send_blocking(2).unwrap(); + assert!(matches!(receive(&receiver, None), Received::Event(2))); + drop(sender); + assert!(matches!(receive(&receiver, None), Received::Closed)); + } }