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 <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-04-28 17:58:51 +08:00
parent 4f5c7db70a
commit 0a1500a72a
2 changed files with 68 additions and 13 deletions

View File

@@ -43,6 +43,9 @@ class TerminalPage extends StatefulWidget {
class _TerminalPageState extends State<TerminalPage> class _TerminalPageState extends State<TerminalPage>
with AutomaticKeepAliveClientMixin { with AutomaticKeepAliveClientMixin {
static const EdgeInsets _defaultTerminalPadding =
EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0);
late FFI _ffi; late FFI _ffi;
late TerminalModel _terminalModel; late TerminalModel _terminalModel;
double? _cellHeight; double? _cellHeight;
@@ -155,11 +158,22 @@ class _TerminalPageState extends State<TerminalPage>
// extra space left after dividing the available height by the height of a single // 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. // terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
EdgeInsets _calculatePadding(double heightPx) { EdgeInsets _calculatePadding(double heightPx) {
if (_cellHeight == null) { final cellHeight = _cellHeight;
return const EdgeInsets.symmetric(horizontal: 5.0, vertical: 2.0); 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; final topBottom = extraSpace / 2.0;
return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom); return EdgeInsets.symmetric(horizontal: 5.0, vertical: topBottom);
} }

View File

@@ -27,10 +27,13 @@ class TerminalModel with ChangeNotifier {
// Buffer for output data received before terminal view has valid dimensions. // Buffer for output data received before terminal view has valid dimensions.
// This prevents NaN errors when writing to terminal before layout is complete. // This prevents NaN errors when writing to terminal before layout is complete.
final _pendingOutputChunks = <String>[]; final _pendingOutputChunks = <String>[];
final _pendingOutputSuppressFlags = <bool>[];
int _pendingOutputSize = 0; int _pendingOutputSize = 0;
static const int _kMaxOutputBufferChars = 8 * 1024; static const int _kMaxOutputBufferChars = 8 * 1024;
// View ready state: true when terminal has valid dimensions, safe to write // View ready state: true when terminal has valid dimensions, safe to write
bool _terminalViewReady = false; bool _terminalViewReady = false;
bool _suppressTerminalOutput = false;
bool _suppressNextTerminalDataOutput = false;
void Function(int w, int h, int pw, int ph)? onResizeExternal; void Function(int w, int h, int pw, int ph)? onResizeExternal;
@@ -71,7 +74,10 @@ class TerminalModel with ChangeNotifier {
terminalController = TerminalController(); terminalController = TerminalController();
// Setup terminal callbacks // Setup terminal callbacks
terminal.onOutput = _handleInput; terminal.onOutput = (data) {
if (_suppressTerminalOutput) return;
_handleInput(data);
};
terminal.onResize = (w, h, pw, ph) async { terminal.onResize = (w, h, pw, ph) async {
// Validate all dimensions before using them // Validate all dimensions before using them
@@ -278,9 +284,11 @@ class TerminalModel with ChangeNotifier {
if (success) { if (success) {
_terminalOpened = true; _terminalOpened = true;
// On reconnect ("Reconnected to existing terminal"), server may replay recent output. // On reconnect, the server may replay recent output. That replay can include
// If this TerminalView instance is reused (not rebuilt), duplicate lines can appear. // terminal queries like DSR/DA; xterm answers them through onOutput as
// We intentionally accept this tradeoff for now to keep logic simple. // "^[[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 // Fallback: if terminal view is not yet ready but already has valid
// dimensions (e.g. layout completed before open response arrived), // dimensions (e.g. layout completed before open response arrived),
@@ -339,6 +347,8 @@ class TerminalModel with ChangeNotifier {
final data = evt['data']; final data = evt['data'];
if (data != null) { if (data != null) {
final suppressTerminalOutput = _suppressNextTerminalDataOutput;
_suppressNextTerminalDataOutput = false;
try { try {
String text = ''; String text = '';
if (data is String) { if (data is String) {
@@ -358,7 +368,7 @@ class TerminalModel with ChangeNotifier {
return; return;
} }
_writeToTerminal(text); _writeToTerminal(text, suppressTerminalOutput: suppressTerminalOutput);
} catch (e) { } catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $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. /// Write text to terminal, buffering if the view is not yet ready.
/// All terminal output should go through this method to avoid NaN errors /// All terminal output should go through this method to avoid NaN errors
/// from writing before the terminal view has valid layout dimensions. /// from writing before the terminal view has valid layout dimensions.
void _writeToTerminal(String text) { void _writeToTerminal(
String text, {
bool suppressTerminalOutput = false,
}) {
if (!_terminalViewReady) { if (!_terminalViewReady) {
// If a single chunk exceeds the cap, keep only its tail. // If a single chunk exceeds the cap, keep only its tail.
// Note: truncation may split a multi-byte ANSI escape sequence, // Note: truncation may split a multi-byte ANSI escape sequence,
@@ -380,33 +393,59 @@ class TerminalModel with ChangeNotifier {
_pendingOutputChunks _pendingOutputChunks
..clear() ..clear()
..add(truncated); ..add(truncated);
_pendingOutputSuppressFlags
..clear()
..add(suppressTerminalOutput);
_pendingOutputSize = truncated.length; _pendingOutputSize = truncated.length;
} else { } else {
_pendingOutputChunks.add(text); _pendingOutputChunks.add(text);
_pendingOutputSuppressFlags.add(suppressTerminalOutput);
_pendingOutputSize += text.length; _pendingOutputSize += text.length;
// Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences) // Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences)
while (_pendingOutputSize > _kMaxOutputBufferChars && while (_pendingOutputSize > _kMaxOutputBufferChars &&
_pendingOutputChunks.length > 1) { _pendingOutputChunks.length > 1) {
final removed = _pendingOutputChunks.removeAt(0); final removed = _pendingOutputChunks.removeAt(0);
_pendingOutputSuppressFlags.removeAt(0);
_pendingOutputSize -= removed.length; _pendingOutputSize -= removed.length;
} }
} }
return; return;
} }
terminal.write(text); _writeTerminalChunk(text, suppressTerminalOutput: suppressTerminalOutput);
} }
void _flushOutputBuffer() { void _flushOutputBuffer() {
if (_pendingOutputChunks.isEmpty) return; if (_pendingOutputChunks.isEmpty) return;
debugPrint( debugPrint(
'[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)'); '[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)');
for (final chunk in _pendingOutputChunks) { for (var i = 0; i < _pendingOutputChunks.length; i++) {
terminal.write(chunk); _writeTerminalChunk(
_pendingOutputChunks[i],
suppressTerminalOutput: _pendingOutputSuppressFlags[i],
);
} }
_pendingOutputChunks.clear(); _pendingOutputChunks.clear();
_pendingOutputSuppressFlags.clear();
_pendingOutputSize = 0; _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. /// Mark terminal view as ready and flush buffered output.
void _markViewReady() { void _markViewReady() {
if (_terminalViewReady) return; if (_terminalViewReady) return;
@@ -433,7 +472,9 @@ class TerminalModel with ChangeNotifier {
// Clear buffers to free memory // Clear buffers to free memory
_inputBuffer.clear(); _inputBuffer.clear();
_pendingOutputChunks.clear(); _pendingOutputChunks.clear();
_pendingOutputSuppressFlags.clear();
_pendingOutputSize = 0; _pendingOutputSize = 0;
_suppressNextTerminalDataOutput = false;
// Terminal cleanup is handled server-side when service closes // Terminal cleanup is handled server-side when service closes
super.dispose(); super.dispose();
} }