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
// 运行flutter test integration_test -d windows
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/foundation.dart';
@@ -74,7 +73,7 @@ String _gridText(TerminalEngine engine) {
final sb = StringBuffer();
for (var r = 0; r < grid.rows; r++) {
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.write('\n');

View File

@@ -31,6 +31,11 @@ class _TerminalScreenState extends State<TerminalScreen> {
StreamSubscription<Uint8List>? _engineOutputSub;
StreamSubscription<Uint8List>? _backendOutputSub;
/// resize 防抖:窗口拖动时 onPtyResize 会高频触发,合并为最后一次。
Timer? _resizeDebounce;
int _pendingCols = 0;
int _pendingRows = 0;
final _host = TextEditingController(text: '127.0.0.1');
final _port = TextEditingController(text: '8080');
final _session = TextEditingController();
@@ -101,6 +106,8 @@ class _TerminalScreenState extends State<TerminalScreen> {
void _disconnect() => _tearDownSession();
void _tearDownSession() {
_resizeDebounce?.cancel();
_resizeDebounce = null;
_engineOutputSub?.cancel();
_engineOutputSub = null;
_backendOutputSub?.cancel();
@@ -121,15 +128,22 @@ class _TerminalScreenState extends State<TerminalScreen> {
}
void _handlePtyResize(int cols, int rows) {
final b = _backend;
if (b == null) return;
b.resize(rows, cols);
if (mounted) {
setState(() {
_cols = cols;
_rows = rows;
});
}
if (!mounted) return;
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

View File

@@ -276,7 +276,7 @@ fn create_session(
)));
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 {
id,
pty: Arc::new(Mutex::new(pty)),