From ff5881ef45086ffef98a907f0009f37f36565681 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Fri, 23 Oct 2026 17:03:42 +0300 Subject: [PATCH 1/4] fix: keep a slow server's socket instead of treating a connect timeout as stale (#5511) refs #5230 --- src/ipc.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ipc.rs b/src/ipc.rs index 64c9b9fa5..6872c90c8 100644 --- a/src/ipc.rs +++ b/src/ipc.rs @@ -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 { @@ -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) From 6b526626f66e22c08962a7f39a83fc9ac2ebda62 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Fri, 23 Oct 2026 17:03:57 +0300 Subject: [PATCH 2/4] fix: keep the saved layout when panes die during host shutdown (#5510) * fix: keep the saved layout when panes die during host shutdown refs #5349 * test: check hangup recording with the server signal monitor installed refs #5349 --- Cargo.lock | 1 + Cargo.toml | 3 ++ src/app/api.rs | 5 ++- src/app/mod.rs | 79 ++++++++++++++++++++++++++++++++++++- src/app/session.rs | 7 +++- src/platform/mod.rs | 13 ++++++ src/platform/unix_common.rs | 63 +++++++++++++++++++++++++---- 7 files changed, 160 insertions(+), 11 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8867e2995..158bf1df8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -801,6 +801,7 @@ dependencies = [ "serde_ignored", "serde_json", "sha2", + "signal-hook", "time", "tokio", "toml", diff --git a/Cargo.toml b/Cargo.toml index 0ea6dbdbc..ff5813a73 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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"] } diff --git a/src/app/api.rs b/src/app/api.rs index dd87c508b..55124aa1e 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -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(); diff --git a/src/app/mod.rs b/src/app/mod.rs index 6effa3a57..e2daa74a2 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -146,6 +146,7 @@ pub struct App { pub(crate) session_save_thread: Option>, session_writer: Arc>, pane_exit_checkpoint_pending: bool, + server_hangup_probe: fn() -> bool, pub(crate) detached_process_children: Vec, tab_bar_status_generation: u64, tab_bar_datetimes: Vec, @@ -659,6 +660,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 +3306,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 +3332,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(); diff --git a/src/app/session.rs b/src/app/session.rs index 509d4050f..c1018ada7 100644 --- a/src/app/session.rs +++ b/src/app/session.rs @@ -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; } } diff --git a/src/platform/mod.rs b/src/platform/mod.rs index 2f4bf16df..c81c32be2 100644 --- a/src/platform/mod.rs +++ b/src/platform/mod.rs @@ -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 diff --git a/src/platform/unix_common.rs b/src/platform/unix_common.rs index 32e94f084..19d81d12f 100644 --- a/src/platform/unix_common.rs +++ b/src/platform/unix_common.rs @@ -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()); From 3f5032c963ee6a3e37eae7c8747b8196ef1dc1db Mon Sep 17 00:00:00 2001 From: Can Celik Date: Fri, 23 Oct 2026 19:18:44 +0300 Subject: [PATCH 3/4] fix: send pane appearance changes only after the matching background (#5517) * fix: send pane appearance changes only after the matching background refs #4183 * fix: answer appearance queries from panes opened during a hold refs #4183 --- src/app/agent_resume.rs | 2 +- src/app/api.rs | 2 +- src/app/api/layouts.rs | 4 +- src/app/api/panes.rs | 2 +- src/app/api/plugins/panes.rs | 6 +- src/app/api/tabs.rs | 2 +- src/app/creation.rs | 2 +- src/app/custom_commands.rs | 6 +- src/app/mod.rs | 4 + src/app/popup.rs | 4 +- src/app/runtime.rs | 1 + src/app/theme_sync.rs | 96 ++++++++++++-- src/app/theme_sync/tests.rs | 212 +++++++++++++++++++++++++++++++ src/pane.rs | 5 +- src/server/headless.rs | 9 +- src/server/headless/tests/mod.rs | 95 ++++++++++++++ src/terminal/runtime.rs | 4 +- 17 files changed, 421 insertions(+), 35 deletions(-) create mode 100644 src/app/theme_sync/tests.rs diff --git a/src/app/agent_resume.rs b/src/app/agent_resume.rs index f088138c7..a90f6b1d2 100644 --- a/src/app/agent_resume.rs +++ b/src/app/agent_resume.rs @@ -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(), diff --git a/src/app/api.rs b/src/app/api.rs index 55124aa1e..067ec2fa8 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -614,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(), diff --git a/src/app/api/layouts.rs b/src/app/api/layouts.rs index bde3421f0..6a6d2db5d 100644 --- a/src/app/api/layouts.rs +++ b/src/app/api/layouts.rs @@ -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() diff --git a/src/app/api/panes.rs b/src/app/api/panes.rs index b5f3baf67..24b391248 100644 --- a/src/app/api/panes.rs +++ b/src/app/api/panes.rs @@ -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"); diff --git a/src/app/api/plugins/panes.rs b/src/app/api/plugins/panes.rs index a4f30b0ce..2fc9f24be 100644 --- a/src/app/api/plugins/panes.rs +++ b/src/app/api/plugins/panes.rs @@ -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()), diff --git a/src/app/api/tabs.rs b/src/app/api/tabs.rs index b00858592..7c7fb76a0 100644 --- a/src/app/api/tabs.rs +++ b/src/app/api/tabs.rs @@ -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), diff --git a/src/app/creation.rs b/src/app/creation.rs index 21e976ce8..dde970cb6 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -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(), diff --git a/src/app/custom_commands.rs b/src/app/custom_commands.rs index 6e8a6c016..55b84228c 100644 --- a/src/app/custom_commands.rs +++ b/src/app/custom_commands.rs @@ -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 { diff --git a/src/app/mod.rs b/src/app/mod.rs index e2daa74a2..32f6e6d55 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -140,6 +140,8 @@ pub struct App { pub(crate) loaded_host_cursor: crate::config::HostCursorModeConfig, pub(crate) agent_metadata_deadline: Option, pub(crate) pending_agent_resume_deadline: Option, + pub(crate) pending_host_appearance_deadline: Option, + pane_host_appearance: Option, startup_per_agent_delay: Duration, next_agent_resume_at: Option, pub(crate) session_save_deadline: Option, @@ -652,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(), ), diff --git a/src/app/popup.rs b/src/app/popup.rs index 988b3cb6f..d7b1057f3 100644 --- a/src/app/popup.rs +++ b/src/app/popup.rs @@ -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(), diff --git a/src/app/runtime.rs b/src/app/runtime.rs index c668087a0..223803c9e 100644 --- a/src/app/runtime.rs +++ b/src/app/runtime.rs @@ -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, diff --git a/src/app/theme_sync.rs b/src/app/theme_sync.rs index ed0f632d3..b11072cde 100644 --- a/src/app/theme_sync.rs +++ b/src/app/theme_sync.rs @@ -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, + appearance: Option, 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, + 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 { + 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); } diff --git a/src/app/theme_sync/tests.rs b/src/app/theme_sync/tests.rs new file mode 100644 index 000000000..ef6176a9b --- /dev/null +++ b/src/app/theme_sync/tests.rs @@ -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) { + 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 { + 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(); + } +} diff --git a/src/pane.rs b/src/pane.rs index f80baeb82..dd53d846d 100644 --- a/src/pane.rs +++ b/src/pane.rs @@ -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 { 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( diff --git a/src/server/headless.rs b/src/server/headless.rs index cb2c61964..60b383c48 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -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 { diff --git a/src/server/headless/tests/mod.rs b/src/server/headless/tests/mod.rs index 3ab7ec0fd..cd7419276 100644 --- a/src/server/headless/tests/mod.rs +++ b/src/server/headless/tests/mod.rs @@ -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::>() + ); + } +} + +#[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(); diff --git a/src/terminal/runtime.rs b/src/terminal/runtime.rs index 23b61da26..6bc38694f 100644 --- a/src/terminal/runtime.rs +++ b/src/terminal/runtime.rs @@ -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 { + self.0.test_process_pty_bytes(bytes) } pub(crate) fn test_with_scrollback_bytes( From 43b36b68caf593e7623fa389462d8ba097edcb11 Mon Sep 17 00:00:00 2001 From: Can Celik Date: Fri, 23 Oct 2026 19:19:00 +0300 Subject: [PATCH 4/4] fix: detect hermes started by the newer installer launcher (#5518) refs #5068 --- src/detect/mod.rs | 100 ++++++++++++++++-- .../hermes-installer-process-info-5068.json | 26 +++++ 2 files changed, 117 insertions(+), 9 deletions(-) create mode 100644 tests/fixtures/hermes-installer-process-info-5068.json diff --git a/src/detect/mod.rs b/src/detect/mod.rs index 380a41fbd..10f2015a8 100644 --- a/src/detect/mod.rs +++ b/src/detect/mod.rs @@ -450,23 +450,41 @@ fn hermes_installer_agent_name(argv: &[String]) -> Option { 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(); diff --git a/tests/fixtures/hermes-installer-process-info-5068.json b/tests/fixtures/hermes-installer-process-info-5068.json new file mode 100644 index 000000000..e6eb2561f --- /dev/null +++ b/tests/fixtures/hermes-installer-process-info-5068.json @@ -0,0 +1,26 @@ +{ + "id": "cli:pane:process_info", + "result": { + "process_info": { + "foreground_process_group_id": 13565, + "foreground_processes": [ + { + "argv": [ + "/Users/username/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3", + "-I", + "-c", + "import os, re, sys\nimport contextlib, hashlib, io, pathlib, shutil, subprocess\nos.environ.pop('PYTHONHOME', None)\nos.environ.pop('PYTHONPATH', None)\nsys.path.insert(0, '/Users/username/.hermes/hermes-agent')\ndef _hermes_closure_repair():\n import contextlib, hashlib, io, shutil, subprocess\n from pathlib import Path\n root = Path('/Users/username/.hermes/hermes-agent')\n git = root / '.git'\n try:\n if git.is_file():\n text = git.read_text(encoding='utf-8-sig').strip()\n git = root / text[7:].strip() if text.startswith('gitdir:') else git\n fields = dict(line.partition('=')[::2] for line in (git / 'hermes-update-pull').read_text(encoding='utf-8-sig').splitlines())\n except OSError:\n return\n pre = fields.get('pre', '').strip()\n if not re.fullmatch('[0-9a-f]{40}|[0-9a-f]{64}', pre):\n return\n closure = git / 'hermes-update-recovery' / pre\n files = (*('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'), 'hermes_cli/__init__.py')\n\n def blob_id(data):\n return hashlib.new('sha1' if len(pre) == 40 else 'sha256', b'blob %d\\0' % len(data) + data).hexdigest()\n\n def verified():\n try:\n listed = dict(line.split(' ', 1)[::-1] for line in (closure / 'MANIFEST').read_text(encoding='utf-8-sig').splitlines())\n return (set(listed) == set(files) and listed['hermes_cli/__init__.py'] == blob_id(b'')\n and all(blob_id((closure / rel).read_bytes()) == oid for rel, oid in listed.items()))\n except (OSError, ValueError):\n return False\n\n if not verified():\n exe = fields.get('git', '').strip()\n if not (os.path.isabs(exe) and os.path.isfile(exe)):\n names = ('git.exe',) if os.name == 'nt' else ('git',)\n exe = next((os.path.join(d, n) for d in os.environ.get('PATH', '').split(os.pathsep) if os.path.isabs(d)\n for n in names if os.path.isfile(os.path.join(d, n))), '')\n if not exe:\n return\n\n def git_out(*args):\n done = subprocess.run([exe, '--no-replace-objects', '-C', str(root), *args], capture_output=True, timeout=60,\n stdin=subprocess.DEVNULL, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))\n if done.returncode != 0:\n raise OSError(done.stderr)\n return done.stdout\n\n staging = closure.with_name('.%s.%d.launch' % (pre, os.getpid()))\n try:\n ids = {}\n for entry in git_out('ls-tree', '-z', '--full-tree', pre, '--', *('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py')).split(b'\\0'):\n meta, _, rel = entry.decode('utf-8', 'replace').partition('\\t')\n if meta.split()[1:2] == ['blob']:\n ids[rel] = meta.split()[2]\n blobs = {'hermes_cli/__init__.py': b''}\n for rel in ('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'):\n blobs[rel] = git_out('cat-file', 'blob', ids[rel])\n if blob_id(blobs[rel]) != ids[rel]: # an object that inflates to bytes pre never had\n raise OSError('%s does not hash to %s' % (rel, ids[rel]))\n blobs['MANIFEST'] = ''.join('%s %s\\n' % (blob_id(data), rel) for rel, data in blobs.items()).encode('utf-8')\n for rel, data in blobs.items():\n (staging / rel).parent.mkdir(parents=True, exist_ok=True)\n (staging / rel).write_bytes(data)\n shutil.rmtree(closure, ignore_errors=True)\n os.replace(staging, closure)\n except (OSError, KeyError, subprocess.SubprocessError):\n pass\n finally:\n shutil.rmtree(staging, ignore_errors=True)\n if not verified():\n return\n for name in [n for n in sys.modules if n.split('.')[0] in ('hermes_cli', 'hermes_bootstrap', 'hermes_constants', 'pm')]:\n del sys.modules[name]\n tree = os.path.normcase(os.path.realpath(root))\n sys.path[:] = [str(closure)] + [p for p in sys.path if p and os.path.normcase(os.path.realpath(p)) != tree\n and not os.path.normcase(os.path.realpath(p)).startswith(tree + os.sep)]\n sys.dont_write_bytecode = True\n said = io.StringIO()\n restored = False\n try:\n with contextlib.redirect_stderr(said):\n from hermes_cli import _early_recovery\n restored = _early_recovery.restore_interrupted_pull(root)\n except Exception as exc:\n said.write('hermes: the checkout cannot start after an interrupted `hermes update`, and it was not '\n 'repaired: %s\\n' % (exc,))\n if restored:\n sys.stderr.write('hermes: the checkout could not start after an interrupted `hermes update`; '\n 'repaired it with the recovery code saved before the update.\\n' + said.getvalue())\n _early_recovery.relaunch_after_restore()\n sys.stderr.write(said.getvalue() or 'hermes: the checkout cannot start after an interrupted `hermes update`; '\n 'launch again once the update that owns it finishes.\\n')\n raise SystemExit(1)\nif sys.argv[1:2] == ['--print-runtime-command']:\n sys.dont_write_bytecode = True\n from pathlib import Path\n try:\n from hermes_cli import _early_recovery\n except Exception as exc:\n if _hermes_closure_repair() or not isinstance(exc, ImportError):\n raise\n _early_recovery = None # a tree without the repair: the imports below report real damage\n if _early_recovery is not None and _early_recovery.restore_interrupted_pull(Path('/Users/username/.hermes/hermes-agent')):\n _early_recovery.relaunch_after_restore()\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(get_default_hermes_root())\n from hermes_cli._launchers import print_runtime_command\n print_runtime_command(Path('/Users/username/.hermes/hermes-agent'), sys.argv[2:])\n sys.exit(0)\nsys._hermes_pin_default_home = True\ntry:\n import hermes_bootstrap\nexcept Exception:\n _hermes_closure_repair()\n raise\nif not os.environ.get('HERMES_HOME'):\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = str(get_default_hermes_root())\nif sys.argv[1:2] == ['--run-module']:\n import runpy\n if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module')\n module = sys.argv.pop(2)\n del sys.argv[1]\n runpy.run_module(module, run_name='__main__', alter_sys=True)\n sys.exit(0)\nfrom hermes_cli.main import main\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(main())\n" + ], + "argv0": "python3", + "cmdline": "/Users/username/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3 -I -c import os, re, sys\nimport contextlib, hashlib, io, pathlib, shutil, subprocess\nos.environ.pop('PYTHONHOME', None)\nos.environ.pop('PYTHONPATH', None)\nsys.path.insert(0, '/Users/username/.hermes/hermes-agent')\ndef _hermes_closure_repair():\n import contextlib, hashlib, io, shutil, subprocess\n from pathlib import Path\n root = Path('/Users/username/.hermes/hermes-agent')\n git = root / '.git'\n try:\n if git.is_file():\n text = git.read_text(encoding='utf-8-sig').strip()\n git = root / text[7:].strip() if text.startswith('gitdir:') else git\n fields = dict(line.partition('=')[::2] for line in (git / 'hermes-update-pull').read_text(encoding='utf-8-sig').splitlines())\n except OSError:\n return\n pre = fields.get('pre', '').strip()\n if not re.fullmatch('[0-9a-f]{40}|[0-9a-f]{64}', pre):\n return\n closure = git / 'hermes-update-recovery' / pre\n files = (*('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'), 'hermes_cli/__init__.py')\n\n def blob_id(data):\n return hashlib.new('sha1' if len(pre) == 40 else 'sha256', b'blob %d\\0' % len(data) + data).hexdigest()\n\n def verified():\n try:\n listed = dict(line.split(' ', 1)[::-1] for line in (closure / 'MANIFEST').read_text(encoding='utf-8-sig').splitlines())\n return (set(listed) == set(files) and listed['hermes_cli/__init__.py'] == blob_id(b'')\n and all(blob_id((closure / rel).read_bytes()) == oid for rel, oid in listed.items()))\n except (OSError, ValueError):\n return False\n\n if not verified():\n exe = fields.get('git', '').strip()\n if not (os.path.isabs(exe) and os.path.isfile(exe)):\n names = ('git.exe',) if os.name == 'nt' else ('git',)\n exe = next((os.path.join(d, n) for d in os.environ.get('PATH', '').split(os.pathsep) if os.path.isabs(d)\n for n in names if os.path.isfile(os.path.join(d, n))), '')\n if not exe:\n return\n\n def git_out(*args):\n done = subprocess.run([exe, '--no-replace-objects', '-C', str(root), *args], capture_output=True, timeout=60,\n stdin=subprocess.DEVNULL, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0))\n if done.returncode != 0:\n raise OSError(done.stderr)\n return done.stdout\n\n staging = closure.with_name('.%s.%d.launch' % (pre, os.getpid()))\n try:\n ids = {}\n for entry in git_out('ls-tree', '-z', '--full-tree', pre, '--', *('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py')).split(b'\\0'):\n meta, _, rel = entry.decode('utf-8', 'replace').partition('\\t')\n if meta.split()[1:2] == ['blob']:\n ids[rel] = meta.split()[2]\n blobs = {'hermes_cli/__init__.py': b''}\n for rel in ('hermes_cli/_early_recovery.py', 'hermes_cli/update_lock.py', 'hermes_cli/update_custody.py'):\n blobs[rel] = git_out('cat-file', 'blob', ids[rel])\n if blob_id(blobs[rel]) != ids[rel]: # an object that inflates to bytes pre never had\n raise OSError('%s does not hash to %s' % (rel, ids[rel]))\n blobs['MANIFEST'] = ''.join('%s %s\\n' % (blob_id(data), rel) for rel, data in blobs.items()).encode('utf-8')\n for rel, data in blobs.items():\n (staging / rel).parent.mkdir(parents=True, exist_ok=True)\n (staging / rel).write_bytes(data)\n shutil.rmtree(closure, ignore_errors=True)\n os.replace(staging, closure)\n except (OSError, KeyError, subprocess.SubprocessError):\n pass\n finally:\n shutil.rmtree(staging, ignore_errors=True)\n if not verified():\n return\n for name in [n for n in sys.modules if n.split('.')[0] in ('hermes_cli', 'hermes_bootstrap', 'hermes_constants', 'pm')]:\n del sys.modules[name]\n tree = os.path.normcase(os.path.realpath(root))\n sys.path[:] = [str(closure)] + [p for p in sys.path if p and os.path.normcase(os.path.realpath(p)) != tree\n and not os.path.normcase(os.path.realpath(p)).startswith(tree + os.sep)]\n sys.dont_write_bytecode = True\n said = io.StringIO()\n restored = False\n try:\n with contextlib.redirect_stderr(said):\n from hermes_cli import _early_recovery\n restored = _early_recovery.restore_interrupted_pull(root)\n except Exception as exc:\n said.write('hermes: the checkout cannot start after an interrupted `hermes update`, and it was not '\n 'repaired: %s\\n' % (exc,))\n if restored:\n sys.stderr.write('hermes: the checkout could not start after an interrupted `hermes update`; '\n 'repaired it with the recovery code saved before the update.\\n' + said.getvalue())\n _early_recovery.relaunch_after_restore()\n sys.stderr.write(said.getvalue() or 'hermes: the checkout cannot start after an interrupted `hermes update`; '\n 'launch again once the update that owns it finishes.\\n')\n raise SystemExit(1)\nif sys.argv[1:2] == ['--print-runtime-command']:\n sys.dont_write_bytecode = True\n from pathlib import Path\n try:\n from hermes_cli import _early_recovery\n except Exception as exc:\n if _hermes_closure_repair() or not isinstance(exc, ImportError):\n raise\n _early_recovery = None # a tree without the repair: the imports below report real damage\n if _early_recovery is not None and _early_recovery.restore_interrupted_pull(Path('/Users/username/.hermes/hermes-agent')):\n _early_recovery.relaunch_after_restore()\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(get_default_hermes_root())\n from hermes_cli._launchers import print_runtime_command\n print_runtime_command(Path('/Users/username/.hermes/hermes-agent'), sys.argv[2:])\n sys.exit(0)\nsys._hermes_pin_default_home = True\ntry:\n import hermes_bootstrap\nexcept Exception:\n _hermes_closure_repair()\n raise\nif not os.environ.get('HERMES_HOME'):\n from hermes_constants import get_default_hermes_root\n os.environ['HERMES_HOME'] = str(get_default_hermes_root())\nif sys.argv[1:2] == ['--run-module']:\n import runpy\n if len(sys.argv) < 3: sys.exit('hermes: --run-module needs a module')\n module = sys.argv.pop(2)\n del sys.argv[1]\n runpy.run_module(module, run_name='__main__', alter_sys=True)\n sys.exit(0)\nfrom hermes_cli.main import main\nsys.argv[0] = re.sub(r'(-script\\.pyw|\\.exe)?$', '', sys.argv[0])\nsys.exit(main())\n", + "cwd": "/Users/username", + "name": "python3.14", + "pid": 13565 + } + ], + "pane_id": "wH:p38", + "shell_pid": 3742 + }, + "type": "pane_process_info" + } +}