aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-26 04:01:01 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-26 17:06:00 +0300
commite9547a8e0de21d0ce81867b06511b68def16aff2 (patch)
tree8a025301146434b79ff295ff0d0afc833c682502
parent3aa7903d28f94e67c14dd5cad48bbe942fbef2e9 (diff)
feat: refine live config lifecycle
-rw-r--r--CONFIGURATION.md6
-rw-r--r--src/app/input/mod.rs2
-rw-r--r--src/app/input/sidebar.rs1
-rw-r--r--src/app/mod.rs266
-rw-r--r--src/app/state.rs13
-rw-r--r--src/client/mod.rs23
-rw-r--r--src/config/io.rs93
-rw-r--r--src/config/model.rs1
-rw-r--r--src/config/sound.rs4
-rw-r--r--src/server/headless.rs121
-rw-r--r--src/server/protocol.rs47
-rw-r--r--tests/client_mode.rs36
-rw-r--r--tests/cross_area.rs16
-rw-r--r--tests/detach_reattach.rs40
-rw-r--r--tests/multi_client.rs2
-rw-r--r--tests/server_headless.rs16
16 files changed, 520 insertions, 167 deletions
diff --git a/CONFIGURATION.md b/CONFIGURATION.md
index a80e6bd0..a494d0cc 100644
--- a/CONFIGURATION.md
+++ b/CONFIGURATION.md
@@ -31,15 +31,15 @@ Reloadable now:
- theme, custom theme colors, and legacy `ui.accent`
- `ui.confirm_close`
- `ui.toast.delivery`
-- server-side `ui.sound` policy
+- server-side `ui.sound` policy; attached thin clients refresh local sound config after a successful sound-policy change
- `advanced.scrollback_limit_bytes` for panes created after reload
-- `ui.sidebar_width` as the default width
+- `ui.sidebar_width` as the default width; current width updates only while it is still config-owned
Startup-only or special-case:
- `onboarding` does not reopen onboarding during reload
- `advanced.allow_nested` is checked before launch and needs a restart
- existing pane scrollback buffers are not resized during reload
-- already-attached thin clients may need to reconnect before client-local sound file/path changes are picked up
+- terminal notifications and sounds are client-local side effects and are sent to the foreground attached client
If the TOML cannot be read or parsed, reload applies nothing and keeps the current running state. If keybindings are invalid, herdr keeps the current keybindings while applying other valid reloadable settings where possible.
diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs
index 9676da62..e2454861 100644
--- a/src/app/input/mod.rs
+++ b/src/app/input/mod.rs
@@ -138,6 +138,8 @@ impl App {
if is_double_click {
self.state.sidebar_width = self.state.default_sidebar_width;
+ self.state.sidebar_width_source =
+ crate::app::state::SidebarWidthSource::ConfigDefault;
self.state.sidebar_width_auto = false;
self.state.mark_session_dirty();
self.state.drag = None;
diff --git a/src/app/input/sidebar.rs b/src/app/input/sidebar.rs
index 0abfd446..b806271e 100644
--- a/src/app/input/sidebar.rs
+++ b/src/app/input/sidebar.rs
@@ -242,6 +242,7 @@ impl AppState {
let width = divider_col.saturating_sub(sidebar.x).saturating_add(1);
self.sidebar_width =
width.clamp(crate::ui::MIN_SIDEBAR_WIDTH, crate::ui::MAX_SIDEBAR_WIDTH);
+ self.sidebar_width_source = crate::app::state::SidebarWidthSource::Manual;
self.mark_session_dirty();
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index c4806a77..26090ebf 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -116,6 +116,13 @@ fn auto_updates_enabled(no_session: bool) -> bool {
/// Resolve the palette from config: base theme + optional custom overrides.
fn resolve_palette(config: &crate::config::Config) -> state::Palette {
+ resolve_palette_with_legacy_accent(config, true)
+}
+
+fn resolve_palette_with_legacy_accent(
+ config: &crate::config::Config,
+ use_legacy_ui_accent: bool,
+) -> state::Palette {
// Start with the named theme (default: catppuccin)
let base_name = config.theme.name.as_deref().unwrap_or("catppuccin");
let mut palette = state::Palette::from_name(base_name).unwrap_or_else(|| {
@@ -132,7 +139,8 @@ fn resolve_palette(config: &crate::config::Config) -> state::Palette {
}
// Legacy: if ui.accent is set and no theme.custom.accent, use it for compat
- if config.ui.accent != "cyan"
+ if use_legacy_ui_accent
+ && config.ui.accent != "cyan"
&& config
.theme
.custom
@@ -161,59 +169,78 @@ impl App {
let render_dirty = Arc::new(AtomicBool::new(false));
// Try to restore previous session
- let (workspaces, active, selected, agent_panel_scope, sidebar_width, sidebar_section_split) =
- if no_session {
+ let (
+ workspaces,
+ active,
+ selected,
+ agent_panel_scope,
+ sidebar_width,
+ sidebar_width_source,
+ sidebar_section_split,
+ ) = if no_session {
+ (
+ Vec::new(),
+ None,
+ 0,
+ state::AgentPanelScope::CurrentWorkspace,
+ config.ui.sidebar_width,
+ state::SidebarWidthSource::ConfigDefault,
+ 0.5_f32,
+ )
+ } else if let Some(snap) = crate::persist::load() {
+ let ws = crate::persist::restore(
+ &snap,
+ 24,
+ 80,
+ config.advanced.scrollback_limit_bytes,
+ event_tx.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ );
+ if ws.is_empty() {
+ crate::logging::session_restored(0, "empty");
(
Vec::new(),
None,
0,
- state::AgentPanelScope::CurrentWorkspace,
- config.ui.sidebar_width,
- 0.5_f32,
+ snap.agent_panel_scope,
+ snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
+ if snap.sidebar_width.is_some() {
+ state::SidebarWidthSource::Persisted
+ } else {
+ state::SidebarWidthSource::ConfigDefault
+ },
+ snap.sidebar_section_split.unwrap_or(0.5),
)
- } else if let Some(snap) = crate::persist::load() {
- let ws = crate::persist::restore(
- &snap,
- 24,
- 80,
- config.advanced.scrollback_limit_bytes,
- event_tx.clone(),
- render_notify.clone(),
- render_dirty.clone(),
- );
- if ws.is_empty() {
- crate::logging::session_restored(0, "empty");
- (
- Vec::new(),
- None,
- 0,
- snap.agent_panel_scope,
- snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
- snap.sidebar_section_split.unwrap_or(0.5),
- )
- } else {
- crate::logging::session_restored(ws.len(), "ok");
- let active = snap.active.filter(|&i| i < ws.len());
- let selected = snap.selected.min(ws.len().saturating_sub(1));
- (
- ws,
- active,
- selected,
- snap.agent_panel_scope,
- snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
- snap.sidebar_section_split.unwrap_or(0.5),
- )
- }
} else {
+ crate::logging::session_restored(ws.len(), "ok");
+ let active = snap.active.filter(|&i| i < ws.len());
+ let selected = snap.selected.min(ws.len().saturating_sub(1));
(
- Vec::new(),
- None,
- 0,
- state::AgentPanelScope::CurrentWorkspace,
- config.ui.sidebar_width,
- 0.5_f32,
+ ws,
+ active,
+ selected,
+ snap.agent_panel_scope,
+ snap.sidebar_width.unwrap_or(config.ui.sidebar_width),
+ if snap.sidebar_width.is_some() {
+ state::SidebarWidthSource::Persisted
+ } else {
+ state::SidebarWidthSource::ConfigDefault
+ },
+ snap.sidebar_section_split.unwrap_or(0.5),
)
- };
+ }
+ } else {
+ (
+ Vec::new(),
+ None,
+ 0,
+ state::AgentPanelScope::CurrentWorkspace,
+ config.ui.sidebar_width,
+ state::SidebarWidthSource::ConfigDefault,
+ 0.5_f32,
+ )
+ };
info!(
pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes,
@@ -248,6 +275,7 @@ impl App {
request_new_workspace: false,
request_new_tab: false,
request_reload_config: false,
+ request_client_sound_config_reload: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
@@ -291,6 +319,7 @@ impl App {
prefix_mods,
default_sidebar_width: config.ui.sidebar_width,
sidebar_width,
+ sidebar_width_source,
sidebar_width_auto: false,
sidebar_collapsed: false,
sidebar_section_split,
@@ -542,7 +571,12 @@ impl App {
) -> crate::config::ConfigReloadReport {
let previous_toast = self.state.toast.clone();
let report = match crate::config::load_live_config() {
- Ok(loaded) => self.apply_live_config(&loaded.config, notify_success),
+ Ok(loaded) => self.apply_live_config(
+ &loaded.config,
+ &loaded.diagnostics,
+ &loaded.invalid_sections,
+ notify_success,
+ ),
Err(diagnostics) => {
self.state.toast = None;
self.state.config_diagnostic =
@@ -561,43 +595,59 @@ impl App {
fn apply_live_config(
&mut self,
config: &crate::config::Config,
+ load_diagnostics: &[String],
+ invalid_sections: &[String],
notify_success: bool,
) -> crate::config::ConfigReloadReport {
- let mut diagnostics = Vec::new();
+ let mut diagnostics = load_diagnostics.to_vec();
+ let invalid_section =
+ |section: &str| invalid_sections.iter().any(|invalid| invalid == section);
+
+ if !invalid_section("keys") {
+ match config.live_keybinds() {
+ Ok(live) => {
+ self.state.prefix_code = live.prefix.0;
+ self.state.prefix_mods = live.prefix.1;
+ self.state.keybinds = live.keybinds;
+ }
+ Err(keybind_diagnostics) => {
+ let mut message = keybind_diagnostics.join("; ");
+ if !message.contains("keeping current keybinds") {
+ message.push_str("; keeping current keybinds");
+ }
+ diagnostics.push(message);
+ }
+ }
+ }
+
+ if !invalid_section("ui") {
+ diagnostics.extend(config.ui.sound.diagnostics());
- match config.live_keybinds() {
- Ok(live) => {
- self.state.prefix_code = live.prefix.0;
- self.state.prefix_mods = live.prefix.1;
- self.state.keybinds = live.keybinds;
+ self.state.default_sidebar_width = config.ui.sidebar_width;
+ if self.state.sidebar_width_source == state::SidebarWidthSource::ConfigDefault {
+ self.state.sidebar_width = config.ui.sidebar_width;
}
- Err(keybind_diagnostics) => {
- let mut message = keybind_diagnostics.join("; ");
- if !message.contains("keeping current keybinds") {
- message.push_str("; keeping current keybinds");
- }
- diagnostics.push(message);
+ self.state.confirm_close = config.ui.confirm_close;
+ self.state.accent = crate::config::parse_color(&config.ui.accent);
+ if !self.state.local_sound_playback && self.state.sound != config.ui.sound {
+ self.state.request_client_sound_config_reload = true;
}
+ self.state.sound = config.ui.sound.clone();
+ self.state.toast_config = config.ui.toast.clone();
}
- diagnostics.extend(config.ui.sound.diagnostics());
+ if !invalid_section("advanced") {
+ self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
+ }
- let previous_default_sidebar_width = self.state.default_sidebar_width;
- self.state.default_sidebar_width = config.ui.sidebar_width;
- if self.state.sidebar_width == previous_default_sidebar_width {
- self.state.sidebar_width = config.ui.sidebar_width;
+ if !invalid_section("theme") {
+ self.state.palette = resolve_palette_with_legacy_accent(config, !invalid_section("ui"));
+ self.state.theme_name = config
+ .theme
+ .name
+ .clone()
+ .unwrap_or_else(|| "catppuccin".to_string());
}
- self.state.confirm_close = config.ui.confirm_close;
- self.state.pane_scrollback_limit_bytes = config.advanced.scrollback_limit_bytes;
- self.state.accent = crate::config::parse_color(&config.ui.accent);
- self.state.sound = config.ui.sound.clone();
- self.state.toast_config = config.ui.toast.clone();
- self.state.palette = resolve_palette(config);
- self.state.theme_name = config
- .theme
- .name
- .clone()
- .unwrap_or_else(|| "catppuccin".to_string());
let status = if diagnostics.is_empty() {
crate::config::ConfigReloadStatus::Applied
@@ -904,6 +954,37 @@ mod tests {
}
#[test]
+ fn reload_config_updates_sidebar_width_only_when_config_owned() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-sidebar-width");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ assert_eq!(
+ app.state.sidebar_width_source,
+ state::SidebarWidthSource::ConfigDefault
+ );
+
+ std::fs::write(&path, "[ui]\nsidebar_width = 42\n").unwrap();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.default_sidebar_width, 42);
+ assert_eq!(app.state.sidebar_width, 42);
+
+ app.state.sidebar_width = 31;
+ app.state.sidebar_width_source = state::SidebarWidthSource::Manual;
+ std::fs::write(&path, "[ui]\nsidebar_width = 44\n").unwrap();
+ let report = app.reload_config();
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Applied);
+ assert_eq!(app.state.default_sidebar_width, 44);
+ assert_eq!(app.state.sidebar_width, 31);
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn reload_config_keeps_current_keybinds_on_invalid_binding_but_applies_other_sections() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("reload-config-invalid-keybind");
@@ -944,6 +1025,41 @@ mod tests {
}
#[test]
+ fn reload_config_preserves_invalid_ui_section_but_applies_valid_keys() {
+ let _guard = config_env_lock().lock().unwrap();
+ let path = temp_config_path("reload-config-invalid-ui-section");
+ std::fs::create_dir_all(path.parent().unwrap()).unwrap();
+ std::fs::write(
+ &path,
+ "[keys]\nnew_workspace = \"g\"\n[ui.toast]\ndelivery = \"desktop\"\n",
+ )
+ .unwrap();
+ std::env::set_var(crate::config::CONFIG_PATH_ENV_VAR, &path);
+
+ let mut app = test_app();
+ app.state.toast_config.delivery = crate::config::ToastDelivery::Herdr;
+ let report = app.reload_config();
+
+ assert_eq!(report.status, crate::config::ConfigReloadStatus::Partial);
+ assert_eq!(
+ app.state.keybinds.new_workspace,
+ (KeyCode::Char('g'), KeyModifiers::empty())
+ );
+ assert_eq!(
+ app.state.toast_config.delivery,
+ crate::config::ToastDelivery::Herdr
+ );
+ assert!(app
+ .state
+ .config_diagnostic
+ .as_deref()
+ .is_some_and(|message| message.contains("invalid ui config")));
+
+ std::env::remove_var(crate::config::CONFIG_PATH_ENV_VAR);
+ let _ = std::fs::remove_dir_all(path.parent().unwrap());
+ }
+
+ #[test]
fn settings_save_toast_delivery_persists_then_applies_live_config() {
let _guard = config_env_lock().lock().unwrap();
let path = temp_config_path("settings-save-toast-delivery");
diff --git a/src/app/state.rs b/src/app/state.rs
index a51a72e7..971b1476 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -578,6 +578,13 @@ pub struct KeybindHelpState {
pub scroll: u16,
}
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum SidebarWidthSource {
+ ConfigDefault,
+ Persisted,
+ Manual,
+}
+
/// All application state — pure data, no channels or async runtime.
/// Testable without PTYs or a tokio runtime.
pub struct AppState {
@@ -594,6 +601,9 @@ pub struct AppState {
pub request_new_workspace: bool,
pub request_new_tab: bool,
pub request_reload_config: bool,
+ /// Set when the headless server should ask attached clients to reload
+ /// their client-local sound config from disk.
+ pub request_client_sound_config_reload: bool,
/// Set when UI interaction requested a clipboard write that must be
/// handled by the outer App/event loop instead of directly from AppState.
pub request_clipboard_write: Option<Vec<u8>>,
@@ -626,6 +636,7 @@ pub struct AppState {
pub prefix_mods: KeyModifiers,
pub default_sidebar_width: u16,
pub sidebar_width: u16,
+ pub sidebar_width_source: SidebarWidthSource,
pub sidebar_width_auto: bool,
pub sidebar_collapsed: bool,
/// Ratio of sidebar height allocated to the workspaces section.
@@ -719,6 +730,7 @@ impl AppState {
request_new_workspace: false,
request_new_tab: false,
request_reload_config: false,
+ request_client_sound_config_reload: false,
request_clipboard_write: None,
creating_new_tab: false,
requested_new_tab_name: None,
@@ -757,6 +769,7 @@ impl AppState {
prefix_mods: KeyModifiers::CONTROL,
default_sidebar_width: 26,
sidebar_width: 26,
+ sidebar_width_source: SidebarWidthSource::ConfigDefault,
sidebar_width_auto: false,
sidebar_collapsed: false,
sidebar_section_split: 0.5,
diff --git a/src/client/mod.rs b/src/client/mod.rs
index add890c9..19d07bfb 100644
--- a/src/client/mod.rs
+++ b/src/client/mod.rs
@@ -43,6 +43,8 @@ struct ClientState {
last_frame: Option<FrameData>,
/// The terminal size we reported to the server in our last Hello/Resize.
reported_size: (u16, u16),
+ /// Client-local sound playback config, refreshed on server request.
+ sound_config: crate::config::SoundConfig,
}
// ---------------------------------------------------------------------------
@@ -355,6 +357,7 @@ async fn run_client_loop(
let mut state = ClientState {
last_frame: None,
reported_size: (cols, rows),
+ sound_config,
};
// Channel for events from the stdin, resize, and server reader threads.
@@ -426,12 +429,15 @@ async fn run_client_loop(
return Err(ClientError::ServerShutdown { reason });
}
ServerMessage::Notify { kind, message } => {
- handle_notify(kind, &message, &sound_config);
+ handle_notify(kind, &message, &state.sound_config);
}
ServerMessage::Clipboard { data } => {
forward_clipboard(&data);
let _ = io::stdout().flush();
}
+ ServerMessage::ReloadSoundConfig => {
+ reload_local_sound_config(&mut state.sound_config);
+ }
ServerMessage::Welcome { .. } => {
debug!("received unexpected Welcome in main loop");
}
@@ -524,6 +530,21 @@ fn write_to_server(stream: &mut UnixStream, msg: &ClientMessage) -> io::Result<(
// Notifications
// ---------------------------------------------------------------------------
+fn reload_local_sound_config(sound_config: &mut crate::config::SoundConfig) {
+ match crate::config::load_live_config() {
+ Ok(loaded) => {
+ for diagnostic in loaded.config.ui.sound.diagnostics() {
+ warn!(diagnostic = %diagnostic, "local sound config diagnostic");
+ }
+ *sound_config = loaded.config.ui.sound;
+ debug!("reloaded local sound config");
+ }
+ Err(diagnostics) => {
+ warn!(diagnostics = ?diagnostics, "failed to reload local sound config; keeping current sound config");
+ }
+ }
+}
+
fn handle_notify(kind: NotifyKind, message: &str, sound_config: &crate::config::SoundConfig) {
handle_notify_with_terminal_notifier(
kind,
diff --git a/src/config/io.rs b/src/config/io.rs
index 2774fc93..16602972 100644
--- a/src/config/io.rs
+++ b/src/config/io.rs
@@ -33,6 +33,7 @@ impl Config {
return LoadedConfig {
config,
diagnostics,
+ invalid_sections: Vec::new(),
};
}
Err(err) => {
@@ -40,6 +41,7 @@ impl Config {
return LoadedConfig {
config: Self::default(),
diagnostics: vec![format!("config parse error: {err}; using defaults")],
+ invalid_sections: Vec::new(),
};
}
},
@@ -48,6 +50,7 @@ impl Config {
return LoadedConfig {
config: Self::default(),
diagnostics: vec![format!("config read error: {err}; using defaults")],
+ invalid_sections: Vec::new(),
};
}
}
@@ -55,6 +58,7 @@ impl Config {
LoadedConfig {
config: Self::default(),
diagnostics: Vec::new(),
+ invalid_sections: Vec::new(),
}
}
}
@@ -97,19 +101,104 @@ pub fn load_live_config() -> Result<LoadedConfig, Vec<String>> {
return Ok(LoadedConfig {
config: Config::default(),
diagnostics: Vec::new(),
+ invalid_sections: Vec::new(),
});
}
let content = std::fs::read_to_string(&path)
.map_err(|err| vec![format!("config read error: {err}; keeping current config")])?;
- let config = toml::from_str::<Config>(&content)
+ load_live_config_from_str(&content)
+}
+
+fn load_live_config_from_str(content: &str) -> Result<LoadedConfig, Vec<String>> {
+ let value = content
+ .parse::<toml::Value>()
.map_err(|err| vec![format!("config parse error: {err}; keeping current config")])?;
+ let table = value.as_table().ok_or_else(|| {
+ vec![
+ "config parse error: top-level config must be a table; keeping current config"
+ .to_string(),
+ ]
+ })?;
+
+ let mut config = Config::default();
+ let mut diagnostics = Vec::new();
+ let mut invalid_sections = Vec::new();
+
+ if let Some(value) = table.get("onboarding") {
+ match value.clone().try_into::<Option<bool>>() {
+ Ok(onboarding) => config.onboarding = onboarding,
+ Err(err) => diagnostics.push(format!(
+ "invalid onboarding setting: {err}; keeping current onboarding state"
+ )),
+ }
+ }
+
+ load_live_section(
+ table,
+ "theme",
+ "theme config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.theme = section,
+ );
+ load_live_section(
+ table,
+ "keys",
+ "keybinding config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.keys = section,
+ );
+ load_live_section(
+ table,
+ "ui",
+ "ui config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.ui = section,
+ );
+ load_live_section(
+ table,
+ "advanced",
+ "advanced config",
+ &mut diagnostics,
+ &mut invalid_sections,
+ |section| config.advanced = section,
+ );
+
Ok(LoadedConfig {
config,
- diagnostics: Vec::new(),
+ diagnostics,
+ invalid_sections,
})
}
+fn load_live_section<T>(
+ table: &toml::map::Map<String, toml::Value>,
+ section: &'static str,
+ label: &str,
+ diagnostics: &mut Vec<String>,
+ invalid_sections: &mut Vec<String>,
+ apply: impl FnOnce(T),
+) where
+ T: serde::de::DeserializeOwned,
+{
+ let Some(value) = table.get(section) else {
+ return;
+ };
+
+ match value.clone().try_into::<T>() {
+ Ok(section_config) => apply(section_config),
+ Err(err) => {
+ diagnostics.push(format!(
+ "invalid {label}: {err}; keeping current {section} settings"
+ ));
+ invalid_sections.push(section.to_string());
+ }
+ }
+}
+
pub(crate) fn upsert_top_level_bool(content: &str, key: &str, value: bool) -> String {
let replacement = format!("{key} = {value}");
let mut lines: Vec<String> = content.lines().map(|line| line.to_string()).collect();
diff --git a/src/config/model.rs b/src/config/model.rs
index 32d60493..5a17bea7 100644
--- a/src/config/model.rs
+++ b/src/config/model.rs
@@ -44,6 +44,7 @@ pub struct Config {
pub struct LoadedConfig {
pub config: Config,
pub diagnostics: Vec<String>,
+ pub invalid_sections: Vec<String>,
}
#[derive(Debug, Deserialize)]
diff --git a/src/config/sound.rs b/src/config/sound.rs
index 063a521b..6219d698 100644
--- a/src/config/sound.rs
+++ b/src/config/sound.rs
@@ -6,7 +6,7 @@ use crate::detect::Agent;
use super::io::resolve_config_relative_path;
-#[derive(Debug, Clone, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct SoundConfig {
pub enabled: bool,
@@ -22,7 +22,7 @@ pub struct SoundConfig {
pub agents: AgentSoundOverrides,
}
-#[derive(Debug, Clone, Deserialize)]
+#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(default)]
pub struct AgentSoundOverrides {
pub pi: AgentSoundSetting,
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c2e814bf..b37538df 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -518,6 +518,8 @@ impl HeadlessServer {
needs_render = true;
}
+ self.drain_client_sound_config_reload_request();
+
self.app.sync_headless_animation_timer(now);
// 7. Render virtually and stream frames.
@@ -825,7 +827,7 @@ impl HeadlessServer {
crate::sound::Sound::Done => "agent done",
crate::sound::Sound::Request => "agent attention",
};
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Sound,
message: msg.to_owned(),
});
@@ -854,7 +856,7 @@ impl HeadlessServer {
};
if let Some(msg) = toast_msg {
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
message: msg,
});
@@ -921,7 +923,7 @@ impl HeadlessServer {
crate::sound::Sound::Done => "agent done",
crate::sound::Sound::Request => "agent attention",
};
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Sound,
message: msg.to_owned(),
});
@@ -950,7 +952,7 @@ impl HeadlessServer {
};
if let Some(msg) = toast_msg {
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
message: msg,
});
@@ -982,7 +984,7 @@ impl HeadlessServer {
};
if let Some(msg) = toast_msg {
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
message: msg,
});
@@ -1010,9 +1012,9 @@ impl HeadlessServer {
/// - Forward `ClipboardWrite` as `ServerMessage::Clipboard` to the
/// foreground client only.
/// - Detect when a sound would be played and forward as
- /// `ServerMessage::Notify { kind: Sound }` to all clients.
+ /// `ServerMessage::Notify { kind: Sound }` to the foreground client.
/// - Detect when a toast is set on AppState and forward as
- /// `ServerMessage::Notify { kind: Toast }` to all clients.
+ /// `ServerMessage::Notify { kind: Toast }` to the foreground client.
fn drain_internal_events_with_forwarding(&mut self) -> bool {
let mut changed = false;
while let Ok(ev) = self.app.event_rx.try_recv() {
@@ -1021,6 +1023,14 @@ impl HeadlessServer {
changed
}
+ fn drain_client_sound_config_reload_request(&mut self) {
+ if !self.app.state.request_client_sound_config_reload {
+ return;
+ }
+ self.app.state.request_client_sound_config_reload = false;
+ self.send_to_all_clients(ServerMessage::ReloadSoundConfig);
+ }
+
/// Encodes a server message into a length-prefixed frame.
fn frame_server_message(msg: &ServerMessage) -> Result<Vec<u8>, protocol::FramingError> {
let mut framed = Vec::new();
@@ -1058,6 +1068,14 @@ impl HeadlessServer {
}
}
+ /// Sends a client-local side effect to the foreground client only.
+ fn send_to_foreground_client(&mut self, msg: ServerMessage) -> bool {
+ let Some(client_id) = self.foreground_client_id else {
+ return false;
+ };
+ self.send_to_client(client_id, msg)
+ }
+
/// Sends a message to a specific client. Returns false if the client
/// was not found or the send failed (client removed).
fn send_to_client(&mut self, client_id: u64, msg: ServerMessage) -> bool {
@@ -1298,7 +1316,7 @@ impl HeadlessServer {
if let Some(toast) = &toast_after {
let msg_text = format!("{}: {}", toast.title, toast.context);
debug!(msg = %msg_text, "forwarding toast notification from API request");
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
message: msg_text,
});
@@ -1392,7 +1410,7 @@ impl HeadlessServer {
*pane_id,
)
);
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Toast,
message: msg_text,
});
@@ -1413,7 +1431,7 @@ impl HeadlessServer {
crate::sound::Sound::Request => "agent attention",
};
debug!(sound = ?sound, "forwarding sound notification from API request");
- self.send_to_all_clients(ServerMessage::Notify {
+ self.send_to_foreground_client(ServerMessage::Notify {
kind: protocol::NotifyKind::Sound,
message: msg_text.to_owned(),
});
@@ -2370,6 +2388,36 @@ mod tests {
}
#[test]
+ fn client_sound_reload_request_refreshes_attached_clients() {
+ let mut server = test_headless_server();
+ let (client_tx, client_rx) = std::sync::mpsc::channel();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ last_frame: None,
+ writer: Some(client_tx),
+ },
+ );
+ server.app.state.request_client_sound_config_reload = true;
+
+ server.drain_client_sound_config_reload_request();
+
+ match read_server_message(
+ client_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("client sound reload message"),
+ ) {
+ ServerMessage::ReloadSoundConfig => {}
+ other => panic!("expected ReloadSoundConfig, got {other:?}"),
+ }
+ assert!(!server.app.state.request_client_sound_config_reload);
+ }
+
+ #[test]
fn clipboard_write_targets_foreground_client_only() {
let mut server = test_headless_server();
let (background_tx, background_rx) = std::sync::mpsc::channel();
@@ -2420,6 +2468,59 @@ mod tests {
}
#[test]
+ fn client_local_notifications_target_foreground_client_only() {
+ let mut server = test_headless_server();
+ let (background_tx, background_rx) = std::sync::mpsc::channel();
+ let (foreground_tx, foreground_rx) = std::sync::mpsc::channel();
+
+ server.clients.insert(
+ 1,
+ ClientConnection {
+ terminal_size: (120, 40),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 1,
+ last_frame: None,
+ writer: Some(background_tx),
+ },
+ );
+ server.clients.insert(
+ 2,
+ ClientConnection {
+ terminal_size: (80, 24),
+ host_terminal_theme: crate::terminal_theme::TerminalTheme::default(),
+ last_activity: 2,
+ last_frame: None,
+ writer: Some(foreground_tx),
+ },
+ );
+ server.foreground_client_id = Some(2);
+ server.sync_foreground_client_state();
+
+ assert!(server.send_to_foreground_client(ServerMessage::Notify {
+ kind: protocol::NotifyKind::Toast,
+ message: "pi finished: workspace 1".to_string(),
+ }));
+
+ match read_server_message(
+ foreground_rx
+ .recv_timeout(Duration::from_millis(100))
+ .expect("foreground toast message"),
+ ) {
+ ServerMessage::Notify { kind, message } => {
+ assert_eq!(kind, protocol::NotifyKind::Toast);
+ assert_eq!(message, "pi finished: workspace 1");
+ }
+ other => panic!("expected toast notify, got {other:?}"),
+ }
+ assert!(
+ background_rx
+ .recv_timeout(Duration::from_millis(50))
+ .is_err(),
+ "background client should not receive client-local notifications"
+ );
+ }
+
+ #[test]
fn herdr_toast_delivery_keeps_toast_in_frame_without_client_notify() {
let mut server = test_headless_server();
let (client_tx, client_rx) = std::sync::mpsc::channel();
diff --git a/src/server/protocol.rs b/src/server/protocol.rs
index f3784a5e..ed0c6961 100644
--- a/src/server/protocol.rs
+++ b/src/server/protocol.rs
@@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------------------
/// Current protocol version. Bumped when wire format changes incompatibly.
-pub const PROTOCOL_VERSION: u32 = 1;
+pub const PROTOCOL_VERSION: u32 = 2;
/// Maximum allowed frame payload size (2 MB). Frames larger than this are
/// rejected to prevent denial-of-service via oversized length prefixes.
@@ -209,6 +209,9 @@ pub enum ServerMessage {
/// Base64-encoded clipboard data.
data: String,
},
+
+ /// Client-local sound config changed on disk; refresh it without reconnecting.
+ ReloadSoundConfig,
}
// ---------------------------------------------------------------------------
@@ -476,7 +479,7 @@ mod tests {
#[test]
fn client_hello_roundtrip() {
let msg = ClientMessage::Hello {
- version: 1,
+ version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
};
@@ -520,7 +523,7 @@ mod tests {
#[test]
fn server_welcome_roundtrip() {
let msg = ServerMessage::Welcome {
- version: 1,
+ version: PROTOCOL_VERSION,
error: None,
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
@@ -532,7 +535,7 @@ mod tests {
#[test]
fn server_welcome_with_error_roundtrip() {
let msg = ServerMessage::Welcome {
- version: 1,
+ version: PROTOCOL_VERSION,
error: Some("incompatible version".to_owned()),
};
let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
@@ -638,12 +641,21 @@ mod tests {
assert_eq!(msg, decoded);
}
+ #[test]
+ fn server_reload_sound_config_roundtrip() {
+ let msg = ServerMessage::ReloadSoundConfig;
+ let encoded = bincode::serde::encode_to_vec(&msg, bincode::config::standard()).unwrap();
+ let (decoded, _): (ServerMessage, _) =
+ bincode::serde::decode_from_slice(&encoded, bincode::config::standard()).unwrap();
+ assert_eq!(msg, decoded);
+ }
+
// ---- Framing ----
#[test]
fn framing_small_message_roundtrip() {
let msg = ClientMessage::Hello {
- version: 1,
+ version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
};
@@ -708,7 +720,7 @@ mod tests {
for i in 0..150u32 {
let msg = match i % 4 {
0 => ClientMessage::Hello {
- version: 1,
+ version: PROTOCOL_VERSION,
cols: (80 + (i % 40) as u16),
rows: (24 + (i % 20) as u16),
},
@@ -820,22 +832,19 @@ mod tests {
#[test]
fn version_compatible() {
- assert_eq!(check_client_version(1), VersionCheck::Compatible);
+ assert_eq!(
+ check_client_version(PROTOCOL_VERSION),
+ VersionCheck::Compatible
+ );
}
#[test]
fn version_older_client_rejected() {
- match check_client_version(1) {
- VersionCheck::Compatible => {} // current version
- _ => panic!("current version should be compatible"),
+ let result = check_client_version(PROTOCOL_VERSION - 1);
+ assert!(matches!(result, VersionCheck::Incompatible(_)));
+ if let VersionCheck::Incompatible(msg) = result {
+ assert!(msg.contains("older"), "error should mention older version");
}
- // If we imagine a future server version 2, a v1 client would be rejected.
- // We test this by checking the logic directly.
- let result = check_client_version(0);
- assert!(
- matches!(result, VersionCheck::Incompatible(_)),
- "version 0 should be incompatible"
- );
}
#[test]
@@ -1119,7 +1128,7 @@ mod tests {
fn read_message_accepts_exact_payload() {
// A normally-framed message should decode without error.
let msg = ClientMessage::Hello {
- version: 1,
+ version: PROTOCOL_VERSION,
cols: 80,
rows: 24,
};
@@ -1149,7 +1158,7 @@ mod tests {
let messages = vec![
ClientMessage::Hello {
- version: 1,
+ version: PROTOCOL_VERSION,
cols: 200,
rows: 60,
},
diff --git a/tests/client_mode.rs b/tests/client_mode.rs
index 3ee16aea..958b48a3 100644
--- a/tests/client_mode.rs
+++ b/tests/client_mode.rs
@@ -246,8 +246,8 @@ fn client_connects_and_receives_frame() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1, "server should report protocol version 1");
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2, "server should report protocol version 2");
assert!(
error.is_none(),
"handshake should not have error: {:?}",
@@ -326,8 +326,8 @@ fn client_sees_headless_startup_config_diagnostic() {
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
stream
@@ -375,8 +375,8 @@ fn client_input_forwarded_to_pane() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Send an Input message containing "echo hello\n".
@@ -429,8 +429,8 @@ fn client_resize_sends_message() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain the initial frame(s).
@@ -486,8 +486,8 @@ fn server_shutdown_sends_message_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Send SIGINT so the server takes the graceful shutdown path and
@@ -713,8 +713,8 @@ fn client_receives_frame_after_pane_output() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Read the initial frame (server renders immediately on client connect).
@@ -767,8 +767,8 @@ fn navigate_mode_keybind_dispatch_in_server() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -885,8 +885,8 @@ fn graceful_shutdown_sends_server_shutdown_to_client() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
@@ -983,8 +983,8 @@ fn client_receives_notify_on_agent_state_change() {
// Connect as a client and perform handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frame(s).
diff --git a/tests/cross_area.rs b/tests/cross_area.rs
index 5758fdd2..df2dced1 100644
--- a/tests/cross_area.rs
+++ b/tests/cross_area.rs
@@ -665,7 +665,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Local attach (client A).
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 1, 100, 30);
+ client_handshake(&mut client_a, 2, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
// Use herdr: create a workspace and write output into its pane.
@@ -702,7 +702,7 @@ fn cross_area_detach_and_reattach_preserves_state() {
// Reattach from another terminal/session (client B).
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 1, 80, 24);
+ client_handshake(&mut client_b, 2, 80, 24);
assert!(
wait_for_frame(&mut client_b, Duration::from_secs(5)),
"reattached client should receive frame"
@@ -758,7 +758,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 1, 100, 30);
+ client_handshake(&mut client_a, 2, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
let created = workspace_create(&api_socket, "agent-persist");
@@ -811,7 +811,7 @@ fn cross_area_agent_process_survives_detach_and_reattach() {
// Reattach and ensure client-side state reflects the persisted working status.
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 1, 80, 24);
+ client_handshake(&mut client_b, 2, 80, 24);
let saw_working_on_client =
wait_for_frame_matching(&mut client_b, Duration::from_secs(5), |frame| {
frame_contains_text(frame, "working")
@@ -856,7 +856,7 @@ fn cross_area_client_and_api_workspace_views_are_consistent() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client = UnixStream::connect(&client_socket).expect("client should connect");
- client_handshake(&mut client, 1, 100, 30);
+ client_handshake(&mut client, 2, 100, 30);
assert!(wait_for_frame(&mut client, Duration::from_secs(2)));
drain_server_messages(&mut client, Duration::from_millis(300));
@@ -919,9 +919,9 @@ fn cross_area_two_clients_shared_view_and_single_detach_stability() {
wait_for_socket(&client_socket, Duration::from_secs(10));
let mut client_a = UnixStream::connect(&client_socket).expect("client A should connect");
- client_handshake(&mut client_a, 1, 110, 30);
+ client_handshake(&mut client_a, 2, 110, 30);
let mut client_b = UnixStream::connect(&client_socket).expect("client B should connect");
- client_handshake(&mut client_b, 1, 100, 30);
+ client_handshake(&mut client_b, 2, 100, 30);
assert!(wait_for_frame(&mut client_a, Duration::from_secs(2)));
assert!(wait_for_frame(&mut client_b, Duration::from_secs(2)));
@@ -1087,7 +1087,7 @@ fn cross_area_server_kill_then_restart_and_reconnect() {
let mut reconnect_client =
UnixStream::connect(&client_socket).expect("new client should connect after restart");
- client_handshake(&mut reconnect_client, 1, 80, 24);
+ client_handshake(&mut reconnect_client, 2, 80, 24);
assert!(
wait_for_frame(&mut reconnect_client, Duration::from_secs(5)),
"new client should receive frame after restart"
diff --git a/tests/detach_reattach.rs b/tests/detach_reattach.rs
index 9eda9e36..8c772db5 100644
--- a/tests/detach_reattach.rs
+++ b/tests/detach_reattach.rs
@@ -216,8 +216,8 @@ fn navigate_q_detaches_client_and_server_persists() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -278,8 +278,8 @@ fn explicit_detach_message_causes_clean_disconnect() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -337,8 +337,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client A ---
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream_a, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -376,8 +376,8 @@ fn reattach_after_detach_shows_current_state() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream_b, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(
error.is_none(),
"reattach handshake should succeed: {:?}",
@@ -456,8 +456,8 @@ fn processes_survive_during_and_after_detach() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -495,8 +495,8 @@ fn processes_survive_during_and_after_detach() {
// Reattach — verify we can connect and receive a frame.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 1, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream_b, 2, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Verify the reattached client receives a frame.
@@ -544,8 +544,8 @@ fn server_persists_after_client_connection_drop() {
// Connect and handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect");
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Drain initial frames.
@@ -571,8 +571,8 @@ fn server_persists_after_client_connection_drop() {
// Reattach — verify we can connect and handshake again.
let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach");
let (version, error) =
- client_handshake(&mut stream_b, 1, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream_b, 2, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "reattach should succeed: {:?}", error);
cleanup_spawned_herdr(spawned, base);
@@ -604,8 +604,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// Connect and handshake client A.
let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect");
let (version, error) =
- client_handshake(&mut stream_a, 1, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream_a, 2, 80, 24).expect("handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Detach client A immediately.
@@ -662,8 +662,8 @@ fn output_accumulated_while_detached_visible_on_reattach() {
// --- Client B (reattach) ---
let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect");
let (version, error) =
- client_handshake(&mut stream_b, 1, 80, 24).expect("reattach handshake should succeed");
- assert_eq!(version, 1);
+ client_handshake(&mut stream_b, 2, 80, 24).expect("reattach handshake should succeed");
+ assert_eq!(version, 2);
assert!(error.is_none(), "{:?}", error);
// Client B should receive a frame with the current state.
diff --git a/tests/multi_client.rs b/tests/multi_client.rs
index 46eca891..062a0f60 100644
--- a/tests/multi_client.rs
+++ b/tests/multi_client.rs
@@ -542,7 +542,7 @@ fn client_handshake(
fn connect_raw_client(client_socket: &Path, cols: u16, rows: u16) -> UnixStream {
let mut stream = UnixStream::connect(client_socket).expect("should connect to client socket");
- client_handshake(&mut stream, 1, cols, rows).expect("handshake should succeed");
+ client_handshake(&mut stream, 2, cols, rows).expect("handshake should succeed");
stream
}
diff --git a/tests/server_headless.rs b/tests/server_headless.rs
index 98ca0b08..19b78e95 100644
--- a/tests/server_headless.rs
+++ b/tests/server_headless.rs
@@ -576,11 +576,11 @@ fn client_handshake_succeeds() {
// Connect to the client socket and perform a handshake.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- // Send Hello with version 1, 80 cols, 24 rows.
+ // Send Hello with version 2, 80 cols, 24 rows.
let (version, error) =
- client_handshake(&mut stream, 1, 80, 24).expect("handshake should succeed");
+ client_handshake(&mut stream, 2, 80, 24).expect("handshake should succeed");
- assert_eq!(version, 1, "server should report protocol version 1");
+ assert_eq!(version, 2, "server should report protocol version 2");
assert!(
error.is_none(),
"handshake should not have an error: {:?}",
@@ -609,7 +609,7 @@ fn client_handshake_rejects_incompatible_version() {
let (version, error) = client_handshake(&mut stream, 0, 80, 24)
.expect("should read Welcome response even on rejection");
- assert_eq!(version, 1, "server should report its version 1");
+ assert_eq!(version, 2, "server should report its version 2");
assert!(
error.is_some(),
"version 0 should be rejected with an error"
@@ -634,10 +634,10 @@ fn client_handshake_clamps_small_terminal_size() {
// Send Hello with 0x0 terminal size — should be clamped.
let mut stream = UnixStream::connect(&client_socket).expect("should connect to client socket");
- let (version, error) = client_handshake(&mut stream, 1, 0, 0)
+ let (version, error) = client_handshake(&mut stream, 2, 0, 0)
.expect("handshake with 0x0 should succeed (server clamps)");
- assert_eq!(version, 1);
+ assert_eq!(version, 2);
assert!(
error.is_none(),
"0x0 size should be accepted (clamped): {:?}",
@@ -697,9 +697,9 @@ fn no_hello_client_closed_within_five_seconds() {
// Verify the server is still healthy — a proper client can still connect.
let mut good_stream =
UnixStream::connect(&client_socket).expect("should connect after no-hello client");
- let (version, error) = client_handshake(&mut good_stream, 1, 80, 24)
+ let (version, error) = client_handshake(&mut good_stream, 2, 80, 24)
.expect("proper handshake should still work after no-hello client");
- assert_eq!(version, 1);
+ assert_eq!(version, 2);
assert!(error.is_none());
// API should still work.