aboutsummaryrefslogtreecommitdiff
path: root/src/server
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 /src/server
parent3aa7903d28f94e67c14dd5cad48bbe942fbef2e9 (diff)
feat: refine live config lifecycle
Diffstat (limited to 'src/server')
-rw-r--r--src/server/headless.rs121
-rw-r--r--src/server/protocol.rs47
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![
ClientMessage::Hello {
- version: 1,
+ version: PROTOCOL_VERSION,
cols: 200,
rows: 60,
},