Compare commits

...

1 Commits

Author SHA1 Message Date
DuProcess 4c9e31fd16 Harden alternate screen tracking
ci / test (push) Has been cancelled
ci / fuzz-smoke (push) Has been cancelled
ci / windows-client (push) Has been cancelled
ci / package-release (linux-x86_64, ubuntu-latest) (push) Has been cancelled
ci / package-release (macos-aarch64, macos-14) (push) Has been cancelled
ci / package-release (macos-x86_64, macos-13) (push) Has been cancelled
ci / package-release (windows-x86_64, windows-latest) (push) Has been cancelled
ci / remote-bench (push) Has been cancelled
2026-07-07 20:37:16 -04:00
3 changed files with 134 additions and 16 deletions
Generated
+1 -1
View File
@@ -436,7 +436,7 @@ dependencies = [
[[package]]
name = "dosh"
version = "1.0.0-rc8"
version = "1.0.0-rc9"
dependencies = [
"anyhow",
"base64",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "dosh"
version = "1.0.0-rc8"
version = "1.0.0-rc9"
edition = "2024"
license = "MIT"
+132 -14
View File
@@ -6296,6 +6296,60 @@ const FLAG_TRIGGER_LOW_MS: f64 = 50.0;
/// (covers a sudden latency spike on the very first slow keystroke).
const GLITCH_FORCE_MS: u128 = 250;
/// Enough recent bytes to join a CSI private-mode sequence split across frames
/// without retaining arbitrary terminal output.
const TERMINAL_OUTPUT_PARSE_TAIL: usize = 64;
fn alternate_screen_after_output(mut active: bool, bytes: &[u8]) -> bool {
let mut offset = 0usize;
while offset < bytes.len() {
let Some((next, transition)) = terminal_private_mode_transition(bytes, offset) else {
offset += 1;
continue;
};
if let Some(enable) = transition {
active = enable;
}
offset = next.max(offset + 1);
}
active
}
fn terminal_private_mode_transition(bytes: &[u8], offset: usize) -> Option<(usize, Option<bool>)> {
let mut cursor = offset;
match bytes.get(cursor).copied()? {
0x1b if bytes.get(cursor + 1) == Some(&b'[') => cursor += 2,
0x9b => cursor += 1,
_ => return None,
}
if bytes.get(cursor) != Some(&b'?') {
return None;
}
cursor += 1;
let params_start = cursor;
while let Some(byte) = bytes.get(cursor).copied() {
match byte {
b'0'..=b'9' | b';' | b':' => cursor += 1,
b'h' | b'l' => {
let params = &bytes[params_start..cursor];
let touches_alt = terminal_private_params_include_alt_screen(params);
return Some((cursor + 1, touches_alt.then_some(byte == b'h')));
}
0x40..=0x7e => return Some((cursor + 1, None)),
_ => return None,
}
}
None
}
fn terminal_private_params_include_alt_screen(params: &[u8]) -> bool {
params
.split(|byte| matches!(byte, b';' | b':'))
.any(|param| matches!(param, b"47" | b"1047" | b"1049"))
}
/// One speculatively-echoed character on the current line.
#[derive(Clone, Debug)]
struct PredictedCell {
@@ -6313,6 +6367,9 @@ struct Predictor {
/// as vim/htop); we never speculate there because we cannot model arbitrary
/// cursor addressing safely.
alternate_screen: bool,
/// Tail of recent terminal output, retained so alternate-screen transitions
/// split across UDP frames are still detected.
output_parse_tail: Vec<u8>,
/// Predicted cells for the current line, left-to-right starting at the
/// column where the cursor sat when this run of predictions began.
@@ -6359,6 +6416,7 @@ impl Predictor {
mode,
enabled: enabled && mode != PredictMode::Off,
alternate_screen: false,
output_parse_tail: Vec::new(),
cells: Vec::new(),
cursor: 0,
epoch: 1,
@@ -6381,6 +6439,7 @@ impl Predictor {
self.epoch += 1;
self.confirmed_epoch = self.epoch - 1;
self.alternate_screen = false;
self.output_parse_tail.clear();
self.oldest_pending_at = None;
}
@@ -6537,20 +6596,20 @@ impl Predictor {
/// and confirms outstanding predictions: a fresh frame means the server has
/// re-rendered the line, so our speculative glyphs are superseded and erased.
fn observe_output(&mut self, bytes: &[u8]) {
if contains_bytes(bytes, b"\x1b[?1049h")
|| contains_bytes(bytes, b"\x1b[?1047h")
|| contains_bytes(bytes, b"\x1b[?47h")
{
self.alternate_screen = true;
let _ = self.discard_all();
}
if contains_bytes(bytes, b"\x1b[?1049l")
|| contains_bytes(bytes, b"\x1b[?1047l")
|| contains_bytes(bytes, b"\x1b[?47l")
{
self.alternate_screen = false;
let mut parse = Vec::with_capacity(self.output_parse_tail.len() + bytes.len());
parse.extend_from_slice(&self.output_parse_tail);
parse.extend_from_slice(bytes);
let before = self.alternate_screen;
self.alternate_screen = alternate_screen_after_output(self.alternate_screen, &parse);
if self.alternate_screen != before {
let _ = self.discard_all();
}
self.output_parse_tail.clear();
let keep = parse.len().min(TERMINAL_OUTPUT_PARSE_TAIL);
self.output_parse_tail
.extend_from_slice(&parse[parse.len().saturating_sub(keep)..]);
}
/// Sample SRTT from a newly arrived frame and confirm/clear outstanding
@@ -7344,10 +7403,13 @@ const TERMINAL_CLEANUP: &[u8] = concat!(
"\x1b[?1002l",
"\x1b[?1001l",
"\x1b[?1000l",
"\x1b[?1004l",
"\x1b[?1015l",
"\x1b[?1006l",
"\x1b[?1005l",
"\x1b[?2004l",
"\x1b[?47l",
"\x1b[?1047l",
"\x1b[?1049l"
)
.as_bytes();
@@ -7372,8 +7434,9 @@ mod tests {
should_hold_during_startup_gate, should_hold_post_submit_input,
should_repaint_idle_alternate_screen, split_after_command_submit, ssh_destination_host,
ssh_username, ssh_with_user, startup_command, status_ssh_target, strip_stale_mouse_reports,
strip_terminal_focus_reports, toml_bare_key_or_quoted, update_check_requested,
update_version_status, upsert_managed_block, valid_forward_host, vscode_safe_alias,
strip_terminal_focus_reports, terminal_private_mode_transition, toml_bare_key_or_quoted,
update_check_requested, update_version_status, upsert_managed_block, valid_forward_host,
vscode_safe_alias,
};
use dosh::config::{ClientConfig, CommandExtension, HostConfig};
use dosh::native::EnvVar;
@@ -7892,6 +7955,61 @@ mod tests {
assert!(!predictor.alternate_screen);
}
#[test]
fn predictor_tracks_legacy_and_combined_alternate_screen_modes() {
let mut predictor = Predictor::new(true);
predictor.observe_output(b"\x1b[?1000;1006;1049h");
assert!(predictor.alternate_screen);
predictor.observe_output(b"\x1b[?1006;1000l");
assert!(
predictor.alternate_screen,
"leaving mouse modes must not imply leaving alternate screen"
);
predictor.observe_output(b"\x1b[?1047l");
assert!(!predictor.alternate_screen);
predictor.observe_output(b"\x1b[?47h");
assert!(predictor.alternate_screen);
predictor.observe_output(b"\x1b[?47l");
assert!(!predictor.alternate_screen);
}
#[test]
fn predictor_tracks_alternate_screen_split_across_frames() {
let mut predictor = Predictor::new(true);
predictor.observe_output(b"\x1b");
assert!(!predictor.alternate_screen);
predictor.observe_output(b"[?");
assert!(!predictor.alternate_screen);
predictor.observe_output(b"10");
assert!(!predictor.alternate_screen);
predictor.observe_output(b"49h");
assert!(predictor.alternate_screen);
predictor.observe_output(b"\x1b");
assert!(predictor.alternate_screen);
predictor.observe_output(b"[?104");
assert!(predictor.alternate_screen);
predictor.observe_output(b"9l");
assert!(!predictor.alternate_screen);
}
#[test]
fn private_mode_parser_ignores_non_alt_modes() {
assert_eq!(
terminal_private_mode_transition(b"\x1b[?1000;1006h", 0),
Some((13, None))
);
assert_eq!(
terminal_private_mode_transition(b"\x1b[?1000;1049h", 0),
Some((13, Some(true)))
);
}
/// Typing printable characters builds an in-order predicted line and the
/// local cursor tracks the end of it.
#[test]