How Herdr Headless Server Mode Works: Architecture and Event Loop Implementation
Herdr’s headless server mode runs the full application state machine without a local terminal, streaming rendered UI frames to thin clients over a Unix-domain socket while processing API requests and maintaining PTY runtimes.
Herdr is a terminal workspace manager that implements a detached server architecture for persistent sessions. The herdr headless server mode—accessed via the herdr server command—eliminates direct terminal dependencies by replacing the standard TUI event loop with an asynchronous Tokio runtime. This allows the server to continue managing workspaces after client disconnections, handle JSON API requests, and support live handoff updates without dropping PTY state.
Architecture Overview
The headless implementation centers on the HeadlessServer struct defined in src/server/headless.rs. Key components include:
run_server()– The entry point insrc/main.rs(lines 46–48) that initializes logging, raises file-descriptor limits, loads configuration, and spawns the async runtime before delegating to the headless implementation.HeadlessServer– A struct holding theAppstate, a Unix-domain listener onherdr-client.sock, client connection maps, foreground-client tracking, and shutdown flags (lines 104–122).- Event Loop – The
HeadlessServer::runmethod (lines 173–229) that replacesApp::run()when no TUI is present, driving state updates, input routing, and frame streaming. - Protocol Layer – Binary client protocol definitions in
src/protocol/mod.rshandlingServerMessageenums, frame encoding, and size limits. - Client Management – Connection acceptance logic in
src/server/client_accept.rsand state structures insrc/server/clients.rs.
Startup Sequence (herdr server)
When you execute herdr server, the following initialization occurs:
// src/main.rs → server::headless::run_server()
pub fn run_server() {
init_logging();
raise_file_limit();
let config = load_config();
start_json_api_socket(&config); // herdr.sock
let rt = tokio::runtime::Runtime::new().unwrap();
let app = App::new(config);
// Disable local sound/terminal notifications
let server = HeadlessServer::new(app);
server.run();
}
The server binds a client socket at herdr-client.sock (path resolution handled in socket_paths.rs) with strict permissions. If the socket is already bound, run_server() aborts immediately with a clear error (lines 43–49).
The Headless Event Loop
The HeadlessServer::run method implements a non-blocking event loop that never touches stdin or the host terminal. Each iteration performs these steps:
- Shutdown Check – Exits if
self.shutting_downor a SIGINT was captured. - Event Draining – Calls
drain_internal_events_with_forwarding()to route clipboard writes, sound notifications, and state changes to the foreground client. - API Processing – Handles JSON commands from
herdr.sockviahandle_api_request_with_shutdown_check(). - Client Acceptance – Polls the non-blocking
UnixListenerfor new connections (250 ms deadline) viaaccept_client_connections. - Server Events – Processes messages from client threads (input, scroll events) through
drain_server_events. - Scheduled Tasks – Executes resize polls, animation timers, and autosave logic via
handle_scheduled_tasks_headless. - Conditional Render – If
needs_renderis set andapp.can_render_now()returns true, generates a virtual frame.
The loop uses tokio::select! to coordinate these operations, ensuring the server remains responsive while maintaining minimal CPU usage during idle periods.
Client Connections and Input Routing
When a thin client connects to herdr-client.sock, the server creates a ClientConnection entry with a unique client_id. The implementation tracks a foreground driver—the client that dictates shared runtime size, keybindings, and theme settings.
Input handling follows this path:
- Raw keyboard and mouse events arrive as binary protocol messages.
- The server translates these into
RawInputEventinstances. - Events are fed to
App::route_client_eventsfor processing by the active workspace. - Special events (clipboard paste, scroll buffers) route through dedicated handlers like
handle_terminal_attach_scroll.
The foreground client receives all forwarded notifications (toast, sound alerts) via ServerMessage::Notify variants.
Rendering and Frame Streaming
Unlike the TUI mode that writes directly to a terminal, the headless server renders to a virtual buffer:
// src/server/headless.rs → render_and_stream()
let frame = self.app.render_to_buffer(effective_area);
let data = FrameData::new(frame, encoding_config);
self.broadcast(ServerMessage::Render(data));
The render_and_stream function (lines 379–398) generates a ratatui::buffer::Buffer containing the complete UI state. This buffer is encoded into FrameData using ANSI escape sequences (with optional Kitty graphics protocol support) and broadcast to all attached clients via ServerMessage::Render.
Clients decode these frames and display them locally, achieving a terminal-agnostic UI that functions over SSH or containerized environments.
Graceful Shutdown and Live Handoff
The server supports two termination modes:
Standard Shutdown
- Triggered by
Ctrl+Cor theherdr server stopcommand. - Sets
should_quitflag → callsinitiate_shutdown()→complete_shutdown(). - Pauses all PTY readers, persists session state via
app.save_session_now(), removes Unix sockets, and exits cleanly.
Live Handoff (herdr update --handoff)
- Temporarily pauses PTY readers without killing processes.
- Exports runtime state and file descriptors via
perform_live_handoff(lines 670–764). - Spawns a replacement server process that imports PTY handles through
run_handoff_import_server. - Transfers ownership of
herdr.sockandherdr-client.sockatomically.
This mechanism enables zero-downtime binary updates while preserving all active terminal sessions.
Practical Usage Examples
Starting a Headless Server
herdr server
Expected output:
herdr server running; you can use any herdr CLI command in another terminal.
api socket: /home/user/.local/share/herdr/herdr.sock
client socket: /home/user/.local/share/herdr/herdr-client.sock
logs: /home/user/.local/share/herdr/herdr-server.log
Attaching a Client
From another terminal or machine:
herdr client
The client connects to herdr-client.sock, receives the initial full frame, and enters an interactive mode where keystrokes are forwarded to the server.
API-Only Interaction
Create a workspace without attaching a UI:
curl --unix-socket $(herdr status --format=api-socket) \
-X POST \
-d '{"type":"CreateWorkspace","name":"production"}' \
http://api/
The JSON API socket (herdr.sock) operates independently of client connections, allowing automation scripts to manage workspaces while users remain attached via thin clients.
Live Binary Update
Update Herdr without disconnecting PTY sessions:
# Terminal 1: Existing server
herdr server stop
# Terminal 2: Immediate handoff
herdr update --handoff
The handoff protocol exports all pane state from src/server/handoff.rs and imports it into the new process version.
Summary
- Herdr headless server mode runs the complete
Applogic in a Tokio async runtime without terminal dependencies, located insrc/server/headless.rs. - The
HeadlessServer::runevent loop processes internal events, API requests, and client connections concurrently, never blocking on terminal I/O. - Virtual rendering via
render_and_streamencodesratatuibuffers asFrameDataand streams them to thin clients overherdr-client.sock. - Input routing maps client keystrokes from the binary protocol to
RawInputEventinstances processed by the standard application logic. - Live handoff in
perform_live_handoffenables zero-downtime updates by transferring PTY file descriptors and socket bindings to replacement processes.
Frequently Asked Questions
How does Herdr handle client reconnections in headless mode?
When a client disconnects, the server retains the workspace state in the App struct. Subsequent connections create new ClientConnection entries that receive the current frame buffer immediately. Since the server maintains the canonical PTY state—not the client—reconnections resume the session exactly where it was left, provided the server process remains running.
What is the difference between the API socket and the client socket?
The API socket (herdr.sock) exposes a JSON-over-HTTP interface defined in src/api.rs for programmatic control (creating workspaces, sending commands). The client socket (herdr-client.sock) uses a binary protocol defined in src/protocol/mod.rs for thin clients that need interactive terminal emulation, receiving rendered frames and sending keyboard input.
Can the headless server run without Tokio?
No. The implementation relies on tokio::select! and Tokio’s async runtime to coordinate the event loop, handle non-blocking Unix socket accepts, and manage background tasks like autosave and animation timers. The run_server() function explicitly constructs a Tokio runtime before instantiating the HeadlessServer.
How does the 250ms polling interval affect performance?
The 250 millisecond deadline in tokio::select! balances latency and CPU efficiency. It ensures the server checks for new client connections frequently enough for responsive attach/detach operations while yielding CPU during idle periods. The render loop only executes when needs_render is set by PTY updates, preventing unnecessary frame generation.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →