feat(terminal): add reconnection buffer support for persistent sessions (#14377)

* feat(terminal): add reconnection buffer support for persistent sessions

Fix two related issues:
1. Reconnecting to persistent sessions shows blank screen - server now
   automatically sends historical buffer on reconnection via SessionState
   machine with pending_buffer, eliminating the need for client-initiated
   buffer requests.
2. Terminal output before view ready causes NaN errors - buffer output
   chunks on client side until terminal view has valid dimensions, then
   flush in order on first valid resize.

Rust side:
- Introduce SessionState enum (Closed/Active) replacing bool is_opened
- Auto-attach pending buffer on reconnection in handle_open()
- Always drain output channel in read_outputs() to prevent overflow
- Increase channel buffer from 100 to 500
- Optimize get_recent() to collect whole chunks (avoids ANSI truncation)
- Extract create_terminal_data_response() helper (DRY)
- Add reconnected flag to TerminalOpened protobuf message

Flutter side:
- Buffer output chunks until terminal view has valid dimensions
- Flush buffered output on first valid resize via _markViewReady()
- Clear terminal on reconnection to avoid duplicate output from buffer replay
- Fix max_bytes type (u32) to match protobuf definition
- Pass reconnected field through FlutterHandler event

Signed-off-by: fufesou <linlong1266@gmail.com>

* fix(terminal): add two-phase SIGWINCH for TUI app redraw and session remap on reconnection

Fix TUI apps (top, htop) not redrawing after reconnection. A single
resize-then-restore is too fast for ncurses to detect a size change,
so split across two read_outputs() polling cycles (~30ms apart) to
force a full redraw.

Also fix reconnection failure when client terminal_id doesn't match
any surviving server-side session ID by remapping the lowest surviving
session to the requested ID.

Rust side:
- Add two-phase SIGWINCH state machine (SigwinchPhase: TempResize →
  Restore → Idle) with retry logic (max 3 attempts per phase)
- Add do_sigwinch_resize() for cross-platform PTY resize (direct PTY
  and Windows helper mode)
- Add session remap logic for non-contiguous terminal_id reconnection
- Extract try_send_output() helper with rate-limited drop logging (DRY)
- Add 3-byte limit to UTF-8 continuation byte skipping in get_recent()
  to prevent runaway on non-UTF-8 binary data
- Remove reconnected flag from flutter.rs (unused on client side)

Flutter side:
- Add reconnection screen clear and deferred flush logic
- Filter self from persistent_sessions restore list
- Add comments for web-related changes

Signed-off-by: fufesou <linlong1266@gmail.com>

---------

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-02-24 21:12:06 +08:00
committed by GitHub
parent 50c62d5eac
commit 0016033937
5 changed files with 449 additions and 77 deletions

View File

@@ -36,6 +36,8 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
int _nextTerminalId = 1;
// Lightweight idempotency guard for async close operations
final Set<String> _closingTabs = {};
// When true, all session cleanup should persist (window-level close in progress)
bool _windowClosing = false;
_TerminalTabPageState(Map<String, dynamic> params) {
Get.put(DesktopTabController(tabType: DesktopTabType.terminal));
@@ -139,6 +141,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
/// UI tabs are removed immediately; session cleanup runs in parallel with a
/// bounded timeout so window close is not blocked indefinitely.
Future<void> _closeAllTabs() async {
_windowClosing = true;
final tabKeys = tabController.state.value.tabs.map((t) => t.key).toList();
// Remove all UI tabs immediately (same instant behavior as the old tabController.clear())
tabController.clear();
@@ -171,8 +174,17 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
/// - `true` (window close): persist all sessions, don't close any.
/// - `false` (tab close): only persist the last session for the peer,
/// close others so only the most recent disconnected session survives.
///
/// Note: if [_windowClosing] is true, persistAll is forced to true so that
/// in-flight _closeTab() calls don't accidentally close sessions that the
/// window-close flow intends to preserve.
Future<void> _closeTerminalSessionIfNeeded(String tabKey,
{bool persistAll = false, int? peerTabCount}) async {
// If window close is in progress, override to persist all sessions
// even if this call originated from an individual tab close.
if (_windowClosing) {
persistAll = true;
}
final parsed = _parseTabKey(tabKey);
if (parsed == null) return;
final (peerId, terminalId) = parsed;