aboutsummaryrefslogtreecommitdiff
path: root/src/server/headless.rs
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 00:07:24 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-06-07 16:29:44 +0300
commit3c2b46d24aa6c8011a1e198143752f2aa414d585 (patch)
treea52280bccc824ca656f8bc986a756f30901974e4 /src/server/headless.rs
parent5d7e56737ef30d9de5802b0684a9428c40131dda (diff)
feat: add native windows beta support
Diffstat (limited to 'src/server/headless.rs')
-rw-r--r--src/server/headless.rs405
1 files changed, 319 insertions, 86 deletions
diff --git a/src/server/headless.rs b/src/server/headless.rs
index c78df8a1..f313161f 100644
--- a/src/server/headless.rs
+++ b/src/server/headless.rs
@@ -16,13 +16,17 @@
use std::collections::HashMap;
use std::io;
-use std::os::unix::net::UnixListener;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use std::time::{Duration, Instant};
use crossterm::event::{KeyModifiers, MouseEventKind};
+use interprocess::local_socket::traits::Listener as _;
+#[cfg(windows)]
+use interprocess::local_socket::traits::Stream as _;
+#[cfg(unix)]
+use interprocess::local_socket::ListenerNonblockingMode;
use ratatui::layout::Rect;
use tokio::sync::mpsc;
use tracing::{debug, error, info, warn};
@@ -34,11 +38,15 @@ use crate::api;
use crate::app;
use crate::config;
use crate::events::AppEvent;
-use crate::ipc::{remove_socket_file_if_owned, socket_file_identity, SocketFileIdentity};
+use crate::ipc::{
+ bind_local_listener, remove_socket_file_if_owned, socket_file_identity, LocalListener,
+ SocketFileIdentity,
+};
use crate::protocol::{
self, AttachScrollDirection, AttachScrollSource, FrameData, ServerMessage, MAX_FRAME_SIZE,
MAX_GRAPHICS_FRAME_SIZE,
};
+#[cfg(unix)]
use crate::server::client_accept::{
accept_pending_client_connections, reject_pending_client_connections,
};
@@ -167,7 +175,7 @@ const MIN_ROWS: u16 = 24;
#[allow(dead_code)]
const SHUTDOWN_API_TIMEOUT: Duration = Duration::from_secs(5);
-/// How often the idle headless loop wakes to poll the std UnixListener for new
+/// How often the idle headless loop wakes to poll the local listener for new
/// client connections.
///
/// The listener is non-blocking and not integrated into `tokio::select!`, so
@@ -183,12 +191,16 @@ const CLIENT_ACCEPT_POLL_INTERVAL: Duration = Duration::from_millis(250);
/// The headless server — runs the herdr event loop without a real terminal.
pub struct HeadlessServer {
app: app::App,
+ #[cfg(unix)]
api_tx: Option<api::ApiRequestSender>,
+ #[cfg(unix)]
api_server: Option<api::ServerHandle>,
- client_listener: UnixListener,
+ #[cfg(unix)]
+ client_listener: LocalListener,
client_socket_path: PathBuf,
client_socket_identity: SocketFileIdentity,
clients: HashMap<u64, ClientConnection>,
+ #[cfg(unix)]
next_client_id: u64,
/// The client currently driving the shared pane runtime size, theme, and input keybindings.
foreground_client_id: Option<u64>,
@@ -210,6 +222,7 @@ pub struct HeadlessServer {
/// Flag set while exporting live PTYs to a replacement server.
handoff_in_progress: bool,
/// Imported panes get one app-safe resize nudge after the first client attaches.
+ #[cfg(unix)]
pending_handoff_repaint_nudge: bool,
/// Flag set by Ctrl+C or `server stop` signal.
should_quit: Arc<AtomicBool>,
@@ -292,6 +305,51 @@ fn apply_terminal_attach_input(
.map_err(|err| format!("terminal attach input failed: {err}"))
}
+#[cfg(windows)]
+fn spawn_windows_client_accept_thread(
+ listener: LocalListener,
+ should_quit: Arc<AtomicBool>,
+ server_event_tx: mpsc::Sender<ServerEvent>,
+) {
+ std::thread::spawn(move || {
+ let mut next_client_id = 1_u64;
+ while !should_quit.load(Ordering::Acquire) {
+ let stream = match listener.accept() {
+ Ok(stream) => stream,
+ Err(err) => {
+ if should_quit.load(Ordering::Acquire) {
+ break;
+ }
+ error!(err = %err, "client listener accept failed");
+ std::thread::sleep(Duration::from_millis(50));
+ continue;
+ }
+ };
+
+ let client_id = next_client_id;
+ next_client_id = next_client_id.saturating_add(1);
+
+ if let Err(err) = stream.set_nonblocking(true) {
+ warn!(err = %err, "failed to set client stream nonblocking");
+ continue;
+ }
+
+ let should_quit = should_quit.clone();
+ let server_event_tx = server_event_tx.clone();
+ std::thread::spawn(move || {
+ if let Err(err) = crate::server::client_transport::handle_client_handshake(
+ stream,
+ client_id,
+ &server_event_tx,
+ &should_quit,
+ ) {
+ debug!(client_id, err = %err, "client handshake failed");
+ }
+ });
+ }
+ });
+}
+
impl HeadlessServer {
/// Creates and starts the headless server.
///
@@ -308,30 +366,40 @@ impl HeadlessServer {
let client_path = client_socket_path();
prepare_socket_path(&client_path)?;
- let listener = UnixListener::bind(&client_path)?;
+ let listener = bind_local_listener(&client_path)?;
restrict_socket_permissions(&client_path)?;
let client_socket_identity = socket_file_identity(&client_path)?;
info!(path = %client_path.display(), "client protocol socket listening");
- // Set non-blocking on the listener so we can poll it from the event loop.
- listener.set_nonblocking(true)?;
+ // Set non-blocking on Unix so we can poll it from the event loop.
+ #[cfg(unix)]
+ listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
let should_quit = Arc::new(AtomicBool::new(false));
// Channel for server events from client threads.
let (server_event_tx, server_event_rx) = mpsc::channel(64);
+ #[cfg(windows)]
+ spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
+
let server_keybindings = app_keybindings(&app);
let (server_config_diagnostic, server_config_diagnostic_without_keybindings) =
server_config_diagnostic_summaries(config_diagnostics);
+ #[cfg(not(unix))]
+ let _ = (&api_tx, &api_server);
Ok(Self {
app,
+ #[cfg(unix)]
api_tx,
+ #[cfg(unix)]
api_server,
+ #[cfg(unix)]
client_listener: listener,
client_socket_path: client_path,
client_socket_identity,
clients: HashMap::new(),
+ #[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
server_keybindings,
@@ -342,6 +410,7 @@ impl HeadlessServer {
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
handoff_in_progress: false,
+ #[cfg(unix)]
pending_handoff_repaint_nudge: false,
should_quit,
server_event_rx,
@@ -891,7 +960,7 @@ impl HeadlessServer {
} else {
let _ = std::fs::remove_file(crate::api::socket_path());
}
- let _ = remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity);
+ let _ = remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity);
if let Err(err) = crate::server::handoff::wait_ready(&mut stream) {
crate::server::handoff::cleanup_failed_import_child(&mut import_child);
match self.wait_then_restore_public_sockets_after_failed_handoff() {
@@ -972,10 +1041,10 @@ impl HeadlessServer {
let client_path = client_socket_path();
prepare_socket_path(&client_path)?;
- let listener = UnixListener::bind(&client_path)?;
+ let listener = bind_local_listener(&client_path)?;
restrict_socket_permissions(&client_path)?;
let client_socket_identity = socket_file_identity(&client_path)?;
- listener.set_nonblocking(true)?;
+ listener.set_nonblocking(ListenerNonblockingMode::Accept)?;
self.api_server = Some(api_server);
self.client_listener = listener;
@@ -1188,6 +1257,7 @@ impl HeadlessServer {
}
/// Accepts pending client connections from the non-blocking listener.
+ #[cfg(unix)]
fn accept_client_connections(&mut self) -> io::Result<()> {
if self.handoff_in_progress {
return reject_pending_client_connections(&self.client_listener);
@@ -1200,6 +1270,13 @@ impl HeadlessServer {
)
}
+ /// Windows named-pipe clients can block in connect unless the server has a
+ /// pending blocking accept. The dedicated accept thread handles that path.
+ #[cfg(windows)]
+ fn accept_client_connections(&mut self) -> io::Result<()> {
+ Ok(())
+ }
+
/// Drains server events from the dedicated channel.
///
/// Returns true if any input was processed (requiring a re-render).
@@ -1863,6 +1940,7 @@ impl HeadlessServer {
}
}
+ #[cfg(unix)]
fn disconnect_all_clients_for_handoff(&mut self) {
let client_ids = self.clients.keys().copied().collect::<Vec<_>>();
for client_id in client_ids {
@@ -1961,6 +2039,68 @@ impl HeadlessServer {
}
/// Handles a server event. Returns true if the event requires a re-render.
+ fn handle_client_input_events(
+ &mut self,
+ client_id: u64,
+ events: Vec<crate::raw_input::RawInputEvent>,
+ ) -> bool {
+ let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
+ &events,
+ self.app.state.redraw_on_focus_gained,
+ );
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ if host_surface_redraw {
+ client.request_full_redraw();
+ client.render_pending = true;
+ } else {
+ // Ensure semantic clients receive one post-input frame even if the
+ // semantic buffer compares equal. Terminal-ANSI clients must keep their
+ // server-side blit baseline; resetting it here forces a full redraw on
+ // every keypress and makes remote sessions feel extremely slow.
+ client.request_semantic_redraw_after_input();
+ }
+ }
+ self.update_client_outer_focus_from_events(client_id, &events);
+ let interaction = events_include_interaction(&events);
+ let foreground_changed = if interaction {
+ self.promote_client_to_foreground(client_id)
+ } else {
+ false
+ };
+ if foreground_changed {
+ self.resize_shared_runtime_to_effective_size_before_input();
+ }
+ let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
+ self.app
+ .route_client_events(events, self.foreground_client_id == Some(client_id));
+ if self.app.take_config_reloaded_from_disk() {
+ self.reload_server_config(false);
+ } else {
+ self.sync_foreground_client_state();
+ }
+
+ if self.app.state.detach_requested {
+ self.app.state.detach_requested = false;
+ info!(client_id, "client detach requested via keybind");
+
+ self.send_client_graphics_cleanup(client_id);
+ self.send_to_client(
+ client_id,
+ ServerMessage::ServerShutdown {
+ reason: Some("detached".to_owned()),
+ },
+ );
+
+ if let Some(client) = self.clients.get_mut(&client_id) {
+ client.writer = None;
+ }
+
+ false
+ } else {
+ foreground_changed || theme_changed || interaction
+ }
+ }
+
fn handle_server_event(&mut self, ev: ServerEvent) -> bool {
if self.handoff_in_progress && Self::ignore_client_event_during_handoff(&ev) {
return false;
@@ -2079,78 +2219,27 @@ impl HeadlessServer {
} else {
Vec::new()
};
- let host_surface_redraw = crate::raw_input::events_require_host_surface_redraw(
- &events,
- self.app.state.redraw_on_focus_gained,
- );
- if let Some(client) = self.clients.get_mut(&client_id) {
- if host_surface_redraw {
- client.request_full_redraw();
- client.render_pending = true;
- } else {
- // Ensure semantic clients receive one post-input frame even if the
- // semantic buffer compares equal. Terminal-ANSI clients must keep their
- // server-side blit baseline; resetting it here forces a full redraw on
- // every keypress and makes remote sessions feel extremely slow.
- client.request_semantic_redraw_after_input();
- }
- }
- self.update_client_outer_focus_from_events(client_id, &events);
- let interaction = events_include_interaction(&events);
- let foreground_changed = if interaction {
- self.promote_client_to_foreground(client_id)
- } else {
- false
- };
- if foreground_changed {
- self.resize_shared_runtime_to_effective_size_before_input();
- }
- let theme_changed = self.update_client_host_theme_from_events(client_id, &events);
- self.app
- .route_client_events(events, self.foreground_client_id == Some(client_id));
- if self.app.take_config_reloaded_from_disk() {
- self.reload_server_config(false);
- } else {
- self.sync_foreground_client_state();
- }
-
- // Check if the detach keybind was triggered during input processing.
- if self.app.state.detach_requested {
- self.app.state.detach_requested = false;
- info!(client_id, "client detach requested via keybind");
-
- // Clear client-local host graphics before sending ServerShutdown
- // so the outer terminal does not retain stale images.
- self.send_client_graphics_cleanup(client_id);
-
- // Send a ServerShutdown with "detached" reason to this client
- // so it exits cleanly (not with a connection-lost error).
- // The client will close its connection after receiving this,
- // which triggers a ClientDisconnected event that removes it.
- self.send_to_client(
+ self.handle_client_input_events(client_id, events)
+ }
+ ServerEvent::ClientInputEvents { client_id, events } => {
+ if self.handoff_in_progress {
+ debug!(
client_id,
- ServerMessage::ServerShutdown {
- reason: Some("detached".to_owned()),
- },
+ len = events.len(),
+ "ignored client input events during handoff"
);
-
- // Don't remove the client here — let the client disconnect
- // naturally after receiving the ServerShutdown. The client's
- // read loop will see EOF and the server will get a
- // ClientDisconnected event which handles cleanup.
- //
- // However, we do need to stop sending frames to this client
- // since it's detaching. Drop the writer channel so no more
- // frames are queued for this client.
- if let Some(client) = self.clients.get_mut(&client_id) {
- client.writer = None;
- }
-
- // No re-render needed for remaining clients.
- false
- } else {
- foreground_changed || theme_changed || interaction
+ return false;
}
+ debug!(
+ client_id,
+ len = events.len(),
+ "client input events received"
+ );
+ let events = events
+ .iter()
+ .map(crate::protocol::ClientInputEvent::to_raw_input_event)
+ .collect();
+ self.handle_client_input_events(client_id, events)
}
ServerEvent::ClientClipboardImage {
client_id,
@@ -3241,7 +3330,7 @@ impl HeadlessServer {
/// Removes socket files created by the server.
fn cleanup_sockets(&self) -> io::Result<()> {
if let Err(err) =
- remove_socket_file_if_owned(&self.client_socket_path, self.client_socket_identity)
+ remove_socket_file_if_owned(&self.client_socket_path, &self.client_socket_identity)
{
if err.kind() != io::ErrorKind::NotFound {
warn!(
@@ -3389,6 +3478,7 @@ pub fn run_server() -> io::Result<()> {
api_rx,
event_hub,
);
+ seed_startup_workspace_if_empty(&mut app);
// The server runs headless — disable local notification side effects.
// Sound and terminal notifications are forwarded to connected clients
@@ -3427,6 +3517,36 @@ pub fn run_server() -> io::Result<()> {
result
}
+fn seed_startup_workspace_if_empty(app: &mut app::App) {
+ let Some(cwd) = take_startup_cwd() else {
+ return;
+ };
+
+ if !app.state.workspaces.is_empty() {
+ info!(
+ cwd = %cwd.display(),
+ "restored session already has workspaces; ignoring startup cwd"
+ );
+ return;
+ }
+
+ match app.create_workspace_with_options(cwd.clone(), true) {
+ Ok(_) => {
+ info!(cwd = %cwd.display(), "created startup workspace");
+ }
+ Err(err) => {
+ warn!(cwd = %cwd.display(), err = %err, "failed to create startup workspace");
+ app.state.mode = app::Mode::Navigate;
+ }
+ }
+}
+
+fn take_startup_cwd() -> Option<PathBuf> {
+ let cwd = std::env::var_os(crate::server::autodetect::STARTUP_CWD_ENV_VAR)?;
+ std::env::remove_var(crate::server::autodetect::STARTUP_CWD_ENV_VAR);
+ (!cwd.is_empty()).then(|| PathBuf::from(cwd))
+}
+
#[cfg(unix)]
fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()> {
let loaded_config = config::Config::load();
@@ -3499,14 +3619,13 @@ fn run_handoff_import_server(socket_path: &Path, token: &str) -> io::Result<()>
#[cfg(unix)]
fn wait_for_old_public_sockets_to_close(timeout: Duration) -> io::Result<()> {
- use std::os::unix::net::UnixStream;
-
let deadline = Instant::now() + timeout;
let api_socket = api::socket_path();
let client_socket = client_socket_path();
while Instant::now() < deadline {
- let api_open = api_socket.exists() && UnixStream::connect(&api_socket).is_ok();
- let client_open = client_socket.exists() && UnixStream::connect(&client_socket).is_ok();
+ let api_open = api_socket.exists() && crate::ipc::connect_local_stream(&api_socket).is_ok();
+ let client_open =
+ client_socket.exists() && crate::ipc::connect_local_stream(&client_socket).is_ok();
if !api_open && !client_open {
return Ok(());
}
@@ -3570,23 +3689,32 @@ mod tests {
let _ = fs::create_dir_all(&dir);
let socket_path = dir.join("client.sock");
let _ = fs::remove_file(&socket_path);
- let listener = UnixListener::bind(&socket_path).expect("bind test listener");
+ let listener = bind_local_listener(&socket_path).expect("bind test listener");
let client_socket_identity =
socket_file_identity(&socket_path).expect("test listener socket identity");
+ #[cfg(unix)]
listener
- .set_nonblocking(true)
+ .set_nonblocking(ListenerNonblockingMode::Accept)
.expect("set listener nonblocking");
let (server_event_tx, server_event_rx) = mpsc::channel(64);
+ #[cfg(windows)]
+ let should_quit = Arc::new(AtomicBool::new(false));
+ #[cfg(windows)]
+ spawn_windows_client_accept_thread(listener, should_quit.clone(), server_event_tx.clone());
let server_keybindings = app_keybindings(&app);
HeadlessServer {
app,
+ #[cfg(unix)]
api_tx: None,
+ #[cfg(unix)]
api_server: None,
+ #[cfg(unix)]
client_listener: listener,
client_socket_path: socket_path,
client_socket_identity,
clients: HashMap::new(),
+ #[cfg(unix)]
next_client_id: 1,
foreground_client_id: None,
server_keybindings,
@@ -3597,8 +3725,12 @@ mod tests {
effective_size: (MIN_COLS, MIN_ROWS),
shutting_down: false,
handoff_in_progress: false,
+ #[cfg(unix)]
pending_handoff_repaint_nudge: false,
+ #[cfg(unix)]
should_quit: Arc::new(AtomicBool::new(false)),
+ #[cfg(windows)]
+ should_quit,
server_event_rx,
server_event_tx,
}
@@ -5186,6 +5318,107 @@ next_tab = ""
assert_eq!(server.app.state.mode, crate::app::Mode::Terminal);
}
+ #[test]
+ fn semantic_client_input_events_route_through_app_input() {
+ let mut server = test_headless_server();
+ server.app.state.mode = crate::app::Mode::Onboarding;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (80, 24),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ Some(true),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 1,
+ events: vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Enter,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }],
+ }));
+
+ assert_eq!(server.app.state.mode, crate::app::Mode::Settings);
+ assert_eq!(
+ server.app.state.settings.section,
+ crate::app::state::SettingsSection::Integrations
+ );
+ }
+
+ #[test]
+ fn semantic_client_escape_closes_keybind_help() {
+ let mut server = test_headless_server();
+ server.app.state.mode = crate::app::Mode::KeybindHelp;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (100, 30),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ Some(true),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 1,
+ events: vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Esc,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }],
+ }));
+
+ assert_eq!(server.app.state.mode, crate::app::Mode::Navigate);
+ }
+
+ #[test]
+ fn semantic_client_down_scrolls_keybind_help() {
+ let mut server = test_headless_server();
+ server.app.state.mode = crate::app::Mode::KeybindHelp;
+ server.clients.insert(
+ 1,
+ ClientConnection::new(
+ (100, 30),
+ crate::kitty_graphics::HostCellSize::default(),
+ crate::terminal_theme::TerminalTheme::default(),
+ Some(true),
+ 1,
+ RenderEncoding::SemanticFrame,
+ None,
+ ),
+ );
+ server.foreground_client_id = Some(1);
+ server.sync_foreground_client_state();
+ server.resize_shared_runtime_to_effective_size();
+
+ assert!(server.app.state.keybind_help_max_scroll() > 0);
+ assert!(server.handle_server_event(ServerEvent::ClientInputEvents {
+ client_id: 1,
+ events: vec![crate::protocol::ClientInputEvent::Key {
+ code: crate::protocol::ClientKeyCode::Down,
+ modifiers: 0,
+ kind: crate::protocol::ClientKeyKind::Press,
+ }],
+ }));
+
+ assert_eq!(server.app.state.mode, crate::app::Mode::KeybindHelp);
+ assert_eq!(server.app.state.keybind_help.scroll, 1);
+ }
+
#[tokio::test]
async fn split_default_background_response_updates_theme_without_forwarding_tail() {
let mut server = test_headless_server();