diff --git a/src-tauri/src/backend/events.rs b/src-tauri/src/backend/events.rs index 69da89ae..3fe03b2e 100644 --- a/src-tauri/src/backend/events.rs +++ b/src-tauri/src/backend/events.rs @@ -16,7 +16,16 @@ pub(crate) struct TerminalOutput { pub(crate) data: String, } +#[derive(Debug, Serialize, Clone)] +pub(crate) struct TerminalExit { + #[serde(rename = "workspaceId")] + pub(crate) workspace_id: String, + #[serde(rename = "terminalId")] + pub(crate) terminal_id: String, +} + pub(crate) trait EventSink: Clone + Send + Sync + 'static { fn emit_app_server_event(&self, event: AppServerEvent); fn emit_terminal_output(&self, event: TerminalOutput); + fn emit_terminal_exit(&self, event: TerminalExit); } diff --git a/src-tauri/src/bin/codex_monitor_daemon.rs b/src-tauri/src/bin/codex_monitor_daemon.rs index c01ce445..abac87ee 100644 --- a/src-tauri/src/bin/codex_monitor_daemon.rs +++ b/src-tauri/src/bin/codex_monitor_daemon.rs @@ -70,7 +70,7 @@ use tokio::sync::{broadcast, mpsc, Mutex}; use backend::app_server::{ spawn_workspace_session, WorkspaceSession, }; -use backend::events::{AppServerEvent, EventSink, TerminalOutput}; +use backend::events::{AppServerEvent, EventSink, TerminalExit, TerminalOutput}; use storage::{read_settings, read_workspaces}; use shared::{codex_core, files_core, git_core, settings_core, workspaces_core, worktree_core}; use shared::codex_core::CodexLoginCancelState; @@ -109,6 +109,8 @@ enum DaemonEvent { AppServer(AppServerEvent), #[allow(dead_code)] TerminalOutput(TerminalOutput), + #[allow(dead_code)] + TerminalExit(TerminalExit), } impl EventSink for DaemonEventSink { @@ -119,6 +121,10 @@ impl EventSink for DaemonEventSink { fn emit_terminal_output(&self, event: TerminalOutput) { let _ = self.tx.send(DaemonEvent::TerminalOutput(event)); } + + fn emit_terminal_exit(&self, event: TerminalExit) { + let _ = self.tx.send(DaemonEvent::TerminalExit(event)); + } } struct DaemonConfig { @@ -849,6 +855,10 @@ fn build_event_notification(event: DaemonEvent) -> Option { "method": "terminal-output", "params": payload, }), + DaemonEvent::TerminalExit(payload) => json!({ + "method": "terminal-exit", + "params": payload, + }), }; serde_json::to_string(&payload).ok() } diff --git a/src-tauri/src/event_sink.rs b/src-tauri/src/event_sink.rs index d1933346..f27b0d31 100644 --- a/src-tauri/src/event_sink.rs +++ b/src-tauri/src/event_sink.rs @@ -1,6 +1,6 @@ use tauri::{AppHandle, Emitter}; -use crate::backend::events::{AppServerEvent, EventSink, TerminalOutput}; +use crate::backend::events::{AppServerEvent, EventSink, TerminalExit, TerminalOutput}; #[derive(Clone)] pub(crate) struct TauriEventSink { @@ -21,4 +21,8 @@ impl EventSink for TauriEventSink { fn emit_terminal_output(&self, event: TerminalOutput) { let _ = self.app.emit("terminal-output", event); } + + fn emit_terminal_exit(&self, event: TerminalExit) { + let _ = self.app.emit("terminal-exit", event); + } } diff --git a/src-tauri/src/remote_backend.rs b/src-tauri/src/remote_backend.rs index a844f0ff..3fc19ebc 100644 --- a/src-tauri/src/remote_backend.rs +++ b/src-tauri/src/remote_backend.rs @@ -251,6 +251,9 @@ async fn read_loop( "terminal-output" => { let _ = app.emit("terminal-output", params); } + "terminal-exit" => { + let _ = app.emit("terminal-exit", params); + } _ => {} } } diff --git a/src-tauri/src/terminal.rs b/src-tauri/src/terminal.rs index bb5afaea..490bd0dd 100644 --- a/src-tauri/src/terminal.rs +++ b/src-tauri/src/terminal.rs @@ -7,7 +7,7 @@ use serde::Serialize; use tauri::{AppHandle, State}; use tokio::sync::Mutex; -use crate::backend::events::{EventSink, TerminalOutput}; +use crate::backend::events::{EventSink, TerminalExit, TerminalOutput}; use crate::event_sink::TauriEventSink; use crate::state::AppState; @@ -27,6 +27,28 @@ fn terminal_key(workspace_id: &str, terminal_id: &str) -> String { format!("{workspace_id}:{terminal_id}") } +fn is_terminal_closed_error(message: &str) -> bool { + let lower = message.to_ascii_lowercase(); + lower.contains("broken pipe") + || lower.contains("input/output error") + || lower.contains("os error 5") + || lower.contains("eio") + || lower.contains("io error") + || lower.contains("not connected") + || lower.contains("closed") +} + +async fn get_terminal_session( + state: &State<'_, AppState>, + key: &str, +) -> Result, String> { + let sessions = state.terminal_sessions.lock().await; + sessions + .get(key) + .cloned() + .ok_or_else(|| "Terminal session not found".to_string()) +} + fn shell_path() -> String { std::env::var("SHELL").unwrap_or_else(|_| "/bin/zsh".to_string()) } @@ -102,6 +124,10 @@ fn spawn_terminal_reader( Err(_) => break, } } + event_sink.emit_terminal_exit(TerminalExit { + workspace_id, + terminal_id, + }); }); } @@ -183,11 +209,14 @@ pub(crate) async fn terminal_open( { let mut sessions = state.terminal_sessions.lock().await; if let Some(existing) = sessions.get(&key) { - let mut child = session.child.lock().await; - let _ = child.kill(); - return Ok(TerminalSessionInfo { - id: existing.id.clone(), - }); + let id = existing.id.clone(); + drop(sessions); + let _ = tokio::task::spawn_blocking(move || { + let mut child = session.child.blocking_lock(); + let _ = child.kill(); + }) + .await; + return Ok(TerminalSessionInfo { id }); } sessions.insert(key, session); } @@ -207,17 +236,27 @@ pub(crate) async fn terminal_write( state: State<'_, AppState>, ) -> Result<(), String> { let key = terminal_key(&workspace_id, &terminal_id); - let sessions = state.terminal_sessions.lock().await; - let session = sessions - .get(&key) - .ok_or_else(|| "Terminal session not found".to_string())?; - let mut writer = session.writer.lock().await; - writer - .write_all(data.as_bytes()) - .map_err(|e| format!("Failed to write to pty: {e}"))?; - writer - .flush() - .map_err(|e| format!("Failed to flush pty: {e}"))?; + let session = get_terminal_session(&state, &key).await?; + let write_result = tokio::task::spawn_blocking(move || { + let mut writer = session.writer.blocking_lock(); + writer + .write_all(data.as_bytes()) + .map_err(|e| format!("Failed to write to pty: {e}"))?; + writer + .flush() + .map_err(|e| format!("Failed to flush pty: {e}"))?; + Ok::<(), String>(()) + }) + .await + .map_err(|e| format!("Terminal write task failed: {e}"))?; + + if let Err(err) = write_result { + if is_terminal_closed_error(&err) { + let mut sessions = state.terminal_sessions.lock().await; + sessions.remove(&key); + } + return Err(err); + } Ok(()) } @@ -230,20 +269,28 @@ pub(crate) async fn terminal_resize( state: State<'_, AppState>, ) -> Result<(), String> { let key = terminal_key(&workspace_id, &terminal_id); - let sessions = state.terminal_sessions.lock().await; - let session = sessions - .get(&key) - .ok_or_else(|| "Terminal session not found".to_string())?; + let session = get_terminal_session(&state, &key).await?; let size = PtySize { rows: rows.max(2), cols: cols.max(2), pixel_width: 0, pixel_height: 0, }; - let master = session.master.lock().await; - master - .resize(size) - .map_err(|e| format!("Failed to resize pty: {e}"))?; + let resize_result = tokio::task::spawn_blocking(move || { + let master = session.master.blocking_lock(); + master + .resize(size) + .map_err(|e| format!("Failed to resize pty: {e}")) + }) + .await + .map_err(|e| format!("Terminal resize task failed: {e}"))?; + if let Err(err) = resize_result { + if is_terminal_closed_error(&err) { + let mut sessions = state.terminal_sessions.lock().await; + sessions.remove(&key); + } + return Err(err); + } Ok(()) } @@ -258,7 +305,11 @@ pub(crate) async fn terminal_close( let session = sessions .remove(&key) .ok_or_else(|| "Terminal session not found".to_string())?; - let mut child = session.child.lock().await; - let _ = child.kill(); + drop(sessions); + let _ = tokio::task::spawn_blocking(move || { + let mut child = session.child.blocking_lock(); + let _ = child.kill(); + }) + .await; Ok(()) } diff --git a/src/features/terminal/hooks/useTerminalController.ts b/src/features/terminal/hooks/useTerminalController.ts index d6a878d7..2274b9aa 100644 --- a/src/features/terminal/hooks/useTerminalController.ts +++ b/src/features/terminal/hooks/useTerminalController.ts @@ -23,6 +23,10 @@ export function useTerminalController({ const cleanupTerminalRef = useRef<((workspaceId: string, terminalId: string) => void) | null>( null, ); + const shouldIgnoreTerminalCloseError = useCallback((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + return message.includes("Terminal session not found"); + }, []); const handleTerminalClose = useCallback( async (workspaceId: string, terminalId: string) => { @@ -30,10 +34,13 @@ export function useTerminalController({ try { await closeTerminalSession(workspaceId, terminalId); } catch (error) { + if (shouldIgnoreTerminalCloseError(error)) { + return; + } onDebug(buildErrorDebugEntry("terminal close error", error)); } }, - [onDebug], + [onDebug, shouldIgnoreTerminalCloseError], ); const { @@ -60,6 +67,16 @@ export function useTerminalController({ activeTerminalId, isVisible: terminalOpen, onDebug, + onSessionExit: (workspaceId, terminalId) => { + const shouldClosePanel = + workspaceId === activeWorkspaceId && + terminalTabs.length === 1 && + terminalTabs[0]?.id === terminalId; + closeTerminal(workspaceId, terminalId); + if (shouldClosePanel) { + onCloseTerminalPanel?.(); + } + }, }); useEffect(() => { @@ -104,14 +121,13 @@ export function useTerminalController({ try { await closeTerminalSession(workspaceId, terminalId); } catch (error) { - const message = error instanceof Error ? error.message : String(error); - if (!message.includes("Terminal session not found")) { + if (!shouldIgnoreTerminalCloseError(error)) { onDebug(buildErrorDebugEntry("terminal close error", error)); throw error; } } }, - [onDebug], + [onDebug, shouldIgnoreTerminalCloseError], ); return { diff --git a/src/features/terminal/hooks/useTerminalSession.ts b/src/features/terminal/hooks/useTerminalSession.ts index ec34db5b..3726bb9a 100644 --- a/src/features/terminal/hooks/useTerminalSession.ts +++ b/src/features/terminal/hooks/useTerminalSession.ts @@ -5,7 +5,12 @@ import { FitAddon } from "@xterm/addon-fit"; import "@xterm/xterm/css/xterm.css"; import type { DebugEntry, TerminalStatus, WorkspaceInfo } from "../../../types"; import { buildErrorDebugEntry } from "../../../utils/debugEntries"; -import { subscribeTerminalOutput, type TerminalOutputEvent } from "../../../services/events"; +import { + subscribeTerminalExit, + subscribeTerminalOutput, + type TerminalExitEvent, + type TerminalOutputEvent, +} from "../../../services/events"; import { openTerminalSession, resizeTerminalSession, @@ -19,6 +24,7 @@ type UseTerminalSessionOptions = { activeTerminalId: string | null; isVisible: boolean; onDebug?: (entry: DebugEntry) => void; + onSessionExit?: (workspaceId: string, terminalId: string) => void; }; type TerminalAppearance = { @@ -50,7 +56,16 @@ function appendBuffer(existing: string | undefined, data: string): string { function shouldIgnoreTerminalError(error: unknown) { const message = error instanceof Error ? error.message : String(error); - return message.includes("Terminal session not found"); + const lower = message.toLowerCase(); + return ( + lower.includes("terminal session not found") || + lower.includes("broken pipe") || + lower.includes("input/output error") || + lower.includes("os error 5") || + lower.includes("eio") || + lower.includes("not connected") || + lower.includes("closed") + ); } function getTerminalAppearance(container: HTMLElement | null): TerminalAppearance { @@ -100,6 +115,7 @@ export function useTerminalSession({ activeTerminalId, isVisible, onDebug, + onSessionExit, }: UseTerminalSessionOptions): TerminalSessionState { const containerRef = useRef(null); const terminalRef = useRef(null); @@ -197,6 +213,23 @@ export function useTerminalSession({ }; }, [onDebug, writeToTerminal]); + useEffect(() => { + const unlisten = subscribeTerminalExit( + (payload: TerminalExitEvent) => { + cleanupTerminalSession(payload.workspaceId, payload.terminalId); + onSessionExit?.(payload.workspaceId, payload.terminalId); + }, + { + onError: (error) => { + onDebug?.(buildErrorDebugEntry("terminal exit listen error", error)); + }, + }, + ); + return () => { + unlisten(); + }; + }, [cleanupTerminalSession, onDebug, onSessionExit]); + useEffect(() => { if (!isVisible) { inputDisposableRef.current?.dispose(); diff --git a/src/services/events.ts b/src/services/events.ts index 87935018..d288db77 100644 --- a/src/services/events.ts +++ b/src/services/events.ts @@ -9,6 +9,11 @@ export type TerminalOutputEvent = { data: string; }; +export type TerminalExitEvent = { + workspaceId: string; + terminalId: string; +}; + type SubscriptionOptions = { onError?: (error: unknown) => void; }; @@ -80,6 +85,7 @@ const appServerHub = createEventHub("app-server-event"); const dictationDownloadHub = createEventHub("dictation-download"); const dictationEventHub = createEventHub("dictation-event"); const terminalOutputHub = createEventHub("terminal-output"); +const terminalExitHub = createEventHub("terminal-exit"); const updaterCheckHub = createEventHub("updater-check"); const menuNewAgentHub = createEventHub("menu-new-agent"); const menuNewWorktreeAgentHub = createEventHub("menu-new-worktree-agent"); @@ -133,6 +139,13 @@ export function subscribeTerminalOutput( return terminalOutputHub.subscribe(onEvent, options); } +export function subscribeTerminalExit( + onEvent: (event: TerminalExitEvent) => void, + options?: SubscriptionOptions, +): Unsubscribe { + return terminalExitHub.subscribe(onEvent, options); +} + export function subscribeUpdaterCheck( onEvent: () => void, options?: SubscriptionOptions,