Compare commits

...

3 Commits

Author SHA1 Message Date
fufesou
929a4e78ba fix(terminal): env en_US.UTF-8
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-29 22:30:39 +08:00
fufesou
18479129a2 fix: cap terminal reconnect replay output
- split reconnect replay backlog into capped chunks
  - mark terminal data replay chunks for client-side suppression
  - avoid using open-message text to suppress xterm replies
  - reuse default terminal padding value
  - remove misleading Enter-key normalization PR link

Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-29 22:12:26 +08:00
fufesou
b516dfb15b fix(terminal): reconnect suppress next output
Signed-off-by: fufesou <linlong1266@gmail.com>
2026-04-29 20:54:04 +08:00
4 changed files with 91 additions and 54 deletions

View File

@@ -58,8 +58,7 @@ class _TerminalPageState extends State<TerminalPage>
super.initState();
// Listen for tab selection changes to request focus
_tabStateSubscription =
widget.tabController.state.listen(_onTabStateChanged);
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
// Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection(
@@ -146,9 +145,7 @@ class _TerminalPageState extends State<TerminalPage>
// Use post-frame callback to ensure widget is fully laid out in focus tree
WidgetsBinding.instance.addPostFrameCallback((_) {
// Re-check conditions after frame: mounted, focusable, still selected, not already focused
if (!mounted ||
!_terminalFocusNode.canRequestFocus ||
_terminalFocusNode.hasFocus) return;
if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return;
final state = widget.tabController.state.value;
if (state.selected >= 0 && state.selected < state.tabs.length) {
if (state.tabs[state.selected].key == widget.tabKey) {
@@ -179,7 +176,10 @@ class _TerminalPageState extends State<TerminalPage>
return _defaultTerminalPadding;
}
final topBottom = extraSpace / 2.0;
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
return EdgeInsets.symmetric(
horizontal: _defaultTerminalPadding.horizontal / 2,
vertical: topBottom,
);
}
@override

View File

@@ -41,7 +41,7 @@ class TerminalModel with ChangeNotifier {
Future<void> _handleInput(String data) async {
// Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a
// real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'.
// - Peer Windows: '\r' works, '\n' is just a newline (https://github.com/rustdesk/rustdesk/pull/14736).
// - Peer Windows: '\r' works, '\n' is just a newline.
// - Peer Linux: canonical-mode shells accept both, but raw-mode apps
// (readline, prompt_toolkit, vim, TUI frameworks) expect '\r'.
// - Peer macOS: same as Linux, raw-mode apps expect '\r'
@@ -288,8 +288,7 @@ class TerminalModel with ChangeNotifier {
// On reconnect, the server may replay recent output. That replay can include
// terminal queries like DSR/DA; xterm answers them through onOutput as
// "^[[1;1R^[[2;2R^[[>0;0;0c", which must not be sent back to the peer.
_suppressNextTerminalDataOutput =
message == 'Reconnected to existing terminal with pending output';
_suppressNextTerminalDataOutput = evt['replay_in_next_data'] == true;
// Fallback: if terminal view is not yet ready but already has valid
// dimensions (e.g. layout completed before open response arrived),
@@ -348,7 +347,8 @@ class TerminalModel with ChangeNotifier {
final data = evt['data'];
if (data != null) {
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
final suppressTerminalOutput =
evt['replay'] == true || _suppressNextTerminalDataOutput;
_suppressNextTerminalDataOutput = false;
try {
String text = '';

View File

@@ -1135,6 +1135,7 @@ impl InvokeUiSession for FlutterHandler {
("message", json!(&opened.message)),
("pid", json!(opened.pid)),
("service_id", json!(&opened.service_id)),
("replay_in_next_data", json!(opened.replay_in_next_data)),
];
if !opened.persistent_sessions.is_empty() {
event_data.push(("persistent_sessions", json!(opened.persistent_sessions)));
@@ -1154,6 +1155,7 @@ impl InvokeUiSession for FlutterHandler {
("type", json!("data")),
("terminal_id", json!(data.terminal_id)),
("data", json!(&encoded)),
("replay", json!(data.replay)),
];
self.push_event_("terminal_response", &event_data, &[], &[]);
}

View File

@@ -35,6 +35,7 @@ const CHANNEL_BUFFER_SIZE: usize = 500; // Channel buffer size. Max per-message
const COMPRESS_THRESHOLD: usize = 512; // Compress terminal data larger than this
// Default max bytes for reconnection buffer replay.
const DEFAULT_RECONNECT_BUFFER_BYTES: usize = 8 * 1024;
const MAX_REPLAY_RESPONSE_BYTES: usize = DEFAULT_RECONNECT_BUFFER_BYTES;
const MAX_SIGWINCH_PHASE_ATTEMPTS: u8 = 3; // Max attempts per SIGWINCH phase before giving up
/// Two-phase SIGWINCH trigger for TUI app redraw on reconnection.
@@ -722,6 +723,7 @@ pub struct TerminalSession {
reader_thread: Option<thread::JoinHandle<()>>,
writer_thread: Option<thread::JoinHandle<()>>,
output_buffer: OutputBuffer,
pending_replay_chunks: VecDeque<Vec<u8>>,
title: String,
pid: u32,
rows: u16,
@@ -751,6 +753,7 @@ impl TerminalSession {
reader_thread: None,
writer_thread: None,
output_buffer: OutputBuffer::new(),
pending_replay_chunks: VecDeque::new(),
title: format!("Terminal {}", terminal_id),
pid: 0,
rows,
@@ -1070,15 +1073,15 @@ impl TerminalServiceProxy {
// Reconnect to existing terminal
let mut session = session_arc.lock().unwrap();
// Directly enter Active state with pending replay for immediate streaming.
// The replay starts with output_buffer history, then drains any current channel
// backlog into the same pending response. Keeping reconnect backlog in the first
// response lets the client suppress xterm query answers for the whole replay batch.
// The replay starts with output_buffer history and the channel backlog that was
// already pending at reconnect time. Keep replay data in capped chunks so the
// client can suppress stale xterm query answers without oversized messages.
// During disconnect, read_outputs() is not called; channel data can still be lost
// if output_rx fills before reconnect drains it.
let buffer = session
.output_buffer
.get_recent(DEFAULT_RECONNECT_BUFFER_BYTES);
let mut pending_buffer = buffer;
session.pending_replay_chunks.clear();
let mut reconnect_backlog = Vec::new();
if let Some(output_rx) = &session.output_rx {
while let Ok(data) = output_rx.try_recv() {
@@ -1087,15 +1090,16 @@ impl TerminalServiceProxy {
}
for data in reconnect_backlog {
session.output_buffer.append(&data);
pending_buffer.extend_from_slice(&data);
Self::push_replay_chunk(&mut session.pending_replay_chunks, data);
}
let has_pending = !pending_buffer.is_empty();
let has_pending = !buffer.is_empty() || !session.pending_replay_chunks.is_empty();
let pending_buffer = if !buffer.is_empty() {
Some(buffer)
} else {
session.pending_replay_chunks.pop_front()
};
session.state = SessionState::Active {
pending_buffer: if has_pending {
Some(pending_buffer)
} else {
None
},
pending_buffer,
// Always trigger two-phase SIGWINCH on reconnect to force TUI app redraw,
// regardless of whether there's pending buffer data. This avoids edge cases
// where buffer is empty but a TUI app (top/htop) still needs a full redraw.
@@ -1111,6 +1115,7 @@ impl TerminalServiceProxy {
} else {
"Reconnected to existing terminal".to_string()
};
opened.replay_in_next_data = has_pending;
opened.pid = session.pid;
opened.service_id = self.service_id.clone();
if service.needs_session_sync {
@@ -1193,8 +1198,8 @@ impl TerminalServiceProxy {
if should_force_process_utf8_ctype() {
cmd.env_remove("LC_ALL");
cmd.env("LC_CTYPE", "UTF-8");
log::debug!("Set LC_CTYPE=UTF-8 for macOS PTY");
cmd.env("LC_CTYPE", "en_US.UTF-8");
log::debug!("Set LC_CTYPE=en_US.UTF-8 for macOS PTY");
}
}
@@ -1799,11 +1804,29 @@ impl TerminalServiceProxy {
}
}
fn push_replay_chunk(chunks: &mut VecDeque<Vec<u8>>, data: Vec<u8>) {
if data.is_empty() {
return;
}
if let Some(last) = chunks.back_mut() {
if last.len() + data.len() <= MAX_REPLAY_RESPONSE_BYTES {
last.extend_from_slice(&data);
return;
}
}
chunks.push_back(data);
}
/// Helper to create a TerminalResponse with optional compression.
fn create_terminal_data_response(terminal_id: i32, data: Vec<u8>) -> TerminalResponse {
fn create_terminal_data_response(
terminal_id: i32,
data: Vec<u8>,
replay: bool,
) -> TerminalResponse {
let mut response = TerminalResponse::new();
let mut terminal_data = TerminalData::new();
terminal_data.terminal_id = terminal_id;
terminal_data.replay = replay;
if data.len() > COMPRESS_THRESHOLD {
let compressed = compress::compress(&data);
@@ -1869,16 +1892,21 @@ impl TerminalServiceProxy {
// is not called, so channel data produced after disconnect may be lost.
let mut has_activity = false;
let mut received_data = Vec::new();
if let Some(output_rx) = &session.output_rx {
// Try to read all available data
while let Ok(data) = output_rx.try_recv() {
has_activity = true;
received_data.push(data);
let has_pending_replay = matches!(
&session.state,
SessionState::Active {
pending_buffer: Some(_),
..
}
);
if !has_pending_replay {
if let Some(output_rx) = &session.output_rx {
// Try to read all available data
while let Ok(data) = output_rx.try_recv() {
has_activity = true;
received_data.push(data);
}
}
}
if has_activity {
session.update_activity();
}
// Update buffer (always buffer for reconnection support)
@@ -1890,7 +1918,7 @@ impl TerminalServiceProxy {
// Data is already buffered above and will be sent on next reconnection.
// Use a scoped block to limit the mutable borrow of session.state,
// so we can immutably borrow other session fields afterwards.
let sigwinch_action = {
let (replay_buffer, sigwinch_action) = {
let (pending_buffer, sigwinch) = match &mut session.state {
SessionState::Active {
pending_buffer,
@@ -1899,28 +1927,12 @@ impl TerminalServiceProxy {
_ => continue,
};
// Send pending replay first (set on reconnection in handle_open). If new
// channel data was drained in this same read_outputs() cycle, keep it in the
// replay response so the client suppresses one complete reconnect batch.
if let Some(buffer) = pending_buffer.take() {
let mut buffer = buffer;
for data in received_data.drain(..) {
// Reconnect replay can include terminal queries like DSR/DA.
// Keep this first backlog batch in one response so the client can
// suppress xterm-generated answers and avoid printing
// "^[[1;1R^[[2;2R^[[>0;0;0c" back to the remote shell.
buffer.extend_from_slice(&data);
}
if !buffer.is_empty() {
responses
.push(Self::create_terminal_data_response(terminal_id, buffer));
}
}
let replay_buffer = pending_buffer.take();
// Two-phase SIGWINCH: see SigwinchPhase doc comments for rationale.
// Each phase is a single PTY resize, spaced ~30ms apart by the polling
// interval, ensuring the TUI app sees a real size change on each signal.
match sigwinch {
let sigwinch_action = match sigwinch {
SigwinchPhase::TempResize { retries } => {
if *retries == 0 {
log::warn!(
@@ -1948,9 +1960,28 @@ impl TerminalServiceProxy {
}
}
SigwinchPhase::Idle => None,
}
};
(replay_buffer, sigwinch_action)
};
if let Some(buffer) = replay_buffer {
if !buffer.is_empty() {
responses.push(Self::create_terminal_data_response(
terminal_id,
buffer,
true,
));
}
let next_replay_buffer = session.pending_replay_chunks.pop_front();
if let SessionState::Active { pending_buffer, .. } = &mut session.state {
*pending_buffer = next_replay_buffer;
}
}
if has_activity {
session.update_activity();
}
// Execute SIGWINCH resize outside the mutable borrow scope of session.state.
if let Some(action) = sigwinch_action {
#[cfg(target_os = "windows")]
@@ -1990,7 +2021,11 @@ impl TerminalServiceProxy {
// Send real-time data after historical buffer
for data in received_data {
responses.push(Self::create_terminal_data_response(terminal_id, data));
responses.push(Self::create_terminal_data_response(
terminal_id,
data,
false,
));
}
}
}