Merge branch 'master' into auto-issue-5478-8692ae

This commit is contained in:
JJ Liebig
2026-10-23 19:17:55 +02:00
committed by GitHub
25 changed files with 704 additions and 58 deletions
Generated
+1
View File
@@ -801,6 +801,7 @@ dependencies = [
"serde_ignored",
"serde_json",
"sha2",
"signal-hook",
"time",
"tokio",
"toml",
+3
View File
@@ -57,6 +57,9 @@ schemars = { version = "1.2.1", features = ["derive"] }
[patch.crates-io]
portable-pty = { path = "vendor/portable-pty" }
[target.'cfg(unix)'.dependencies]
signal-hook = "0.3"
[target.'cfg(target_os = "linux")'.dependencies]
# logind's delay inhibitor lets the server save before host shutdown kills panes.
zbus = { version = "5.19", default-features = false, features = ["tokio"] }
+1 -1
View File
@@ -270,7 +270,7 @@ impl App {
cwd,
self.state.pane_scrollback_limit_bytes,
host_terminal_theme,
self.state.host_terminal_appearance,
self.pane_host_terminal_appearance(),
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
&launch_env,
self.event_tx.clone(),
+5 -2
View File
@@ -272,7 +272,10 @@ impl App {
AppEvent::PaneDied {
pane_id,
exit_reason,
} if exit_reason.requires_session_checkpoint() && self.find_pane(*pane_id).is_some() && !self.overlay_panes.contains_key(pane_id)
} if (exit_reason.requires_session_checkpoint()
|| (*exit_reason == crate::platform::ChildExitReason::Exited
&& (self.server_hangup_probe)()))
&& self.find_pane(*pane_id).is_some() && !self.overlay_panes.contains_key(pane_id)
);
if checkpointed_pane_exit {
self.checkpoint_session_before_pane_exit();
@@ -611,7 +614,7 @@ impl App {
cwd,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
self.pane_host_terminal_appearance(),
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
&launch_env,
self.event_tx.clone(),
+2 -2
View File
@@ -99,7 +99,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let extra_env = match super::env::normalize_launch_env(root_leaf.env.clone()) {
Ok(env) => env,
Err((code, message)) => return encode_error(id, &code, message),
@@ -415,7 +415,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let cwd = pane
.cwd
.as_ref()
+1 -1
View File
@@ -71,7 +71,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let previous_focus = self.state.current_pane_focus_target();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return encode_error(id, "pane_not_found", "pane not found");
+4 -2
View File
@@ -127,6 +127,7 @@ impl App {
};
let (rows, cols) = self.state.new_pane_size(new_pane_placement);
let previous_focus = self.state.current_pane_focus_target();
let host_terminal_appearance = self.pane_host_terminal_appearance();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return encode_error(id, "workspace_not_found", "workspace not found");
};
@@ -140,7 +141,7 @@ impl App {
extra_env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
params.focus || placement == PluginPanePlacement::Zoomed,
);
let (tab_idx, new_pane) = match result {
@@ -206,6 +207,7 @@ impl App {
Err((code, message)) => return encode_error(id, &code, message),
};
let (rows, cols) = self.state.new_pane_size(crate::ui::NewPanePlacement::Alone);
let host_terminal_appearance = self.pane_host_terminal_appearance();
let Some(ws) = self.state.workspaces.get_mut(ws_idx) else {
return encode_error(id, "workspace_not_found", "workspace not found");
};
@@ -217,7 +219,7 @@ impl App {
extra_env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
) {
Ok(result) => result,
Err(err) => return encode_error(id, "plugin_pane_open_failed", err.to_string()),
+1 -1
View File
@@ -69,7 +69,7 @@ impl App {
let default_shell = self.state.default_shell.clone();
let scrollback_limit_bytes = self.state.pane_scrollback_limit_bytes;
let host_terminal_theme = self.state.host_terminal_theme;
let host_terminal_appearance = self.state.host_terminal_appearance;
let host_terminal_appearance = self.pane_host_terminal_appearance();
let extra_env = match super::env::normalize_launch_env(env) {
Ok(env) => env,
Err((code, message)) => return encode_error(id, &code, message),
+1 -1
View File
@@ -136,7 +136,7 @@ impl App {
cols,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
self.pane_host_terminal_appearance(),
crate::pane::PaneShellConfig::new(&self.state.default_shell, self.state.shell_mode),
self.event_tx.clone(),
self.render_notify.clone(),
+4 -2
View File
@@ -373,6 +373,7 @@ impl App {
.state
.new_pane_size(crate::ui::NewPanePlacement::ZoomedOverlay);
let (env, _) = self.custom_command_env();
let host_terminal_appearance = self.pane_host_terminal_appearance();
let ws = self
.state
@@ -400,7 +401,7 @@ impl App {
env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
)?;
let new_pane_id = new_pane.pane_id;
self.terminal_runtimes
@@ -470,6 +471,7 @@ impl App {
})
});
let host_terminal_appearance = self.pane_host_terminal_appearance();
let (tab_idx, new_pane, workspace_id) = {
let ws = self
.state
@@ -487,7 +489,7 @@ impl App {
extra_env,
self.state.pane_scrollback_limit_bytes,
self.state.host_terminal_theme,
self.state.host_terminal_appearance,
host_terminal_appearance,
true,
);
let (tab_idx, new_pane) = match result {
+82 -1
View File
@@ -140,12 +140,15 @@ pub struct App {
pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig,
pub(crate) agent_metadata_deadline: Option<Instant>,
pub(crate) pending_agent_resume_deadline: Option<Instant>,
pub(crate) pending_host_appearance_deadline: Option<Instant>,
pane_host_appearance: Option<crate::terminal_theme::HostAppearance>,
startup_per_agent_delay: Duration,
next_agent_resume_at: Option<Instant>,
pub(crate) session_save_deadline: Option<Instant>,
pub(crate) session_save_thread: Option<std::thread::JoinHandle<()>>,
session_writer: Arc<std::sync::Mutex<crate::persist::SessionWriter>>,
pane_exit_checkpoint_pending: bool,
server_hangup_probe: fn() -> bool,
pub(crate) detached_process_children: Vec<std::process::Child>,
tab_bar_status_generation: u64,
tab_bar_datetimes: Vec<tab_bar_status::TabBarDatetimeRuntime>,
@@ -651,6 +654,8 @@ impl App {
loaded_host_cursor: config.ui.host_cursor,
agent_metadata_deadline: None,
pending_agent_resume_deadline: None,
pending_host_appearance_deadline: None,
pane_host_appearance: None,
startup_per_agent_delay: Duration::from_millis(
config.session.startup_per_agent_delay_ms.into(),
),
@@ -659,6 +664,11 @@ impl App {
session_save_thread: None,
session_writer,
pane_exit_checkpoint_pending: false,
#[cfg(not(test))]
server_hangup_probe: crate::platform::server_hangup_is_recent,
// Real SIGHUP evidence is process-global; tests opt in per App.
#[cfg(test)]
server_hangup_probe: || false,
detached_process_children: Vec::new(),
tab_bar_status_generation: 0,
tab_bar_datetimes: Vec::new(),
@@ -3300,7 +3310,7 @@ mod tests {
}
#[test]
fn normal_autosave_replaces_a_signaled_exit_checkpoint() {
fn normal_autosave_preserves_a_signaled_exit_checkpoint() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let config_home = unique_temp_path("signaled-pane-autosave");
std::env::set_var("XDG_CONFIG_HOME", &config_home);
@@ -3326,12 +3336,83 @@ mod tests {
}
app.save_session_on_shutdown();
assert!(crate::persist::load().is_some());
std::env::remove_var("XDG_CONFIG_HOME");
let _ = std::fs::remove_dir_all(config_home);
}
#[test]
fn normal_last_pane_exit_clears_saved_session() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let config_home = unique_temp_path("normal-last-pane-exit");
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::remove_var(crate::session::SESSION_ENV_VAR);
let mut app = test_app();
app.policy.persist_session = true;
let workspace = Workspace::test_new("closed");
let pane_id = workspace.tabs[0].root_pane;
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.ensure_test_terminals();
app.save_session_now();
app.handle_internal_event(AppEvent::PaneDied {
pane_id,
exit_reason: crate::platform::ChildExitReason::Exited,
});
app.start_background_session_save();
if let Some(thread) = app.session_save_thread.take() {
thread.join().unwrap();
}
app.save_session_on_shutdown();
assert!(crate::persist::load().is_none());
std::env::remove_var("XDG_CONFIG_HOME");
let _ = std::fs::remove_dir_all(config_home);
}
#[cfg(unix)]
#[tokio::test]
async fn server_hangup_preserves_unsignaled_pane_exit_checkpoint() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
let config_home = unique_temp_path("hangup-unsignaled-pane-exit");
std::env::set_var("XDG_CONFIG_HOME", &config_home);
std::env::remove_var(crate::session::SESSION_ENV_VAR);
let mut app = test_app();
app.policy.persist_session = true;
let mut workspace = Workspace::test_new("preserved");
let first_pane = workspace.tabs[0].root_pane;
let second_pane = workspace.test_split(ratatui::layout::Direction::Horizontal);
app.state.workspaces = vec![workspace];
app.state.active = Some(0);
app.state.ensure_test_terminals();
app.save_session_now();
app.server_hangup_probe = || true;
for pane_id in [first_pane, second_pane] {
app.handle_internal_event(AppEvent::PaneDied {
pane_id,
exit_reason: crate::platform::ChildExitReason::Exited,
});
}
assert!(app.state.workspaces.is_empty());
app.start_background_session_save();
if let Some(thread) = app.session_save_thread.take() {
thread.join().unwrap();
}
app.save_session_on_shutdown();
let snapshot = crate::persist::load().expect("hangup checkpoint must survive autosave");
assert_eq!(snapshot.workspaces.len(), 1);
assert_eq!(snapshot.workspaces[0].tabs[0].panes.len(), 2);
std::env::remove_var("XDG_CONFIG_HOME");
let _ = std::fs::remove_dir_all(config_home);
}
#[test]
fn durable_mutation_after_pane_exit_checkpoint_wins_on_shutdown() {
let _guard = crate::config::test_config_env_lock().lock().unwrap();
+2 -2
View File
@@ -54,7 +54,7 @@ impl App {
crate::pane::AgentDetection::Disabled,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
app.state.host_terminal_appearance,
app.pane_host_terminal_appearance(),
app.event_tx.clone(),
app.render_notify.clone(),
app.render_dirty.clone(),
@@ -86,7 +86,7 @@ impl App {
crate::pane::AgentDetection::Disabled,
app.state.pane_scrollback_limit_bytes,
app.state.host_terminal_theme,
app.state.host_terminal_appearance,
app.pane_host_terminal_appearance(),
app.event_tx.clone(),
app.render_notify.clone(),
app.render_dirty.clone(),
+1
View File
@@ -167,6 +167,7 @@ impl App {
self.next_agent_manifest_update_check,
self.agent_metadata_deadline,
self.pending_agent_resume_deadline,
self.pending_host_appearance_deadline,
self.session_save_deadline,
self.next_tab_bar_status_deadline(),
render_deadline,
+6 -1
View File
@@ -65,6 +65,11 @@ impl App {
return;
}
if self.pane_exit_checkpoint_pending && !self.state.session_dirty {
self.session_save_deadline = None;
return;
}
self.reap_finished_session_save();
if self.session_save_thread.is_some() {
self.session_save_deadline = Some(Instant::now() + Duration::from_millis(250));
@@ -116,7 +121,7 @@ impl App {
pub(crate) fn finish_checkpointed_pane_exit(&mut self) {
if self.pane_exit_checkpoint_pending {
self.state.session_dirty = false;
self.session_save_deadline = Some(Instant::now() + SESSION_SAVE_DEBOUNCE);
self.session_save_deadline = None;
}
}
+82 -14
View File
@@ -1,31 +1,98 @@
use std::time::{Duration, Instant};
use super::App;
use crate::terminal_theme::HostAppearance;
#[cfg(test)]
mod tests;
impl App {
#[cfg(test)]
pub(crate) fn set_host_terminal_appearance_state(
&mut self,
appearance: Option<crate::terminal_theme::HostAppearance>,
appearance: Option<HostAppearance>,
explicit: bool,
) -> bool {
if self.state.host_terminal_appearance == appearance
&& self.state.host_terminal_appearance_explicit == explicit
{
return false;
}
self.state.host_terminal_appearance = appearance;
self.state.host_terminal_appearance_explicit = explicit;
self.apply_host_terminal_appearance_to_panes();
self.refresh_effective_app_theme()
self.set_host_terminal_state(self.state.host_terminal_theme, appearance, explicit)
}
#[cfg(test)]
pub(crate) fn set_host_terminal_theme(
&mut self,
theme: crate::terminal_theme::TerminalTheme,
) -> bool {
if theme == self.state.host_terminal_theme {
self.set_host_terminal_state(
theme,
self.state.host_terminal_appearance,
self.state.host_terminal_appearance_explicit,
)
}
pub(crate) fn set_host_terminal_state(
&mut self,
theme: crate::terminal_theme::TerminalTheme,
appearance: Option<HostAppearance>,
explicit: bool,
) -> bool {
let theme_changed = theme != self.state.host_terminal_theme;
if theme_changed {
self.state.host_terminal_theme = theme;
self.apply_host_terminal_theme_to_panes();
}
let previous = self.state.host_terminal_appearance;
let appearance_changed =
previous != appearance || self.state.host_terminal_appearance_explicit != explicit;
if appearance_changed {
self.state.host_terminal_appearance = appearance;
self.state.host_terminal_appearance_explicit = explicit;
if previous.is_some() && appearance.is_some() {
if previous != appearance {
// The matching OSC 11 arrives after the appearance report's round trip.
self.pending_host_appearance_deadline
.get_or_insert(Instant::now() + Duration::from_millis(750));
}
} else {
self.pending_host_appearance_deadline = None;
self.apply_host_terminal_appearance_to_panes();
}
}
// A foreground switch must install both facts before releasing the outgoing hold.
let released = self.release_host_appearance_if_ready(Instant::now());
let repainted = appearance_changed && self.refresh_effective_app_theme();
theme_changed || released || repainted
}
pub(crate) fn pane_host_terminal_appearance(&self) -> Option<HostAppearance> {
if self.pending_host_appearance_deadline.is_some() {
// New panes must describe the background available during the hold.
self.state
.host_terminal_theme
.background
.map(|color| color.inferred_appearance())
.or(self.pane_host_appearance)
} else {
self.state.host_terminal_appearance
}
}
pub(crate) fn release_host_appearance_if_ready(&mut self, now: Instant) -> bool {
let Some(deadline) = self.pending_host_appearance_deadline else {
return false;
};
let matches = self
.state
.host_terminal_theme
.background
.is_some_and(|color| {
Some(color.inferred_appearance()) == self.state.host_terminal_appearance
});
if !matches && now < deadline {
return false;
}
self.state.host_terminal_theme = theme;
self.apply_host_terminal_theme_to_panes();
self.pending_host_appearance_deadline = None;
self.apply_host_terminal_appearance_to_panes();
true
}
@@ -44,7 +111,8 @@ impl App {
true
}
fn apply_host_terminal_appearance_to_panes(&self) {
fn apply_host_terminal_appearance_to_panes(&mut self) {
self.pane_host_appearance = self.state.host_terminal_appearance;
for runtime in self.terminal_runtimes.values() {
runtime.apply_host_terminal_appearance(self.state.host_terminal_appearance);
}
+212
View File
@@ -0,0 +1,212 @@
use super::*;
use crate::terminal::{TerminalId, TerminalRuntime};
use crate::terminal_theme::{RgbColor, TerminalTheme};
use bytes::Bytes;
use tokio::sync::mpsc;
const DARK: RgbColor = RgbColor {
r: 17,
g: 17,
b: 17,
};
const LIGHT: RgbColor = RgbColor {
r: 255,
g: 255,
b: 255,
};
fn app() -> App {
let (_, api_rx) = mpsc::unbounded_channel();
App::new(
&crate::config::Config::default(),
crate::app::AppPolicy::TEST,
None,
api_rx,
crate::api::EventHub::default(),
)
}
fn child(app: &mut App) -> (TerminalId, mpsc::Receiver<Bytes>) {
let (runtime, rx) = TerminalRuntime::test_with_channel(80, 24);
runtime.apply_host_terminal_theme(app.state.host_terminal_theme);
runtime.apply_host_terminal_appearance(app.pane_host_terminal_appearance());
runtime.test_process_pty_bytes(b"\x1b[?2031h");
let id = TerminalId::alloc();
app.terminal_runtimes.insert(id.clone(), runtime);
(id, rx)
}
fn background(app: &mut App, color: RgbColor) {
app.set_host_terminal_theme(TerminalTheme {
background: Some(color),
..app.state.host_terminal_theme
});
}
fn query(app: &App, id: &TerminalId, request: &[u8]) -> Vec<Bytes> {
app.terminal_runtimes
.get(id)
.unwrap()
.test_process_pty_bytes(request)
}
#[tokio::test]
async fn appearance_waits_for_background_not_foreground_or_palette() {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (id, mut rx) = child(&mut app);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert!(rx.try_recv().is_err(), "notification must wait for OSC 11");
let mut theme = app.state.host_terminal_theme;
theme.foreground = Some(DARK);
theme.palette[1] = Some(LIGHT);
app.set_host_terminal_theme(theme);
assert!(
rx.try_recv().is_err(),
"OSC 10/palette must not release the hold"
);
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;1n")]
);
background(&mut app, LIGHT);
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert_eq!(
query(&app, &id, b"\x1b]11;?\x07"),
[Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")]
);
}
#[tokio::test]
async fn appearance_with_matching_background_is_immediate() {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (_, mut rx) = child(&mut app);
background(&mut app, LIGHT);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert!(app.pending_host_appearance_deadline.is_none());
}
#[tokio::test]
async fn appearance_deadline_bounds_missing_or_inconsistent_background() {
for missing in [false, true] {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (_, mut rx) = child(&mut app);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
if missing {
app.set_host_terminal_theme(TerminalTheme::default());
}
let deadline = app.pending_host_appearance_deadline.unwrap();
assert!(deadline <= Instant::now() + Duration::from_millis(750));
assert!(!app.release_host_appearance_if_ready(deadline - Duration::from_nanos(1)));
assert!(rx.try_recv().is_err());
assert!(app.release_host_appearance_if_ready(deadline));
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert!(!app.release_host_appearance_if_ready(deadline));
}
}
#[tokio::test]
async fn pane_opened_without_background_during_appearance_hold_answers_and_receives_release() {
for (outgoing, incoming) in [
(HostAppearance::Dark, HostAppearance::Light),
(HostAppearance::Light, HostAppearance::Dark),
] {
let mut app = app();
app.set_host_terminal_appearance_state(Some(outgoing), true);
app.set_host_terminal_appearance_state(Some(incoming), true);
let (id, mut rx) = child(&mut app);
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(outgoing.color_scheme_report())],
"a pane opened during the hold must answer with the known outgoing appearance"
);
assert!(rx.try_recv().is_err());
let deadline = app.pending_host_appearance_deadline.unwrap();
assert!(app.release_host_appearance_if_ready(deadline));
assert_eq!(rx.try_recv().unwrap(), incoming.color_scheme_report());
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(incoming.color_scheme_report())]
);
}
}
#[tokio::test]
async fn appearance_coalesces_without_extending_deadline() {
let mut app = app();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (_, mut rx) = child(&mut app);
app.set_host_terminal_theme(TerminalTheme::default());
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
let deadline = app.pending_host_appearance_deadline;
// A provenance-only update must not cancel a pending flip either.
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), false);
assert_eq!(app.pending_host_appearance_deadline, deadline);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert_eq!(app.pending_host_appearance_deadline, deadline);
assert!(rx.try_recv().is_err());
background(&mut app, LIGHT);
assert_eq!(rx.try_recv().unwrap(), b"\x1b[?997;2n"[..]);
assert!(rx.try_recv().is_err());
}
#[tokio::test]
async fn appearance_baseline_establish_and_clear_are_immediate() {
let mut app = app();
let (id, mut rx) = child(&mut app);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;1n")]
);
assert!(app.pending_host_appearance_deadline.is_none());
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
app.set_host_terminal_appearance_state(None, false);
assert!(app.pending_host_appearance_deadline.is_none());
assert!(query(&app, &id, b"\x1b[?996n").is_empty());
assert!(rx.try_recv().is_err());
}
#[cfg(unix)]
#[tokio::test]
async fn pane_created_during_appearance_hold_gets_consistent_pair() {
let mut app = app();
app.state.default_shell = "/bin/sh".into();
background(&mut app, DARK);
app.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
app.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
let index = app
.create_workspace_with_options(std::env::temp_dir(), false)
.unwrap();
let ws = &app.state.workspaces[index];
let id = ws.terminal_id(ws.tabs[0].root_pane).unwrap().clone();
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;1n")]
);
assert_eq!(
query(&app, &id, b"\x1b]11;?\x07"),
[Bytes::from_static(b"\x1b]11;rgb:1111/1111/1111\x1b\\")]
);
background(&mut app, LIGHT);
assert_eq!(
query(&app, &id, b"\x1b[?996n"),
[Bytes::from_static(b"\x1b[?997;2n")]
);
assert_eq!(
query(&app, &id, b"\x1b]11;?\x07"),
[Bytes::from_static(b"\x1b]11;rgb:ffff/ffff/ffff\x1b\\")]
);
for (_, runtime) in app.terminal_runtimes.drain() {
runtime.shutdown();
}
}
+91 -9
View File
@@ -450,23 +450,41 @@ fn hermes_installer_agent_name(argv: &[String]) -> Option<String> {
return None;
}
// Recognize the captured installer bootstrap, not arbitrary Python source.
// Both root literals must agree; reject escapes or quotes rather than parse Python.
let code = code.strip_prefix("import os, re, sys\n")?;
let (expanded, code) = if code.starts_with("import ") {
(true, code.split_once('\n')?.1)
} else {
(false, code)
};
let rest = code.strip_prefix(HERMES_INSTALLER_PREFIX)?;
let (root, rest) = rest.split_once("')\n")?;
if root.is_empty() || root.contains(['\'', '\\', '\n', '\r']) {
return None;
}
if expanded {
// Recovery helpers change independently of the interactive entry point.
return (rest.ends_with(HERMES_INSTALLER_ENTRY)
&& rest.lines().any(|line| {
matches!(
line,
"import hermes_bootstrap" | " import hermes_bootstrap"
)
}))
.then(|| agent_label(Agent::Hermes).to_string());
}
let rest = rest
.strip_prefix(HERMES_INSTALLER_MIDDLE)?
.strip_prefix(root)?;
(rest == HERMES_INSTALLER_SUFFIX).then(|| agent_label(Agent::Hermes).to_string())
}
// Installer source captured in #4910. Only the installation root varies. Unknown
// bootstrap revisions deliberately fall back to ordinary process identification.
const HERMES_INSTALLER_PREFIX: &str = "import os, re, sys
os.environ.pop('PYTHONHOME', None)
const HERMES_INSTALLER_ENTRY: &str = r"from hermes_cli.main import main
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())
";
// Short installer source captured in #4910. Only the installation root varies.
const HERMES_INSTALLER_PREFIX: &str = "os.environ.pop('PYTHONHOME', None)
os.environ.pop('PYTHONPATH', None)
sys.path.insert(0, '";
const HERMES_INSTALLER_MIDDLE: &str =
@@ -1432,11 +1450,13 @@ mod tests {
}
fn hermes_installer_capture() -> crate::platform::ForegroundJob {
// Exact reporter capture from #4910; private path components were redacted.
let capture: serde_json::Value = serde_json::from_str(include_str!(
hermes_installer_capture_from(include_str!(
"../../tests/fixtures/hermes-installer-process-info-4910.json"
))
.unwrap();
}
fn hermes_installer_capture_from(capture: &str) -> crate::platform::ForegroundJob {
let capture: serde_json::Value = serde_json::from_str(capture).unwrap();
let info = &capture["result"]["process_info"];
crate::platform::ForegroundJob {
process_group_id: info["foreground_process_group_id"].as_u64().unwrap() as u32,
@@ -1471,6 +1491,68 @@ mod tests {
);
}
#[test]
fn identify_agent_in_job_detects_newer_hermes_installer_capture() {
let mut job = hermes_installer_capture_from(include_str!(
"../../tests/fixtures/hermes-installer-process-info-5068.json"
));
assert_eq!(
identify_agent_in_job(&job),
Some((Agent::Hermes, "hermes".to_string()))
);
let process = job.processes.last_mut().unwrap();
let argv = process.argv.as_mut().unwrap();
argv[3] = argv[3]
.replace(
"/Users/username/.hermes/hermes-agent",
"/opt/another install/hermes-agent",
)
.replace(
"import contextlib, hashlib, io, pathlib, shutil, subprocess",
"import contextlib, hashlib, io, pathlib, shutil, subprocess, warnings",
);
argv.push("--resume".into());
argv.push("session-id".into());
assert_eq!(
identify_agent_in_job(&job),
Some((Agent::Hermes, "hermes".to_string()))
);
}
#[test]
fn newer_hermes_installer_rejects_non_entry_source_and_helper_modes() {
let job = hermes_installer_capture_from(include_str!(
"../../tests/fixtures/hermes-installer-process-info-5068.json"
));
let mut argv = job.processes.last().unwrap().argv.clone().unwrap();
let original = argv[3].clone();
for code in [
"print('hermes_cli.main')".to_string(),
format!("'''{original}'''"),
original.lines().map(|line| format!("# {line}\n")).collect(),
original.replace(
"from hermes_cli.main import main",
"from unrelated.main import main",
),
original.replace("sys.exit(main())", "sys.exit(0)"),
original.replace("import hermes_bootstrap", "import unrelated_bootstrap"),
original.replace("/Users/username", r"/Users/has\escape"),
original.replace("/Users/username", "/Users/has'quote"),
] {
argv[3] = code;
assert_eq!(hermes_installer_agent_name(&argv), None);
}
argv[3] = original;
for mode in ["--run-module", "--print-runtime-command"] {
argv.truncate(4);
argv.push(mode.into());
assert_eq!(hermes_installer_agent_name(&argv), None);
}
argv.truncate(4);
argv[1] = "-m".into();
assert_eq!(hermes_installer_agent_name(&argv), None);
}
#[test]
fn hermes_installer_signature_allows_another_install_root() {
let job = hermes_installer_capture();
+6 -3
View File
@@ -157,8 +157,8 @@ pub(crate) fn prepare_socket_path(
fn stale_socket_connect_error(kind: io::ErrorKind) -> bool {
matches!(
kind,
io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound | io::ErrorKind::TimedOut
) || (cfg!(windows) && kind == io::ErrorKind::WouldBlock)
io::ErrorKind::ConnectionRefused | io::ErrorKind::NotFound
) || (cfg!(windows) && matches!(kind, io::ErrorKind::TimedOut | io::ErrorKind::WouldBlock))
}
pub(crate) fn local_stream_peer_closed(stream: &mut LocalStream) -> io::Result<bool> {
@@ -382,7 +382,10 @@ mod tests {
fn stale_socket_connect_errors_keep_unix_would_block_strict() {
assert!(stale_socket_connect_error(io::ErrorKind::ConnectionRefused));
assert!(stale_socket_connect_error(io::ErrorKind::NotFound));
assert!(stale_socket_connect_error(io::ErrorKind::TimedOut));
assert_eq!(
stale_socket_connect_error(io::ErrorKind::TimedOut),
cfg!(windows)
);
assert_eq!(
stale_socket_connect_error(io::ErrorKind::WouldBlock),
cfg!(windows)
+3 -2
View File
@@ -4019,16 +4019,17 @@ impl PaneRuntime {
(release_tx, writer)
}
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec<Bytes> {
let _content_write_guard = match self.content_write_lock.lock() {
Ok(guard) => guard,
Err(poisoned) => poisoned.into_inner(),
};
self.content_seq.fetch_add(1, Ordering::AcqRel);
let (tx, _rx) = mpsc::channel(1);
let _ = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
let result = self.terminal.process_pty_bytes(self.pane_id, 0, bytes, &tx);
self.content_seq.fetch_add(1, Ordering::Release);
self.compression.wake();
result.terminal_responses
}
pub(crate) fn test_with_scrollback_bytes(
+13
View File
@@ -226,6 +226,19 @@ pub(crate) use unix_common::{
classify_child_exit, poll_fd_readable, read_fd, shared_ssh_control_path,
};
// Tests inject their own probe because the recorded hangup is process-global.
#[cfg_attr(test, allow(dead_code))]
pub(crate) fn server_hangup_is_recent() -> bool {
#[cfg(unix)]
{
unix_common::server_hangup_is_recent()
}
#[cfg(not(unix))]
{
false
}
}
#[cfg(not(any(unix, windows)))]
pub(crate) fn classify_child_exit(_status: &portable_pty::ExitStatus) -> ChildExitReason {
ChildExitReason::Exited
+55 -8
View File
@@ -319,19 +319,47 @@ fn set_sigpipe_disposition(handler: libc::sighandler_t) {
}
}
extern "C" fn discard_signal(_signal: libc::c_int) {}
static LAST_SERVER_HANGUP_MS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
fn monotonic_millis() -> u64 {
let mut now: libc::timespec = unsafe { std::mem::zeroed() };
if unsafe { libc::clock_gettime(libc::CLOCK_MONOTONIC, &mut now) } != 0 {
return 0;
}
(now.tv_sec as u64)
.saturating_mul(1000)
.saturating_add(now.tv_nsec as u64 / 1_000_000)
.saturating_add(1)
}
fn record_server_hangup() {
LAST_SERVER_HANGUP_MS.store(monotonic_millis(), std::sync::atomic::Ordering::Relaxed);
}
fn within_server_hangup_window(hangup_ms: u64, now_ms: u64) -> bool {
hangup_ms != 0
&& now_ms
.checked_sub(hangup_ms)
.is_some_and(|elapsed| elapsed <= 5_000)
}
pub(crate) fn server_hangup_is_recent() -> bool {
within_server_hangup_window(
LAST_SERVER_HANGUP_MS.load(std::sync::atomic::Ordering::Relaxed),
monotonic_millis(),
)
}
/// The server must outlive the terminal or shell that launched it, like tmux.
/// Installed at process start so SIGHUP cannot stop the server before its event
/// loop runs. A handler, unlike SIG_IGN, resets to the default on exec, so
/// child processes keep normal hangup behavior.
pub(crate) fn ignore_server_hangup() {
let mut action: libc::sigaction = unsafe { std::mem::zeroed() };
action.sa_sigaction = discard_signal as extern "C" fn(libc::c_int) as libc::sighandler_t;
action.sa_flags = libc::SA_RESTART;
unsafe {
libc::sigemptyset(&mut action.sa_mask);
libc::sigaction(libc::SIGHUP, &action, std::ptr::null_mut());
// Record before async pane-exit handling, even if Tokio has not polled SIGHUP yet.
if let Err(err) =
unsafe { signal_hook::low_level::register(libc::SIGHUP, record_server_hangup) }
{
tracing::warn!(%err, "failed to install SIGHUP handler");
}
}
@@ -617,6 +645,22 @@ pub(crate) fn set_default_plugin_pane_pwd(env: &mut Vec<(String, String)>, cwd:
mod tests {
use super::*;
#[test]
fn server_hangup_evidence_expires() {
assert!(!within_server_hangup_window(0, 100));
assert!(within_server_hangup_window(100, 100));
assert!(within_server_hangup_window(100, 5_100));
assert!(!within_server_hangup_window(100, 5_101));
assert!(!within_server_hangup_window(100, 99));
}
#[test]
fn server_hangup_is_recorded_before_async_handling() {
ignore_server_hangup();
assert_eq!(unsafe { libc::raise(libc::SIGHUP) }, 0);
assert!(server_hangup_is_recent());
}
#[test]
fn server_signal_monitor_survives_hangup_and_reports_terminate() {
let runtime = tokio::runtime::Builder::new_current_thread()
@@ -625,12 +669,15 @@ mod tests {
.unwrap();
runtime.block_on(async {
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
ignore_server_hangup();
spawn_server_signal_monitor(move |signal| {
let _ = tx.send(signal);
});
// An unhandled SIGHUP would end the test process here.
assert_eq!(unsafe { libc::kill(libc::getpid(), libc::SIGHUP) }, 0);
// raise() runs the handler on this thread before returning, unlike kill().
assert_eq!(unsafe { libc::raise(libc::SIGHUP) }, 0);
assert!(server_hangup_is_recent());
tokio::time::sleep(std::time::Duration::from_millis(100)).await;
assert!(rx.try_recv().is_err());
+5 -4
View File
@@ -845,11 +845,11 @@ impl HeadlessServer {
if outer_terminal_focus == Some(true) {
self.app.state.mark_active_tab_seen();
}
self.app.set_host_terminal_appearance_state(
self.app.set_host_terminal_state(
host_terminal_theme,
host_terminal_appearance,
host_terminal_appearance_explicit,
);
self.app.set_host_terminal_theme(host_terminal_theme);
}
fn sync_visible_server_config_diagnostic(&mut self, uses_local_keybindings: bool) {
@@ -2270,11 +2270,11 @@ impl HeadlessServer {
if !client.shell_surface_active || self.foreground_client_id != Some(client_id) {
return false;
}
let mut changed = self.app.set_host_terminal_appearance_state(
let changed = self.app.set_host_terminal_state(
client.host_terminal_theme,
client.host_terminal_appearance,
client.host_terminal_appearance_explicit,
);
changed |= self.app.set_host_terminal_theme(client.host_terminal_theme);
if changed {
self.resize_shared_runtime_to_effective_size_before_input();
}
@@ -3365,6 +3365,7 @@ impl HeadlessServer {
changed = true;
}
changed |= self.app.release_host_appearance_if_ready(now);
changed |= self.app.handle_tab_bar_status_tasks(now);
if geometry_dirty {
+95
View File
@@ -4019,6 +4019,101 @@ fn client_shell_host_theme_follows_foreground_client() {
assert!(!server.app.state.host_terminal_appearance_explicit);
}
#[tokio::test]
async fn foreground_switch_coalesces_pending_appearance_before_releasing_colors() {
use crate::terminal::{TerminalId, TerminalRuntime};
use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme};
for incoming in [Some(HostAppearance::Dark), None] {
let mut server = test_headless_server();
server.app.set_host_terminal_theme(TerminalTheme {
background: Some(RgbColor {
r: 17,
g: 17,
b: 17,
}),
..Default::default()
});
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
let (runtime, mut rx) = TerminalRuntime::test_with_channel(80, 24);
runtime.apply_host_terminal_theme(server.app.state.host_terminal_theme);
runtime.apply_host_terminal_appearance(Some(HostAppearance::Dark));
runtime.test_process_pty_bytes(b"\x1b[?2031h");
let id = TerminalId::alloc();
server.app.terminal_runtimes.insert(id.clone(), runtime);
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
assert!(rx.try_recv().is_err());
let mut client = ClientConnection::new(
(80, 24),
crate::kitty_graphics::HostCellSize::default(),
2,
RenderEncoding::SemanticFrame,
None,
);
client.host_terminal_theme.background = Some(RgbColor {
r: 255,
g: 255,
b: 255,
});
client.host_terminal_appearance = incoming;
client.host_terminal_appearance_explicit = incoming.is_some();
server.clients.insert(2, client);
server.foreground_client_id = Some(2);
server.sync_foreground_client_state();
assert!(
rx.try_recv().is_err(),
"must not announce outgoing client A Light"
);
let expected =
incoming.map(|appearance| bytes::Bytes::from_static(appearance.color_scheme_report()));
assert_eq!(
server
.app
.terminal_runtimes
.get(&id)
.unwrap()
.test_process_pty_bytes(b"\x1b[?996n"),
expected.into_iter().collect::<Vec<_>>()
);
}
}
#[test]
fn pending_host_appearance_is_released_by_headless_deadline() {
use crate::terminal_theme::{HostAppearance, RgbColor, TerminalTheme};
let mut server = test_headless_server();
server.app.set_host_terminal_theme(TerminalTheme {
background: Some(RgbColor {
r: 17,
g: 17,
b: 17,
}),
..Default::default()
});
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Dark), true);
server
.app
.set_host_terminal_appearance_state(Some(HostAppearance::Light), true);
let deadline = server.app.pending_host_appearance_deadline.unwrap();
assert!(
server
.app
.next_headless_loop_deadline_with_git_refresh(Instant::now(), false, false)
.unwrap()
<= deadline
);
server.handle_scheduled_tasks_headless(deadline, false);
assert!(server.app.pending_host_appearance_deadline.is_none());
}
#[test]
fn terminal_clients_store_known_cell_geometry_independently_of_pixel_mouse() {
let mut server = test_headless_server();
+2 -2
View File
@@ -657,8 +657,8 @@ impl TerminalRuntime {
))
}
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) {
self.0.test_process_pty_bytes(bytes);
pub(crate) fn test_process_pty_bytes(&self, bytes: &[u8]) -> Vec<Bytes> {
self.0.test_process_pty_bytes(bytes)
}
pub(crate) fn test_with_scrollback_bytes(
File diff suppressed because one or more lines are too long