From d57e0dc930301555eaa748af1d7c1e67b63780fd Mon Sep 17 00:00:00 2001 From: dawn <93917549+dawNotPoi@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:02:02 +0000 Subject: [PATCH 01/16] fix(terminal): restore panel sessions after refresh --- src-tauri/src/terminal/manager.rs | 575 ++++++++++++++++-- src-tauri/src/terminal/types.rs | 16 +- .../canvas/terminal-card-lifecycle.test.ts | 14 +- src/components/terminal/terminal-panel.tsx | 14 +- .../terminal/terminal-view-recovery.test.tsx | 477 +++++++++++++++ src/components/terminal/terminal-view.tsx | 238 ++++++-- src/contexts/terminal-context.test.tsx | 193 ++++++ src/contexts/terminal-context.tsx | 183 +++++- src/i18n/messages/ar.json | 3 + src/i18n/messages/de.json | 3 + src/i18n/messages/en.json | 3 + src/i18n/messages/es.json | 3 + src/i18n/messages/fr.json | 3 + src/i18n/messages/ja.json | 3 + src/i18n/messages/ko.json | 3 + src/i18n/messages/pt.json | 3 + src/i18n/messages/zh-CN.json | 3 + src/i18n/messages/zh-TW.json | 3 + src/lib/api.ts | 6 +- src/lib/platform.ts | 5 + src/lib/transport/remote-desktop-transport.ts | 16 + src/lib/transport/types.ts | 2 + src/lib/transport/web-transport.test.ts | 6 + src/lib/transport/web-transport.ts | 16 + src/lib/types.ts | 12 +- vitest.config.ts | 6 + 26 files changed, 1695 insertions(+), 114 deletions(-) create mode 100644 src/components/terminal/terminal-view-recovery.test.tsx create mode 100644 src/contexts/terminal-context.test.tsx diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index 35e1d5d371..ef8e278e3a 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -2,6 +2,7 @@ use std::collections::{HashMap, VecDeque}; use std::io::{Read, Write}; use std::sync::mpsc; use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; use portable_pty::{native_pty_system, CommandBuilder, MasterPty, PtySize}; @@ -18,6 +19,10 @@ use crate::web::event_bridge::EventEmitter; /// to cover a screenful of `ls -R` or a compile log, not a whole session: this /// is a "pick the pane back up where you left it" buffer, not a transcript. const SCROLLBACK_MAX_CHARS: usize = 128 * 1024; +const COMPLETED_MAX_COUNT: usize = 64; +const COMPLETED_TTL: Duration = Duration::from_secs(10 * 60); +const CANCELLED_SPAWN_TTL: Duration = Duration::from_secs(30); +const CANCELLED_SPAWN_MAX_COUNT: usize = 128; /// Recent output of one terminal, kept so a viewer that mounts after the spawn /// (or re-mounts after its host view was unmounted — a canvas terminal card @@ -64,6 +69,7 @@ struct TerminalInstance { _child: Box, title: String, owner_window_label: String, + generation: String, /// Shared with this terminal's reader thread — the thread appends, viewers /// read. Held behind its own lock rather than the map's so a chunk of /// output never waits on a write / resize / list call. @@ -74,6 +80,96 @@ struct TerminalInstance { pub struct TerminalManager { terminals: Arc>>, + completed: Arc>, + cancelled_spawns: Arc>>, + spawning: Arc>>, +} + +/// Kept through the whole spawn call, including its fallible setup steps. +/// A second request with the same id must never start a second OS child. +struct SpawnReservation { + id: String, + spawning: Arc>>, +} + +impl Drop for SpawnReservation { + fn drop(&mut self) { + self.spawning + .lock() + .unwrap_or_else(|p| p.into_inner()) + .remove(&self.id); + } +} + +struct CompletedTerminal { + scrollback: Arc>, + owner_window_label: String, + generation: String, + child: Box, + exit_code: Option, + finished_at: Instant, +} + +#[derive(Default)] +struct CompletedTerminals { + entries: HashMap, + order: VecDeque, +} + +impl CompletedTerminals { + fn prune(&mut self) { + let now = Instant::now(); + while let Some(id) = self.order.front() { + if self.order.len() <= COMPLETED_MAX_COUNT + && self + .entries + .get(id) + .is_some_and(|entry| now.duration_since(entry.finished_at) < COMPLETED_TTL) + { + break; + } + let id = self.order.pop_front().expect("front exists"); + self.remove(&id); + } + } + + fn insert(&mut self, id: String, mut instance: TerminalInstance, exit_code: Option) { + cleanup_temp_files(&mut instance.temp_files); + self.remove(&id); + self.order.push_back(id.clone()); + self.entries.insert( + id, + CompletedTerminal { + scrollback: instance.scrollback.clone(), + owner_window_label: instance.owner_window_label.clone(), + generation: instance.generation.clone(), + child: instance._child, + exit_code, + finished_at: Instant::now(), + }, + ); + self.prune(); + } + + fn remove(&mut self, id: &str) -> bool { + let removed = self.entries.remove(id); + if let Some(mut entry) = removed { + if entry.exit_code.is_none() { + let _ = entry.child.kill(); + let _ = entry.child.wait(); + } + self.order.retain(|entry_id| entry_id != id); + true + } else { + false + } + } +} + +fn lock_completed( + completed: &Mutex, +) -> std::sync::MutexGuard<'_, CompletedTerminals> { + completed.lock().unwrap_or_else(|p| p.into_inner()) } /// Lock the terminal table, ignoring the poison flag. @@ -291,6 +387,9 @@ impl TerminalManager { pub fn new() -> Self { Self { terminals: Arc::new(Mutex::new(HashMap::new())), + completed: Arc::new(Mutex::new(CompletedTerminals::default())), + cancelled_spawns: Arc::new(Mutex::new(HashMap::new())), + spawning: Arc::new(Mutex::new(HashMap::new())), } } @@ -298,6 +397,9 @@ impl TerminalManager { pub fn clone_ref(&self) -> Self { Self { terminals: self.terminals.clone(), + completed: self.completed.clone(), + cancelled_spawns: self.cancelled_spawns.clone(), + spawning: self.spawning.clone(), } } @@ -307,16 +409,35 @@ impl TerminalManager { opts: SpawnOptions, emitter: EventEmitter, ) -> Result { - // Reject duplicate IDs to prevent orphaning an existing PTY process. - { + // Reserve the ID before any fallible OS work. The guard releases it + // on every error path, and insertion happens before the guard drops. + let _reservation = { let terminals = lock_terminals(&self.terminals); - if terminals.contains_key(&opts.terminal_id) { + let mut spawning = self.spawning.lock().unwrap_or_else(|p| p.into_inner()); + if terminals.contains_key(&opts.terminal_id) || spawning.contains_key(&opts.terminal_id) + { return Err(TerminalError::SpawnFailed(format!( "terminal id '{}' already exists", opts.terminal_id ))); } - } + // Usually an explicit tab close is followed by a still-pending + // spawn request. Reject before starting the child when possible. + let mut pending = self + .cancelled_spawns + .lock() + .unwrap_or_else(|p| p.into_inner()); + if pending.remove(&opts.terminal_id).is_some() { + return Err(TerminalError::SpawnFailed( + "terminal launch was cancelled".to_string(), + )); + } + spawning.insert(opts.terminal_id.clone(), opts.owner_window_label.clone()); + SpawnReservation { + id: opts.terminal_id.clone(), + spawning: self.spawning.clone(), + } + }; let pty_system = native_pty_system(); @@ -365,6 +486,7 @@ impl TerminalManager { .map_err(|e| TerminalError::SpawnFailed(e.to_string()))?; let terminal_id = opts.terminal_id; + let generation = uuid::Uuid::new_v4().to_string(); // Boundary-, length-, and NUL-safe prefix for the PTY thread names; see // `thread_name_prefix`. `terminal_id` is caller-supplied. let short_id = thread_name_prefix(&terminal_id); @@ -379,11 +501,33 @@ impl TerminalManager { _child: child, title: "Terminal".to_string(), owner_window_label: opts.owner_window_label, + generation: generation.clone(), scrollback: scrollback.clone(), temp_files: opts.temp_files, }; - lock_terminals(&self.terminals).insert(terminal_id.clone(), instance); + { + // A completed terminal may be explicitly restarted by another + // feature (canvas cards). Its old scrollback must not mask the new PTY. + let mut terminals = lock_terminals(&self.terminals); + let cancelled = { + let mut pending = self + .cancelled_spawns + .lock() + .unwrap_or_else(|p| p.into_inner()); + pending.remove(&terminal_id).is_some() + }; + if cancelled { + drop(terminals); + let mut instance = instance; + terminate_terminal(&mut instance); + return Err(TerminalError::SpawnFailed( + "terminal launch was cancelled".to_string(), + )); + } + lock_completed(&self.completed).remove(&terminal_id); + terminals.insert(terminal_id.clone(), instance); + } // Named writer thread std::thread::Builder::new() @@ -396,6 +540,7 @@ impl TerminalManager { // Named reader thread — emits per-terminal events let id_for_reader = terminal_id.clone(); let terminals_ref = self.terminals.clone(); + let completed_ref = self.completed.clone(); // The watch that notices a dev server announcing itself in this // terminal's output. Built here because this is where the owning // window is known; it probes and emits on threads of its own, so the @@ -407,8 +552,10 @@ impl TerminalManager { read_loop( reader, id_for_reader, + generation, &emitter, &terminals_ref, + &completed_ref, &scrollback, &watch, ); @@ -447,45 +594,109 @@ impl TerminalManager { Ok(()) } - /// Recent output of a live terminal, for a viewer attaching to a PTY that - /// was spawned before it mounted. + /// Recent output of a live or recently completed PTY, for a viewer that + /// mounts after it spawned or reconnects after losing events. /// - /// Never an error: "no such terminal" is the answer that tells the caller - /// to spawn one instead, and a caller that has to distinguish that from a - /// transport failure would have to parse the error string to do it. Note + /// Never an error: an unknown id returns `exists: false`. A canvas card + /// may spawn then, while a restored panel tab must never replay its old + /// command. Note /// the map lock is taken only to find the instance — the buffer has its own, /// so a large scrollback is never copied while output is blocked. pub fn snapshot(&self, terminal_id: &str) -> TerminalSnapshot { - let buffer = { + // The active→completed transfer happens under the active map lock, so + // a snapshot never mistakes an exiting PTY for a missing one. + let (buffer, alive, exit_code, generation) = { let terminals = lock_terminals(&self.terminals); - match terminals.get(terminal_id) { - Some(instance) => instance.scrollback.clone(), - None => { - return TerminalSnapshot { - alive: false, - data: String::new(), - seq: 0, + if let Some(instance) = terminals.get(terminal_id) { + ( + Some(instance.scrollback.clone()), + true, + None, + Some(instance.generation.clone()), + ) + } else { + let mut completed = lock_completed(&self.completed); + completed.prune(); + match completed.entries.get_mut(terminal_id) { + Some(entry) => { + if entry.exit_code.is_none() { + entry.exit_code = entry + .child + .try_wait() + .ok() + .flatten() + .map(|status| status.exit_code()); + } + ( + Some(entry.scrollback.clone()), + false, + entry.exit_code, + Some(entry.generation.clone()), + ) } + None => (None, false, None, None), } } }; + let Some(buffer) = buffer else { + return TerminalSnapshot { + exists: false, + alive: false, + data: String::new(), + seq: 0, + exit_code: None, + generation: None, + }; + }; let (data, seq) = buffer .lock() .map(|s| s.read()) .unwrap_or_else(|_| (String::new(), 0)); TerminalSnapshot { - alive: true, + exists: true, + alive, data, seq, + exit_code, + generation, } } pub fn kill(&self, terminal_id: &str) -> Result<(), TerminalError> { - let mut instance = lock_terminals(&self.terminals) - .remove(terminal_id) - .ok_or_else(|| TerminalError::NotFound(terminal_id.to_string()))?; - terminate_terminal(&mut instance); - Ok(()) + let mut terminals = lock_terminals(&self.terminals); + if let Some(mut instance) = terminals.remove(terminal_id) { + lock_completed(&self.completed).remove(terminal_id); + drop(terminals); + terminate_terminal(&mut instance); + return Ok(()); + } + if lock_completed(&self.completed).remove(terminal_id) { + return Ok(()); + } + // A close can overtake an in-flight spawn request (or arrive just + // before it). Remember only random terminal IDs, for a short bounded + // interval. The later insert consumes this reservation atomically under + // the terminal map lock; ordinary view unmount never calls kill. + if uuid::Uuid::parse_str(terminal_id).is_ok() { + let mut pending = self + .cancelled_spawns + .lock() + .unwrap_or_else(|p| p.into_inner()); + let now = Instant::now(); + pending.retain(|_, time| now.duration_since(*time) < CANCELLED_SPAWN_TTL); + if pending.len() >= CANCELLED_SPAWN_MAX_COUNT { + if let Some(oldest) = pending + .iter() + .min_by_key(|(_, time)| *time) + .map(|(id, _)| id.clone()) + { + pending.remove(&oldest); + } + } + pending.insert(terminal_id.to_string(), now); + return Ok(()); + } + Err(TerminalError::NotFound(terminal_id.to_string())) } /// THE liveness gate. Drops terminals whose child has exited and returns @@ -500,37 +711,48 @@ impl TerminalManager { /// `TerminalInstance` releases the PTY but not the credential store and /// helper script on disk — only [`terminate_terminal`] did that, and it is /// not on this path. - fn reap_exited(terminals: &mut HashMap) -> Vec { - let mut exited_terminal_ids: Vec = Vec::new(); + fn reap_exited( + terminals: &mut HashMap, + completed: &Mutex, + ) -> Vec<(String, String)> { + let mut exited_terminal_ids: Vec<(String, String, Option)> = Vec::new(); // Windows ConPTY may not always surface EOF promptly; reconcile exited // child processes here so frontend running-state can recover reliably. for (id, instance) in terminals.iter_mut() { match instance._child.try_wait() { - Ok(Some(_)) => exited_terminal_ids.push(id.clone()), + Ok(Some(status)) => exited_terminal_ids.push(( + id.clone(), + instance.generation.clone(), + Some(status.exit_code()), + )), Ok(None) => {} Err(err) => { tracing::error!( "[TERM] failed to query child status for terminal {}: {}", - id, err + id, + err ); - exited_terminal_ids.push(id.clone()); + exited_terminal_ids.push((id.clone(), instance.generation.clone(), None)); } } } - for terminal_id in &exited_terminal_ids { - if let Some(mut instance) = terminals.remove(terminal_id) { - cleanup_temp_files(&mut instance.temp_files); + for (terminal_id, _, exit_code) in &exited_terminal_ids { + if let Some(instance) = terminals.remove(terminal_id) { + lock_completed(completed).insert(terminal_id.clone(), instance, *exit_code); } } exited_terminal_ids + .into_iter() + .map(|(id, generation, _)| (id, generation)) + .collect() } pub fn list_with_exit_check(&self, emitter: Option<&EventEmitter>) -> Vec { let mut terminals = lock_terminals(&self.terminals); - let exited_terminal_ids = Self::reap_exited(&mut terminals); + let exited_terminal_ids = Self::reap_exited(&mut terminals, &self.completed); let infos = terminals .iter() @@ -543,8 +765,8 @@ impl TerminalManager { drop(terminals); if let Some(emitter) = emitter { - for terminal_id in exited_terminal_ids { - emit_terminal_exit_event(emitter, &terminal_id); + for (terminal_id, generation) in exited_terminal_ids { + emit_terminal_exit_event(emitter, &terminal_id, &generation); } } @@ -562,7 +784,7 @@ impl TerminalManager { emitter: Option<&EventEmitter>, ) -> usize { let mut terminals = lock_terminals(&self.terminals); - let exited_terminal_ids = Self::reap_exited(&mut terminals); + let exited_terminal_ids = Self::reap_exited(&mut terminals, &self.completed); let live = terminals .values() @@ -572,8 +794,8 @@ impl TerminalManager { drop(terminals); if let Some(emitter) = emitter { - for terminal_id in exited_terminal_ids { - emit_terminal_exit_event(emitter, &terminal_id); + for (terminal_id, generation) in exited_terminal_ids { + emit_terminal_exit_event(emitter, &terminal_id, &generation); } } @@ -600,10 +822,32 @@ impl TerminalManager { removed.push(instance); } } + let spawning = self.spawning.lock().unwrap_or_else(|p| p.into_inner()); + let mut pending = self + .cancelled_spawns + .lock() + .unwrap_or_else(|p| p.into_inner()); + for (id, owner) in spawning.iter() { + if owner == owner_window_label { + pending.insert(id.clone(), Instant::now()); + } + } removed }; let killed = instances.len(); + { + let mut completed = lock_completed(&self.completed); + let ids: Vec<_> = completed + .entries + .iter() + .filter(|(_, item)| item.owner_window_label == owner_window_label) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + completed.remove(&id); + } + } for instance in &mut instances { terminate_terminal(instance); } @@ -615,9 +859,24 @@ impl TerminalManager { pub fn kill_all(&self) -> usize { let mut instances: Vec = { let mut terminals = lock_terminals(&self.terminals); + let spawning = self.spawning.lock().unwrap_or_else(|p| p.into_inner()); + let mut pending = self + .cancelled_spawns + .lock() + .unwrap_or_else(|p| p.into_inner()); + for id in spawning.keys() { + pending.insert(id.clone(), Instant::now()); + } terminals.drain().map(|(_, inst)| inst).collect() }; let killed = instances.len(); + { + let mut completed = lock_completed(&self.completed); + let ids: Vec<_> = completed.entries.keys().cloned().collect(); + for id in ids { + completed.remove(&id); + } + } for instance in &mut instances { terminate_terminal(instance); } @@ -654,11 +913,14 @@ fn write_loop(mut writer: Box, rx: mpsc::Receiver>) { } } +#[allow(clippy::too_many_arguments)] fn read_loop( mut reader: Box, terminal_id: String, + generation: String, emitter: &EventEmitter, terminals: &Arc>>, + completed: &Arc>, scrollback: &Arc>, watch: &ServiceWatch, ) { @@ -693,6 +955,7 @@ fn read_loop( terminal_id: terminal_id.clone(), data, seq, + generation: generation.clone(), }; crate::web::event_bridge::emit_event(emitter, &output_event, event.clone()); } @@ -704,19 +967,41 @@ fn read_loop( // like the scrollback lock above: this runs on the long-lived `pty-reader-*` // thread, and refusing the removal would leak the entry and its temp files // for the rest of the process. - if let Some(mut instance) = lock_terminals(terminals).remove(&terminal_id) { - cleanup_temp_files(&mut instance.temp_files); - } + finish_terminal_reader(&terminal_id, terminals, completed, scrollback); + emit_terminal_exit_event(emitter, &terminal_id, &generation); +} - emit_terminal_exit_event(emitter, &terminal_id); +/// A previous reader can finish after a same-ID canvas terminal has restarted. +/// Only the reader of the current process may transfer that row to completed. +fn finish_terminal_reader( + terminal_id: &str, + terminals: &Mutex>, + completed: &Mutex, + scrollback: &Arc>, +) { + let mut terminals = lock_terminals(terminals); + if terminals + .get(terminal_id) + .is_some_and(|instance| Arc::ptr_eq(&instance.scrollback, scrollback)) + { + let mut instance = terminals.remove(terminal_id).expect("matching terminal"); + let exit_code = instance + ._child + .try_wait() + .ok() + .flatten() + .map(|status| status.exit_code()); + lock_completed(completed).insert(terminal_id.to_string(), instance, exit_code); + } } -fn emit_terminal_exit_event(emitter: &EventEmitter, terminal_id: &str) { +fn emit_terminal_exit_event(emitter: &EventEmitter, terminal_id: &str, generation: &str) { let exit_event = format!("terminal://exit/{}", terminal_id); let event = TerminalEvent { terminal_id: terminal_id.to_string(), data: String::new(), seq: 0, + generation: generation.to_string(), }; crate::web::event_bridge::emit_event(emitter, &exit_event, event.clone()); } @@ -739,9 +1024,9 @@ fn thread_name_prefix(terminal_id: &str) -> String { #[cfg(test)] mod tests { + use super::{thread_name_prefix, Scrollback, SCROLLBACK_MAX_CHARS}; #[cfg(not(target_os = "windows"))] use super::{Arc, EventEmitter, SpawnOptions, TerminalManager}; - use super::{thread_name_prefix, Scrollback, SCROLLBACK_MAX_CHARS}; #[test] fn scrollback_seq_counts_every_chunk_and_never_rewinds() { @@ -798,6 +1083,208 @@ mod tests { assert_eq!(snapshot.seq, 0); } + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn finished_terminal_keeps_final_output_and_exit_status_until_explicit_close() { + use crate::web::event_bridge::WebEventBroadcaster; + use std::time::{Duration, Instant}; + + let manager = TerminalManager::new(); + let emitter = EventEmitter::test_web_only(Arc::new(WebEventBroadcaster::new())); + manager + .spawn_with_id( + SpawnOptions { + terminal_id: "finished-output".to_string(), + working_dir: std::env::temp_dir().to_string_lossy().to_string(), + owner_window_label: "main".to_string(), + shell: Some("/bin/sh".to_string()), + initial_command: Some("printf 'last-output-marker\\n'; exit 7".to_string()), + extra_env: None, + temp_files: vec![], + }, + emitter, + ) + .expect("spawn"); + + let deadline = Instant::now() + Duration::from_secs(10); + let completed = loop { + // list can observe child exit before reader EOF. Neither order may + // throw away the final bytes needed by a reloaded pane. + let _ = manager.list_with_exit_check(None); + let snapshot = manager.snapshot("finished-output"); + if !snapshot.alive && snapshot.exists && snapshot.data.contains("last-output-marker") { + break snapshot; + } + assert!( + Instant::now() < deadline, + "last output vanished: {snapshot:?}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + assert_eq!(completed.exit_code, Some(7)); + manager.kill("finished-output").expect("explicit close"); + let removed = manager.snapshot("finished-output"); + assert!(!removed.exists); + assert!(removed.data.is_empty()); + } + + #[cfg(not(target_os = "windows"))] + #[tokio::test] + async fn reader_eof_first_preserves_status_and_stale_reader_cannot_remove_new_pty() { + use crate::web::event_bridge::WebEventBroadcaster; + use std::time::{Duration, Instant}; + + let manager = TerminalManager::new(); + let emitter = EventEmitter::test_web_only(Arc::new(WebEventBroadcaster::new())); + let opts = |command: &str| SpawnOptions { + terminal_id: "reused-id".to_string(), + working_dir: std::env::temp_dir().to_string_lossy().to_string(), + owner_window_label: "main".to_string(), + shell: Some("/bin/sh".to_string()), + initial_command: Some(command.to_string()), + extra_env: None, + temp_files: vec![], + }; + + manager + .spawn_with_id( + opts("printf 'eof-first-marker\\n'; exit 9"), + emitter.clone(), + ) + .expect("first spawn"); + let old_scrollback = super::lock_terminals(&manager.terminals) + .get("reused-id") + .expect("first terminal") + .scrollback + .clone(); + + // Do not call list: the reader must be the one to move it to completed. + let deadline = Instant::now() + Duration::from_secs(10); + loop { + let snapshot = manager.snapshot("reused-id"); + if !snapshot.alive + && snapshot.exists + && snapshot.data.contains("eof-first-marker") + && snapshot.exit_code == Some(9) + { + break; + } + assert!( + Instant::now() < deadline, + "EOF-first status missing: {snapshot:?}" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + manager + .spawn_with_id(opts("sleep 30"), emitter) + .expect("new process with reused id"); + let new_generation = manager.snapshot("reused-id").generation; + super::finish_terminal_reader( + "reused-id", + &manager.terminals, + &manager.completed, + &old_scrollback, + ); + let current = manager.snapshot("reused-id"); + assert!(current.alive, "old reader removed new PTY"); + assert_eq!(current.generation, new_generation); + assert!(!Arc::ptr_eq( + &old_scrollback, + &super::lock_terminals(&manager.terminals) + .get("reused-id") + .expect("new terminal") + .scrollback + )); + manager.kill("reused-id").expect("cleanup"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn explicit_close_before_spawn_cancels_only_that_uuid_once() { + let manager = TerminalManager::new(); + let id = uuid::Uuid::new_v4().to_string(); + let opts = |id: &str| SpawnOptions { + terminal_id: id.to_string(), + working_dir: std::env::temp_dir().to_string_lossy().to_string(), + owner_window_label: "main".to_string(), + shell: Some("/bin/sh".to_string()), + initial_command: Some("sleep 30".to_string()), + extra_env: None, + temp_files: vec![], + }; + manager.kill(&id).expect("close overtakes spawn"); + assert!(manager + .spawn_with_id(opts(&id), EventEmitter::Noop) + .is_err()); + assert!(!manager.snapshot(&id).exists); + // Reservation is consumed. An intentional later new tab may use the ID. + manager + .spawn_with_id(opts(&id), EventEmitter::Noop) + .expect("new launch"); + manager.kill(&id).expect("close live process"); + + // Canvas cards use deterministic, non-UUID IDs. Their missing kill + // must not reserve the ID and prevent the existing restart behavior. + assert!(manager.kill("canvas-term-42").is_err()); + manager + .spawn_with_id(opts("canvas-term-42"), EventEmitter::Noop) + .expect("canvas restart"); + manager.kill("canvas-term-42").expect("canvas cleanup"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn concurrent_same_id_spawn_creates_one_child() { + use std::sync::Barrier; + use std::time::{Duration, Instant}; + + let manager = Arc::new(TerminalManager::new()); + let id = uuid::Uuid::new_v4().to_string(); + let output = std::env::temp_dir().join(format!("codeg-749-spawn-{}", uuid::Uuid::new_v4())); + let gate = Arc::new(Barrier::new(3)); + let mut threads = Vec::new(); + for _ in 0..2 { + let manager = manager.clone(); + let id = id.clone(); + let output = output.clone(); + let gate = gate.clone(); + threads.push(std::thread::spawn(move || { + gate.wait(); + manager.spawn_with_id( + SpawnOptions { + terminal_id: id, + working_dir: std::env::temp_dir().to_string_lossy().to_string(), + owner_window_label: "main".to_string(), + shell: Some("/bin/sh".to_string()), + initial_command: Some(format!( + "printf 'started\\n' >> '{}'; sleep 30", + output.display() + )), + extra_env: None, + temp_files: vec![], + }, + EventEmitter::Noop, + ) + })); + } + gate.wait(); + let results: Vec<_> = threads + .into_iter() + .map(|thread| thread.join().expect("join")) + .collect(); + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + + let deadline = Instant::now() + Duration::from_secs(5); + while !output.exists() && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(10)); + } + let text = std::fs::read_to_string(&output).expect("spawn marker"); + assert_eq!(text.lines().count(), 1, "command was run twice"); + manager.kill(&id).expect("the winning child is reachable"); + let _ = std::fs::remove_file(output); + } + #[test] fn keeps_short_ascii_id() { assert_eq!(thread_name_prefix("abc"), "abc"); diff --git a/src-tauri/src/terminal/types.rs b/src-tauri/src/terminal/types.rs index 5fb51639a0..0af669cc9b 100644 --- a/src-tauri/src/terminal/types.rs +++ b/src-tauri/src/terminal/types.rs @@ -16,14 +16,22 @@ pub struct TerminalEvent { /// `0` on the exit event, which carries no output. #[serde(default)] pub seq: u64, + /// Distinguishes a restarted PTY that reused the same terminal id. + pub generation: String, } -/// Recent output of a live terminal plus the cursor it was read at — the -/// re-attach payload behind `terminal_snapshot`. +/// Recent output of a live or recently completed terminal plus its cursor — +/// the re-attach payload behind `terminal_snapshot`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TerminalSnapshot { - /// False when no terminal with that id is running: the caller should spawn - /// one rather than attach. `data` is then empty. + /// Whether a live or recently completed PTY with this id is known. + pub exists: bool, + /// Exit status retained with the completed output, if the child reported it. + pub exit_code: Option, + /// Same token carried by output/exit events for this PTY generation. + pub generation: Option, + /// False for a completed PTY or an unknown id. Use `exists` to distinguish + /// retained final output from a missing session. pub alive: bool, /// Recent PTY output, oldest chunk possibly trimmed by the scrollback cap. /// Raw terminal bytes (escape sequences included) — written straight back diff --git a/src/components/canvas/terminal-card-lifecycle.test.ts b/src/components/canvas/terminal-card-lifecycle.test.ts index e6311a2c6f..1e0e03d814 100644 --- a/src/components/canvas/terminal-card-lifecycle.test.ts +++ b/src/components/canvas/terminal-card-lifecycle.test.ts @@ -105,7 +105,7 @@ describe("a terminal card crossing a route switch", () => { const spawnAt = view.indexOf("terminalSpawn(") const retryAt = view.indexOf("const retry = attach", spawnAt) expect(retryAt).toBeGreaterThan(spawnAt) - expect(view).toContain("if (retry?.alive)") + expect(view).toContain("if (retry?.alive || retry?.exists)") }) it("keeps buffering across the spawn so a lost race cannot double-print", () => { @@ -116,10 +116,14 @@ describe("a terminal card crossing a route switch", () => { // of it when the spawn succeeded (nothing else can have written to that // id), deduped against the snapshot when it lost the race. const view = read(VIEW) - const snapshotAt = view.indexOf("terminalSnapshot(terminalId)") - const spawnAt = view.indexOf("terminalSpawn(", snapshotAt) - const firstFlushAt = view.indexOf("flushReplay(", snapshotAt) - expect(firstFlushAt).toBeGreaterThan(spawnAt) + const spawnBranchAt = view.indexOf("if (!attached && spawnOnMissing)") + const spawnAt = view.indexOf("terminalSpawn(", spawnBranchAt) + expect(spawnBranchAt).toBeGreaterThan(-1) + expect(spawnAt).toBeGreaterThan(spawnBranchAt) + // A restored panel tab has a separate no-spawn branch. Within the canvas + // spawn branch, output must remain buffered until spawn settles. + expect(view.slice(spawnBranchAt, spawnAt)).not.toContain("flushReplay(") + expect(view.slice(spawnAt)).toContain("if (retry?.alive") }) it("holds a PERMANENT seq floor, because the buffer is not a gate", () => { diff --git a/src/components/terminal/terminal-panel.tsx b/src/components/terminal/terminal-panel.tsx index fca89d1036..8c9a15cf34 100644 --- a/src/components/terminal/terminal-panel.tsx +++ b/src/components/terminal/terminal-panel.tsx @@ -18,7 +18,14 @@ const KEYBAR_COLLAPSED_STORAGE_KEY = "codeg:term-keybar" * 后展开/折叠不一致。 */ export function TerminalPanel() { - const { isOpen, tabs, activeTabId, markTerminalExited } = useTerminalContext() + const { + isOpen, + tabs, + activeTabId, + markTerminalExited, + markTerminalRunning, + markTerminalStarted, + } = useTerminalContext() const isMobile = useIsMobile() const [keybarCollapsed, setKeybarCollapsed] = useState(() => { @@ -69,6 +76,11 @@ export function TerminalPanel() { workingDir={tab.workingDir} shell={tab.shell} initialCommand={tab.initialCommand} + attach + spawnOnMissing={!tab.restored} + reuseCompleted + onSpawned={markTerminalStarted} + onProcessRestored={markTerminalRunning} isActive={tab.id === activeTabId} isVisible={isOpen} keybarVisible={keybarVisible} diff --git a/src/components/terminal/terminal-view-recovery.test.tsx b/src/components/terminal/terminal-view-recovery.test.tsx new file mode 100644 index 0000000000..ae6d9844b2 --- /dev/null +++ b/src/components/terminal/terminal-view-recovery.test.tsx @@ -0,0 +1,477 @@ +import { act, render, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { TerminalView } from "./terminal-view" +import type { TerminalEvent, TerminalSnapshot } from "@/lib/types" + +const h = vi.hoisted(() => ({ + writes: [] as string[], + resets: 0, + snapshot: vi.fn(), + spawn: vi.fn(), + kill: vi.fn(async () => {}), + write: vi.fn(async () => {}), + onData: null as ((data: string) => void) | null, + handlers: new Map void>(), + ready: null as (() => void) | null, +})) + +vi.mock("next-intl", () => ({ + useTranslations: () => (key: string, args?: { code: number }) => + key === "processExitedWithCode" + ? `Process exited (code ${args?.code})` + : key, +})) +vi.mock("@/lib/api", () => ({ + terminalSnapshot: h.snapshot, + terminalSpawn: h.spawn, + terminalWrite: h.write, + terminalResize: vi.fn(async () => {}), + terminalKill: h.kill, +})) +vi.mock("@/lib/platform", () => ({ + subscribe: async (channel: string, cb: (event: TerminalEvent) => void) => { + h.handlers.set(channel, cb) + return () => { + h.handlers.delete(channel) + } + }, + onTransportReady: (cb: () => void) => { + h.ready = cb + return () => { + h.ready = null + } + }, +})) +vi.mock("@/hooks/use-appearance", () => ({ + useZoomLevel: () => ({ zoomLevel: 100 }), + useTerminalFont: () => ({ + terminalFontStack: "monospace", + terminalFontSize: 12, + terminalLigatures: false, + }), +})) +vi.mock("@/hooks/use-open-url-target", () => ({ + useOpenUrlTarget: () => vi.fn(), + isPrimaryModifier: () => false, +})) +vi.mock("@/hooks/use-platform", () => ({ detectPlatform: () => "linux" })) +vi.mock("@/lib/terminal/theme", () => ({ getTerminalTheme: () => ({}) })) +vi.mock("@/lib/browser/browser-prefs", () => ({ getBrowserPrefs: () => ({}) })) +vi.mock("@/components/terminal/terminal-link-menu", () => ({ + TerminalLinkMenu: () => null, + terminalLinkClickOpensMenu: () => false, +})) +vi.mock("@/components/terminal/term-keybar", () => ({ TermKeybar: () => null })) +vi.mock("@xterm/xterm", () => ({ + Terminal: class { + options = { fontFamily: "", fontSize: 12, theme: {} } + modes = { applicationCursorKeysMode: false } + loadAddon() {} + open() {} + attachCustomKeyEventHandler() {} + onData(cb: (data: string) => void) { + h.onData = cb + return { + dispose() { + h.onData = null + }, + } + } + onResize() { + return { dispose() {} } + } + write(data: string) { + h.writes.push(data) + } + reset() { + h.resets++ + h.writes.push("") + } + dispose() {} + focus() {} + }, +})) +vi.mock("@xterm/addon-ligatures", () => ({ LigaturesAddon: class {} })) +vi.mock("@xterm/addon-fit", () => ({ + FitAddon: class { + fit() {} + }, +})) +vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} })) + +const props = { + terminalId: "terminal-1", + workingDir: "/tmp", + isActive: true, + isVisible: true, +} + +function snapshot(data: string, seq: number, alive = true): TerminalSnapshot { + return { + exists: true, + alive, + data, + seq, + exit_code: null, + generation: "new-generation", + } +} + +describe("TerminalView recovery", () => { + beforeEach(() => { + h.writes.length = 0 + h.resets = 0 + h.snapshot.mockReset() + h.snapshot.mockResolvedValue({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + h.spawn.mockReset() + h.spawn.mockResolvedValue("terminal-1") + h.kill.mockClear() + h.write.mockClear() + h.onData = null + h.handlers.clear() + h.ready = null + }) + + it("restores a live PTY, filters old-generation events, and fills WS gaps from a snapshot", async () => { + h.snapshot.mockResolvedValueOnce(snapshot("before", 2)) + const view = render( + + ) + await waitFor(() => expect(h.writes).toContain("before")) + expect(h.spawn).not.toHaveBeenCalled() + + act(() => { + h.handlers.get("terminal://output/terminal-1")?.({ + terminal_id: "terminal-1", + data: "old", + seq: 99, + generation: "old-generation", + }) + h.handlers.get("terminal://exit/terminal-1")?.({ + terminal_id: "terminal-1", + data: "", + seq: 0, + generation: "old-generation", + }) + h.handlers.get("terminal://output/terminal-1")?.({ + terminal_id: "terminal-1", + data: "after", + seq: 3, + generation: "new-generation", + }) + }) + expect(h.writes.join("")).toContain("beforeafter") + expect(h.writes.join("")).not.toContain("old") + expect(h.writes.join("")).not.toContain("processExited") + + let finishSnapshot!: (value: TerminalSnapshot) => void + h.snapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + finishSnapshot = resolve + }) + ) + act(() => { + h.ready?.() + }) + act(() => { + h.handlers.get("terminal://output/terminal-1")?.({ + terminal_id: "terminal-1", + data: "duplicate", + seq: 4, + generation: "new-generation", + }) + h.handlers.get("terminal://output/terminal-1")?.({ + terminal_id: "terminal-1", + data: "newest", + seq: 5, + generation: "new-generation", + }) + }) + await act(async () => { + finishSnapshot(snapshot("beforeafterlost", 4)) + }) + expect(h.resets).toBe(1) + expect(h.writes.slice(h.writes.indexOf("") + 1).join("")).toBe( + "beforeafterlostnewest" + ) + expect(h.spawn).not.toHaveBeenCalled() + view.unmount() + expect(h.kill).not.toHaveBeenCalled() + }) + + it("keeps canvas output buffered through a lost concurrent spawn race", async () => { + h.snapshot.mockResolvedValueOnce({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + let rejectSpawn!: (reason: Error) => void + h.spawn.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + rejectSpawn = reject + }) + ) + let finishRetry!: (value: TerminalSnapshot) => void + h.snapshot.mockResolvedValueOnce({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + h.snapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + finishRetry = resolve + }) + ) + + const view = render() + await waitFor(() => expect(h.spawn).toHaveBeenCalledTimes(1)) + act(() => { + h.handlers.get("terminal://output/terminal-1")?.({ + terminal_id: "terminal-1", + data: "duplicate", + seq: 1, + generation: "new-generation", + }) + }) + expect(h.writes).not.toContain("duplicate") + await act(async () => { + rejectSpawn(new Error("terminal id already exists")) + }) + await waitFor(() => expect(h.snapshot).toHaveBeenCalledTimes(3)) + act(() => { + h.handlers.get("terminal://output/terminal-1")?.({ + terminal_id: "terminal-1", + data: "newer", + seq: 2, + generation: "new-generation", + }) + }) + await act(async () => { + finishRetry(snapshot("duplicate", 1)) + }) + expect(h.writes.join("")).toContain("duplicatenewer") + expect(h.writes.join("").match(/duplicate/g)).toHaveLength(1) + view.unmount() + }) + + it("accepts a completed winner when duplicate spawn loses after it exits", async () => { + h.snapshot.mockResolvedValueOnce({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + h.spawn.mockRejectedValueOnce(new Error("terminal id already exists")) + h.snapshot.mockResolvedValueOnce({ + ...snapshot("short command finished", 2, false), + exit_code: 4, + }) + const view = render() + await waitFor(() => + expect(h.writes.join("")).toContain("Process exited (code 4)") + ) + expect(h.writes.join("")).toContain("short command finished") + expect(h.writes.join("")).not.toContain("Failed to start terminal") + view.unmount() + }) + + it("reports a genuine launch failure without waiting for another PTY", async () => { + h.snapshot.mockResolvedValueOnce({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + h.spawn.mockRejectedValueOnce(new Error("shell missing")) + const view = render() + await waitFor(() => + expect(h.writes.join("")).toContain("Failed to start terminal") + ) + expect(h.snapshot).toHaveBeenCalledTimes(2) + view.unmount() + }) + + it("retains completed output and its exit code", async () => { + h.snapshot.mockResolvedValueOnce({ + ...snapshot("final output", 3, false), + exit_code: 7, + }) + const view = render( + + ) + await waitFor(() => + expect(h.writes.join("")).toContain("Process exited (code 7)") + ) + expect(h.writes.join("")).toContain("final output") + expect(h.spawn).not.toHaveBeenCalled() + view.unmount() + }) + + it("does not replay a command after a backend restart, and accepts old servers' live snapshots", async () => { + h.snapshot.mockResolvedValueOnce({ + alive: true, + data: "legacy live", + seq: 2, + }) + const view = render( + + ) + await waitFor(() => expect(h.writes).toContain("legacy live")) + expect(h.spawn).not.toHaveBeenCalled() + + h.snapshot.mockResolvedValueOnce({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + act(() => { + h.ready?.() + }) + await waitFor(() => expect(h.writes.join("")).toContain("unavailable")) + expect(h.spawn).not.toHaveBeenCalled() + + // A delayed in-flight spawn can still appear after the first missing + // snapshot. The recovered pane must accept Ctrl-C/input again. + h.snapshot.mockResolvedValueOnce(snapshot("late process", 1)) + act(() => { + h.ready?.() + }) + await waitFor(() => expect(h.writes.join("")).toContain("late process")) + act(() => { + h.onData?.("\x03") + }) + await waitFor(() => + expect(h.write).toHaveBeenCalledWith("terminal-1", "\x03") + ) + view.unmount() + }) + + it("recovers input when the first server ready arrives after an offline startup", async () => { + let now = 0 + const clock = vi.spyOn(Date, "now").mockImplementation(() => { + now += 20_000 + return now + }) + h.snapshot.mockRejectedValueOnce(new Error("temporarily offline")) + const view = render( + + ) + await waitFor(() => expect(h.writes.join("")).toContain("unavailable")) + clock.mockRestore() + + // This is the transport's first __ready__, not an onReconnect event. + h.snapshot.mockResolvedValueOnce(snapshot("restored after offline", 4)) + act(() => { + h.ready?.() + }) + await waitFor(() => + expect(h.writes.join("")).toContain("restored after offline") + ) + act(() => { + h.onData?.("\x03") + }) + await waitFor(() => + expect(h.write).toHaveBeenCalledWith("terminal-1", "\x03") + ) + expect(h.spawn).not.toHaveBeenCalled() + view.unmount() + }) + + it("resamples after the first ready frame races with its startup probe", async () => { + let finishFirst!: (value: TerminalSnapshot) => void + h.snapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirst = resolve + }) + ) + h.snapshot.mockResolvedValueOnce(snapshot("ready after probe", 3)) + let now = 0 + const clock = vi.spyOn(Date, "now").mockImplementation(() => { + now += 20_000 + return now + }) + const view = render( + + ) + await waitFor(() => expect(h.ready).not.toBeNull()) + + // The Web socket becomes ready while the HTTP snapshot is still in flight. + act(() => { + h.ready?.() + }) + await act(async () => { + finishFirst({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + }) + clock.mockRestore() + await waitFor(() => + expect(h.writes.join("")).toContain("ready after probe") + ) + expect(h.spawn).not.toHaveBeenCalled() + view.unmount() + }) + + it("keeps an in-flight spawn alive through a view unmount and does not replay the command", async () => { + h.snapshot.mockResolvedValueOnce({ + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + }) + let finishSpawn!: (id: string) => void + h.spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + finishSpawn = resolve + }) + ) + const view = render( + + ) + await waitFor(() => expect(h.spawn).toHaveBeenCalledTimes(1)) + view.unmount() + await act(async () => { + finishSpawn("terminal-1") + }) + expect(h.kill).not.toHaveBeenCalled() + + h.snapshot.mockResolvedValueOnce(snapshot("still running", 1)) + render( + + ) + await waitFor(() => expect(h.writes).toContain("still running")) + expect(h.spawn).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index a14a535147..c332542b5c 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -1,7 +1,8 @@ "use client" import { useEffect, useRef, useState } from "react" -import { subscribe } from "@/lib/platform" +import { useTranslations } from "next-intl" +import { onTransportReady, subscribe } from "@/lib/platform" import { terminalSpawn, terminalSnapshot, @@ -91,8 +92,8 @@ interface TerminalViewProps { /** * 重新接管一个可能已经存在的 PTY,而不是「必然新建」。 * - * 终端面板的 tab 与它的 xterm 同生共死,所以那条路径永远是新建;而画布上的 - * 终端卡片不是——整个画布路由在切到别的页面时会真卸载,PTY 却应该继续跑 + * 终端面板刷新时会恢复同一个 PTY;画布终端卡片切换路由时也要继续运行。 + * 画布路由在切到别的页面时会真卸载,PTY 却应该继续跑 * (`pnpm dev` 不该因为用户瞄了一眼待办看板就被杀掉)。此模式下先问后端要 * 快照:拿到就把最近输出重放进新开的 xterm 并跳过 spawn,没拿到才新建。 * @@ -100,6 +101,12 @@ interface TerminalViewProps { * 不属于这一次挂载。 */ attach?: boolean + /** Restored panel tabs never re-run an old command when the backend has no PTY. */ + spawnOnMissing?: boolean + /** Panel tabs retain finished output; canvas cards keep their spawn fallback. */ + reuseCompleted?: boolean + onSpawned?: (terminalId: string) => void + onProcessRestored?: (terminalId: string) => void /** * 让字号无视应用缩放(`useZoomLevel`)。画布用自己的一套缩放,卡片内容一律 * 以「board units」绘制,再乘一次应用缩放就会和它所在的盒子对不上。 @@ -117,6 +124,10 @@ export function TerminalView({ isVisible, keybarVisible = false, attach = false, + spawnOnMissing = true, + reuseCompleted = false, + onSpawned, + onProcessRestored, ignoreAppZoom = false, onProcessExited, }: TerminalViewProps) { @@ -127,6 +138,8 @@ export function TerminalView({ const isActiveRef = useRef(isActive) const isVisibleRef = useRef(isVisible) const onProcessExitedRef = useRef(onProcessExited) + const onSpawnedRef = useRef(onSpawned) + const onProcessRestoredRef = useRef(onProcessRestored) // Link clicks route through the app's link decision (built-in browser vs // system browser, ⌘/Ctrl inverts). xterm's default handler is a bare // `window.open`, which the desktop webview turns into a dead click. @@ -146,6 +159,10 @@ export function TerminalView({ const terminalLigaturesRef = useRef(terminalLigatures) const ligaturesAddonRef = useRef(null) const [loading, setLoading] = useState(true) + const t = useTranslations("Folder.terminal") + // Locale changes affect future status text, not ownership of the live PTY. + const tRef = useRef(t) + tRef.current = t // ---- 移动端虚拟键栏(CTRL/ALT 闩锁 + 软键盘跟随)---- // modsRef 是编码真值(xterm.onData 闭包里读它不会过期);modsUi 只驱动按钮 @@ -238,7 +255,9 @@ export function TerminalView({ useEffect(() => { onProcessExitedRef.current = onProcessExited - }, [onProcessExited]) + onSpawnedRef.current = onSpawned + onProcessRestoredRef.current = onProcessRestored + }, [onProcessExited, onSpawned, onProcessRestored]) useEffect(() => { openUrlTargetRef.current = openUrlTarget @@ -302,7 +321,7 @@ export function TerminalView({ // send is dropped, not retried — re-sending an ambiguous write could // duplicate already-delivered bytes, worse than a drop in a shell. See // lib/terminal/write-queue.ts. - const writeQueue = createWriteQueue((d) => terminalWrite(terminalId, d)) + let writeQueue = createWriteQueue((d) => terminalWrite(terminalId, d)) // 暴露给键栏按键——必须走这条队列,否则与软键盘输入抢着发会乱序。 writeQueueRef.current = writeQueue @@ -396,6 +415,33 @@ export function TerminalView({ // 往返,中间到的 chunk 既可能已经在快照里(重复),也可能不在(丢失)。 // 两者靠 seq 区分——见下面的 writeEvent 与 `TerminalEvent.seq`。 let replayBuffer: TerminalEvent[] | null = attach ? [] : null + let exitWhileBuffering: TerminalEvent | null = null + let currentGeneration: string | null = null + let exitShown = false + let unlistenReconnect: (() => void) | null = null + let reconnectGeneration = 0 + + const showExit = (code?: number | null) => { + if (exitShown) return + exitShown = true + writeQueue.dispose() + onProcessExitedRef.current?.(terminalId) + const message = + code == null + ? tRef.current("processExited") + : tRef.current("processExitedWithCode", { code }) + term.write(`\r\n\x1b[90m[${message}]\x1b[0m\r\n`) + } + + const showUnavailable = (confirmedMissing: boolean) => { + if (exitShown) return + exitShown = true + if (confirmedMissing) { + writeQueue.dispose() + onProcessExitedRef.current?.(terminalId) + } + term.write(`\r\n\x1b[90m[${tRef.current("unavailable")}]\x1b[0m\r\n`) + } // 已被快照画进这块屏幕的最高游标。缓冲**不足以**当闸门:事件走的通道 // (Tauri IPC / WebSocket)和快照那次请求-响应之间没有任何顺序保证, @@ -410,6 +456,12 @@ export function TerminalView({ const writeEvent = (event: TerminalEvent) => { // seq 为 0/缺失 = 后端没能给出可比较的游标(旧后端、锁中毒)。此时 // 宁可重复也不能丢:多打一行远比吞掉一行输出容易发现和容忍。 + if ( + currentGeneration && + event.generation && + event.generation !== currentGeneration + ) + return const seq = event.seq ?? 0 if (seq > 0 && seq <= snapshotSeqFloor) return term.write(event.data) @@ -435,16 +487,28 @@ export function TerminalView({ snapshotSeqFloor = snapshotSeq } for (const event of buffered) writeEvent(event) + if (exitWhileBuffering) { + const exit = exitWhileBuffering + exitWhileBuffering = null + if ( + !currentGeneration || + !exit.generation || + exit.generation === currentGeneration + ) + showExit() + } } const unlistenExit = await subscribe( `terminal://exit/${terminalId}`, - () => { - // PTY is gone — stop the input pump (the reliable terminal-gone - // signal; the queue's error-string match is only a fast-path). - writeQueue.dispose() - onProcessExitedRef.current?.(terminalId) - term.write("\r\n\x1b[90m[Process exited]\x1b[0m\r\n") + (event) => { + if (replayBuffer) exitWhileBuffering = event + else if ( + !currentGeneration || + !event.generation || + event.generation === currentGeneration + ) + showExit() } ) @@ -460,9 +524,34 @@ export function TerminalView({ } /** 把快照写进这个全新的 xterm,并放行不在快照里的那段缓冲输出。 */ - const applySnapshot = (snapshot: { data: string; seq: number }) => { + const applySnapshot = (snapshot: { + data: string + seq: number + alive: boolean + exit_code?: number | null + generation?: string | null + }) => { + if (snapshot.alive && exitShown) { + writeQueue.dispose() + writeQueue = createWriteQueue((d) => terminalWrite(terminalId, d)) + writeQueueRef.current = writeQueue + onProcessRestoredRef.current?.(terminalId) + exitShown = false + } + currentGeneration = snapshot.generation ?? null if (snapshot.data) term.write(snapshot.data) + const pendingExit = exitWhileBuffering + exitWhileBuffering = null flushReplay(snapshot.seq) + if ( + !snapshot.alive || + (pendingExit && + (!currentGeneration || + !pendingExit.generation || + pendingExit.generation === currentGeneration)) + ) { + showExit(snapshot.exit_code) + } setLoading(false) } @@ -475,52 +564,119 @@ export function TerminalView({ onResizeDisposable.dispose() unlisten() unlistenExit() + unlistenReconnect?.() term.dispose() } - // attach 模式:先看这个 id 上有没有活着的 PTY。有就把最近输出重放进这个 - // 全新的 xterm 并跳过 spawn(spawn 会因 id 重复而失败,而那个失败恰好长得 - // 像「终端起不来」——实际上进程好好的,只是屏幕是空的)。 + // Web and remote-desktop event sockets can miss chunks while offline. + // Their ready notification fires after the server subscribes, so buffer + // live chunks, redraw from one backend snapshot, then apply only events + // newer than that snapshot's sequence. + let bootstrapping = true + let readyDuringStartup = false + const syncFromBackend = () => { + if (cancelled) return + const generation = ++reconnectGeneration + replayBuffer = [] + exitWhileBuffering = null + void terminalSnapshot(terminalId) + .then((snapshot) => { + if (cancelled || generation !== reconnectGeneration) return + if (!snapshot.alive && !snapshot.exists) { + flushReplay(null) + showUnavailable(true) + return + } + term.reset() + snapshotSeqFloor = 0 + applySnapshot(snapshot) + }) + .catch(() => { + if (cancelled || generation !== reconnectGeneration) return + // A transient HTTP failure is not proof that the PTY exited. + flushReplay(null) + }) + } + unlistenReconnect = onTransportReady(() => { + // If the first server ready lands while the startup snapshot is still + // in flight, let that snapshot finish, then resample. The ready event + // cannot be missed between the probe and listener registration. + if (bootstrapping) { + readyDuringStartup = true + return + } + syncFromBackend() + }) + + // A restored tab only knows an ID. A reload can beat a terminal_spawn + // request still in flight, so probe for a short bounded interval before + // declaring it unavailable; never replay its original command. + const readSnapshot = async (retryMissing: boolean) => { + const deadline = Date.now() + 10_000 + do { + const snapshot = await terminalSnapshot(terminalId).catch(() => null) + if (cancelled) return null + if (snapshot?.alive || snapshot?.exists) return snapshot + if (!retryMissing) return snapshot + await new Promise((resolve) => setTimeout(resolve, 150)) + } while (!cancelled && Date.now() < deadline) + return null + } + let attached = false + let confirmedMissing = false if (attach) { - const snapshot = await terminalSnapshot(terminalId).catch(() => null) + const snapshot = await readSnapshot(!spawnOnMissing) if (cancelled) { teardown() return } - if (snapshot?.alive) { + confirmedMissing = + snapshot != null && !snapshot.alive && !snapshot.exists + if (snapshot?.alive || (reuseCompleted && snapshot?.exists)) { attached = true applySnapshot(snapshot) } - // 没活着时**不**在这里放行缓冲:spawn 还有一次往返,而那段窗口里到达的 - // 输出可能属于「别的窗口刚 spawn 成功的那个 PTY」。现在写进屏幕,等下面 - // 的重试快照再把同一段重放一遍,就会重复且顺序错乱(控制序列尤其致命)。 - // 缓冲一直留到 spawn 有了结果为止。 + // Keep buffering through spawn: a competing mount may win the ID and + // its first output may otherwise be replayed twice. } - // Spawn the terminal AFTER subscribing to events - if (!attached) { + if (!attached && !spawnOnMissing) { + flushReplay(null) + showUnavailable(confirmedMissing) + setLoading(false) + } + + // A new panel tab and canvas card can spawn. Restored panel tabs cannot. + if (!attached && spawnOnMissing) { try { await terminalSpawn(workingDir, shell, initialCommand, terminalId) - // spawn 成功 ⇒ 这个 id 上此前没有 PTY,缓冲里只可能是我们刚起的这个 - // 进程的头几个字节,没有任何东西需要去重。 - flushReplay(null) - } catch (err) { - // 在 attach 模式下,「这个 id 已存在」不是失败而是竞态:两个窗口(或 - // StrictMode 的双跑 effect)同时看到「没活着」并同时 spawn,只有一个 - // 会赢。输者此刻其实有一个健康的终端可以接管——再问一次快照,拿到就 - // 当作 attach 走完,而不是在一个正常运行的 shell 上打红字并把卡片 - // 切到「已退出」。只有再问也没有时才是真失败。 - const retry = attach + // This handshake gives buffered output its PTY generation. A delayed + // event from a previous process with this same id must not leak in. + const launched = attach ? await terminalSnapshot(terminalId).catch(() => null) : null if (cancelled) { teardown() return } - if (retry?.alive) { + if (launched?.alive || launched?.exists) applySnapshot(launched) + else flushReplay(null) + onSpawnedRef.current?.(terminalId) + } catch (err) { + // A second mount can lose to the first while its PTY is still in + // openpty/spawn and absent from snapshots. Wait only for that known + // duplicate race; a genuine launch error should surface promptly. + const duplicateId = String(err).includes("already exists") + const retry = attach ? await readSnapshot(duplicateId) : null + if (cancelled) { + teardown() + return + } + if (retry?.alive || retry?.exists) { attached = true applySnapshot(retry) + onSpawnedRef.current?.(terminalId) } else { flushReplay(null) onProcessExitedRef.current?.(terminalId) @@ -543,6 +699,9 @@ export function TerminalView({ return } + bootstrapping = false + if (readyDuringStartup) syncFromBackend() + const fitIfReady = () => { const el = containerRef.current if (!el) return @@ -576,6 +735,7 @@ export function TerminalView({ onResizeDisposable.dispose() unlisten() unlistenExit() + unlistenReconnect?.() resizeObserver.disconnect() term.dispose() fitAddonRef.current = null @@ -595,7 +755,15 @@ export function TerminalView({ // menu must not outlive it and open the old terminal's link. setLinkClick(null) } - }, [terminalId, workingDir, shell, initialCommand, attach]) + }, [ + terminalId, + workingDir, + shell, + initialCommand, + attach, + spawnOnMissing, + reuseCompleted, + ]) // Refit and focus when becoming active or panel becomes visible useEffect(() => { diff --git a/src/contexts/terminal-context.test.tsx b/src/contexts/terminal-context.test.tsx new file mode 100644 index 0000000000..4e28ddc9c9 --- /dev/null +++ b/src/contexts/terminal-context.test.tsx @@ -0,0 +1,193 @@ +import { fireEvent, render, screen } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { TerminalProvider, useTerminalContext } from "./terminal-context" + +const h = vi.hoisted(() => ({ + terminalKill: vi.fn(async () => {}), + remoteId: null as number | null, + windowLabel: "main", +})) + +vi.mock("@/lib/api", () => ({ + getSystemTerminalSettings: vi.fn(async () => ({ default_shell: null })), + terminalKill: h.terminalKill, +})) +vi.mock("@/lib/transport", () => ({ + getTransport: () => ({ subscribe: async () => () => {} }), + getActiveRemoteConnectionId: () => h.remoteId, +})) +vi.mock("@/lib/browser/window-label", () => ({ + getCurrentWindowLabel: () => h.windowLabel, +})) +vi.mock("@/contexts/active-folder-context", () => ({ + useActiveFolder: () => ({ + activeFolder: { id: 7, path: "/tmp/codeg-749" }, + activeFolderId: 7, + }), +})) +vi.mock("@/hooks/use-shortcut-settings", () => ({ + useShortcutSettings: () => ({ + shortcuts: { + new_terminal_tab: null, + close_current_terminal_tab: null, + }, + }), +})) +vi.mock("@/lib/keyboard-shortcuts", () => ({ + matchShortcutEvent: () => false, +})) + +function Probe() { + const terminal = useTerminalContext() + return ( +
+ + + {terminal.tabs.length} + {terminal.activeTabId ?? ""} + + {terminal.tabs[0]?.initialCommand ?? ""} + +
+ ) +} + +describe("TerminalProvider reload recovery", () => { + beforeEach(() => { + h.terminalKill.mockClear() + h.remoteId = null + h.windowLabel = "main" + sessionStorage.clear() + window.name = "" + }) + + it("keeps a running command owned by this page and restores its tab after reload", () => { + const first = render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Run" })) + expect(screen.getByTestId("tabs")).toHaveTextContent("1") + expect(screen.getByTestId("command")).toHaveTextContent("sleep 120") + const originalId = screen.getByTestId("active").textContent + + expect(sessionStorage.getItem("codeg:terminal-session:v1")).not.toContain( + "sleep 120" + ) + first.unmount() + expect(h.terminalKill).not.toHaveBeenCalled() + + render( + + + + ) + expect(screen.getByTestId("tabs")).toHaveTextContent("1") + expect(screen.getByTestId("active").textContent).toBe(originalId) + expect(screen.getByTestId("command")).toBeEmptyDOMElement() + }) + + it("explicit close records a kill even while spawn is still pending", () => { + render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Run" })) + const id = screen.getByTestId("active").textContent + fireEvent.click(screen.getByRole("button", { name: "Close" })) + expect(h.terminalKill).toHaveBeenCalledWith(id) + expect(screen.getByTestId("tabs")).toHaveTextContent("0") + expect(sessionStorage.getItem("codeg:terminal-session:v1")).not.toContain( + id + ) + }) + + it("rejects a forged terminal ID and preserves an unrelated window name", () => { + window.name = "host-window" + sessionStorage.setItem( + "codeg:terminal-session:v1", + JSON.stringify({ + version: 1, + scope: JSON.stringify(["main", null]), + pageId: "host-window", + isOpen: true, + activeTabId: "other-user-terminal", + tabs: [ + { + id: "other-user-terminal", + folderId: 7, + title: "bad", + workingDir: "/tmp", + }, + ], + }) + ) + render( + + + + ) + expect(window.name).toBe("host-window") + expect(screen.getByTestId("tabs")).toHaveTextContent("0") + }) + + it("does not auto-attach a copied opener session in a new tab", () => { + const first = render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Run" })) + const copiedStorage = sessionStorage.getItem("codeg:terminal-session:v1") + expect(copiedStorage).toBeTruthy() + first.unmount() + + // New browsing context: the opener's sessionStorage is cloned, but the + // browser gives this tab its own window.name. + window.name = "" + sessionStorage.setItem("codeg:terminal-session:v1", copiedStorage!) + render( + + + + ) + expect(screen.getByTestId("tabs")).toHaveTextContent("0") + expect(h.terminalKill).not.toHaveBeenCalled() + }) + + it("does not restore another remote workspace window's terminals", () => { + h.windowLabel = "remote-workspace-3" + h.remoteId = 3 + const first = render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Run" })) + expect(screen.getByTestId("tabs")).toHaveTextContent("1") + first.unmount() + + h.windowLabel = "remote-workspace-4" + h.remoteId = 4 + render( + + + + ) + expect(screen.getByTestId("tabs")).toHaveTextContent("0") + }) +}) diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index 187cb12e3d..c1dce15e02 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -5,14 +5,16 @@ import { useCallback, useContext, useEffect, + useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react" import { getSystemTerminalSettings, terminalKill } from "@/lib/api" -import { getTransport } from "@/lib/transport" +import { getActiveRemoteConnectionId, getTransport } from "@/lib/transport" import { randomUUID } from "@/lib/utils" +import { getCurrentWindowLabel } from "@/lib/browser/window-label" import { useActiveFolder } from "@/contexts/active-folder-context" import { useShortcutSettings } from "@/hooks/use-shortcut-settings" import { matchShortcutEvent } from "@/lib/keyboard-shortcuts" @@ -24,6 +26,7 @@ export interface TerminalTab { workingDir: string shell?: string initialCommand?: string + restored?: boolean } const DEFAULT_HEIGHT = 300 @@ -31,6 +34,108 @@ const MIN_HEIGHT = 150 const MAX_HEIGHT = 600 const TERMINAL_SETTINGS_UPDATED_EVENT = "app://terminal-settings-updated" +const TERMINAL_SESSION_KEY = "codeg:terminal-session:v1" +const PAGE_NAME_PREFIX = "codeg-terminal-page:" +const MAX_STORED_TABS = 32 +const UUID_V4 = + /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +interface StoredTerminalSession { + version: 1 + scope: string + pageId: string + isOpen: boolean + activeTabId: string | null + tabs: Pick< + TerminalTab, + "id" | "folderId" | "title" | "workingDir" | "shell" + >[] +} + +function terminalScope(): string { + return JSON.stringify([ + getCurrentWindowLabel(), + getActiveRemoteConnectionId(), + ]) +} + +/** + * sessionStorage is a discovery hint, not permission to use a PTY. A tab opened + * with window.opener can inherit a copy of that storage; window.name belongs to + * the browsing context and survives reload, but is not cloned into the new tab. + */ +function currentPageId(): string | null { + if (typeof window === "undefined") return null + // Do not overwrite a host/application-assigned window name. Those windows + // keep their normal terminal behavior, but opt out of reload recovery. + if (!window.name) window.name = `${PAGE_NAME_PREFIX}${randomUUID()}` + if (!window.name.startsWith(PAGE_NAME_PREFIX)) return null + const id = window.name.slice(PAGE_NAME_PREFIX.length) + return UUID_V4.test(id) ? id : null +} + +function readTerminalSession(): { + pageId: string | null + scope: string + isOpen: boolean + activeTabId: string | null + tabs: TerminalTab[] +} { + const scope = terminalScope() + const pageId = currentPageId() + const empty = { pageId, scope, isOpen: false, activeTabId: null, tabs: [] } + if (!pageId || typeof window === "undefined") return empty + try { + const raw = window.sessionStorage.getItem(TERMINAL_SESSION_KEY) + if (!raw) return empty + const saved: StoredTerminalSession = JSON.parse(raw) + if ( + saved.version !== 1 || + saved.scope !== scope || + saved.pageId !== pageId || + !Array.isArray(saved.tabs) || + saved.tabs.length > MAX_STORED_TABS + ) + return empty + const tabs: TerminalTab[] = saved.tabs.map((tab) => { + if ( + !UUID_V4.test(tab.id) || + !Number.isSafeInteger(tab.folderId) || + tab.folderId <= 0 || + typeof tab.title !== "string" || + tab.title.length > 256 || + typeof tab.workingDir !== "string" || + tab.workingDir.length === 0 || + tab.workingDir.length > 4096 || + !/^(\/|[A-Za-z]:[\\/]|\\\\)/.test(tab.workingDir) || + (tab.shell !== undefined && + (typeof tab.shell !== "string" || tab.shell.length > 512)) + ) { + throw new Error("invalid terminal tab") + } + return { + id: tab.id, + folderId: tab.folderId, + title: tab.title, + workingDir: tab.workingDir, + shell: tab.shell, + restored: true, + } + }) + return { + pageId, + scope, + isOpen: saved.isOpen === true, + tabs, + activeTabId: tabs.some((tab) => tab.id === saved.activeTabId) + ? saved.activeTabId + : (tabs[0]?.id ?? null), + } + } catch { + return empty + } +} + interface TerminalContextValue { isOpen: boolean height: number @@ -42,6 +147,8 @@ interface TerminalContextValue { activeTabId: string | null exitedTerminals: Set markTerminalExited: (id: string) => void + markTerminalRunning: (id: string) => void + markTerminalStarted: (id: string) => void createTerminal: () => Promise createTerminalInDirectory: ( workingDir: string, @@ -72,21 +179,47 @@ export function useTerminalContext() { export function TerminalProvider({ children }: { children: ReactNode }) { const { activeFolder, activeFolderId } = useActiveFolder() const { shortcuts } = useShortcutSettings() - const [isOpen, setIsOpen] = useState(false) + const [restoredSession] = useState(readTerminalSession) + const [isOpen, setIsOpen] = useState(restoredSession.isOpen) const [height, setHeightState] = useState(DEFAULT_HEIGHT) - const [tabs, setTabs] = useState([]) - const [activeTabId, setActiveTabId] = useState(null) - const tabCounterRef = useRef(0) + const [tabs, setTabs] = useState(restoredSession.tabs) + const [activeTabId, setActiveTabId] = useState( + restoredSession.activeTabId + ) + const tabCounterRef = useRef(restoredSession.tabs.length) const [exitedTerminals, setExitedTerminals] = useState>(new Set()) const [defaultTerminalShell, setDefaultTerminalShell] = useState< string | null >(null) const lastMouseActivityInTerminalRef = useRef(false) - // Keep a ref of tabs for cleanup on unmount (effect [] captures stale state) - const tabsRef = useRef(tabs) - useEffect(() => { - tabsRef.current = tabs - }, [tabs]) + // Persist before TerminalView's passive spawn effect. A refresh during the + // spawn request must leave an ID that the next page can probe without replay. + useLayoutEffect(() => { + if (!restoredSession.pageId || typeof window === "undefined") return + const state: StoredTerminalSession = { + version: 1, + scope: restoredSession.scope, + pageId: restoredSession.pageId, + isOpen, + activeTabId, + // The command is intentionally never stored: recovery only attaches to + // the existing PTY and must not retain sensitive command arguments. + tabs: tabs + .slice(0, MAX_STORED_TABS) + .map(({ id, folderId, title, workingDir, shell }) => ({ + id, + folderId, + title, + workingDir, + shell, + })), + } + try { + window.sessionStorage.setItem(TERMINAL_SESSION_KEY, JSON.stringify(state)) + } catch { + // Private mode or quota failure: terminal remains usable in this page. + } + }, [restoredSession, isOpen, activeTabId, tabs]) const folderPath = activeFolder?.path ?? "" const currentFolderId = activeFolderId ?? 0 @@ -131,6 +264,23 @@ export function TerminalProvider({ children }: { children: ReactNode }) { } }, []) + const markTerminalRunning = useCallback((id: string) => { + setExitedTerminals((prev) => { + if (!prev.has(id)) return prev + const next = new Set(prev) + next.delete(id) + return next + }) + }, []) + + const markTerminalStarted = useCallback((id: string) => { + setTabs((current) => + current.map((tab) => + tab.id === id && !tab.restored ? { ...tab, restored: true } : tab + ) + ) + }, []) + const markTerminalExited = useCallback((id: string) => { setExitedTerminals((prev) => { if (prev.has(id)) return prev @@ -382,15 +532,6 @@ export function TerminalProvider({ children }: { children: ReactNode }) { shortcuts.new_terminal_tab, ]) - // Cleanup all terminals on unmount — uses ref to get current tabs - useEffect(() => { - return () => { - tabsRef.current.forEach((t) => { - terminalKill(t.id).catch(() => {}) - }) - } - }, []) - const value = useMemo( () => ({ isOpen, @@ -403,6 +544,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) { activeTabId, exitedTerminals, markTerminalExited, + markTerminalRunning, + markTerminalStarted, createTerminal, createTerminalInDirectory, createTerminalWithCommand, @@ -421,6 +564,8 @@ export function TerminalProvider({ children }: { children: ReactNode }) { activeTabId, exitedTerminals, markTerminalExited, + markTerminalRunning, + markTerminalStarted, createTerminal, createTerminalInDirectory, createTerminalWithCommand, diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 6afe2d16e4..0c0ca2a4b5 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -2276,6 +2276,9 @@ "closeAll": "إغلاق الكل", "hideTerminal": "إخفاء الطرفية ({shortcut})", "openFolderFirst": "افتح مجلدا أولا", + "processExited": "انتهت العملية", + "processExitedWithCode": "انتهت العملية (الرمز {code})", + "unavailable": "جلسة الطرفية غير متاحة. ربما انتهت أو أُعيد تشغيل الخادم.", "keybar": { "label": "مفاتيح الطرفية الافتراضية", "show": "إظهار المفاتيح الافتراضية", diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 15a420acde..720db36e5a 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -2276,6 +2276,9 @@ "closeAll": "Alle schließen", "hideTerminal": "Terminal ausblenden ({shortcut})", "openFolderFirst": "Öffnen Sie zuerst einen Ordner", + "processExited": "Prozess beendet", + "processExitedWithCode": "Prozess beendet (Code {code})", + "unavailable": "Terminalsitzung nicht verfügbar. Sie wurde möglicherweise beendet oder das Backend neu gestartet.", "keybar": { "label": "Virtuelle Terminaltasten", "show": "Virtuelle Tasten anzeigen", diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index 2e013dcb8b..1b1d9e3857 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -2276,6 +2276,9 @@ "closeAll": "Close All", "hideTerminal": "Hide Terminal ({shortcut})", "openFolderFirst": "Open a folder first", + "processExited": "Process exited", + "processExitedWithCode": "Process exited (code {code})", + "unavailable": "Terminal session unavailable. It may have ended or the backend restarted.", "keybar": { "label": "Terminal virtual keys", "show": "Show virtual keys", diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 6869d98db7..d3df7e2551 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -2276,6 +2276,9 @@ "closeAll": "Cerrar todo", "hideTerminal": "Ocultar terminal ({shortcut})", "openFolderFirst": "Abre primero una carpeta", + "processExited": "Proceso finalizado", + "processExitedWithCode": "Proceso finalizado (código {code})", + "unavailable": "La sesión de terminal no está disponible. Puede haber terminado o el servidor se ha reiniciado.", "keybar": { "label": "Teclas virtuales del terminal", "show": "Mostrar teclas virtuales", diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 8f58f901fc..1f18fe5324 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -2276,6 +2276,9 @@ "closeAll": "Tout fermer", "hideTerminal": "Masquer le terminal ({shortcut})", "openFolderFirst": "Ouvrez d'abord un dossier", + "processExited": "Processus terminé", + "processExitedWithCode": "Processus terminé (code {code})", + "unavailable": "Session de terminal indisponible. Elle s'est peut-être terminée ou le serveur a redémarré.", "keybar": { "label": "Touches virtuelles du terminal", "show": "Afficher les touches virtuelles", diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 8c16619f4b..f4d6cdce18 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -2276,6 +2276,9 @@ "closeAll": "すべて閉じる", "hideTerminal": "ターミナルを隠す ({shortcut})", "openFolderFirst": "先にフォルダを開いてください", + "processExited": "プロセスが終了しました", + "processExitedWithCode": "プロセスが終了しました(コード {code})", + "unavailable": "ターミナルセッションを利用できません。終了したか、バックエンドが再起動した可能性があります。", "keybar": { "label": "仮想ターミナルキー", "show": "仮想キーを表示", diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index d8bcafd668..1e18688c86 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -2276,6 +2276,9 @@ "closeAll": "모두 닫기", "hideTerminal": "터미널 숨기기 ({shortcut})", "openFolderFirst": "먼저 폴더를 여세요", + "processExited": "프로세스가 종료되었습니다", + "processExitedWithCode": "프로세스가 종료되었습니다(코드 {code})", + "unavailable": "터미널 세션을 사용할 수 없습니다. 세션이 종료되었거나 백엔드가 다시 시작되었을 수 있습니다.", "keybar": { "label": "터미널 가상 키", "show": "가상 키 표시", diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 4c83f0b15d..f6be67ddf1 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -2276,6 +2276,9 @@ "closeAll": "Fechar tudo", "hideTerminal": "Ocultar terminal ({shortcut})", "openFolderFirst": "Abra primeiro uma pasta", + "processExited": "Processo encerrado", + "processExitedWithCode": "Processo encerrado (código {code})", + "unavailable": "Sessão do terminal indisponível. Ela pode ter terminado ou o servidor pode ter reiniciado.", "keybar": { "label": "Teclas virtuais do terminal", "show": "Mostrar teclas virtuais", diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index 998e32945f..db99721cd9 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -2276,6 +2276,9 @@ "closeAll": "关闭所有", "hideTerminal": "隐藏终端({shortcut})", "openFolderFirst": "请先打开一个文件夹", + "processExited": "进程已退出", + "processExitedWithCode": "进程已退出(代码 {code})", + "unavailable": "终端会话不可用;它可能已结束,或后端已重启。", "keybar": { "label": "终端虚拟键", "show": "显示虚拟键", diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index 6841aa52f3..c49bab3b45 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -2276,6 +2276,9 @@ "closeAll": "關閉所有", "hideTerminal": "隱藏終端({shortcut})", "openFolderFirst": "請先開啟一個資料夾", + "processExited": "程序已結束", + "processExitedWithCode": "程序已結束(代碼 {code})", + "unavailable": "終端工作階段無法使用。它可能已結束,或後端已重新啟動。", "keybar": { "label": "終端虛擬鍵", "show": "顯示虛擬鍵", diff --git a/src/lib/api.ts b/src/lib/api.ts index c0afe26d54..baacd21047 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4727,9 +4727,9 @@ export async function terminalResize( /** * Recent output of an already-running terminal, for a viewer attaching to a - * PTY it did not spawn (a canvas terminal card coming back from another - * route). `alive: false` is the settled answer "nothing to attach to" — spawn - * instead; it is never an error, so callers don't have to parse one. + * PTY it did not spawn (a canvas terminal card or restored panel tab). + * `alive: false, exists: true` carries retained final output; `exists: false` + * means no session is known. Older backends omit `exists`. * * Subscribe to `terminal://output/` BEFORE calling this, and drop the * events whose `seq` is at or below the returned `seq` — that overlap is diff --git a/src/lib/platform.ts b/src/lib/platform.ts index 89883c3d3e..3af22fff96 100644 --- a/src/lib/platform.ts +++ b/src/lib/platform.ts @@ -66,6 +66,11 @@ export function onTransportReconnect( return getTransport().onReconnect?.(callback) ?? null } +/** Includes first server readiness after a page started while offline. */ +export function onTransportReady(callback: () => void): UnsubscribeFn | null { + return getTransport().onReady?.(callback) ?? null +} + /** * Per-connection Subscribe-with-Snapshot stream. Returns `null` only on * the desktop Tauri transport (which uses local IPC and is race-free, so diff --git a/src/lib/transport/remote-desktop-transport.ts b/src/lib/transport/remote-desktop-transport.ts index 3816c093ce..9bad26757e 100644 --- a/src/lib/transport/remote-desktop-transport.ts +++ b/src/lib/transport/remote-desktop-transport.ts @@ -97,6 +97,7 @@ export class RemoteDesktopTransport implements Transport { /// Reconnect callbacks fire only on subsequent arrivals. private hasReadiedOnce = false private reconnectCallbacks = new Set<() => void>() + private readyCallbacks = new Set<() => void>() /// Listener handle for `remote-ws-event-{id}`. Null when not subscribed. private unlistenWsEvent: UnlistenFn | null = null /// Opaque ID generated at construction time, passed to `remote_ws_subscribe` @@ -225,6 +226,13 @@ export class RemoteDesktopTransport implements Transport { } } + onReady(callback: () => void): UnsubscribeFn { + this.readyCallbacks.add(callback) + return () => { + this.readyCallbacks.delete(callback) + } + } + eventStream(): EventStream { if (!this.eventStreamInstance) { const host: AttachTransportHost = { @@ -351,6 +359,13 @@ export class RemoteDesktopTransport implements Transport { if (channel === WS_READY_CHANNEL) { this.wsOpen = true this.readyResolve() + for (const cb of this.readyCallbacks) { + try { + cb() + } catch (err) { + console.error("[RemoteDesktopTransport] ready callback threw:", err) + } + } // Notify EventStream so it can re-issue attach frames for any // active subscriptions. Fires on initial connect AND every reconnect. for (const cb of this.wsReadyCallbacks) { @@ -426,6 +441,7 @@ export class RemoteDesktopTransport implements Transport { } this.handlers.clear() this.reconnectCallbacks.clear() + this.readyCallbacks.clear() this.wsReadyCallbacks.clear() this.eventStreamInstance?.destroy() this.eventStreamInstance = null diff --git a/src/lib/transport/types.ts b/src/lib/transport/types.ts index 562bc89994..eacd44939c 100644 --- a/src/lib/transport/types.ts +++ b/src/lib/transport/types.ts @@ -126,6 +126,8 @@ export interface Transport { * Tauri) leave this undefined. */ onReconnect?(callback: () => void): UnsubscribeFn + /** Fires after every server __ready__, including the first after offline startup. */ + onReady?(callback: () => void): UnsubscribeFn /** * Resolves when the server-side broadcaster receiver is currently diff --git a/src/lib/transport/web-transport.test.ts b/src/lib/transport/web-transport.test.ts index da4bdcc679..c09348292b 100644 --- a/src/lib/transport/web-transport.test.ts +++ b/src/lib/transport/web-transport.test.ts @@ -85,7 +85,9 @@ describe("WebTransport connection state machine", () => { it("starts connected and the first __ready__ does not fire reconnect callbacks", () => { const t = new WebTransport("http://localhost") const onReconnect = vi.fn() + const onReady = vi.fn() t.onReconnect(onReconnect) + t.onReady(onReady) expect(t.getConnectionSnapshot()).toBe("connected") t.eventStream() @@ -96,6 +98,7 @@ describe("WebTransport connection state machine", () => { expect(t.getConnectionSnapshot()).toBe("connected") // First ready = initial connect, not a reconnect. expect(onReconnect).not.toHaveBeenCalled() + expect(onReady).toHaveBeenCalledTimes(1) }) it("treats a dropped socket as reconnecting — never logs out or wipes the token", () => { @@ -113,7 +116,9 @@ describe("WebTransport connection state machine", () => { it("probes /api/health on backoff and reconnects on 200; the 2nd ready fires reconnect callbacks", async () => { const { t, ws } = connectReady() const onReconnect = vi.fn() + const onReady = vi.fn() t.onReconnect(onReconnect) + t.onReady(onReady) fetchMock.mockResolvedValue(ok200()) ws.drop() @@ -131,6 +136,7 @@ describe("WebTransport connection state machine", () => { expect(t.getConnectionSnapshot()).toBe("connected") // Reconnect (2nd ready) refreshes consumer state exactly once. expect(onReconnect).toHaveBeenCalledTimes(1) + expect(onReady).toHaveBeenCalledTimes(1) }) it("enters unauthorized on a 401 probe and stops retrying (token left intact)", async () => { diff --git a/src/lib/transport/web-transport.ts b/src/lib/transport/web-transport.ts index ac4303abd8..4b55997e95 100644 --- a/src/lib/transport/web-transport.ts +++ b/src/lib/transport/web-transport.ts @@ -70,6 +70,7 @@ export class WebTransport implements Transport { // may have desynced during the disconnect window. private hasReadiedOnce = false private reconnectCallbacks = new Set<() => void>() + private readyCallbacks = new Set<() => void>() // Latched in `destroy()`. The async `onclose` fired by `ws.close()` inside // `destroy()`/`teardownWs()` would otherwise flip the state machine to // "reconnecting" and schedule a fresh backoff on a transport the caller @@ -241,6 +242,13 @@ export class WebTransport implements Transport { } } + onReady(callback: () => void): UnsubscribeFn { + this.readyCallbacks.add(callback) + return () => { + this.readyCallbacks.delete(callback) + } + } + // ── Connection-health surface (consumed by the reconnect dialog) ───────── /** Current transport health. Drives the global reconnect dialog. */ @@ -407,6 +415,13 @@ export class WebTransport implements Transport { // keeps growing its backoff instead of restarting from 1s. this.wsFailCount = 0 this.setConnState("connected") + for (const cb of this.readyCallbacks) { + try { + cb() + } catch (err) { + console.error("[WebTransport] ready callback threw:", err) + } + } if (this.hasReadiedOnce) { // Reconnect path: server-side receiver_count was 0 during the // disconnect window, so any event fired in that gap was dropped. @@ -573,6 +588,7 @@ export class WebTransport implements Transport { this.teardownWs() this.handlers.clear() this.reconnectCallbacks.clear() + this.readyCallbacks.clear() this.wsReadyCallbacks.clear() this.connListeners.clear() this.eventStreamInstance?.destroy() diff --git a/src/lib/types.ts b/src/lib/types.ts index b7c77f4712..6108e65f1a 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -4420,12 +4420,18 @@ export interface TerminalEvent { * snapshot already contains (`seq <= snapshot.seq`). Absent on the exit * event, which carries no output. */ seq?: number + generation?: string } -/** Recent output of a live terminal plus the cursor it was read at. `alive` - * false means no such terminal is running — the caller should spawn one - * rather than attach. */ +/** Recent output of a live or recently completed terminal. + * When `alive` is false, `exists` distinguishes retained final output + * from a missing session. */ export interface TerminalSnapshot { + /** False after a backend restart or explicit close. Absent on old servers. */ + exists?: boolean + /** Process exit code when a completed PTY reported one. */ + exit_code?: number | null + generation?: string | null alive: boolean data: string seq: number diff --git a/vitest.config.ts b/vitest.config.ts index 7d3a72f938..b1d3c35eb4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,6 +6,12 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { + // The package ships an ESM file but its declared CJS main is absent. + // Vite needs the concrete module when a real TerminalView is imported. + "@xterm/addon-ligatures": path.resolve( + __dirname, + "./node_modules/@xterm/addon-ligatures/lib/addon-ligatures.mjs" + ), "@": path.resolve(__dirname, "./src"), // The same alias esbuild resolves for the bundle: Playwright's sources // import each other through it, and honouring it here is what lets them From 49161b63d525e81bdbe3dd3a74dbf441e6c5118b Mon Sep 17 00:00:00 2001 From: dawn <93917549+dawNotPoi@users.noreply.github.com> Date: Thu, 24 Sep 2026 09:20:16 +0000 Subject: [PATCH 02/16] fix(terminal): ignore stale snapshots during canvas restart --- .../canvas/terminal-card-lifecycle.test.ts | 7 +- .../terminal/terminal-view-recovery.test.tsx | 130 ++++++++++++++++++ src/components/terminal/terminal-view.tsx | 32 ++++- 3 files changed, 162 insertions(+), 7 deletions(-) diff --git a/src/components/canvas/terminal-card-lifecycle.test.ts b/src/components/canvas/terminal-card-lifecycle.test.ts index 1e0e03d814..8188b3ee00 100644 --- a/src/components/canvas/terminal-card-lifecycle.test.ts +++ b/src/components/canvas/terminal-card-lifecycle.test.ts @@ -105,7 +105,8 @@ describe("a terminal card crossing a route switch", () => { const spawnAt = view.indexOf("terminalSpawn(") const retryAt = view.indexOf("const retry = attach", spawnAt) expect(retryAt).toBeGreaterThan(spawnAt) - expect(view).toContain("if (retry?.alive || retry?.exists)") + expect(view).toContain("await readSnapshot(duplicateId, isNewProcess)") + expect(view).toContain("if (retry && isNewProcess(retry))") }) it("keeps buffering across the spawn so a lost race cannot double-print", () => { @@ -123,7 +124,9 @@ describe("a terminal card crossing a route switch", () => { // A restored panel tab has a separate no-spawn branch. Within the canvas // spawn branch, output must remain buffered until spawn settles. expect(view.slice(spawnBranchAt, spawnAt)).not.toContain("flushReplay(") - expect(view.slice(spawnAt)).toContain("if (retry?.alive") + const retryAt = view.indexOf("const retry = attach", spawnAt) + expect(retryAt).toBeGreaterThan(spawnAt) + expect(view.slice(retryAt)).toContain("applySnapshot(retry)") }) it("holds a PERMANENT seq floor, because the buffer is not a gate", () => { diff --git a/src/components/terminal/terminal-view-recovery.test.tsx b/src/components/terminal/terminal-view-recovery.test.tsx index ae6d9844b2..a735d523d0 100644 --- a/src/components/terminal/terminal-view-recovery.test.tsx +++ b/src/components/terminal/terminal-view-recovery.test.tsx @@ -270,6 +270,112 @@ describe("TerminalView recovery", () => { view.unmount() }) + it("does not attach an old completed canvas PTY while another mount restarts the same ID", async () => { + const old = { + ...snapshot("old completed output", 3, false), + exit_code: 6, + generation: "old-generation", + } + let newProcessStarted = false + h.snapshot.mockImplementation(async () => + newProcessStarted ? snapshot("new live output", 1) : old + ) + let finishFirstSpawn!: (id: string) => void + h.spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstSpawn = resolve + }) + ) + h.spawn.mockRejectedValueOnce(new Error("terminal id already exists")) + const secondSpawned = vi.fn() + + const first = render() + await waitFor(() => expect(h.spawn).toHaveBeenCalledTimes(1)) + const second = render( + + ) + await waitFor(() => expect(h.spawn).toHaveBeenCalledTimes(2)) + await waitFor(() => + expect(h.snapshot.mock.calls.length).toBeGreaterThanOrEqual(3) + ) + expect(h.writes.join("")).not.toContain("old completed output") + expect(secondSpawned).not.toHaveBeenCalled() + + newProcessStarted = true + await act(async () => { + finishFirstSpawn("terminal-1") + }) + await waitFor(() => + expect(secondSpawned).toHaveBeenCalledWith("terminal-1") + ) + expect(h.writes.join("")).toContain("new live output") + expect(h.writes.join("")).not.toContain("old completed output") + first.unmount() + second.unmount() + }) + + it("does not trust an old completed PTY after the competing mount's first snapshot fails", async () => { + const old = { + ...snapshot("old completed output", 3, false), + exit_code: 6, + generation: "old-generation", + } + let probes = 0 + let newProcessStarted = false + h.snapshot.mockImplementation(async () => { + probes++ + if (probes === 2) throw new Error("snapshot temporarily unavailable") + return newProcessStarted ? snapshot("new live output", 1) : old + }) + let finishFirstSpawn!: (id: string) => void + h.spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + finishFirstSpawn = resolve + }) + ) + h.spawn.mockRejectedValueOnce(new Error("terminal id already exists")) + const secondSpawned = vi.fn() + + const first = render() + await waitFor(() => expect(h.spawn).toHaveBeenCalledTimes(1)) + const second = render( + + ) + await waitFor(() => expect(h.spawn).toHaveBeenCalledTimes(2)) + await waitFor(() => expect(probes).toBeGreaterThanOrEqual(3)) + expect(h.writes.join("")).not.toContain("old completed output") + expect(secondSpawned).not.toHaveBeenCalled() + + newProcessStarted = true + await act(async () => { + finishFirstSpawn("terminal-1") + }) + await waitFor(() => + expect(secondSpawned).toHaveBeenCalledWith("terminal-1") + ) + expect(h.writes.join("")).toContain("new live output") + expect(h.writes.join("")).not.toContain("old completed output") + first.unmount() + second.unmount() + }) + + it("surfaces a canvas launch error instead of resurrecting its old completed PTY", async () => { + h.snapshot.mockResolvedValue({ + ...snapshot("old completed output", 3, false), + exit_code: 6, + generation: "old-generation", + }) + h.spawn.mockRejectedValueOnce(new Error("shell missing")) + const view = render() + await waitFor(() => + expect(h.writes.join("")).toContain("Failed to start terminal") + ) + expect(h.writes.join("")).not.toContain("old completed output") + view.unmount() + }) + it("accepts a completed winner when duplicate spawn loses after it exits", async () => { h.snapshot.mockResolvedValueOnce({ exists: false, @@ -293,6 +399,30 @@ describe("TerminalView recovery", () => { view.unmount() }) + it("accepts a newly completed canvas winner after ignoring the old generation", async () => { + const old = { + ...snapshot("old completed output", 3, false), + exit_code: 6, + generation: "old-generation", + } + const winner = { + ...snapshot("new completed output", 2, false), + exit_code: 4, + } + let probes = 0 + h.snapshot.mockImplementation(async () => (++probes <= 2 ? old : winner)) + h.spawn.mockRejectedValueOnce(new Error("terminal id already exists")) + + const view = render() + await waitFor(() => + expect(h.writes.join("")).toContain("Process exited (code 4)") + ) + expect(probes).toBeGreaterThanOrEqual(3) + expect(h.writes.join("")).toContain("new completed output") + expect(h.writes.join("")).not.toContain("old completed output") + view.unmount() + }) + it("reports a genuine launch failure without waiting for another PTY", async () => { h.snapshot.mockResolvedValueOnce({ exists: false, diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index c332542b5c..a67ca5ef9b 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -36,7 +36,7 @@ import { useOpenUrlTarget, } from "@/hooks/use-open-url-target" import { detectPlatform } from "@/hooks/use-platform" -import type { TerminalEvent } from "@/lib/types" +import type { TerminalEvent, TerminalSnapshot } from "@/lib/types" import type { ITerminalAddon, Terminal as XTermTerminal } from "@xterm/xterm" function computeTerminalFontSize(base: number, zoomLevel: number): number { @@ -611,12 +611,16 @@ export function TerminalView({ // A restored tab only knows an ID. A reload can beat a terminal_spawn // request still in flight, so probe for a short bounded interval before // declaring it unavailable; never replay its original command. - const readSnapshot = async (retryMissing: boolean) => { + const readSnapshot = async ( + retryMissing: boolean, + accept: (snapshot: TerminalSnapshot) => boolean = (snapshot) => + !!(snapshot.alive || snapshot.exists) + ) => { const deadline = Date.now() + 10_000 do { const snapshot = await terminalSnapshot(terminalId).catch(() => null) if (cancelled) return null - if (snapshot?.alive || snapshot?.exists) return snapshot + if (snapshot && accept(snapshot)) return snapshot if (!retryMissing) return snapshot await new Promise((resolve) => setTimeout(resolve, 150)) } while (!cancelled && Date.now() < deadline) @@ -625,6 +629,7 @@ export function TerminalView({ let attached = false let confirmedMissing = false + let previousCompleted: TerminalSnapshot | null = null if (attach) { const snapshot = await readSnapshot(!spawnOnMissing) if (cancelled) { @@ -633,6 +638,7 @@ export function TerminalView({ } confirmedMissing = snapshot != null && !snapshot.alive && !snapshot.exists + if (snapshot?.exists && !snapshot.alive) previousCompleted = snapshot if (snapshot?.alive || (reuseCompleted && snapshot?.exists)) { attached = true applySnapshot(snapshot) @@ -668,12 +674,28 @@ export function TerminalView({ // openpty/spawn and absent from snapshots. Wait only for that known // duplicate race; a genuine launch error should surface promptly. const duplicateId = String(err).includes("already exists") - const retry = attach ? await readSnapshot(duplicateId) : null + // A canvas may have deliberately ignored a completed PTY before + // spawning. That old tombstone can remain visible while another + // mount is still spawning this ID. A completed retry needs either + // a confirmed empty snapshot before spawning or a new generation; + // an initial snapshot request failure proves neither. + const isNewProcess = (candidate: TerminalSnapshot) => + !!( + candidate.alive || + (candidate.exists && + (confirmedMissing || + (previousCompleted?.generation && + candidate.generation && + candidate.generation !== previousCompleted.generation))) + ) + const retry = attach + ? await readSnapshot(duplicateId, isNewProcess) + : null if (cancelled) { teardown() return } - if (retry?.alive || retry?.exists) { + if (retry && isNewProcess(retry)) { attached = true applySnapshot(retry) onSpawnedRef.current?.(terminalId) From 20cd2e87dad0422bf6a1d896fef7550df8bba20b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 30 Sep 2026 21:10:17 +0800 Subject: [PATCH 03/16] fix(terminal): keep a new panel tab's emulator and its launch record A new panel tab marks itself started once its spawn succeeds, and that flip changed a dependency of the view's mount effect. Every new tab disposed the emulator it had just opened and reattached to the same PTY: the terminal lost keyboard focus, flickered, and dropped keys typed in between. The attach policies are now read when an attach starts instead of re-running the mount. A view that unmounted while its spawn was in flight, such as a mobile drawer swiped shut, never reported the launch, so its tab still read as never started. Once the finished PTY aged out of the backend's retained output, reopening the drawer ran the tab's command a second time. The launch is now reported as soon as the spawn succeeds. The handshake that learns a new PTY's generation is retried when its request fails, instead of releasing buffered events with no generation filter, where a late exit from the previous process on the same id could disable input to the new one. A resync that overlaps one still in flight keeps what the earlier one buffered, so a failed newer snapshot no longer drops output and exit events that arrived but were never drawn. --- .../terminal/terminal-view-recovery.test.tsx | 157 +++++++++++++++++- src/components/terminal/terminal-view.tsx | 49 ++++-- 2 files changed, 187 insertions(+), 19 deletions(-) diff --git a/src/components/terminal/terminal-view-recovery.test.tsx b/src/components/terminal/terminal-view-recovery.test.tsx index a735d523d0..8b52f75970 100644 --- a/src/components/terminal/terminal-view-recovery.test.tsx +++ b/src/components/terminal/terminal-view-recovery.test.tsx @@ -1,4 +1,5 @@ import { act, render, waitFor } from "@testing-library/react" +import { useState } from "react" import { beforeEach, describe, expect, it, vi } from "vitest" import { TerminalView } from "./terminal-view" import type { TerminalEvent, TerminalSnapshot } from "@/lib/types" @@ -6,6 +7,8 @@ import type { TerminalEvent, TerminalSnapshot } from "@/lib/types" const h = vi.hoisted(() => ({ writes: [] as string[], resets: 0, + emulators: 0, + disposed: 0, snapshot: vi.fn(), spawn: vi.fn(), kill: vi.fn(async () => {}), @@ -64,6 +67,9 @@ vi.mock("@/components/terminal/terminal-link-menu", () => ({ vi.mock("@/components/terminal/term-keybar", () => ({ TermKeybar: () => null })) vi.mock("@xterm/xterm", () => ({ Terminal: class { + constructor() { + h.emulators++ + } options = { fontFamily: "", fontSize: 12, theme: {} } modes = { applicationCursorKeysMode: false } loadAddon() {} @@ -87,7 +93,9 @@ vi.mock("@xterm/xterm", () => ({ h.resets++ h.writes.push("") } - dispose() {} + dispose() { + h.disposed++ + } focus() {} }, })) @@ -121,6 +129,8 @@ describe("TerminalView recovery", () => { beforeEach(() => { h.writes.length = 0 h.resets = 0 + h.emulators = 0 + h.disposed = 0 h.snapshot.mockReset() h.snapshot.mockResolvedValue({ exists: false, @@ -604,4 +614,149 @@ describe("TerminalView recovery", () => { await waitFor(() => expect(h.writes).toContain("still running")) expect(h.spawn).toHaveBeenCalledTimes(1) }) + + it("keeps one emulator when a new panel tab is marked started by its own spawn", async () => { + // Wired the way TerminalPanel wires it: `spawnOnMissing={!tab.restored}`, + // with `onSpawned` setting `restored`. That flip only governs a LATER + // mount. Re-running this one for it would dispose the emulator a moment + // after the shell started — with its focus and any keys typed meanwhile. + let started = false + h.snapshot.mockImplementation(async () => + started + ? snapshot("prompt$ ", 1) + : { + exists: false, + alive: false, + data: "", + seq: 0, + exit_code: null, + generation: null, + } + ) + h.spawn.mockImplementation(async () => { + started = true + return "terminal-1" + }) + function PanelTab() { + const [restored, setRestored] = useState(false) + return ( + setRestored(true)} + /> + ) + } + + const view = render() + await waitFor(() => expect(h.writes.join("")).toContain("prompt$ ")) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(h.spawn).toHaveBeenCalledTimes(1) + expect(h.emulators).toBe(1) + expect(h.disposed).toBe(0) + view.unmount() + expect(h.disposed).toBe(1) + }) + + it("keeps a new PTY's input when the handshake after its spawn fails once", async () => { + // A canvas card restarting over a completed PTY: the old reader's late + // exit can land while the new process's generation is still unknown. A + // failed handshake must be retried, not taken as "accept every generation". + h.snapshot.mockResolvedValueOnce({ + ...snapshot("old completed output", 3, false), + exit_code: 6, + generation: "old-generation", + }) + let failHandshake!: (reason: Error) => void + h.snapshot.mockImplementationOnce( + () => + new Promise((_resolve, reject) => { + failHandshake = reject + }) + ) + h.snapshot.mockResolvedValueOnce(snapshot("new shell$ ", 1)) + + const view = render() + await waitFor(() => expect(h.snapshot).toHaveBeenCalledTimes(2)) + expect(h.spawn).toHaveBeenCalledTimes(1) + act(() => { + h.handlers.get("terminal://exit/terminal-1")?.({ + terminal_id: "terminal-1", + data: "", + seq: 0, + generation: "old-generation", + }) + }) + await act(async () => { + failHandshake(new Error("snapshot temporarily unavailable")) + }) + await waitFor(() => expect(h.writes.join("")).toContain("new shell$ ")) + expect(h.writes.join("")).not.toContain("processExited") + act(() => { + h.onData?.("x") + }) + await waitFor(() => expect(h.write).toHaveBeenCalledWith("terminal-1", "x")) + view.unmount() + }) + + it("flushes what an overlapping resync buffered when the newer snapshot fails", async () => { + h.snapshot.mockResolvedValueOnce(snapshot("before", 2)) + const view = render( + + ) + await waitFor(() => expect(h.writes).toContain("before")) + + // The first resync's snapshot is still out when a second ready arrives. + h.snapshot.mockImplementationOnce(() => new Promise(() => {})) + act(() => { + h.ready?.() + }) + act(() => { + h.handlers.get("terminal://output/terminal-1")?.({ + terminal_id: "terminal-1", + data: "between", + seq: 3, + generation: "new-generation", + }) + }) + h.snapshot.mockRejectedValueOnce(new Error("offline again")) + await act(async () => { + h.ready?.() + }) + await waitFor(() => expect(h.writes).toContain("between")) + view.unmount() + }) + + it("reports a launch that lands after its view unmounted, so a later mount attaches", async () => { + // A drawer swiped shut mid-spawn unmounts the view, but the tab stays. + // Unless the launch is reported anyway, the tab still reads as never + // started, and the next mount after the PTY is gone runs the command again. + let finishSpawn!: (id: string) => void + h.spawn.mockImplementationOnce( + () => + new Promise((resolve) => { + finishSpawn = resolve + }) + ) + const onSpawned = vi.fn() + const view = render( + + ) + await waitFor(() => expect(h.spawn).toHaveBeenCalledTimes(1)) + view.unmount() + await act(async () => { + finishSpawn("terminal-1") + }) + await waitFor(() => expect(onSpawned).toHaveBeenCalledWith("terminal-1")) + expect(h.kill).not.toHaveBeenCalled() + }) }) diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index a67ca5ef9b..2479c9d0ed 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -124,8 +124,8 @@ export function TerminalView({ isVisible, keybarVisible = false, attach = false, - spawnOnMissing = true, - reuseCompleted = false, + spawnOnMissing: spawnOnMissingProp = true, + reuseCompleted: reuseCompletedProp = false, onSpawned, onProcessRestored, ignoreAppZoom = false, @@ -140,6 +140,12 @@ export function TerminalView({ const onProcessExitedRef = useRef(onProcessExited) const onSpawnedRef = useRef(onSpawned) const onProcessRestoredRef = useRef(onProcessRestored) + // Policy for the NEXT attach, read when one starts, not part of what the + // mount is attached to. The panel turns `spawnOnMissing` off the moment its + // spawn succeeds; re-running the effect on that would dispose the emulator + // it just opened (and its focus) to reattach to the very same PTY. + const spawnOnMissingRef = useRef(spawnOnMissingProp) + const reuseCompletedRef = useRef(reuseCompletedProp) // Link clicks route through the app's link decision (built-in browser vs // system browser, ⌘/Ctrl inverts). xterm's default handler is a bare // `window.open`, which the desktop webview turns into a dead click. @@ -259,6 +265,11 @@ export function TerminalView({ onProcessRestoredRef.current = onProcessRestored }, [onProcessExited, onSpawned, onProcessRestored]) + useEffect(() => { + spawnOnMissingRef.current = spawnOnMissingProp + reuseCompletedRef.current = reuseCompletedProp + }, [spawnOnMissingProp, reuseCompletedProp]) + useEffect(() => { openUrlTargetRef.current = openUrlTarget }, [openUrlTarget]) @@ -577,8 +588,13 @@ export function TerminalView({ const syncFromBackend = () => { if (cancelled) return const generation = ++reconnectGeneration - replayBuffer = [] - exitWhileBuffering = null + // A sync still in flight keeps what it buffered. This newer snapshot + // supersedes it, but if the request fails, those events — received + // and never drawn — must still be flushed rather than dropped. + if (!replayBuffer) { + replayBuffer = [] + exitWhileBuffering = null + } void terminalSnapshot(terminalId) .then((snapshot) => { if (cancelled || generation !== reconnectGeneration) return @@ -627,6 +643,9 @@ export function TerminalView({ return null } + // Read once: this attach runs under the policy it started with. + const spawnOnMissing = spawnOnMissingRef.current + const reuseCompleted = reuseCompletedRef.current let attached = false let confirmedMissing = false let previousCompleted: TerminalSnapshot | null = null @@ -657,18 +676,20 @@ export function TerminalView({ if (!attached && spawnOnMissing) { try { await terminalSpawn(workingDir, shell, initialCommand, terminalId) + // Reported before anything that bails out on unmount: the tab + // outlives this view (a drawer swiped shut mid-spawn), and whichever + // view mounts for it next must attach, never launch the command twice. + onSpawnedRef.current?.(terminalId) // This handshake gives buffered output its PTY generation. A delayed - // event from a previous process with this same id must not leak in. - const launched = attach - ? await terminalSnapshot(terminalId).catch(() => null) - : null + // event from a previous process with this same id must not leak in, + // so a failed request is retried rather than taken as "no filter". + const launched = attach ? await readSnapshot(true, () => true) : null if (cancelled) { teardown() return } if (launched?.alive || launched?.exists) applySnapshot(launched) else flushReplay(null) - onSpawnedRef.current?.(terminalId) } catch (err) { // A second mount can lose to the first while its PTY is still in // openpty/spawn and absent from snapshots. Wait only for that known @@ -777,15 +798,7 @@ export function TerminalView({ // menu must not outlive it and open the old terminal's link. setLinkClick(null) } - }, [ - terminalId, - workingDir, - shell, - initialCommand, - attach, - spawnOnMissing, - reuseCompleted, - ]) + }, [terminalId, workingDir, shell, initialCommand, attach]) // Refit and focus when becoming active or panel becomes visible useEffect(() => { From c293e2255ce5853d38688c0d8ba0a7d1865abcbc Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 30 Sep 2026 21:10:17 +0800 Subject: [PATCH 04/16] fix(terminal): restore every panel tab the saved session can vouch for The saved panel session was validated as a whole: one tab the reader rejected discarded every tab, and the next write replaced the record, so after a refresh every running terminal was orphaned. The app itself wrote two such tabs: folder id 0, which a terminal opened from the sidebar gets when no workspace tab is active, and a title longer than 256 characters, from a rename or a long folder name. Each tab is now validated on its own, folder id 0 is accepted, and a title is clamped to 256 characters when stored and when read back. --- src/contexts/terminal-context.test.tsx | 113 ++++++++++++++++++++++++- src/contexts/terminal-context.tsx | 68 +++++++++------ 2 files changed, 154 insertions(+), 27 deletions(-) diff --git a/src/contexts/terminal-context.test.tsx b/src/contexts/terminal-context.test.tsx index 4e28ddc9c9..8d47119453 100644 --- a/src/contexts/terminal-context.test.tsx +++ b/src/contexts/terminal-context.test.tsx @@ -6,6 +6,7 @@ const h = vi.hoisted(() => ({ terminalKill: vi.fn(async () => {}), remoteId: null as number | null, windowLabel: "main", + activeFolderId: 7 as number | null, })) vi.mock("@/lib/api", () => ({ @@ -21,8 +22,11 @@ vi.mock("@/lib/browser/window-label", () => ({ })) vi.mock("@/contexts/active-folder-context", () => ({ useActiveFolder: () => ({ - activeFolder: { id: 7, path: "/tmp/codeg-749" }, - activeFolderId: 7, + activeFolder: + h.activeFolderId == null + ? null + : { id: h.activeFolderId, path: "/tmp/codeg-749" }, + activeFolderId: h.activeFolderId, }), })) vi.mock("@/hooks/use-shortcut-settings", () => ({ @@ -55,7 +59,25 @@ function Probe() { > Close + + {terminal.tabs.length} + + {terminal.tabs.map((tab) => tab.title.length).join(",")} + {terminal.activeTabId ?? ""} {terminal.tabs[0]?.initialCommand ?? ""} @@ -69,6 +91,7 @@ describe("TerminalProvider reload recovery", () => { h.terminalKill.mockClear() h.remoteId = null h.windowLabel = "main" + h.activeFolderId = 7 sessionStorage.clear() window.name = "" }) @@ -190,4 +213,90 @@ describe("TerminalProvider reload recovery", () => { ) expect(screen.getByTestId("tabs")).toHaveTextContent("0") }) + + it("restores a tab opened while no workspace tab is active, with the rest", () => { + // With no active workspace tab the provider records folder 0 (the + // sidebar's "open in terminal" does this). The reader must take back what + // the writer wrote, or one such tab costs every tab its way back. + const first = render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Run" })) + h.activeFolderId = null + first.rerender( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Open dir" })) + expect(screen.getByTestId("tabs")).toHaveTextContent("2") + first.unmount() + + render( + + + + ) + expect(screen.getByTestId("tabs")).toHaveTextContent("2") + }) + + it("stores an over-long title clamped rather than losing the session", () => { + const first = render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Run" })) + fireEvent.click(screen.getByRole("button", { name: "Rename long" })) + expect(screen.getByTestId("titles")).toHaveTextContent("300") + first.unmount() + + render( + + + + ) + expect(screen.getByTestId("tabs")).toHaveTextContent("1") + expect(screen.getByTestId("titles")).toHaveTextContent("256") + }) + + it("drops only the stored tab it cannot validate, and clamps a long title", () => { + const pageId = "0b6f3d7e-5a1c-4e2b-9f8a-1c2d3e4f5a6b" + const goodId = "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098" + window.name = `codeg-terminal-page:${pageId}` + sessionStorage.setItem( + "codeg:terminal-session:v1", + JSON.stringify({ + version: 1, + scope: JSON.stringify(["main", null]), + pageId, + isOpen: true, + activeTabId: "not-a-terminal-id", + tabs: [ + { + id: "not-a-terminal-id", + folderId: 7, + title: "forged", + workingDir: "/tmp", + }, + { + id: goodId, + folderId: 7, + title: "k".repeat(300), + workingDir: "/tmp", + }, + ], + }) + ) + render( + + + + ) + expect(screen.getByTestId("tabs")).toHaveTextContent("1") + expect(screen.getByTestId("active").textContent).toBe(goodId) + expect(screen.getByTestId("titles")).toHaveTextContent("256") + }) }) diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index c1dce15e02..ff7bceaa18 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -37,19 +37,50 @@ const TERMINAL_SETTINGS_UPDATED_EVENT = "app://terminal-settings-updated" const TERMINAL_SESSION_KEY = "codeg:terminal-session:v1" const PAGE_NAME_PREFIX = "codeg-terminal-page:" const MAX_STORED_TABS = 32 +const MAX_STORED_TITLE_LENGTH = 256 const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i +type StoredTerminalTab = Pick< + TerminalTab, + "id" | "folderId" | "title" | "workingDir" | "shell" +> + interface StoredTerminalSession { version: 1 scope: string pageId: string isOpen: boolean activeTabId: string | null - tabs: Pick< - TerminalTab, - "id" | "folderId" | "title" | "workingDir" | "shell" - >[] + tabs: StoredTerminalTab[] +} + +/** + * Whether one stored tab may come back. Judged per tab, so an entry the reader + * rejects costs that tab alone rather than every other tab's way back to its + * PTY — and every bound here must admit whatever the writer below persists. + */ +function isRestorableTab(tab: unknown): tab is StoredTerminalTab { + if (typeof tab !== "object" || tab === null) return false + const { id, folderId, title, workingDir, shell } = tab as Record< + string, + unknown + > + return ( + typeof id === "string" && + UUID_V4.test(id) && + // 0 is a real value: a tab opened while no workspace tab is active (the + // sidebar's "open in terminal") has no folder to record. + Number.isSafeInteger(folderId) && + (folderId as number) >= 0 && + // Any length: it is only a label, clamped on the way in and out. + typeof title === "string" && + typeof workingDir === "string" && + workingDir.length > 0 && + workingDir.length <= 4096 && + /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(workingDir) && + (shell === undefined || (typeof shell === "string" && shell.length <= 512)) + ) } function terminalScope(): string { @@ -97,31 +128,16 @@ function readTerminalSession(): { saved.tabs.length > MAX_STORED_TABS ) return empty - const tabs: TerminalTab[] = saved.tabs.map((tab) => { - if ( - !UUID_V4.test(tab.id) || - !Number.isSafeInteger(tab.folderId) || - tab.folderId <= 0 || - typeof tab.title !== "string" || - tab.title.length > 256 || - typeof tab.workingDir !== "string" || - tab.workingDir.length === 0 || - tab.workingDir.length > 4096 || - !/^(\/|[A-Za-z]:[\\/]|\\\\)/.test(tab.workingDir) || - (tab.shell !== undefined && - (typeof tab.shell !== "string" || tab.shell.length > 512)) - ) { - throw new Error("invalid terminal tab") - } - return { + const tabs: TerminalTab[] = (saved.tabs as unknown[]) + .filter(isRestorableTab) + .map((tab) => ({ id: tab.id, folderId: tab.folderId, - title: tab.title, + title: tab.title.slice(0, MAX_STORED_TITLE_LENGTH), workingDir: tab.workingDir, shell: tab.shell, restored: true, - } - }) + })) return { pageId, scope, @@ -209,7 +225,9 @@ export function TerminalProvider({ children }: { children: ReactNode }) { .map(({ id, folderId, title, workingDir, shell }) => ({ id, folderId, - title, + // A rename or a long folder name can run past the reader's bound. + // The stored title is only a label until the pane reattaches. + title: title.slice(0, MAX_STORED_TITLE_LENGTH), workingDir, shell, })), From c8547c36d62e6afa09753e967a68e275f1f92fac Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 30 Sep 2026 21:10:17 +0800 Subject: [PATCH 05/16] fix(terminal): leave a detached process running when its record expires A PTY whose reader reached EOF before its child's exit was seen kept the child in the completed-terminal cache, and dropping that entry killed and waited for it. Reader EOF only proves the terminal closed: a child can close its terminal and keep running, and such a process used to be left alone once its PTY closed. An eviction, an explicit close or a bulk clear now ended it instead, and the kill and wait could stall every caller holding the terminal table, including the window-close path on the main thread. Dropping an entry now reaps the child only if it has already exited. --- src-tauri/src/terminal/manager.rs | 101 +++++++++++++++++++++++++++++- 1 file changed, 99 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index ef8e278e3a..dd70630dfa 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -155,8 +155,13 @@ impl CompletedTerminals { let removed = self.entries.remove(id); if let Some(mut entry) = removed { if entry.exit_code.is_none() { - let _ = entry.child.kill(); - let _ = entry.child.wait(); + // Reap it if it has exited since, so it leaves no zombie, but + // never kill it. Reader EOF does not prove the process ended: + // a child can close its terminal and keep running, and such a + // process was always left alone once its PTY closed. Dropping + // a cache entry must not be what ends it — and a kill + wait + // here would stall every caller holding the terminal table. + let _ = entry.child.try_wait(); } self.order.retain(|entry_id| entry_id != id); true @@ -1083,6 +1088,98 @@ mod tests { assert_eq!(snapshot.seq, 0); } + /// A PTY child whose exit the manager never observed. Records whether the + /// manager ended it (a kill, or a blocking wait that only a kill would end). + #[derive(Debug)] + struct UnobservedChild { + ended: std::sync::Arc, + } + + impl portable_pty::ChildKiller for UnobservedChild { + fn kill(&mut self) -> std::io::Result<()> { + self.ended.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(()) + } + + fn clone_killer(&self) -> Box { + Box::new(UnobservedChild { + ended: self.ended.clone(), + }) + } + } + + impl portable_pty::Child for UnobservedChild { + fn try_wait(&mut self) -> std::io::Result> { + Ok(None) + } + + fn wait(&mut self) -> std::io::Result { + self.ended.store(true, std::sync::atomic::Ordering::SeqCst); + Ok(portable_pty::ExitStatus::with_exit_code(0)) + } + + fn process_id(&self) -> Option { + None + } + + #[cfg(windows)] + fn as_raw_handle(&self) -> Option { + None + } + } + + #[test] + fn a_completed_entry_never_ends_a_process_whose_exit_it_did_not_see() { + // Reader EOF only says the PTY closed: a child can close its terminal + // and keep running. However its cache entry goes — an explicit close, + // a count prune, a window's bulk kill, the quit — the process it names + // must be left running, as it was before the cache existed. + use std::sync::atomic::{AtomicBool, Ordering}; + + let manager = super::TerminalManager::new(); + let mut flags = Vec::new(); + { + let mut completed = super::lock_completed(&manager.completed); + for n in 0..=super::COMPLETED_MAX_COUNT + 1 { + let id = format!("detached-{n}"); + let owner = if n % 2 == 0 { "main" } else { "other" }; + let ended = std::sync::Arc::new(AtomicBool::new(false)); + flags.push((id.clone(), ended.clone())); + completed.order.push_back(id.clone()); + completed.entries.insert( + id, + super::CompletedTerminal { + scrollback: std::sync::Arc::new(std::sync::Mutex::new( + Scrollback::default(), + )), + owner_window_label: owner.to_string(), + generation: "generation".to_string(), + child: Box::new(UnobservedChild { ended }), + exit_code: None, + finished_at: std::time::Instant::now(), + }, + ); + } + } + + // Over the count bound: the snapshot's prune evicts the oldest. + let _ = manager.snapshot("unknown"); + let last = format!("detached-{}", super::COMPLETED_MAX_COUNT + 1); + manager + .kill(&last) + .expect("explicit close of a completed entry"); + manager.kill_by_owner_window("main"); + manager.kill_all(); + + assert!(super::lock_completed(&manager.completed).entries.is_empty()); + for (id, ended) in flags { + assert!( + !ended.load(Ordering::SeqCst), + "{id}: dropping its cache entry ended a process still running" + ); + } + } + #[cfg(not(target_os = "windows"))] #[tokio::test] async fn finished_terminal_keeps_final_output_and_exit_status_until_explicit_close() { From d73231adef616ff8281cfc13761d568ec2a9fe5b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Wed, 30 Sep 2026 21:16:58 +0800 Subject: [PATCH 06/16] test(terminal): check the stored title clamp on its own The long-title test only read the title back after a remount, where the read-side clamp alone satisfies it, so dropping the clamp on write went unnoticed. It now also checks the title as stored. A launch-reporting test no longer names a remount it does not perform. --- src/components/terminal/terminal-view-recovery.test.tsx | 2 +- src/contexts/terminal-context.test.tsx | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/src/components/terminal/terminal-view-recovery.test.tsx b/src/components/terminal/terminal-view-recovery.test.tsx index 8b52f75970..f3b4ad059b 100644 --- a/src/components/terminal/terminal-view-recovery.test.tsx +++ b/src/components/terminal/terminal-view-recovery.test.tsx @@ -731,7 +731,7 @@ describe("TerminalView recovery", () => { view.unmount() }) - it("reports a launch that lands after its view unmounted, so a later mount attaches", async () => { + it("reports a launch that lands after its view unmounted", async () => { // A drawer swiped shut mid-spawn unmounts the view, but the tab stays. // Unless the launch is reported anyway, the tab still reads as never // started, and the next mount after the PTY is gone runs the command again. diff --git a/src/contexts/terminal-context.test.tsx b/src/contexts/terminal-context.test.tsx index 8d47119453..7b116b028b 100644 --- a/src/contexts/terminal-context.test.tsx +++ b/src/contexts/terminal-context.test.tsx @@ -251,6 +251,11 @@ describe("TerminalProvider reload recovery", () => { fireEvent.click(screen.getByRole("button", { name: "Run" })) fireEvent.click(screen.getByRole("button", { name: "Rename long" })) expect(screen.getByTestId("titles")).toHaveTextContent("300") + // Clamped on the way in, not only on the way back out. + const stored = JSON.parse( + sessionStorage.getItem("codeg:terminal-session:v1") ?? "{}" + ) + expect(stored.tabs[0].title).toHaveLength(256) first.unmount() render( From 05bd312066bd26b48c6ec469e3ec4296bb2df1f1 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 18:13:17 +0800 Subject: [PATCH 07/16] test(terminal): make three reload-recovery tests check what they claim - The window-name test also stored a forged terminal id, so either gate could have been what refused the session. It now stores a valid tab, leaving the window name as the only gate. - The remote-scope test changed the window and the remote connection together. Each half of the scope is now tested on its own. - The close test is renamed: no view is mounted, so no launch is pending. What it checks is that a close kills before anything has launched. --- src/contexts/terminal-context.test.tsx | 35 +++++++++++++++----------- 1 file changed, 21 insertions(+), 14 deletions(-) diff --git a/src/contexts/terminal-context.test.tsx b/src/contexts/terminal-context.test.tsx index 7b116b028b..a7dd7002f8 100644 --- a/src/contexts/terminal-context.test.tsx +++ b/src/contexts/terminal-context.test.tsx @@ -123,7 +123,9 @@ describe("TerminalProvider reload recovery", () => { expect(screen.getByTestId("command")).toBeEmptyDOMElement() }) - it("explicit close records a kill even while spawn is still pending", () => { + it("explicit close kills the terminal even before a view has started it", () => { + // No view is mounted, so nothing has launched yet. The kill goes out + // anyway: the backend turns it into a cancellation of the launch. render( @@ -139,7 +141,12 @@ describe("TerminalProvider reload recovery", () => { ) }) - it("rejects a forged terminal ID and preserves an unrelated window name", () => { + it("restores nothing under a window name it did not give, and keeps that name", () => { + // Everything else in the stored session is valid, so the window name is + // the only thing that can be refusing it. A forged terminal ID under a + // name of ours costs only that tab: "drops only the stored tab it cannot + // validate" below. + const id = "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098" window.name = "host-window" sessionStorage.setItem( "codeg:terminal-session:v1", @@ -148,15 +155,8 @@ describe("TerminalProvider reload recovery", () => { scope: JSON.stringify(["main", null]), pageId: "host-window", isOpen: true, - activeTabId: "other-user-terminal", - tabs: [ - { - id: "other-user-terminal", - folderId: 7, - title: "bad", - workingDir: "/tmp", - }, - ], + activeTabId: id, + tabs: [{ id, folderId: 7, title: "valid", workingDir: "/tmp" }], }) ) render( @@ -192,7 +192,14 @@ describe("TerminalProvider reload recovery", () => { expect(h.terminalKill).not.toHaveBeenCalled() }) - it("does not restore another remote workspace window's terminals", () => { + it.each([ + ["another window", { windowLabel: "remote-workspace-4", remoteId: 3 }], + [ + "another remote connection", + { windowLabel: "remote-workspace-3", remoteId: 4 }, + ], + ])("does not restore terminals saved for %s", (_, other) => { + // Either half of the scope alone must keep the session out. h.windowLabel = "remote-workspace-3" h.remoteId = 3 const first = render( @@ -204,8 +211,8 @@ describe("TerminalProvider reload recovery", () => { expect(screen.getByTestId("tabs")).toHaveTextContent("1") first.unmount() - h.windowLabel = "remote-workspace-4" - h.remoteId = 4 + h.windowLabel = other.windowLabel + h.remoteId = other.remoteId render( From 381160da2f408da4408b7b748b05c666cd1f588b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 18:13:28 +0800 Subject: [PATCH 08/16] fix(terminal): keep a terminal's final output until its tab is closed - Ended terminals are no longer dropped after 10 minutes or past 64 of them. A record lasts until the terminal is closed, a new process takes its id, or its window's terminals are reclaimed: the output of a command that finished while its page was away is what a reload is for. - `terminal_kill` takes `keepOutput`. The launcher's Stop ends the process but keeps the record, as a natural exit would, so the tab it leaves open still shows its output after a reload. A server without it closes the terminal, as before. - Snapshots report the PTY's size: the geometry their output was laid out for. - A window's bulk kill no longer marks as cancelled a launch whose terminal is already in the table. That mark would outlive the terminal and refuse the next launch of its id. --- src-tauri/src/commands/terminal.rs | 10 +- src-tauri/src/terminal/manager.rs | 335 +++++++++++++++++++------ src-tauri/src/terminal/types.rs | 11 +- src-tauri/src/web/handlers/terminal.rs | 37 ++- src/lib/api.ts | 10 + src/lib/types.ts | 5 +- 6 files changed, 316 insertions(+), 92 deletions(-) diff --git a/src-tauri/src/commands/terminal.rs b/src-tauri/src/commands/terminal.rs index c8a16178de..3aaa020f23 100644 --- a/src-tauri/src/commands/terminal.rs +++ b/src-tauri/src/commands/terminal.rs @@ -138,13 +138,21 @@ pub fn terminal_snapshot( Ok(manager.snapshot(&terminal_id)) } +/// Ends a terminal's process. `keep_output` stops it without closing it: the +/// final output stays for the tab that still shows it (see +/// [`TerminalManager::stop`]); otherwise the terminal is closed and forgotten. #[cfg(feature = "tauri-runtime")] #[cfg_attr(feature = "tauri-runtime", tauri::command)] pub fn terminal_kill( terminal_id: String, + keep_output: Option, manager: State<'_, TerminalManager>, ) -> Result<(), TerminalError> { - manager.kill(&terminal_id) + if keep_output == Some(true) { + manager.stop(&terminal_id) + } else { + manager.kill(&terminal_id) + } } #[cfg(feature = "tauri-runtime")] diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index dd70630dfa..3a4f4a799d 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -19,8 +19,6 @@ use crate::web::event_bridge::EventEmitter; /// to cover a screenful of `ls -R` or a compile log, not a whole session: this /// is a "pick the pane back up where you left it" buffer, not a transcript. const SCROLLBACK_MAX_CHARS: usize = 128 * 1024; -const COMPLETED_MAX_COUNT: usize = 64; -const COMPLETED_TTL: Duration = Duration::from_secs(10 * 60); const CANCELLED_SPAWN_TTL: Duration = Duration::from_secs(30); const CANCELLED_SPAWN_MAX_COUNT: usize = 128; @@ -101,73 +99,87 @@ impl Drop for SpawnReservation { } } +/// A terminal whose process has ended (or is being ended by +/// [`TerminalManager::stop`]), kept so its pane can still be drawn: by a +/// reloaded panel tab, a remounted canvas card, or a viewer that reconnects. struct CompletedTerminal { scrollback: Arc>, owner_window_label: String, generation: String, - child: Box, + /// `None` once whoever is ending the process has taken it to reap. + child: Option>, exit_code: Option, - finished_at: Instant, + /// The PTY's size when it ended: the geometry its output was drawn for. + size: Option, } +impl CompletedTerminal { + /// The record of `instance`, and its child — which the caller either + /// keeps with the record or ends itself. + fn from_instance( + mut instance: TerminalInstance, + exit_code: Option, + ) -> (Self, Box) { + cleanup_temp_files(&mut instance.temp_files); + let entry = Self { + size: instance.master.get_size().ok(), + scrollback: instance.scrollback, + owner_window_label: instance.owner_window_label, + generation: instance.generation, + child: None, + exit_code, + }; + (entry, instance._child) + } +} + +/// Ended terminals, each kept for as long as something may still show it — +/// not for a fixed time or up to a fixed count. A record goes when its +/// terminal is closed ([`TerminalManager::kill`]), when a new process takes +/// its id, or when its window's terminals are reclaimed. The output of a +/// command that finished while its page was away is what a reload is for. #[derive(Default)] struct CompletedTerminals { entries: HashMap, - order: VecDeque, } impl CompletedTerminals { - fn prune(&mut self) { - let now = Instant::now(); - while let Some(id) = self.order.front() { - if self.order.len() <= COMPLETED_MAX_COUNT - && self - .entries - .get(id) - .is_some_and(|entry| now.duration_since(entry.finished_at) < COMPLETED_TTL) - { - break; - } - let id = self.order.pop_front().expect("front exists"); - self.remove(&id); - } + /// Record a terminal whose process has ended, or may have: its child + /// stays with the record, to be reaped once it exits. + fn insert(&mut self, id: String, instance: TerminalInstance, exit_code: Option) { + let (mut entry, child) = CompletedTerminal::from_instance(instance, exit_code); + entry.child = Some(child); + self.remove(&id); + self.entries.insert(id, entry); } - fn insert(&mut self, id: String, mut instance: TerminalInstance, exit_code: Option) { - cleanup_temp_files(&mut instance.temp_files); + /// Record a terminal the caller is about to end, handing its child back + /// so the kill and the wait for it happen outside this table's lock. + fn insert_ending( + &mut self, + id: String, + instance: TerminalInstance, + ) -> Box { + let (entry, child) = CompletedTerminal::from_instance(instance, None); self.remove(&id); - self.order.push_back(id.clone()); - self.entries.insert( - id, - CompletedTerminal { - scrollback: instance.scrollback.clone(), - owner_window_label: instance.owner_window_label.clone(), - generation: instance.generation.clone(), - child: instance._child, - exit_code, - finished_at: Instant::now(), - }, - ); - self.prune(); + self.entries.insert(id, entry); + child } fn remove(&mut self, id: &str) -> bool { - let removed = self.entries.remove(id); - if let Some(mut entry) = removed { - if entry.exit_code.is_none() { - // Reap it if it has exited since, so it leaves no zombie, but - // never kill it. Reader EOF does not prove the process ended: - // a child can close its terminal and keep running, and such a - // process was always left alone once its PTY closed. Dropping - // a cache entry must not be what ends it — and a kill + wait - // here would stall every caller holding the terminal table. - let _ = entry.child.try_wait(); - } - self.order.retain(|entry_id| entry_id != id); - true - } else { - false + let Some(mut entry) = self.entries.remove(id) else { + return false; + }; + if let (None, Some(child)) = (entry.exit_code, entry.child.as_mut()) { + // Reap it if it has exited since, so it leaves no zombie, but + // never kill it. Reader EOF does not prove the process ended: + // a child can close its terminal and keep running, and such a + // process was always left alone once its PTY closed. Dropping + // a cache entry must not be what ends it — and a kill + wait + // here would stall every caller holding the terminal table. + let _ = child.try_wait(); } + true } } @@ -610,7 +622,7 @@ impl TerminalManager { pub fn snapshot(&self, terminal_id: &str) -> TerminalSnapshot { // The active→completed transfer happens under the active map lock, so // a snapshot never mistakes an exiting PTY for a missing one. - let (buffer, alive, exit_code, generation) = { + let (buffer, alive, exit_code, generation, size) = { let terminals = lock_terminals(&self.terminals); if let Some(instance) = terminals.get(terminal_id) { ( @@ -618,15 +630,14 @@ impl TerminalManager { true, None, Some(instance.generation.clone()), + instance.master.get_size().ok(), ) } else { let mut completed = lock_completed(&self.completed); - completed.prune(); match completed.entries.get_mut(terminal_id) { Some(entry) => { - if entry.exit_code.is_none() { - entry.exit_code = entry - .child + if let (None, Some(child)) = (entry.exit_code, entry.child.as_mut()) { + entry.exit_code = child .try_wait() .ok() .flatten() @@ -637,9 +648,10 @@ impl TerminalManager { false, entry.exit_code, Some(entry.generation.clone()), + entry.size, ) } - None => (None, false, None, None), + None => (None, false, None, None, None), } } }; @@ -651,6 +663,8 @@ impl TerminalManager { seq: 0, exit_code: None, generation: None, + cols: None, + rows: None, }; }; let (data, seq) = buffer @@ -664,22 +678,54 @@ impl TerminalManager { seq, exit_code, generation, + cols: size.map(|size| size.cols), + rows: size.map(|size| size.rows), } } + /// Close a terminal: end its process and forget it, final output included. pub fn kill(&self, terminal_id: &str) -> Result<(), TerminalError> { + self.end(terminal_id, false) + } + + /// End a terminal's process but keep its record, as if it had exited on + /// its own: the final output stays there for any viewer — a reloaded tab + /// included — until the terminal is closed with [`Self::kill`]. + pub fn stop(&self, terminal_id: &str) -> Result<(), TerminalError> { + self.end(terminal_id, true) + } + + fn end(&self, terminal_id: &str, keep_record: bool) -> Result<(), TerminalError> { let mut terminals = lock_terminals(&self.terminals); if let Some(mut instance) = terminals.remove(terminal_id) { - lock_completed(&self.completed).remove(terminal_id); - drop(terminals); - terminate_terminal(&mut instance); + if keep_record { + // Recorded before the table lock is released, so no snapshot + // finds this id in neither table while the process dies. + let mut child = lock_completed(&self.completed) + .insert_ending(terminal_id.to_string(), instance); + drop(terminals); + let _ = child.kill(); + let _ = child.wait(); + } else { + lock_completed(&self.completed).remove(terminal_id); + drop(terminals); + terminate_terminal(&mut instance); + } return Ok(()); } - if lock_completed(&self.completed).remove(terminal_id) { - return Ok(()); + { + let mut completed = lock_completed(&self.completed); + if completed.entries.contains_key(terminal_id) { + // It has ended already. A stop leaves that as it is; a close + // forgets it. + if !keep_record { + completed.remove(terminal_id); + } + return Ok(()); + } } - // A close can overtake an in-flight spawn request (or arrive just - // before it). Remember only random terminal IDs, for a short bounded + // A close (or stop) can overtake an in-flight spawn request, or arrive + // just before it. Remember only random terminal IDs, for a short bounded // interval. The later insert consumes this reservation atomically under // the terminal map lock; ordinary view unmount never calls kill. if uuid::Uuid::parse_str(terminal_id).is_ok() { @@ -821,22 +867,25 @@ impl TerminalManager { }) .collect(); - let mut removed = Vec::with_capacity(ids.len()); - for id in ids { - if let Some(instance) = terminals.remove(&id) { - removed.push(instance); - } - } let spawning = self.spawning.lock().unwrap_or_else(|p| p.into_inner()); let mut pending = self .cancelled_spawns .lock() .unwrap_or_else(|p| p.into_inner()); for (id, owner) in spawning.iter() { - if owner == owner_window_label { + // A launch still holds its reservation for a moment after its + // terminal is in the table. That one is ended below; a mark + // for it would only linger and refuse the id's next launch. + if owner == owner_window_label && !terminals.contains_key(id) { pending.insert(id.clone(), Instant::now()); } } + let mut removed = Vec::with_capacity(ids.len()); + for id in ids { + if let Some(instance) = terminals.remove(&id) { + removed.push(instance); + } + } removed }; @@ -870,7 +919,10 @@ impl TerminalManager { .lock() .unwrap_or_else(|p| p.into_inner()); for id in spawning.keys() { - pending.insert(id.clone(), Instant::now()); + // As in `kill_by_owner_window`: only launches not yet in the table. + if !terminals.contains_key(id) { + pending.insert(id.clone(), Instant::now()); + } } terminals.drain().map(|(_, inst)| inst).collect() }; @@ -1131,21 +1183,21 @@ mod tests { #[test] fn a_completed_entry_never_ends_a_process_whose_exit_it_did_not_see() { // Reader EOF only says the PTY closed: a child can close its terminal - // and keep running. However its cache entry goes — an explicit close, - // a count prune, a window's bulk kill, the quit — the process it names - // must be left running, as it was before the cache existed. + // and keep running. However its record goes — an explicit close, a + // window's bulk kill, the quit — the process it names must be left + // running, as it was before the records existed. A stop of a + // terminal that already ended changes nothing. use std::sync::atomic::{AtomicBool, Ordering}; let manager = super::TerminalManager::new(); let mut flags = Vec::new(); { let mut completed = super::lock_completed(&manager.completed); - for n in 0..=super::COMPLETED_MAX_COUNT + 1 { + for n in 0..4 { let id = format!("detached-{n}"); let owner = if n % 2 == 0 { "main" } else { "other" }; let ended = std::sync::Arc::new(AtomicBool::new(false)); flags.push((id.clone(), ended.clone())); - completed.order.push_back(id.clone()); completed.entries.insert( id, super::CompletedTerminal { @@ -1154,20 +1206,19 @@ mod tests { )), owner_window_label: owner.to_string(), generation: "generation".to_string(), - child: Box::new(UnobservedChild { ended }), + child: Some(Box::new(UnobservedChild { ended })), exit_code: None, - finished_at: std::time::Instant::now(), + size: None, }, ); } } - // Over the count bound: the snapshot's prune evicts the oldest. - let _ = manager.snapshot("unknown"); - let last = format!("detached-{}", super::COMPLETED_MAX_COUNT + 1); manager - .kill(&last) - .expect("explicit close of a completed entry"); + .stop("detached-1") + .expect("stop of an ended terminal"); + assert!(manager.snapshot("detached-1").exists, "a stop forgot it"); + manager.kill("detached-3").expect("explicit close"); manager.kill_by_owner_window("main"); manager.kill_all(); @@ -1175,11 +1226,129 @@ mod tests { for (id, ended) in flags { assert!( !ended.load(Ordering::SeqCst), - "{id}: dropping its cache entry ended a process still running" + "{id}: dropping its record ended a process still running" ); } } + #[cfg(not(target_os = "windows"))] + fn sleeping_shell(id: &str, command: &str) -> SpawnOptions { + SpawnOptions { + terminal_id: id.to_string(), + working_dir: std::env::temp_dir().to_string_lossy().to_string(), + owner_window_label: "main".to_string(), + shell: Some("/bin/sh".to_string()), + initial_command: Some(command.to_string()), + extra_env: None, + temp_files: vec![], + } + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn a_stopped_terminal_keeps_its_output_until_it_is_closed() { + // The command launcher's Stop ends the process but leaves the tab + // open, so a reload of that tab must find the output it showed — + // not "unavailable", which is what forgetting the terminal leaves. + use std::time::{Duration, Instant}; + + let manager = TerminalManager::new(); + let id = "stopped-output"; + manager + .spawn_with_id( + sleeping_shell(id, "printf 'stop-marker\\n'; sleep 30"), + EventEmitter::Noop, + ) + .expect("spawn"); + let deadline = Instant::now() + Duration::from_secs(10); + while !manager.snapshot(id).data.contains("stop-marker") { + assert!(Instant::now() < deadline, "no output to keep"); + std::thread::sleep(Duration::from_millis(20)); + } + + manager.stop(id).expect("stop"); + let stopped = manager.snapshot(id); + assert!(stopped.exists, "the stop forgot the terminal"); + assert!(!stopped.alive); + assert!(stopped.data.contains("stop-marker")); + // Ended by us, not by itself: no exit status to report. + assert_eq!(stopped.exit_code, None); + assert!(super::lock_terminals(&manager.terminals).is_empty()); + + manager.kill(id).expect("close"); + assert!(!manager.snapshot(id).exists); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn a_snapshot_reports_the_size_its_output_was_drawn_for() { + let manager = TerminalManager::new(); + let id = "sized-output"; + manager + .spawn_with_id(sleeping_shell(id, "sleep 30"), EventEmitter::Noop) + .expect("spawn"); + manager.resize(id, 133, 41).expect("resize"); + let live = manager.snapshot(id); + assert_eq!((live.cols, live.rows), (Some(133), Some(41))); + + // Kept with the record once the process has ended. + manager.stop(id).expect("stop"); + let ended = manager.snapshot(id); + assert!(!ended.alive); + assert_eq!((ended.cols, ended.rows), (Some(133), Some(41))); + manager.kill(id).expect("close"); + } + + #[cfg(not(target_os = "windows"))] + #[test] + fn bulk_kills_mark_only_launches_not_yet_in_the_table() { + // A launch keeps its reservation for a moment after inserting its + // terminal. A bulk kill in that moment ends the terminal itself; a + // cancellation mark for it would outlive it and refuse the next + // launch of that id. + let manager = TerminalManager::new(); + let pending = |manager: &TerminalManager| { + manager + .cancelled_spawns + .lock() + .unwrap() + .keys() + .cloned() + .collect::>() + }; + let reserve = |manager: &TerminalManager, id: &str| { + manager + .spawning + .lock() + .unwrap() + .insert(id.to_string(), "main".to_string()); + }; + + manager + .spawn_with_id(sleeping_shell("inserted", "sleep 30"), EventEmitter::Noop) + .expect("spawn"); + reserve(&manager, "inserted"); + reserve(&manager, "launching"); + manager.kill_by_owner_window("main"); + assert_eq!( + pending(&manager), + std::collections::HashSet::from(["launching".to_string()]) + ); + + manager.cancelled_spawns.lock().unwrap().clear(); + manager.spawning.lock().unwrap().clear(); + manager + .spawn_with_id(sleeping_shell("inserted", "sleep 30"), EventEmitter::Noop) + .expect("spawn again"); + reserve(&manager, "inserted"); + reserve(&manager, "launching"); + manager.kill_all(); + assert_eq!( + pending(&manager), + std::collections::HashSet::from(["launching".to_string()]) + ); + } + #[cfg(not(target_os = "windows"))] #[tokio::test] async fn finished_terminal_keeps_final_output_and_exit_status_until_explicit_close() { diff --git a/src-tauri/src/terminal/types.rs b/src-tauri/src/terminal/types.rs index 0af669cc9b..3b69ff4660 100644 --- a/src-tauri/src/terminal/types.rs +++ b/src-tauri/src/terminal/types.rs @@ -20,11 +20,11 @@ pub struct TerminalEvent { pub generation: String, } -/// Recent output of a live or recently completed terminal plus its cursor — -/// the re-attach payload behind `terminal_snapshot`. +/// Recent output of a live or completed terminal plus its cursor — the +/// re-attach payload behind `terminal_snapshot`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct TerminalSnapshot { - /// Whether a live or recently completed PTY with this id is known. + /// Whether a live or completed PTY with this id is known. pub exists: bool, /// Exit status retained with the completed output, if the child reported it. pub exit_code: Option, @@ -39,6 +39,11 @@ pub struct TerminalSnapshot { pub data: String, /// The `seq` of the last chunk included in `data`. pub seq: u64, + /// The PTY's size, which `data` was laid out for. A viewer replays it at + /// this size: drawn narrower, a full-width line (zsh's end-of-output + /// mark pads to the right edge) wraps and leaves a stray line behind. + pub cols: Option, + pub rows: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/web/handlers/terminal.rs b/src-tauri/src/web/handlers/terminal.rs index 91a1c8b78f..dabd7455d8 100644 --- a/src-tauri/src/web/handlers/terminal.rs +++ b/src-tauri/src/web/handlers/terminal.rs @@ -28,6 +28,16 @@ pub struct TerminalIdParams { pub terminal_id: String, } +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct TerminalKillParams { + pub terminal_id: String, + /// Stop the process but keep the terminal's final output (see + /// `TerminalManager::stop`) instead of closing the terminal. + #[serde(default)] + pub keep_output: bool, +} + #[derive(Deserialize)] #[serde(rename_all = "camelCase")] pub struct TerminalWriteParams { @@ -111,12 +121,15 @@ pub async fn terminal_snapshot( pub async fn terminal_kill( Extension(state): Extension>, - Json(params): Json, + Json(params): Json, ) -> Result, AppCommandError> { let manager = &state.terminal_manager; - manager - .kill(¶ms.terminal_id) - .map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; + let result = if params.keep_output { + manager.stop(¶ms.terminal_id) + } else { + manager.kill(¶ms.terminal_id) + }; + result.map_err(|e| AppCommandError::task_execution_failed(e.to_string()))?; Ok(Json(())) } @@ -127,3 +140,19 @@ pub async fn terminal_list( let result = manager.list_with_exit_check(Some(&state.emitter)); Ok(Json(result)) } + +#[cfg(test)] +mod tests { + use super::TerminalKillParams; + + #[test] + fn a_kill_without_keep_output_still_closes_the_terminal() { + // Older clients send only the id, and that must stay a close. + let close: TerminalKillParams = + serde_json::from_str(r#"{"terminalId":"t"}"#).expect("close params"); + assert!(!close.keep_output); + let stop: TerminalKillParams = + serde_json::from_str(r#"{"terminalId":"t","keepOutput":true}"#).expect("stop params"); + assert!(stop.keep_output); + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index baacd21047..e1fd9506f5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -4741,10 +4741,20 @@ export async function terminalSnapshot( return getTransport().call("terminal_snapshot", { terminalId }) } +/** Close a terminal: ends its process and forgets it, output included. */ export async function terminalKill(terminalId: string): Promise { return getTransport().call("terminal_kill", { terminalId }) } +/** + * End a terminal's process but keep the terminal: its final output stays on + * the backend for the tab still showing it, as after a natural exit, until + * `terminalKill` closes it. A server without `keepOutput` closes it instead. + */ +export async function terminalStop(terminalId: string): Promise { + return getTransport().call("terminal_kill", { terminalId, keepOutput: true }) +} + export async function terminalList(): Promise { return getTransport().call("terminal_list") } diff --git a/src/lib/types.ts b/src/lib/types.ts index 6108e65f1a..07ed719b95 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -4423,7 +4423,7 @@ export interface TerminalEvent { generation?: string } -/** Recent output of a live or recently completed terminal. +/** Recent output of a live or completed terminal. * When `alive` is false, `exists` distinguishes retained final output * from a missing session. */ export interface TerminalSnapshot { @@ -4435,6 +4435,9 @@ export interface TerminalSnapshot { alive: boolean data: string seq: number + /** The PTY's size, which `data` was laid out for. Absent on old servers. */ + cols?: number | null + rows?: number | null } export interface TokenBreakdown { From ccb40c62c554301268f498038c47fc28c0af346a Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 18:13:28 +0800 Subject: [PATCH 09/16] fix(terminal): replay at the PTY's size and keep exit state across reconnects - A snapshot is replayed at the size its output was laid out for, and the pane is fitted once the replay is drawn. Replayed at xterm's default 80 columns, zsh's full-width end-of-output mark wrapped and left a stray "%" line behind every reload. - A restored tab whose PTY the backend reported missing until the probe's deadline is treated as gone (input dropped, tab marked exited) rather than as unconfirmed. - A reconnect that redraws an ended terminal draws its exit line again; the reset before the redraw had wiped it. - Exits that arrive while output is buffered are all kept until the snapshot names the current generation, so a stale one arriving after the current one can no longer hide it. --- .../terminal/terminal-view-recovery.test.tsx | 191 +++++++++++++++++- src/components/terminal/terminal-view.tsx | 95 +++++---- 2 files changed, 236 insertions(+), 50 deletions(-) diff --git a/src/components/terminal/terminal-view-recovery.test.tsx b/src/components/terminal/terminal-view-recovery.test.tsx index f3b4ad059b..daf5e3275c 100644 --- a/src/components/terminal/terminal-view-recovery.test.tsx +++ b/src/components/terminal/terminal-view-recovery.test.tsx @@ -7,13 +7,16 @@ import type { TerminalEvent, TerminalSnapshot } from "@/lib/types" const h = vi.hoisted(() => ({ writes: [] as string[], resets: 0, + fits: 0, emulators: 0, disposed: 0, snapshot: vi.fn(), spawn: vi.fn(), kill: vi.fn(async () => {}), write: vi.fn(async () => {}), + resize: vi.fn(async () => {}), onData: null as ((data: string) => void) | null, + onResize: null as ((size: { cols: number; rows: number }) => void) | null, handlers: new Map void>(), ready: null as (() => void) | null, })) @@ -28,7 +31,7 @@ vi.mock("@/lib/api", () => ({ terminalSnapshot: h.snapshot, terminalSpawn: h.spawn, terminalWrite: h.write, - terminalResize: vi.fn(async () => {}), + terminalResize: h.resize, terminalKill: h.kill, })) vi.mock("@/lib/platform", () => ({ @@ -83,11 +86,26 @@ vi.mock("@xterm/xterm", () => ({ }, } } - onResize() { - return { dispose() {} } + cols = 80 + rows = 24 + resize(cols: number, rows: number) { + if (cols === this.cols && rows === this.rows) return + this.cols = cols + this.rows = rows + h.writes.push(``) + h.onResize?.({ cols, rows }) } - write(data: string) { - h.writes.push(data) + onResize(cb: (size: { cols: number; rows: number }) => void) { + h.onResize = cb + return { + dispose() { + h.onResize = null + }, + } + } + write(data: string, parsed?: () => void) { + if (data) h.writes.push(data) + parsed?.() } reset() { h.resets++ @@ -102,7 +120,9 @@ vi.mock("@xterm/xterm", () => ({ vi.mock("@xterm/addon-ligatures", () => ({ LigaturesAddon: class {} })) vi.mock("@xterm/addon-fit", () => ({ FitAddon: class { - fit() {} + fit() { + h.fits++ + } }, })) vi.mock("@xterm/addon-web-links", () => ({ WebLinksAddon: class {} })) @@ -129,6 +149,7 @@ describe("TerminalView recovery", () => { beforeEach(() => { h.writes.length = 0 h.resets = 0 + h.fits = 0 h.emulators = 0 h.disposed = 0 h.snapshot.mockReset() @@ -144,7 +165,9 @@ describe("TerminalView recovery", () => { h.spawn.mockResolvedValue("terminal-1") h.kill.mockClear() h.write.mockClear() + h.resize.mockClear() h.onData = null + h.onResize = null h.handlers.clear() h.ready = null }) @@ -759,4 +782,160 @@ describe("TerminalView recovery", () => { await waitFor(() => expect(onSpawned).toHaveBeenCalledWith("terminal-1")) expect(h.kill).not.toHaveBeenCalled() }) + + it("replays a snapshot at the size its output was laid out for", async () => { + // xterm opens at 80 columns. Drawn there, a 133-column line wraps — + // zsh's end-of-output mark pads to the right edge, so every reload left + // a stray "%" line behind. + h.snapshot.mockResolvedValueOnce({ + ...snapshot("wide output", 3), + cols: 133, + rows: 41, + }) + const view = render( + + ) + await waitFor(() => expect(h.writes).toContain("wide output")) + expect(h.writes.indexOf("")).toBeGreaterThan(-1) + expect(h.writes.indexOf("")).toBeLessThan( + h.writes.indexOf("wide output") + ) + // The PTY already has that size: telling it again would be noise. + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 80)) + }) + expect(h.resize).not.toHaveBeenCalled() + view.unmount() + }) + + it("treats a restored tab the backend never knew as gone, not unconfirmed", async () => { + // Retried until the deadline because a reload can beat the spawn request. + // If every answer was "no such terminal", the tab's input is dead and the + // tab is over — the same as an explicit "missing" on reconnect. + let now = 0 + const clock = vi.spyOn(Date, "now").mockImplementation(() => { + now += 20_000 + return now + }) + const exited = vi.fn() + const view = render( + + ) + await waitFor(() => expect(h.writes.join("")).toContain("unavailable")) + clock.mockRestore() + expect(exited).toHaveBeenCalledWith("terminal-1") + act(() => { + h.onData?.("x") + }) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(h.write).not.toHaveBeenCalled() + view.unmount() + }) + + it("draws an ended terminal's exit line again after a reconnect redraws it", async () => { + const ended = { ...snapshot("final output", 3, false), exit_code: 7 } + h.snapshot.mockResolvedValueOnce(ended) + const view = render( + + ) + await waitFor(() => + expect(h.writes.join("")).toContain("Process exited (code 7)") + ) + + h.snapshot.mockResolvedValueOnce(ended) + act(() => { + h.ready?.() + }) + await waitFor(() => expect(h.resets).toBe(1)) + expect(h.writes.slice(h.writes.indexOf("") + 1).join("")).toContain( + "Process exited (code 7)" + ) + view.unmount() + }) + + it("keeps this process's exit when an older one's lands after it", async () => { + // Both arrive before the snapshot names the current generation. Only the + // order of arrival differs from the usual case, and it must not decide + // whether the pane learns that its process ended. + let finish!: (value: TerminalSnapshot) => void + h.snapshot.mockImplementationOnce( + () => + new Promise((resolve) => { + finish = resolve + }) + ) + const view = render( + + ) + await waitFor(() => expect(h.snapshot).toHaveBeenCalledTimes(1)) + act(() => { + for (const generation of ["new-generation", "old-generation"]) { + h.handlers.get("terminal://exit/terminal-1")?.({ + terminal_id: "terminal-1", + data: "", + seq: 0, + generation, + }) + } + }) + await act(async () => { + finish(snapshot("taken before the exit", 1)) + }) + expect(h.writes.join("")).toContain("taken before the exit") + expect(h.writes.join("")).toContain("processExited") + view.unmount() + }) + + it("fits the pane again once a reconnect's replay is drawn", async () => { + // The replay is drawn at the PTY's size, which another viewer may have + // set. The pane has to be fitted back to itself afterwards, or it stays + // at that size until its container happens to change. + const width = vi + .spyOn(Element.prototype, "clientWidth", "get") + .mockReturnValue(800) + const height = vi + .spyOn(Element.prototype, "clientHeight", "get") + .mockReturnValue(600) + try { + h.snapshot.mockResolvedValueOnce({ + ...snapshot("before", 2), + cols: 133, + rows: 41, + }) + const view = render( + + ) + await waitFor(() => expect(h.writes).toContain("before")) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)) + }) + const fitted = h.fits + + h.snapshot.mockResolvedValueOnce({ + ...snapshot("before and after", 3), + cols: 90, + rows: 30, + }) + act(() => { + h.ready?.() + }) + await waitFor(() => expect(h.resets).toBe(1)) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 60)) + }) + expect(h.fits).toBeGreaterThan(fitted) + view.unmount() + } finally { + width.mockRestore() + height.mockRestore() + } + }) }) diff --git a/src/components/terminal/terminal-view.tsx b/src/components/terminal/terminal-view.tsx index 2479c9d0ed..ae4dda7167 100644 --- a/src/components/terminal/terminal-view.tsx +++ b/src/components/terminal/terminal-view.tsx @@ -426,7 +426,10 @@ export function TerminalView({ // 往返,中间到的 chunk 既可能已经在快照里(重复),也可能不在(丢失)。 // 两者靠 seq 区分——见下面的 writeEvent 与 `TerminalEvent.seq`。 let replayBuffer: TerminalEvent[] | null = attach ? [] : null - let exitWhileBuffering: TerminalEvent | null = null + // Every exit that lands while buffering, not just the latest: until the + // snapshot names the current generation there is no telling which one + // is this process's, and a stale one arriving after it must not hide it. + let exitsWhileBuffering: TerminalEvent[] = [] let currentGeneration: string | null = null let exitShown = false let unlistenReconnect: (() => void) | null = null @@ -444,6 +447,11 @@ export function TerminalView({ term.write(`\r\n\x1b[90m[${message}]\x1b[0m\r\n`) } + const isCurrentExit = (event: TerminalEvent) => + !currentGeneration || + !event.generation || + event.generation === currentGeneration + const showUnavailable = (confirmedMissing: boolean) => { if (exitShown) return exitShown = true @@ -498,28 +506,16 @@ export function TerminalView({ snapshotSeqFloor = snapshotSeq } for (const event of buffered) writeEvent(event) - if (exitWhileBuffering) { - const exit = exitWhileBuffering - exitWhileBuffering = null - if ( - !currentGeneration || - !exit.generation || - exit.generation === currentGeneration - ) - showExit() - } + const exits = exitsWhileBuffering + exitsWhileBuffering = [] + if (exits.some(isCurrentExit)) showExit() } const unlistenExit = await subscribe( `terminal://exit/${terminalId}`, (event) => { - if (replayBuffer) exitWhileBuffering = event - else if ( - !currentGeneration || - !event.generation || - event.generation === currentGeneration - ) - showExit() + if (replayBuffer) exitsWhileBuffering.push(event) + else if (isCurrentExit(event)) showExit() } ) @@ -534,14 +530,16 @@ export function TerminalView({ return } + const fitIfReady = () => { + const el = containerRef.current + if (!el) return + if (!isActiveRef.current || !isVisibleRef.current) return + if (el.clientWidth <= 0 || el.clientHeight <= 0) return + fitAddon.fit() + } + /** 把快照写进这个全新的 xterm,并放行不在快照里的那段缓冲输出。 */ - const applySnapshot = (snapshot: { - data: string - seq: number - alive: boolean - exit_code?: number | null - generation?: string | null - }) => { + const applySnapshot = (snapshot: TerminalSnapshot) => { if (snapshot.alive && exitShown) { writeQueue.dispose() writeQueue = createWriteQueue((d) => terminalWrite(terminalId, d)) @@ -550,19 +548,28 @@ export function TerminalView({ exitShown = false } currentGeneration = snapshot.generation ?? null + if (snapshot.cols && snapshot.rows) { + // Replay at the size the output was laid out for. Drawn narrower, + // a full-width line wraps: zsh pads its end-of-output mark to the + // right edge, and a stray "%" line is left behind. The PTY is this + // size already, so this sends no resize; fitting to the pane does, + // once the replay below has been drawn. + lastResizeRef.current = { cols: snapshot.cols, rows: snapshot.rows } + term.resize(snapshot.cols, snapshot.rows) + } if (snapshot.data) term.write(snapshot.data) - const pendingExit = exitWhileBuffering - exitWhileBuffering = null + const pendingExits = exitsWhileBuffering + exitsWhileBuffering = [] flushReplay(snapshot.seq) - if ( - !snapshot.alive || - (pendingExit && - (!currentGeneration || - !pendingExit.generation || - pendingExit.generation === currentGeneration)) - ) { + if (!snapshot.alive || pendingExits.some(isCurrentExit)) { showExit(snapshot.exit_code) } + // xterm parses writes later, in order; this runs after all of them. + term.write("", () => { + requestAnimationFrame(() => { + if (!cancelled) fitIfReady() + }) + }) setLoading(false) } @@ -593,7 +600,7 @@ export function TerminalView({ // and never drawn — must still be flushed rather than dropped. if (!replayBuffer) { replayBuffer = [] - exitWhileBuffering = null + exitsWhileBuffering = [] } void terminalSnapshot(terminalId) .then((snapshot) => { @@ -605,6 +612,9 @@ export function TerminalView({ } term.reset() snapshotSeqFloor = 0 + // The reset wiped the exit line with everything else; an ended + // process gets it drawn again from the snapshot. + if (!snapshot.alive) exitShown = false applySnapshot(snapshot) }) .catch(() => { @@ -633,14 +643,19 @@ export function TerminalView({ !!(snapshot.alive || snapshot.exists) ) => { const deadline = Date.now() + 10_000 + let lastAnswer: TerminalSnapshot | null = null do { const snapshot = await terminalSnapshot(terminalId).catch(() => null) if (cancelled) return null if (snapshot && accept(snapshot)) return snapshot if (!retryMissing) return snapshot + if (snapshot) lastAnswer = snapshot await new Promise((resolve) => setTimeout(resolve, 150)) } while (!cancelled && Date.now() < deadline) - return null + // Out of time: the backend's last word, so a session it reported + // missing all along counts as gone, while one only ever unreachable + // stays unconfirmed. + return cancelled ? null : lastAnswer } // Read once: this attach runs under the policy it started with. @@ -745,14 +760,6 @@ export function TerminalView({ bootstrapping = false if (readyDuringStartup) syncFromBackend() - const fitIfReady = () => { - const el = containerRef.current - if (!el) return - if (!isActiveRef.current || !isVisibleRef.current) return - if (el.clientWidth <= 0 || el.clientHeight <= 0) return - fitAddon.fit() - } - // Only fit when terminal is actually visible/active. requestAnimationFrame(() => { if (!cancelled) fitIfReady() From 49386f30ce8e13bb28f51ebb929d1dbe9e26ee8b Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 18:13:29 +0800 Subject: [PATCH 10/16] fix(terminal): restore every panel tab with its title count and launcher link - No cap on how many tabs a page saves. Past 32, the rest were dropped on reload while their PTYs kept running. - Default titles resume after the highest restored "Terminal N", so a new tab never repeats a title still on screen. - A command tab stores its launcher entry's id (never the command), and a reload links the launcher back to it: a command still running there reads as running instead of offering to start a second copy. - The launcher's Stop keeps the terminal's output instead of closing the terminal under the tab it leaves open. --- .../layout/command-dropdown.test.tsx | 75 +++++++++++ src/components/layout/command-dropdown.tsx | 12 +- src/contexts/terminal-context.test.tsx | 124 +++++++++++++++++- src/contexts/terminal-context.tsx | 58 ++++++-- 4 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 src/components/layout/command-dropdown.test.tsx diff --git a/src/components/layout/command-dropdown.test.tsx b/src/components/layout/command-dropdown.test.tsx new file mode 100644 index 0000000000..8b5f0d17cc --- /dev/null +++ b/src/components/layout/command-dropdown.test.tsx @@ -0,0 +1,75 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react" +import { beforeEach, describe, expect, it, vi } from "vitest" +import { useCommandTerminalLinkStore } from "@/stores/command-terminal-link-store" +import { CommandDropdown } from "./command-dropdown" + +const h = vi.hoisted(() => ({ + stop: vi.fn(async () => {}), + kill: vi.fn(async () => {}), + create: vi.fn(async () => "launched-terminal"), + tabs: [] as { id: string }[], + t: (key: string) => key, +})) + +vi.mock("next-intl", () => ({ useTranslations: () => h.t })) +vi.mock("@/contexts/active-folder-context", () => ({ + useActiveFolder: () => ({ activeFolder: { id: 1, path: "/repo" } }), +})) +vi.mock("@/contexts/terminal-context", () => ({ + useTerminalContext: () => ({ + createTerminalWithCommand: h.create, + exitedTerminals: new Set(), + tabs: h.tabs, + }), +})) +vi.mock("@/lib/api", () => ({ + listFolderCommands: vi.fn(async () => [ + { + id: 5, + folder_id: 1, + name: "dev", + command: "pnpm dev", + sort_order: 0, + created_at: "", + updated_at: "", + }, + ]), + bootstrapFolderCommandsFromPackageJson: vi.fn(async () => []), + terminalStop: h.stop, + terminalKill: h.kill, +})) +vi.mock("./command-manage-dialog", () => ({ CommandManageDialog: () => null })) + +describe("CommandDropdown", () => { + beforeEach(() => { + h.stop.mockClear() + h.kill.mockClear() + h.create.mockClear() + h.tabs = [] + useCommandTerminalLinkStore.setState({ links: {} }) + localStorage.clear() + }) + + it("launches with the command's id, so its tab can find the launcher again", async () => { + render() + fireEvent.click(await screen.findByTitle("runCommandTitle")) + await waitFor(() => + expect(h.create).toHaveBeenCalledWith("dev", "pnpm dev", 5) + ) + expect(useCommandTerminalLinkStore.getState().links).toEqual({ + 5: "launched-terminal", + }) + }) + + it("stops a running command without closing its terminal", async () => { + // The tab stays open on what the command printed. A plain kill would + // also forget that output, and a reload of the tab would find nothing. + h.tabs = [{ id: "running-terminal" }] + useCommandTerminalLinkStore.setState({ links: { 5: "running-terminal" } }) + render() + fireEvent.click(await screen.findByTitle("stopCommandTitle")) + await waitFor(() => expect(h.stop).toHaveBeenCalledWith("running-terminal")) + expect(h.kill).not.toHaveBeenCalled() + expect(useCommandTerminalLinkStore.getState().links).toEqual({}) + }) +}) diff --git a/src/components/layout/command-dropdown.tsx b/src/components/layout/command-dropdown.tsx index 04cb18aad8..d702926aa4 100644 --- a/src/components/layout/command-dropdown.tsx +++ b/src/components/layout/command-dropdown.tsx @@ -16,7 +16,7 @@ import { useTerminalContext } from "@/contexts/terminal-context" import { bootstrapFolderCommandsFromPackageJson, listFolderCommands, - terminalKill, + terminalStop, } from "@/lib/api" import type { FolderCommand } from "@/lib/types" import { @@ -180,7 +180,11 @@ export function CommandDropdown() { return selectCommand(cmd.id) - const terminalId = await createTerminalWithCommand(cmd.name, cmd.command) + const terminalId = await createTerminalWithCommand( + cmd.name, + cmd.command, + cmd.id + ) if (!terminalId) return setLink(cmd.id, terminalId) @@ -201,7 +205,9 @@ export function CommandDropdown() { clearLink(cmd.id) try { - await terminalKill(terminalId) + // The tab stays open with what the command printed, so the backend + // keeps that output for it — a reload of the tab still shows it. + await terminalStop(terminalId) } catch (err) { console.error("Failed to stop command terminal:", err) } diff --git a/src/contexts/terminal-context.test.tsx b/src/contexts/terminal-context.test.tsx index a7dd7002f8..a1dcce769f 100644 --- a/src/contexts/terminal-context.test.tsx +++ b/src/contexts/terminal-context.test.tsx @@ -1,6 +1,7 @@ import { fireEvent, render, screen } from "@testing-library/react" import { beforeEach, describe, expect, it, vi } from "vitest" import { TerminalProvider, useTerminalContext } from "./terminal-context" +import { useCommandTerminalLinkStore } from "@/stores/command-terminal-link-store" const h = vi.hoisted(() => ({ terminalKill: vi.fn(async () => {}), @@ -47,7 +48,7 @@ function Probe() {
+ + + {terminal.tabs.length} {terminal.tabs.map((tab) => tab.title.length).join(",")} @@ -100,6 +130,9 @@ function Probe() { {terminal.tabs[0]?.initialCommand ?? ""} + + {[...terminal.exitedTerminals].join(",")} +
) } @@ -107,6 +140,16 @@ function Probe() { describe("TerminalProvider reload recovery", () => { beforeEach(() => { h.terminalKill.mockClear() + h.terminalSnapshot.mockReset() + h.terminalSnapshot.mockResolvedValue({ + exists: false, + alive: false, + data: "", + seq: 0, + }) + h.listeners.clear() + h.holdSubscribe = false + h.grants.length = 0 h.remoteId = null h.windowLabel = "main" h.activeFolderId = 7 @@ -433,4 +476,174 @@ describe("TerminalProvider reload recovery", () => { expect(screen.getByTestId("tabs")).toHaveTextContent("1") expect(useCommandTerminalLinkStore.getState().links).toEqual({}) }) + + function storeCommandTab(id: string) { + const pageId = "0b6f3d7e-5a1c-4e2b-9f8a-1c2d3e4f5a6b" + window.name = `codeg-terminal-page:${pageId}` + sessionStorage.setItem( + "codeg:terminal-session:v1", + JSON.stringify({ + version: 1, + scope: JSON.stringify(["main", null]), + pageId, + isOpen: false, + activeTabId: id, + tabs: [ + { id, folderId: 7, title: "dev", workingDir: "/tmp", commandId: 42 }, + ], + }) + ) + } + + it("marks a command tab that ended before the reload as exited, with no view", async () => { + // The launcher reads `exitedTerminals`. A closed mobile drawer keeps the + // tab's view unmounted, and the exit itself came before this page did. + const id = "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098" + storeCommandTab(id) + h.terminalSnapshot.mockResolvedValue({ + exists: true, + alive: false, + data: "done", + seq: 1, + }) + render( + + + + ) + await waitFor(() => + expect(screen.getByTestId("exited")).toHaveTextContent(id) + ) + expect(h.terminalSnapshot).toHaveBeenCalledWith(id) + }) + + it("leaves a restored command tab the backend has no record of to its view", async () => { + // Missing can mean the reload overtook its launch. The view waits that + // out before calling it gone; the provider must not decide sooner. + const id = "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098" + storeCommandTab(id) + render( + + + + ) + await waitFor(() => expect(h.terminalSnapshot).toHaveBeenCalledWith(id)) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(screen.getByTestId("exited")).toBeEmptyDOMElement() + }) + + it("hears a command tab's exit with no view mounted", async () => { + render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "Run" })) + const id = screen.getByTestId("active").textContent + await waitFor(() => + expect(h.listeners.has(`terminal://exit/${id}`)).toBe(true) + ) + act(() => { + h.listeners.get(`terminal://exit/${id}`)?.() + }) + expect(screen.getByTestId("exited")).toHaveTextContent(id!) + // Not asked about: it started in this page, which hears its exit. + expect(h.terminalSnapshot).not.toHaveBeenCalled() + }) + + it("asks about a restored command tab only once it is listening for its exit", async () => { + // Asked first, an exit between the answer and the listener would be + // heard by nothing. + const id = "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098" + storeCommandTab(id) + h.holdSubscribe = true + render( + + + + ) + await waitFor(() => expect(h.grants).toHaveLength(1)) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(h.terminalSnapshot).not.toHaveBeenCalled() + await act(async () => { + h.grants[0]() + }) + await waitFor(() => expect(h.terminalSnapshot).toHaveBeenCalledWith(id)) + }) + + it("restores tabs whatever their stored directory or shell", () => { + // Neither ever launches anything for a restored tab, so no bound on them + // protects a thing — while each one cost a tab its way back. + const pageId = "0b6f3d7e-5a1c-4e2b-9f8a-1c2d3e4f5a6b" + window.name = `codeg-terminal-page:${pageId}` + const ids = [ + "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098", + "4e3d2c1b-0a9f-4e8d-b7c6-d5e4f3a2b109", + "5f4e3d2c-1b0a-4f9e-8d7c-b6a5f4e3d2c1", + ] + sessionStorage.setItem( + "codeg:terminal-session:v1", + JSON.stringify({ + version: 1, + scope: JSON.stringify(["main", null]), + pageId, + isOpen: true, + activeTabId: ids[0], + tabs: [ + { + id: ids[0], + folderId: 7, + title: "long shell", + workingDir: "/tmp", + shell: `/nix/store/${"a".repeat(600)}/bin/zsh`, + }, + { + id: ids[1], + folderId: 7, + title: "extended path", + workingDir: `\\\\?\\C:\\${"d\\".repeat(2500)}`, + }, + { + id: ids[2], + folderId: 7, + title: "relative path", + workingDir: "project", + }, + ], + }) + ) + render( + + + + ) + expect(screen.getByTestId("tabs")).toHaveTextContent("3") + }) + + it("does not count on from a restored number too large to count exactly", () => { + const first = render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "New" })) + act(() => { + fireEvent.click(screen.getByRole("button", { name: "Rename huge" })) + }) + first.unmount() + + render( + + + + ) + fireEvent.click(screen.getByRole("button", { name: "New" })) + fireEvent.click(screen.getByRole("button", { name: "New" })) + const titles = screen.getByTestId("title-text").textContent!.split("|") + expect(new Set(titles).size).toBe(titles.length) + }) }) diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index 8ea516ca1f..0feeae69f7 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -11,7 +11,11 @@ import { useState, type ReactNode, } from "react" -import { getSystemTerminalSettings, terminalKill } from "@/lib/api" +import { + getSystemTerminalSettings, + terminalKill, + terminalSnapshot, +} from "@/lib/api" import { getActiveRemoteConnectionId, getTransport } from "@/lib/transport" import { randomUUID } from "@/lib/utils" import { getCurrentWindowLabel } from "@/lib/browser/window-label" @@ -40,7 +44,9 @@ const TERMINAL_SETTINGS_UPDATED_EVENT = "app://terminal-settings-updated" const TERMINAL_SESSION_KEY = "codeg:terminal-session:v1" const PAGE_NAME_PREFIX = "codeg-terminal-page:" const MAX_STORED_TITLE_LENGTH = 256 -const DEFAULT_TITLE = /^Terminal (\d+)$/ +// Up to 15 digits: counting on from any of them stays exact for as many tabs +// as a page will ever open, so no default title can come out twice. +const DEFAULT_TITLE = /^Terminal (\d{1,15})$/ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -64,6 +70,8 @@ interface StoredTerminalSession { * Whether one stored tab may come back. Judged per tab, so an entry the reader * rejects costs that tab alone rather than every other tab's way back to its * PTY — and every bound here must admit whatever the writer below persists. + * `workingDir` and `shell` only ever launch a tab's first process, and a + * restored tab never launches one, so any string the writer kept will do. */ function isRestorableTab(tab: unknown): tab is StoredTerminalTab { if (typeof tab !== "object" || tab === null) return false @@ -81,10 +89,7 @@ function isRestorableTab(tab: unknown): tab is StoredTerminalTab { // Any length: it is only a label, clamped on the way in and out. typeof title === "string" && typeof workingDir === "string" && - workingDir.length > 0 && - workingDir.length <= 4096 && - /^(\/|[A-Za-z]:[\\/]|\\\\)/.test(workingDir) && - (shell === undefined || (typeof shell === "string" && shell.length <= 512)) + (shell === undefined || typeof shell === "string") ) } @@ -99,7 +104,7 @@ function restoredTabCounter(tabs: TerminalTab[]): number { let counter = tabs.length for (const { title } of tabs) { const n = Number(DEFAULT_TITLE.exec(title)?.[1]) - if (Number.isSafeInteger(n)) counter = Math.max(counter, n) + if (n) counter = Math.max(counter, n) } return counter } @@ -264,16 +269,6 @@ export function TerminalProvider({ children }: { children: ReactNode }) { } }, [restoredSession, isOpen, activeTabId, tabs]) - // The launcher's command↔terminal links live in memory. Rebuilt from the - // restored tabs, a command still running in one reads as running, rather - // than offering to start a second copy beside it. - useEffect(() => { - const { setLink } = useCommandTerminalLinkStore.getState() - for (const tab of restoredSession.tabs) { - if (tab.commandId !== undefined) setLink(tab.commandId, tab.id) - } - }, [restoredSession]) - const folderPath = activeFolder?.path ?? "" const currentFolderId = activeFolderId ?? 0 const resolveTerminalShell = useCallback( @@ -343,6 +338,61 @@ export function TerminalProvider({ children }: { children: ReactNode }) { }) }, []) + // The launcher's command↔terminal links live in memory. Rebuilt from the + // restored tabs, a command still running in one reads as running, rather + // than offering to start a second copy beside it. + useEffect(() => { + const { setLink } = useCommandTerminalLinkStore.getState() + for (const tab of restoredSession.tabs) { + if (tab.commandId !== undefined) setLink(tab.commandId, tab.id) + } + }, [restoredSession]) + + // A tab's view reports its exit, but views unmount (a closed mobile drawer) + // and a reload never hears an exit from before it. The launcher tells a + // running command from an ended one by `exitedTerminals`, so command tabs + // are watched here, mounted or not. + const uncheckedRestoredRef = useRef( + new Set( + restoredSession.tabs + .filter((tab) => tab.commandId !== undefined) + .map((tab) => tab.id) + ) + ) + const commandTabIds = tabs + .filter((tab) => tab.commandId !== undefined) + .map((tab) => tab.id) + .join(" ") + useEffect(() => { + if (!commandTabIds) return + const unchecked = uncheckedRestoredRef.current + let disposed = false + const unlisteners: (() => void)[] = [] + for (const id of commandTabIds.split(" ")) { + getTransport() + .subscribe(`terminal://exit/${id}`, () => { + if (!disposed) markTerminalExited(id) + }) + .then(async (unlisten) => { + if (disposed) return unlisten() + unlisteners.push(unlisten) + // Asked only once listening, so an end in between is heard anyway. + // A missing one is left to its view, which waits out a launch the + // reload may have overtaken. + if (!unchecked.has(id)) return + const snapshot = await terminalSnapshot(id) + if (disposed) return + unchecked.delete(id) + if (snapshot.exists && !snapshot.alive) markTerminalExited(id) + }) + .catch(() => {}) + } + return () => { + disposed = true + for (const unlisten of unlisteners) unlisten() + } + }, [commandTabIds, markTerminalExited]) + const removeExitedTerminals = useCallback((ids: string[]) => { setExitedTerminals((prev) => { if (prev.size === 0) return prev From 8c053003c28e1305530c55ea02dec7e31d8e5043 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 19:07:05 +0800 Subject: [PATCH 14/16] fix(terminal): close a stopped terminal's input only once it has died Stop closed the PTY's input before ending the process, and closing it sends a newline and an end-of-input. A live program got an Enter just before it died (a prompt would take its default), and the echoed "^D" ended up in the output kept for the tab. The input is now closed only after the kill and the wait, as a close always did. A cancellation mark is also kept regardless of age while its launch is underway: that launch checks for the mark once more just before it inserts. The cancellation tests now assert the refusal itself, not just any spawn error. --- src-tauri/src/terminal/manager.rs | 99 ++++++++++++++++++++++++++----- 1 file changed, 83 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index 567ed441b1..98cdd252b6 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -118,12 +118,18 @@ struct CompletedTerminal { } impl CompletedTerminal { - /// The record of `instance`, and its child — which the caller either - /// keeps with the record or ends itself. + /// The record of `instance`, plus what the record does not keep: its + /// child, which the caller keeps with the record or ends itself, and its + /// input. Dropping the input sends the PTY a newline and end-of-input (see + /// portable-pty's writer), so the caller decides when that happens. fn from_instance( mut instance: TerminalInstance, exit_code: Option, - ) -> (Self, Box) { + ) -> ( + Self, + Box, + mpsc::Sender>, + ) { cleanup_temp_files(&mut instance.temp_files); let entry = Self { size: instance.master.get_size().ok(), @@ -133,7 +139,7 @@ impl CompletedTerminal { child: None, exit_code, }; - (entry, instance._child) + (entry, instance._child, instance.write_tx) } } @@ -151,23 +157,24 @@ impl CompletedTerminals { /// Record a terminal whose process has ended, or may have: its child /// stays with the record, to be reaped once it exits. fn insert(&mut self, id: String, instance: TerminalInstance, exit_code: Option) { - let (mut entry, child) = CompletedTerminal::from_instance(instance, exit_code); + let (mut entry, child, _input) = CompletedTerminal::from_instance(instance, exit_code); entry.child = Some(child); self.remove(&id); self.entries.insert(id, entry); } - /// Record a terminal the caller is about to end, handing its child back - /// so the kill and the wait for it happen outside this table's lock. + /// Record a terminal the caller is about to end, handing back its child + /// and its input: the kill and the wait happen outside this table's + /// lock, and the input is to be dropped only once the process is gone. fn insert_ending( &mut self, id: String, instance: TerminalInstance, - ) -> Box { - let (entry, child) = CompletedTerminal::from_instance(instance, None); + ) -> (Box, mpsc::Sender>) { + let (entry, child, input) = CompletedTerminal::from_instance(instance, None); self.remove(&id); self.entries.insert(id, entry); - child + (child, input) } fn remove(&mut self, id: &str) -> bool { @@ -705,11 +712,16 @@ impl TerminalManager { if keep_record { // Recorded before the table lock is released, so no snapshot // finds this id in neither table while the process dies. - let mut child = lock_completed(&self.completed) + let (mut child, input) = lock_completed(&self.completed) .insert_ending(terminal_id.to_string(), instance); drop(terminals); let _ = child.kill(); let _ = child.wait(); + // Only now: closed earlier, the input hands a live program an + // Enter and an end-of-input before it dies (a prompt would + // take its default), and the echoed "^D" ends up in the + // output kept for the tab. + drop(input); } else { lock_completed(&self.completed).remove(terminal_id); drop(terminals); @@ -734,12 +746,17 @@ impl TerminalManager { // The later insert consumes it atomically under the terminal map // lock; ordinary view unmount never calls kill. if uuid::Uuid::parse_str(terminal_id).is_ok() { + let spawning = self.spawning.lock().unwrap_or_else(|p| p.into_inner()); let mut pending = self .cancelled_spawns .lock() .unwrap_or_else(|p| p.into_inner()); let now = Instant::now(); - pending.retain(|_, time| now.duration_since(*time) < CANCELLED_SPAWN_TTL); + // A mark whose launch is already underway is kept regardless of + // age: that launch checks it again just before it inserts. + pending.retain(|id, time| { + spawning.contains_key(id) || now.duration_since(*time) < CANCELLED_SPAWN_TTL + }); pending.insert(terminal_id.to_string(), now); return Ok(()); } @@ -1278,10 +1295,14 @@ mod tests { (-1, Some(libc::ECHILD)), "stop left its process running or unreaped" ); + // The input is closed only after that, so no Enter and end-of-input + // reached the process, and no echoed "^D" reached the kept output. + std::thread::sleep(Duration::from_millis(300)); let stopped = manager.snapshot(id); assert!(stopped.exists, "the stop forgot the terminal"); assert!(!stopped.alive); assert!(stopped.data.contains("stop-marker")); + assert!(!stopped.data.contains("^D"), "{:?}", stopped.data); // Ended by us, not by itself: no exit status to report. assert_eq!(stopped.exit_code, None); assert!(super::lock_terminals(&manager.terminals).is_empty()); @@ -1304,12 +1325,57 @@ mod tests { .kill(&uuid::Uuid::new_v4().to_string()) .expect("other closes"); } - assert!(manager + let refused = manager .spawn_with_id(sleeping_shell(&first, "sleep 30"), EventEmitter::Noop) - .is_err()); + .expect_err("the closed tab's launch"); + assert!(refused.to_string().contains("cancelled"), "{refused}"); assert!(!manager.snapshot(&first).exists); } + #[cfg(not(target_os = "windows"))] + #[test] + fn a_launch_underway_keeps_its_mark_however_old() { + // The launch checks for a mark once more just before it inserts. + // Pruning by age must not take the mark out from under it then. + use std::time::{Duration, Instant}; + + let manager = TerminalManager::new(); + let launching = uuid::Uuid::new_v4().to_string(); + let ages_ago = Instant::now() + .checked_sub(super::CANCELLED_SPAWN_TTL + Duration::from_secs(1)) + .expect("a past instant"); + manager + .spawning + .lock() + .unwrap() + .insert(launching.clone(), "main".to_string()); + manager + .cancelled_spawns + .lock() + .unwrap() + .insert(launching.clone(), ages_ago); + + manager + .kill(&uuid::Uuid::new_v4().to_string()) + .expect("prune"); + assert!(manager + .cancelled_spawns + .lock() + .unwrap() + .contains_key(&launching)); + + // Once nothing is launching under that id, age applies again. + manager.spawning.lock().unwrap().remove(&launching); + manager + .kill(&uuid::Uuid::new_v4().to_string()) + .expect("prune"); + assert!(!manager + .cancelled_spawns + .lock() + .unwrap() + .contains_key(&launching)); + } + #[cfg(not(target_os = "windows"))] #[test] fn a_snapshot_reports_the_size_its_output_was_drawn_for() { @@ -1511,9 +1577,10 @@ mod tests { temp_files: vec![], }; manager.kill(&id).expect("close overtakes spawn"); - assert!(manager + let refused = manager .spawn_with_id(opts(&id), EventEmitter::Noop) - .is_err()); + .expect_err("the closed tab's launch"); + assert!(refused.to_string().contains("cancelled"), "{refused}"); assert!(!manager.snapshot(&id).exists); // Reservation is consumed. An intentional later new tab may use the ID. manager From eb4dd1eb6bfce7411d46df90caaa840ed1cba8dd Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 19:07:05 +0800 Subject: [PATCH 15/16] fix(terminal): count default titles exactly and recheck restored command tabs - The default-title counter is a bigint, so counting on from any restored "Terminal N" stays exact. A digit limit only moved the repeat: a title past it was ignored on the next reload and generated again. - When asking whether a restored command tab ended before the reload fails, it is asked again after the next reconnect instead of leaving the launcher showing Stop. --- src/contexts/terminal-context.test.tsx | 106 ++++++++++++++++++++++--- src/contexts/terminal-context.tsx | 61 +++++++++----- 2 files changed, 132 insertions(+), 35 deletions(-) diff --git a/src/contexts/terminal-context.test.tsx b/src/contexts/terminal-context.test.tsx index 4343dd65b3..d2ca0f4c02 100644 --- a/src/contexts/terminal-context.test.tsx +++ b/src/contexts/terminal-context.test.tsx @@ -10,6 +10,7 @@ const h = vi.hoisted(() => ({ // While set, exit subscriptions wait to be granted (see `grant`). holdSubscribe: false, grants: [] as (() => void)[], + ready: null as (() => void) | null, remoteId: null as number | null, windowLabel: "main", activeFolderId: 7 as number | null, @@ -34,6 +35,12 @@ vi.mock("@/lib/transport", () => ({ h.grants.push(() => resolve(unlisten)) }) }, + onReady: (callback: () => void) => { + h.ready = callback + return () => { + h.ready = null + } + }, }), getActiveRemoteConnectionId: () => h.remoteId, })) @@ -119,6 +126,28 @@ function Probe() { > Rename huge + + {terminal.tabs.length} {terminal.tabs.map((tab) => tab.title.length).join(",")} @@ -150,6 +179,7 @@ describe("TerminalProvider reload recovery", () => { h.listeners.clear() h.holdSubscribe = false h.grants.length = 0 + h.ready = null h.remoteId = null h.windowLabel = "main" h.activeFolderId = 7 @@ -624,26 +654,76 @@ describe("TerminalProvider reload recovery", () => { expect(screen.getByTestId("tabs")).toHaveTextContent("3") }) - it("does not count on from a restored number too large to count exactly", () => { - const first = render( - - - + it("counts on exactly from a restored number of any size", () => { + const renderProvider = () => + render( + + + + ) + const titles = () => screen.getByTestId("title-text").textContent + // A long number restored, counted on from, and restored again. + let view = renderProvider() + fireEvent.click(screen.getByRole("button", { name: "New" })) + fireEvent.click(screen.getByRole("button", { name: "Rename 15 digits" })) + view.unmount() + view = renderProvider() + fireEvent.click(screen.getByRole("button", { name: "New" })) + view.unmount() + view = renderProvider() + fireEvent.click(screen.getByRole("button", { name: "New" })) + expect(titles()).toBe( + "Terminal 999999999999999|Terminal 1000000000000000|Terminal 1000000000000001" ) + + // Around the largest exact double: one restored number just below it, + // one past it. + fireEvent.click(screen.getByRole("button", { name: "Rename huge" })) fireEvent.click(screen.getByRole("button", { name: "New" })) - act(() => { - fireEvent.click(screen.getByRole("button", { name: "Rename huge" })) - }) - first.unmount() + fireEvent.click(screen.getByRole("button", { name: "Rename beyond" })) + view.unmount() + renderProvider() + fireEvent.click(screen.getByRole("button", { name: "New" })) + fireEvent.click(screen.getByRole("button", { name: "New" })) + expect(titles()).toBe( + [ + "Terminal 999999999999999", + "Terminal 1000000000000000", + "Terminal 9007199254740991", + "Terminal 9007199254740993", + "Terminal 9007199254740994", + "Terminal 9007199254740995", + ].join("|") + ) + }) + it("asks again after a reconnect when asking about a restored command tab failed", async () => { + const id = "3f2c1b0a-9e8d-4c7b-a6f5-e4d3c2b1a098" + storeCommandTab(id) + h.terminalSnapshot.mockRejectedValueOnce(new Error("offline")) + h.terminalSnapshot.mockResolvedValue({ + exists: true, + alive: false, + data: "done", + seq: 1, + }) render( ) - fireEvent.click(screen.getByRole("button", { name: "New" })) - fireEvent.click(screen.getByRole("button", { name: "New" })) - const titles = screen.getByTestId("title-text").textContent!.split("|") - expect(new Set(titles).size).toBe(titles.length) + await waitFor(() => expect(h.terminalSnapshot).toHaveBeenCalledTimes(1)) + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 20)) + }) + expect(screen.getByTestId("exited")).toBeEmptyDOMElement() + + act(() => { + h.ready?.() + }) + await waitFor(() => + expect(screen.getByTestId("exited")).toHaveTextContent(id) + ) + expect(h.terminalSnapshot).toHaveBeenCalledTimes(2) }) }) diff --git a/src/contexts/terminal-context.tsx b/src/contexts/terminal-context.tsx index 0feeae69f7..e9544468d7 100644 --- a/src/contexts/terminal-context.tsx +++ b/src/contexts/terminal-context.tsx @@ -44,9 +44,7 @@ const TERMINAL_SETTINGS_UPDATED_EVENT = "app://terminal-settings-updated" const TERMINAL_SESSION_KEY = "codeg:terminal-session:v1" const PAGE_NAME_PREFIX = "codeg-terminal-page:" const MAX_STORED_TITLE_LENGTH = 256 -// Up to 15 digits: counting on from any of them stays exact for as many tabs -// as a page will ever open, so no default title can come out twice. -const DEFAULT_TITLE = /^Terminal (\d{1,15})$/ +const DEFAULT_TITLE = /^Terminal (\d+)$/ const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i @@ -99,12 +97,18 @@ function storedCommandId(value: unknown): number | undefined { : undefined } -/** Where default titles resume, so a new tab never repeats one still shown. */ -function restoredTabCounter(tabs: TerminalTab[]): number { - let counter = tabs.length +/** + * Where default titles resume, so a new tab never repeats one still shown. + * A bigint: a restored title can carry any number, and counting on from it + * must stay exact however large it is. + */ +function restoredTabCounter(tabs: TerminalTab[]): bigint { + let counter = BigInt(tabs.length) for (const { title } of tabs) { - const n = Number(DEFAULT_TITLE.exec(title)?.[1]) - if (n) counter = Math.max(counter, n) + const digits = DEFAULT_TITLE.exec(title)?.[1] + if (digits !== undefined && BigInt(digits) > counter) { + counter = BigInt(digits) + } } return counter } @@ -367,28 +371,41 @@ export function TerminalProvider({ children }: { children: ReactNode }) { if (!commandTabIds) return const unchecked = uncheckedRestoredRef.current let disposed = false + const listening: string[] = [] const unlisteners: (() => void)[] = [] + // Asked only once listening, so an end in between is heard anyway, and + // asked again after a reconnect if the asking failed. A missing one is + // left to its view, which waits out a launch the reload may have + // overtaken. + const check = (id: string) => { + if (!unchecked.has(id)) return + terminalSnapshot(id) + .then((snapshot) => { + if (disposed) return + unchecked.delete(id) + if (snapshot.exists && !snapshot.alive) markTerminalExited(id) + }) + .catch(() => {}) + } for (const id of commandTabIds.split(" ")) { getTransport() .subscribe(`terminal://exit/${id}`, () => { if (!disposed) markTerminalExited(id) }) - .then(async (unlisten) => { + .then((unlisten) => { if (disposed) return unlisten() unlisteners.push(unlisten) - // Asked only once listening, so an end in between is heard anyway. - // A missing one is left to its view, which waits out a launch the - // reload may have overtaken. - if (!unchecked.has(id)) return - const snapshot = await terminalSnapshot(id) - if (disposed) return - unchecked.delete(id) - if (snapshot.exists && !snapshot.alive) markTerminalExited(id) + listening.push(id) + check(id) }) .catch(() => {}) } + const unlistenReady = getTransport().onReady?.(() => { + for (const id of listening) check(id) + }) return () => { disposed = true + unlistenReady?.() for (const unlisten of unlisteners) unlisten() } }, [commandTabIds, markTerminalExited]) @@ -413,7 +430,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { const toggle = useCallback(() => { const autoId = randomUUID() - const nextCounter = tabCounterRef.current + 1 + const nextCounter = tabCounterRef.current + 1n setIsOpen((wasOpen) => !wasOpen) @@ -446,7 +463,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { setIsOpen(true) const id = randomUUID() - tabCounterRef.current += 1 + tabCounterRef.current += 1n setTabs((prev) => [ ...prev, { @@ -473,7 +490,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { setIsOpen(true) const id = randomUUID() - tabCounterRef.current += 1 + tabCounterRef.current += 1n const defaultTitle = `Terminal ${tabCounterRef.current}` setTabs((prev) => [ ...prev, @@ -509,7 +526,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { setTabs((prev) => { const next = prev.filter((t) => t.id !== id) if (next.length === 0) { - tabCounterRef.current = 0 + tabCounterRef.current = 0n setIsOpen(false) setActiveTabId(null) } else { @@ -542,7 +559,7 @@ export function TerminalProvider({ children }: { children: ReactNode }) { removeExitedTerminals(prev.map((t) => t.id)) return [] }) - tabCounterRef.current = 0 + tabCounterRef.current = 0n setActiveTabId(null) setIsOpen(false) }, [killTerminalTabs, removeExitedTerminals]) From a98d7b5987710b3548a70094b6579925ca270def Mon Sep 17 00:00:00 2001 From: xintaofei Date: Fri, 9 Oct 2026 19:17:31 +0800 Subject: [PATCH 16/16] test(terminal): wait for the reader before checking a stop's kept output The check that a stop leaves no echoed "^D" in the kept output waited a fixed 300 ms, so a slow reader could hide the echo and let the check pass on a regression. It now waits for the terminal's exit event, which the reader sends only once it has read everything it ever will. --- src-tauri/src/terminal/manager.rs | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/terminal/manager.rs b/src-tauri/src/terminal/manager.rs index 98cdd252b6..c9bbc32d2f 100644 --- a/src-tauri/src/terminal/manager.rs +++ b/src-tauri/src/terminal/manager.rs @@ -1258,27 +1258,30 @@ mod tests { } #[cfg(not(target_os = "windows"))] - #[test] - fn a_stopped_terminal_keeps_its_output_until_it_is_closed() { + #[tokio::test] + async fn a_stopped_terminal_keeps_its_output_until_it_is_closed() { // The command launcher's Stop ends the process but leaves the tab // open, so a reload of that tab must find the output it showed — // not "unavailable", which is what forgetting the terminal leaves. // The command ignores SIGHUP, so ending it takes the escalation to // SIGKILL, and then a wait to reap what that killed. + use crate::web::event_bridge::WebEventBroadcaster; use std::time::{Duration, Instant}; + let broadcaster = Arc::new(WebEventBroadcaster::new()); + let mut events = broadcaster.subscribe(); let manager = TerminalManager::new(); let id = "stopped-output"; manager .spawn_with_id( sleeping_shell(id, "trap '' HUP; printf 'stop-marker\\n'; exec sleep 30"), - EventEmitter::Noop, + EventEmitter::test_web_only(broadcaster), ) .expect("spawn"); let deadline = Instant::now() + Duration::from_secs(10); while !manager.snapshot(id).data.contains("stop-marker") { assert!(Instant::now() < deadline, "no output to keep"); - std::thread::sleep(Duration::from_millis(20)); + tokio::time::sleep(Duration::from_millis(20)).await; } let pid = super::lock_terminals(&manager.terminals) @@ -1297,7 +1300,19 @@ mod tests { ); // The input is closed only after that, so no Enter and end-of-input // reached the process, and no echoed "^D" reached the kept output. - std::thread::sleep(Duration::from_millis(300)); + // The reader reports the exit once it has read all it ever will. + let exit_channel = format!("terminal://exit/{id}"); + tokio::time::timeout(Duration::from_secs(10), async { + loop { + match events.recv().await { + Ok(event) if event.channel == exit_channel => return, + Ok(_) | Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => {} + Err(err) => panic!("event bus: {err}"), + } + } + }) + .await + .expect("the reader's exit"); let stopped = manager.snapshot(id); assert!(stopped.exists, "the stop forgot the terminal"); assert!(!stopped.alive);