diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-26 04:01:01 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-04-26 17:06:00 +0300 |
| commit | e9547a8e0de21d0ce81867b06511b68def16aff2 (patch) | |
| tree | 8a025301146434b79ff295ff0d0afc833c682502 /src/server | |
| parent | 3aa7903d28f94e67c14dd5cad48bbe942fbef2e9 (diff) | |
feat: refine live config lifecycle
Diffstat (limited to 'src/server')
| -rw-r--r-- | src/server/headless.rs | 121 | ||||
| -rw-r--r-- | src/server/protocol.rs | 47 |
2 files changed, 139 insertions, 29 deletions
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