fix: debounce resize and enlarge broadcast buffer for TUI resize stability

This commit is contained in:
2026-08-01 21:10:23 +08:00
parent 1ab485dffe
commit d1cd33de07
4 changed files with 116 additions and 12 deletions

View File

@@ -0,0 +1,91 @@
// 专项验证cd 跨目录 + resize 后内容不重复。
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_alacritty/flutter_alacritty.dart';
import 'package:integration_test/integration_test.dart';
import 'package:rtty_desktop/main.dart' as app;
import 'package:rtty_desktop/src/terminal_screen.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('cd 跨目录 + resize 内容完整性', (WidgetTester tester) async {
await app.main();
await tester.pumpAndSettle();
await tester.tap(find.text('CONNECT'));
await tester.pump();
// 等待连接。
final deadline = DateTime.now().add(const Duration(seconds: 8));
while (DateTime.now().isBefore(deadline)) {
await tester.pump(const Duration(milliseconds: 200));
if (find.text('AWAITING CONNECTION').evaluate().isEmpty) break;
}
expect(find.text('AWAITING CONNECTION'), findsNothing);
final engine = tester
.state<TerminalViewState>(find.byType(TerminalView))
.widget
.engine;
engine.initializeEmpty(24, 80);
void send(String s) =>
engine.write(Uint8List.fromList(s.codeUnits));
String gridText() {
final g = engine.gridForView;
final sb = StringBuffer();
for (var r = 0; r < g.rows; r++) {
for (var c = 0; c < g.columns; c++) {
final cp = g.codepointAt(r, c);
sb.writeCharCode(cp == 0 ? 32 : cp);
}
sb.write('\n');
}
return sb.toString();
}
Future<bool> waitFor(bool Function(String) pred,
{int secs = 6}) async {
final end = DateTime.now().add(Duration(seconds: secs));
while (DateTime.now().isBefore(end)) {
await tester.pump(const Duration(milliseconds: 200));
if (pred(gridText())) return true;
}
return pred(gridText());
}
// ---- 1. cd 跨目录 ----
final target = r'C:\Users\NianJiu\rttytest\subdir';
send('cd $target\r\n');
final cdOk = await waitFor((t) => t.contains('rttytest\\subdir'));
debugPrint('cd 后 prompt 含目标目录: $cdOk');
if (!cdOk) debugPrint('--- grid ---\n${gridText()}');
expect(cdOk, isTrue, reason: 'cd 到绝对路径后 prompt 应显示新目录');
// ---- 2. resize 后内容不重复 ----
// 记录 resize 前的非空行。
send('dir\r\n');
final beforeOk = await waitFor((t) => t.contains('subdir') && t.contains('dir'));
debugPrint('dir 输出: $beforeOk');
// 连续 resize 数次(模拟窗口拖动),验证内容不产生重复/错乱。
for (final (c, r) in [(100, 30), (120, 35), (90, 28), (110, 32)]) {
engine.resize(columns: c, rows: r);
await tester.pump(const Duration(milliseconds: 150));
}
engine.resize(columns: 100, rows: 30);
await tester.pump(const Duration(milliseconds: 300));
// 重置引擎网格尺寸后,发送简单命令,验证仍能正常回显(无重复错乱)。
send('echo RESIZE_OK\r\n');
final ok = await waitFor((t) => t.contains('RESIZE_OK'));
debugPrint('resize 后命令回显: $ok');
expect(ok, isTrue, reason: 'resize 后命令应正常回显且无重复');
debugPrint('--- 最终 grid ---\n${gridText()}');
});
}

View File

@@ -5,7 +5,6 @@
// //
// 运行前需先启动 Rust 服务端cd .. && cargo run // 运行前需先启动 Rust 服务端cd .. && cargo run
// 运行flutter test integration_test -d windows // 运行flutter test integration_test -d windows
import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -74,7 +73,7 @@ String _gridText(TerminalEngine engine) {
final sb = StringBuffer(); final sb = StringBuffer();
for (var r = 0; r < grid.rows; r++) { for (var r = 0; r < grid.rows; r++) {
for (var c = 0; c < grid.columns; c++) { for (var c = 0; c < grid.columns; c++) {
final cp = grid.codepointAt(r, c) as int; final cp = grid.codepointAt(r, c);
sb.writeCharCode(cp == 0 ? 32 : cp); sb.writeCharCode(cp == 0 ? 32 : cp);
} }
sb.write('\n'); sb.write('\n');

View File

@@ -31,6 +31,11 @@ class _TerminalScreenState extends State<TerminalScreen> {
StreamSubscription<Uint8List>? _engineOutputSub; StreamSubscription<Uint8List>? _engineOutputSub;
StreamSubscription<Uint8List>? _backendOutputSub; StreamSubscription<Uint8List>? _backendOutputSub;
/// resize 防抖:窗口拖动时 onPtyResize 会高频触发,合并为最后一次。
Timer? _resizeDebounce;
int _pendingCols = 0;
int _pendingRows = 0;
final _host = TextEditingController(text: '127.0.0.1'); final _host = TextEditingController(text: '127.0.0.1');
final _port = TextEditingController(text: '8080'); final _port = TextEditingController(text: '8080');
final _session = TextEditingController(); final _session = TextEditingController();
@@ -101,6 +106,8 @@ class _TerminalScreenState extends State<TerminalScreen> {
void _disconnect() => _tearDownSession(); void _disconnect() => _tearDownSession();
void _tearDownSession() { void _tearDownSession() {
_resizeDebounce?.cancel();
_resizeDebounce = null;
_engineOutputSub?.cancel(); _engineOutputSub?.cancel();
_engineOutputSub = null; _engineOutputSub = null;
_backendOutputSub?.cancel(); _backendOutputSub?.cancel();
@@ -121,15 +128,22 @@ class _TerminalScreenState extends State<TerminalScreen> {
} }
void _handlePtyResize(int cols, int rows) { void _handlePtyResize(int cols, int rows) {
final b = _backend; if (!mounted) return;
if (b == null) return; setState(() {
b.resize(rows, cols); _cols = cols;
if (mounted) { _rows = rows;
setState(() { });
_cols = cols;
_rows = rows; // 合并连续 resize只发送最后一次避免 TUI 频繁 SIGWINCH 重绘错乱。
}); _pendingCols = cols;
} _pendingRows = rows;
_resizeDebounce?.cancel();
_resizeDebounce = Timer(const Duration(milliseconds: 80), () {
final b = _backend;
if (b == null) return;
// PtyBackend.resize(rows, columns) — 注意参数顺序为行/列。
b.resize(_pendingRows, _pendingCols);
});
} }
@override @override

View File

@@ -276,7 +276,7 @@ fn create_session(
))); )));
let reader = pty.take_reader().ok_or_else(|| anyhow::anyhow!("pty reader already taken"))?; let reader = pty.take_reader().ok_or_else(|| anyhow::anyhow!("pty reader already taken"))?;
let (output, _) = broadcast::channel(256); let (output, _) = broadcast::channel(4096);
let session = Arc::new(Session { let session = Arc::new(Session {
id, id,
pty: Arc::new(Mutex::new(pty)), pty: Arc::new(Mutex::new(pty)),