diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index d38dc4a8b..e5e1dbb8d 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -95,6 +95,13 @@ class _TerminalPageState extends State // Register this terminal model with FFI for event routing _ffi.registerTerminalModel(widget.terminalId, _terminalModel); + // Auto-close tab when shell exits + _terminalModel.onClosed = () { + if (mounted) { + widget.tabController.closeBy(widget.tabKey); + } + }; + // Initialize terminal connection WidgetsBinding.instance.addPostFrameCallback((_) { widget.tabController.onSelected?.call(widget.id); diff --git a/flutter/lib/mobile/pages/terminal_page.dart b/flutter/lib/mobile/pages/terminal_page.dart index aff85b40c..cbf47a7e9 100644 --- a/flutter/lib/mobile/pages/terminal_page.dart +++ b/flutter/lib/mobile/pages/terminal_page.dart @@ -83,6 +83,13 @@ class _TerminalPageState extends State // Register this terminal model with FFI for event routing _ffi.registerTerminalModel(widget.terminalId, _terminalModel); + // Auto-close connection when shell exits + _terminalModel.onClosed = () { + if (mounted) { + closeConnection(id: widget.id); + } + }; + // Web desktop users have full hardware keyboard access, so the on-screen // terminal extra keys bar is unnecessary and disabled. _showTerminalExtraKeys = !isWebDesktop && diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index 8961d2dd8..3374b9782 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -38,6 +38,10 @@ class TerminalModel with ChangeNotifier { void Function(int w, int h, int pw, int ph)? onResizeExternal; + /// Called when the terminal session ends (shell exits). + /// The listener (typically TerminalPage) can use this to auto-close the tab/page. + VoidCallback? onClosed; + Future _handleInput(String data) async { // Soft keyboards (notably iOS) emit '\n' when Enter is pressed, while a // real keyboard's Enter sends '\r'. Some Android keyboards also emit '\n'. @@ -473,6 +477,8 @@ class TerminalModel with ChangeNotifier { _writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n'); _terminalOpened = false; notifyListeners(); + // Auto-close the tab/page + onClosed?.call(); } void _handleTerminalError(Map evt) { diff --git a/src/server/terminal_service.rs b/src/server/terminal_service.rs index 52a296b74..8664a9927 100644 --- a/src/server/terminal_service.rs +++ b/src/server/terminal_service.rs @@ -1857,15 +1857,33 @@ impl TerminalServiceProxy { // Process each session with its own lock for (terminal_id, session_arc) in sessions { if let Ok(mut session) = session_arc.try_lock() { - // Check if reader thread is still alive and we haven't sent closed message yet + // Check if the session has ended (reader thread finished or child exited). + // On Linux, the PTY reader thread may not return EOF when the shell exits + // (the cloned master fd keeps the read side open), so we also poll the child + // process via try_wait() as a fallback detection mechanism. let mut should_send_closed = false; if !session.closed_message_sent { if let Some(thread) = &session.reader_thread { if thread.is_finished() { should_send_closed = true; - session.closed_message_sent = true; } } + if !should_send_closed { + if let Some(child) = &mut session.child { + match child.try_wait() { + Ok(Some(_)) => { + should_send_closed = true; + } + Ok(None) => {} // still running + Err(e) => { + log::warn!("Terminal {} child wait error: {}", terminal_id, e); + } + } + } + } + if should_send_closed { + session.closed_message_sent = true; + } } // It's Ok to put the closed message here. // Because the `reader_thread` is joined in `stop()`, @@ -2018,7 +2036,8 @@ impl TerminalServiceProxy { } } } else { - // For persistent sessions, just clear the child reference + // For persistent sessions, clear the child reference and remove the session + // if the closed message has been sent (shell has exited). if let Some(session_arc) = sessions.get(&terminal_id) { let mut session = session_arc.lock().unwrap(); if let Some(mut child) = session.child.take() { @@ -2028,6 +2047,12 @@ impl TerminalServiceProxy { } add_to_reaper(child); } + if session.closed_message_sent { + // Shell has exited, remove the dead session + drop(session); + sessions.remove(&terminal_id); + service.lock().unwrap().sessions.remove(&terminal_id); + } } }