From d8b36691d10c6055293ad45324b22af8b8f570e5 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Mon, 14 Sep 2026 22:36:04 +0300 Subject: fix: avoid resending terminal cells for metadata updates (#4137) --- src/protocol/surface_reuse.rs | 127 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 127 insertions(+) create mode 100644 src/protocol/surface_reuse.rs (limited to 'src/protocol/surface_reuse.rs') diff --git a/src/protocol/surface_reuse.rs b/src/protocol/surface_reuse.rs new file mode 100644 index 00000000..bba85824 --- /dev/null +++ b/src/protocol/surface_reuse.rs @@ -0,0 +1,127 @@ +//! Optional endpoint encoding that retains unchanged terminal cells across projections. + +use super::{CellData, PaneSurfaceFrame, ServerMessage}; +use serde::{Deserialize, Serialize}; + +pub(crate) const CAPABILITY: &str = "surface_reuse"; +pub(crate) const MESSAGE_KIND: &str = "endpoint.surface-reuse.v1"; + +#[derive(Serialize, Deserialize)] +struct SurfaceReuse { + base_surface_revision: u64, + surface: S, +} + +pub(crate) fn message( + base_surface_revision: u64, + surface: &mut PaneSurfaceFrame, +) -> serde_json::Result> { + let cells = std::mem::take(&mut surface.frame.cells); + let data = serde_json::to_string(&SurfaceReuse { + base_surface_revision, + surface: &*surface, + }); + surface.frame.cells = cells; + let message = ServerMessage::EndpointControl { + kind: MESSAGE_KIND.into(), + data: data?, + }; + // JSON can expand non-cell data (for example escaped hyperlink URLs). A + // failed compact encoding must fall back, not strand a newer snapshot. + let size = bincode::serde::encode_into_std_write( + &message, + &mut std::io::sink(), + bincode::config::standard(), + ); + match size { + Ok(size) => Ok((size <= super::MAX_FRAME_SIZE).then_some(message)), + Err(error) => { + tracing::warn!(%error, "failed to size surface reuse"); + Ok(None) + } + } +} + +#[derive(Default)] +struct CellBaseline { + boot_id: String, + projection_revision: u64, + surface_revision: u64, + width: u16, + height: u16, + cells: Vec, +} + +/// Connection-local decoding happens before activation and presentation filtering, so +/// switching endpoints cannot discard a baseline needed by the next wire message. +#[derive(Default)] +pub(crate) struct Decoder { + baseline: Option, +} + +impl Decoder { + pub(crate) fn decode(&mut self, message: ServerMessage) -> Result { + let message = match message { + ServerMessage::EndpointControl { kind, data } if kind == MESSAGE_KIND => { + let reuse: SurfaceReuse = serde_json::from_str(&data) + .map_err(|error| format!("invalid surface reuse: {error}"))?; + let Some(base) = &mut self.baseline else { + return Err("surface reuse without a baseline".into()); + }; + let mut surface = reuse.surface; + if base.boot_id != surface.boot_id + || base.surface_revision != reuse.base_surface_revision + || surface.surface_revision != base.surface_revision.saturating_add(1) + || base.width != surface.frame.width + || base.height != surface.frame.height + || !surface.frame.cells.is_empty() + { + return Err("surface reuse does not match its baseline".into()); + } + surface.frame.cells.clone_from(&base.cells); + base.projection_revision = surface.projection_revision; + base.surface_revision = surface.surface_revision; + return Ok(ServerMessage::PaneSurface(surface)); + } + message => message, + }; + match &message { + ServerMessage::PaneSurface(surface) => { + let base = self.baseline.get_or_insert_with(CellBaseline::default); + base.boot_id.clone_from(&surface.boot_id); + base.projection_revision = surface.projection_revision; + base.surface_revision = surface.surface_revision; + base.width = surface.frame.width; + base.height = surface.frame.height; + base.cells.clone_from(&surface.frame.cells); + } + ServerMessage::PaneSurfacePatch(patch) => { + if let Some(base) = &mut self.baseline { + if patch.boot_id != base.boot_id + || patch.projection_revision != base.projection_revision + || patch.base_surface_revision != base.surface_revision + || patch.surface_revision != base.surface_revision.saturating_add(1) + { + self.baseline = None; + } else { + for row in &patch.rows { + let start = + usize::from(row.y) * usize::from(base.width) + usize::from(row.x); + let end = start.saturating_add(row.cells.len()); + if row.y >= base.height + || usize::from(row.x) + row.cells.len() > usize::from(base.width) + || end > base.cells.len() + { + return Err("surface patch exceeds the cell baseline".into()); + } + base.cells[start..end].clone_from_slice(&row.cells); + } + base.surface_revision = patch.surface_revision; + } + } + } + _ => {} + } + Ok(message) + } +} -- cgit v1.2.3