Harden terminal lifecycle reconnect

This commit is contained in:
sladro 2026-04-03 09:06:25 +08:00
parent e1d01d539c
commit f2282b8619
4 changed files with 90 additions and 3 deletions

View File

@ -61,6 +61,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
String? _pendingHistorySeed;
bool _receivedSocketFrame = false;
bool _historySeeded = false;
bool _shouldReconnectOnResume = false;
@override
void initState() {
@ -116,12 +117,21 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state != AppLifecycleState.resumed) {
if (state == AppLifecycleState.resumed) {
if (_shouldReconnectOnResume) {
_shouldReconnectOnResume = false;
_diagnosticLog.add('app.lifecycle.resumed', widget.session.sessionId);
unawaited(_coordinator.reconnectNow());
}
return;
}
_diagnosticLog.add('app.lifecycle.resumed', widget.session.sessionId);
unawaited(_coordinator.reconnectNow());
if (state == AppLifecycleState.hidden ||
state == AppLifecycleState.paused) {
_shouldReconnectOnResume = true;
_diagnosticLog.add('app.lifecycle.suspended', state.name);
unawaited(_coordinator.suspendForBackground());
}
}
Future<void> _openSiblingTerminal() async {

View File

@ -195,6 +195,20 @@ class TerminalSessionCoordinator extends ChangeNotifier {
await start(isReconnect: true);
}
Future<void> suspendForBackground() async {
_cancelPendingReconnect();
if (_isDisposed) {
return;
}
controller.markDisconnected();
_connectionStatus = 'App inactive. Reconnecting when active...';
diagnosticLog?.add('socket.suspend', session.sessionId);
notifyListeners();
await _closeActiveSession();
}
Future<void> close() async {
_isDisposed = true;
_cancelPendingReconnect();

View File

@ -125,6 +125,33 @@ void main() {
},
);
test('suspendForBackground closes the active socket session', () async {
final controller = TerminalInteractionController();
final apiClient = _FakeAgentApiClient();
final sessionFactory = _FakeTerminalSessionFactory();
final session = Session(
sessionId: 'abc',
name: 'codex-main',
status: 'idle',
);
final coordinator = TerminalSessionCoordinator(
controller: controller,
apiClient: apiClient,
session: session,
sessionFactory: sessionFactory.create,
onFrame: (_) {},
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
);
await coordinator.start();
final socketSession = sessionFactory.createdSessions.single;
await coordinator.suspendForBackground();
expect(socketSession.disposeCount, 1);
expect(controller.connectionState, TerminalConnectionState.disconnected);
});
test(
'incoming frames while browsing history flag pending live output',
() async {
@ -224,6 +251,7 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
final bool autoConnect;
final resizeCalls = <List<int>>[];
int disposeCount = 0;
Completer<void> _connectCompleter = Completer<void>();
void Function(String frame)? _onFrame;
void Function()? _onDisconnected;
@ -259,6 +287,11 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
void disconnect() {
_onDisconnected?.call();
}
@override
Future<void> dispose() async {
disposeCount += 1;
}
}
class _FakeAgentSocketClient extends AgentSocketClient {

View File

@ -608,6 +608,36 @@ void main() {
expect(find.text('codex-main'), findsOneWidget);
});
testWidgets(
'terminal page does not reconnect after an inactive-only interruption',
(tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
tester,
projectRepository: _FakeProjectRepository(),
sessionRepository: _FakeSessionRepository(),
socketFactory: TerminalSocketSessionFactory(
transportFactory: transportFactory.create,
),
);
await _openProjectTerminal(tester);
expect(transportFactory.createCount, 1);
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.inactive);
await tester.pump();
tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed);
await tester.pump();
await tester.pump(const Duration(seconds: 2));
await tester.pumpAndSettle();
expect(transportFactory.createCount, 1);
expect(find.text('codex-main'), findsOneWidget);
},
);
testWidgets('terminal page can open another terminal for the same project', (
tester,
) async {