From 0a1500a72acf9bcc66f2952e2b1fa8926fda9f70 Mon Sep 17 00:00:00 2001 From: fufesou Date: Tue, 28 Apr 2026 17:58:51 +0800 Subject: [PATCH] fix(terminal): reconnect, error handling 1. Terminal shows "^[[1;1R^[[2;2R^[[>0;0;0c" 2. NaN ``` [ERROR:flutter/runtime/dart_vm_initializer.cc(41)] Unhandled Exception: Converting object to an encodable object failed: NaN ... ``` Signed-off-by: fufesou --- flutter/lib/desktop/pages/terminal_page.dart | 22 ++++++-- flutter/lib/models/terminal_model.dart | 59 +++++++++++++++++--- 2 files changed, 68 insertions(+), 13 deletions(-) diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index 0070cd73b..a885eb011 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -43,6 +43,9 @@ class TerminalPage extends StatefulWidget { class _TerminalPageState extends State with AutomaticKeepAliveClientMixin { + static const EdgeInsets _defaultTerminalPadding = + EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); + late FFI _ffi; late TerminalModel _terminalModel; double? _cellHeight; @@ -155,11 +158,22 @@ class _TerminalPageState extends State // extra space left after dividing the available height by the height of a single // terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding. EdgeInsets _calculatePadding(double heightPx) { - if (_cellHeight == null) { - return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); + final cellHeight = _cellHeight; + if (!heightPx.isFinite || + heightPx <= 0 || + cellHeight == null || + !cellHeight.isFinite || + cellHeight <= 0) { + return _defaultTerminalPadding; + } + final rows = (heightPx / cellHeight).floor(); + if (rows <= 0) { + return _defaultTerminalPadding; + } + final extraSpace = heightPx - rows * cellHeight; + if (!extraSpace.isFinite || extraSpace < 0) { + return _defaultTerminalPadding; } - final rows = (heightPx / _cellHeight!).floor(); - final extraSpace = heightPx - rows * _cellHeight!; final topBottom = extraSpace / 2.0; return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom); } diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 030ae016a..830d872dc 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -27,10 +27,13 @@ class TerminalModel with ChangeNotifier { // Buffer for output data received before terminal view has valid dimensions. // This prevents NaN errors when writing to terminal before layout is complete. final _pendingOutputChunks = []; + final _pendingOutputSuppressFlags = []; int _pendingOutputSize = 0; static const int _kMaxOutputBufferChars = 8 * 1024; // View ready state: true when terminal has valid dimensions, safe to write bool _terminalViewReady = false; + bool _suppressTerminalOutput = false; + bool _suppressNextTerminalDataOutput = false; void Function(int w, int h, int pw, int ph)? onResizeExternal; @@ -71,7 +74,10 @@ class TerminalModel with ChangeNotifier { terminalController = TerminalController(); // Setup terminal callbacks - terminal.onOutput = _handleInput; + terminal.onOutput = (data) { + if (_suppressTerminalOutput) return; + _handleInput(data); + }; terminal.onResize = (w, h, pw, ph) async { // Validate all dimensions before using them @@ -278,9 +284,11 @@ class TerminalModel with ChangeNotifier { if (success) { _terminalOpened = true; - // On reconnect ("Reconnected to existing terminal"), server may replay recent output. - // If this TerminalView instance is reused (not rebuilt), duplicate lines can appear. - // We intentionally accept this tradeoff for now to keep logic simple. + // 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'; // Fallback: if terminal view is not yet ready but already has valid // dimensions (e.g. layout completed before open response arrived), @@ -339,6 +347,8 @@ class TerminalModel with ChangeNotifier { final data = evt['data']; if (data != null) { + final suppressTerminalOutput = _suppressNextTerminalDataOutput; + _suppressNextTerminalDataOutput = false; try { String text = ''; if (data is String) { @@ -358,7 +368,7 @@ class TerminalModel with ChangeNotifier { return; } - _writeToTerminal(text); + _writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput); } catch (e) { debugPrint('[TerminalModel] Failed to process terminal data: $e'); } @@ -368,7 +378,10 @@ class TerminalModel with ChangeNotifier { /// Write text to terminal, buffering if the view is not yet ready. /// All terminal output should go through this method to avoid NaN errors /// from writing before the terminal view has valid layout dimensions. - void _writeToTerminal(String text) { + void _writeToTerminal( + String text, { + bool suppressTerminalOutput = false, + }) { if (!_terminalViewReady) { // If a single chunk exceeds the cap, keep only its tail. // Note: truncation may split a multi-byte ANSI escape sequence, @@ -380,33 +393,59 @@ class TerminalModel with ChangeNotifier { _pendingOutputChunks ..clear() ..add(truncated); + _pendingOutputSuppressFlags + ..clear() + ..add(suppressTerminalOutput); _pendingOutputSize = truncated.length; } else { _pendingOutputChunks.add(text); + _pendingOutputSuppressFlags.add(suppressTerminalOutput); _pendingOutputSize += text.length; // Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences) while (_pendingOutputSize > _kMaxOutputBufferChars && _pendingOutputChunks.length > 1) { final removed = _pendingOutputChunks.removeAt(0); + _pendingOutputSuppressFlags.removeAt(0); _pendingOutputSize -= removed.length; } } return; } - terminal.write(text); + _writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput); } void _flushOutputBuffer() { if (_pendingOutputChunks.isEmpty) return; debugPrint( '[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)'); - for (final chunk in _pendingOutputChunks) { - terminal.write(chunk); + for (var i = 0; i < _pendingOutputChunks.length; i++) { + _writeTerminalChunk( + _pendingOutputChunks[i], + suppressTerminalOutput: _pendingOutputSuppressFlags[i], + ); } _pendingOutputChunks.clear(); + _pendingOutputSuppressFlags.clear(); _pendingOutputSize = 0; } + void _writeTerminalChunk( + String text, { + required bool suppressTerminalOutput, + }) { + if (!suppressTerminalOutput) { + terminal.write(text); + return; + } + final previous = _suppressTerminalOutput; + _suppressTerminalOutput = true; + try { + terminal.write(text); + } finally { + _suppressTerminalOutput = previous; + } + } + /// Mark terminal view as ready and flush buffered output. void _markViewReady() { if (_terminalViewReady) return; @@ -433,7 +472,9 @@ class TerminalModel with ChangeNotifier { // Clear buffers to free memory _inputBuffer.clear(); _pendingOutputChunks.clear(); + _pendingOutputSuppressFlags.clear(); _pendingOutputSize = 0; + _suppressNextTerminalDataOutput = false; // Terminal cleanup is handled server-side when service closes super.dispose(); }