fix: reduce scroll CPU in headless server

refs #512
This commit is contained in:
Ogulcan Celik 2026-06-08 19:48:51 +03:00
parent 07222706c7
commit a7cd7806f2
5 changed files with 143 additions and 48 deletions

View File

@ -1547,10 +1547,7 @@ impl AppState {
else {
return false;
};
if !rt
.input_state()
.is_some_and(crate::pane::InputState::mouse_reporting_enabled)
{
if rt.wheel_routing() != Some(crate::pane::WheelRouting::MouseReport) {
return false;
}
rt.scroll_reset();

View File

@ -2120,14 +2120,7 @@ impl PaneRuntime {
}
pub fn wheel_routing(&self) -> Option<WheelRouting> {
let input_state = self.input_state()?;
Some(if input_state.mouse_reporting_enabled() {
WheelRouting::MouseReport
} else if input_state.alternate_screen && input_state.mouse_alternate_scroll {
WheelRouting::AlternateScroll
} else {
WheelRouting::HostScroll
})
self.terminal.wheel_routing()
}
pub fn encode_mouse_button(

View File

@ -183,6 +183,10 @@ impl PaneTerminal {
self.ghostty.input_state()
}
pub fn wheel_routing(&self) -> Option<crate::pane::WheelRouting> {
self.ghostty.wheel_routing()
}
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
self.ghostty.cursor_state()
}
@ -870,6 +874,29 @@ impl GhosttyPaneTerminal {
})
}
pub fn wheel_routing(&self) -> Option<crate::pane::WheelRouting> {
let Ok(core) = self.core.lock() else {
return None;
};
let alternate_screen =
core.terminal.active_screen().ok()? == crate::ghostty::ActiveScreen::Alternate;
let mouse_alternate_scroll = core
.terminal
.mode_get(crate::ghostty::MODE_MOUSE_ALTERNATE_SCROLL)
.ok()?;
let mouse_reporting = core.terminal.mode_get(MODE_MOUSE_ANY_MOTION).ok()?
|| core.terminal.mode_get(MODE_MOUSE_BUTTON_MOTION).ok()?
|| core.terminal.mode_get(MODE_MOUSE_PRESS_RELEASE).ok()?
|| core.terminal.mode_get(MODE_MOUSE_X10).ok()?;
Some(if mouse_reporting {
crate::pane::WheelRouting::MouseReport
} else if alternate_screen && mouse_alternate_scroll {
crate::pane::WheelRouting::AlternateScroll
} else {
crate::pane::WheelRouting::HostScroll
})
}
pub fn cursor_state(&self) -> Option<TerminalCursorState> {
let mut core = self.core.lock().ok()?;
let GhosttyPaneCore {

View File

@ -2795,7 +2795,7 @@ impl HeadlessServer {
return false;
};
let prepare_started = crate::render_prof::timer();
let Some(prepared) = client.render_state.prepare_frame(&frame) else {
let Some(prepared) = client.render_state.prepare_frame(frame) else {
client.render_pending = false;
crate::render_prof::event("retained_send.skip_identical");
crate::render_prof::duration_since("retained_send.prepare_frame", prepare_started);
@ -2831,7 +2831,7 @@ impl HeadlessServer {
match writer.render.try_send(serialized) {
Ok(()) => {
client.render_pending = false;
client.render_state.commit_sent_frame(frame, prepared);
client.render_state.commit_sent_frame(prepared);
crate::render_prof::event("retained_send.sent");
crate::render_prof::duration_since("retained_send.try_send", send_started);
true
@ -3007,21 +3007,21 @@ impl HeadlessServer {
commit_graphics_cache = false;
}
let max_frame_size = if frame.graphics.is_empty() {
MAX_FRAME_SIZE
} else {
MAX_GRAPHICS_FRAME_SIZE
};
let has_graphics = !frame.graphics.is_empty();
let prepare_started = crate::render_prof::timer();
let Some(mut prepared) = client.render_state.prepare_frame(&frame) else {
let Some(mut prepared) = client.render_state.prepare_frame(frame) else {
client.render_pending = false;
crate::render_prof::event("full_render.skip_identical");
crate::render_prof::duration_since("full_render.prepare_frame", prepare_started);
continue;
};
crate::render_prof::duration_since("full_render.prepare_frame", prepare_started);
let mut frame_to_commit = frame.clone();
let max_frame_size = if frame.graphics.is_empty() {
MAX_FRAME_SIZE
} else {
MAX_GRAPHICS_FRAME_SIZE
};
let serialize_started = crate::render_prof::timer();
let serialized = match Self::frame_server_message_with_max(
prepared.message(),
@ -3031,17 +3031,22 @@ impl HeadlessServer {
crate::render_prof::duration_since("full_render.serialize", serialize_started);
framed
}
Err(protocol::FramingError::Oversized { claimed, max })
if !frame.graphics.is_empty() =>
{
Err(protocol::FramingError::Oversized { claimed, max }) if has_graphics => {
warn!(
client_id,
claimed, max, "dropping graphics from oversized frame for client"
);
let mut text_only_frame = frame.clone();
let Some(mut text_only_frame) = prepared.into_frame() else {
crate::render_prof::event("full_render.serialize_error");
crate::render_prof::duration_since(
"full_render.serialize",
serialize_started,
);
continue;
};
text_only_frame.graphics.clear();
let Some(text_only_prepared) =
client.render_state.prepare_frame(&text_only_frame)
client.render_state.prepare_frame(text_only_frame)
else {
client.render_pending = false;
crate::render_prof::event("full_render.skip_identical_text_only");
@ -3065,7 +3070,6 @@ impl HeadlessServer {
}
};
prepared = text_only_prepared;
frame_to_commit = text_only_frame;
commit_graphics_cache = false;
crate::render_prof::duration_since("full_render.serialize", serialize_started);
framed
@ -3097,9 +3101,7 @@ impl HeadlessServer {
client.graphics_cache = next_graphics_cache;
client.graphics_surface_reset_pending = false;
}
client
.render_state
.commit_sent_frame(frame_to_commit, prepared);
client.render_state.commit_sent_frame(prepared);
crate::render_prof::event("full_render.sent");
crate::render_prof::duration_since("full_render.try_send", send_started);
}
@ -4504,6 +4506,52 @@ next_tab = ""
rt.shutdown_timeout(Duration::from_millis(100));
}
#[test]
fn terminal_attach_page_key_forwards_in_alternate_screen_without_mouse_reporting() {
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.expect("test runtime");
let _runtime_guard = rt.enter();
let mut bytes = b"\x1b[?1049h".to_vec();
for line in 0..80 {
bytes.extend_from_slice(format!("line {line:02}\r\n").as_bytes());
}
let (runtime, mut input_rx) =
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
20, 5, 4096, &bytes, 4,
);
runtime.scroll_up(3);
apply_terminal_attach_scroll(
&runtime,
AttachScrollSource::PageKey {
input: b"\x1b[5~".to_vec(),
},
AttachScrollDirection::Up,
4,
None,
None,
0,
)
.expect("page key forward");
assert_eq!(
runtime
.scroll_metrics()
.expect("scroll metrics")
.offset_from_bottom,
0
);
assert_eq!(
input_rx.try_recv().expect("forwarded page key"),
Bytes::from_static(b"\x1b[5~")
);
drop(runtime);
drop(_runtime_guard);
rt.shutdown_timeout(Duration::from_millis(100));
}
#[test]
fn headless_scheduled_tasks_expire_agent_metadata() {
let mut server = test_headless_server();
@ -6384,9 +6432,9 @@ next_tab = ""
frame.cells[hyperlink_idx].hyperlink = Some(0);
let prepared = client
.render_state
.prepare_frame(&frame)
.prepare_frame(frame)
.expect("hyperlink frame differs");
client.render_state.commit_sent_frame(frame, prepared);
client.render_state.commit_sent_frame(prepared);
let runtime = server
.app

View File

@ -41,25 +41,24 @@ impl ClientRenderState {
}
}
pub(crate) fn prepare_frame(&mut self, frame: &FrameData) -> Option<PreparedRender> {
pub(crate) fn prepare_frame(&mut self, frame: FrameData) -> Option<PreparedRender> {
match self {
Self::Semantic { last_frame } => {
if last_frame.as_ref() == Some(frame) {
if last_frame.as_ref() == Some(&frame) {
crate::render_prof::event("prepare_frame.semantic.skip_current");
return None;
}
crate::render_prof::event("prepare_frame.semantic.changed");
Some(PreparedRender {
message: ServerMessage::Frame(frame.clone()),
encoded: None,
Some(PreparedRender::Semantic {
message: ServerMessage::Frame(frame),
})
}
Self::TerminalAnsi { blit_encoder, seq } => {
if blit_encoder.is_current(frame) {
if blit_encoder.is_current(&frame) {
crate::render_prof::event("prepare_frame.ansi.skip_current");
return None;
}
let mut encoded = blit_encoder.encode(frame, false);
let mut encoded = blit_encoder.encode(&frame, false);
crate::render_prof::event("prepare_frame.ansi.changed");
crate::render_prof::counter("prepare_frame.ansi.bytes", encoded.bytes.len() as u64);
if encoded.full {
@ -72,7 +71,7 @@ impl ClientRenderState {
"prepare_frame.graphics.bytes",
frame.graphics.len() as u64,
);
Some(PreparedRender {
Some(PreparedRender::TerminalAnsi {
message: ServerMessage::Terminal(TerminalFrame {
seq: *seq + 1,
width: frame.width,
@ -80,6 +79,7 @@ impl ClientRenderState {
full: encoded.full,
bytes: encoded.bytes.clone(),
}),
frame,
encoded: Some(encoded),
})
}
@ -93,10 +93,22 @@ impl ClientRenderState {
}
}
pub(crate) fn commit_sent_frame(&mut self, frame: FrameData, prepared: PreparedRender) {
match (self, prepared.encoded) {
(Self::Semantic { last_frame }, None) => *last_frame = Some(frame),
(Self::TerminalAnsi { blit_encoder, seq }, Some(encoded)) => {
pub(crate) fn commit_sent_frame(&mut self, prepared: PreparedRender) {
match (self, prepared) {
(
Self::Semantic { last_frame },
PreparedRender::Semantic {
message: ServerMessage::Frame(frame),
},
) => *last_frame = Some(frame),
(
Self::TerminalAnsi { blit_encoder, seq },
PreparedRender::TerminalAnsi {
frame,
encoded: Some(encoded),
..
},
) => {
blit_encoder.commit(frame, encoded);
*seq += 1;
}
@ -138,14 +150,32 @@ fn rfind_subslice(haystack: &[u8], needle: &[u8]) -> Option<usize> {
}
/// A prepared client render message plus any baseline state needed after send.
pub(crate) struct PreparedRender {
message: ServerMessage,
encoded: Option<EncodedBlit>,
pub(crate) enum PreparedRender {
Semantic {
message: ServerMessage,
},
TerminalAnsi {
message: ServerMessage,
frame: FrameData,
encoded: Option<EncodedBlit>,
},
}
impl PreparedRender {
pub(crate) fn message(&self) -> &ServerMessage {
&self.message
match self {
Self::Semantic { message } | Self::TerminalAnsi { message, .. } => message,
}
}
pub(crate) fn into_frame(self) -> Option<FrameData> {
match self {
Self::Semantic {
message: ServerMessage::Frame(frame),
} => Some(frame),
Self::TerminalAnsi { frame, .. } => Some(frame),
_ => None,
}
}
}