refactor: migrate monolithic crate to Cargo Workspace with Clean Architecture
Transform the single binary crate into a 9-crate workspace monorepo: - Root Cargo.toml as [workspace] manager with resolver = "2" - zesdex-entities: Domain entity types (session, settings, store, message, etc.) - zesdex-utils: Pure utility functions (error, logger, pagination, slug, clipboard) - zesdex-dto: Data Transfer Objects for LLM provider API communication - zesdex-ipc: Unix-socket IPC layer (client/server/framing/protocol) - zesdex-iam: Identity & Access Management (Clean Architecture: domain/application/infrastructure) - zesdex-cms: Content Management (Clean Architecture: domain/application/infrastructure) - zesdex-middleware: HTTP middleware (Auth, CORS, Rate Limiting) - zesdex-libs: Composition root (AppContext, DB init, JWT, Argon2) - zesdex-backend: Main binary entry point + seed/migrate binaries - DevOps: Dockerfile, docker-compose, Nix (flake/shell/default), CI/CD updates - Remove dead root src/ and src-misc/ directories All crate re-exports maintain backward compatibility with original crate::model::*, crate::dto::*, crate::ipc::* module paths. Feature crates enforce strict layer separation: domain -> application -> infrastructure with generic trait-based dependency injection.
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "zesdex-ipc"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
zesdex-entities = { path = "../zesdex-entities" }
|
||||
zesdex-dto = { path = "../zesdex-dto" }
|
||||
@@ -0,0 +1,111 @@
|
||||
//! IPC client — connects to the daemon's Unix socket and sends/receives
|
||||
//! framed JSON messages.
|
||||
//!
|
||||
//! [`IpcClient`] wraps a [`Connection`] behind a [`Mutex`] so it can be
|
||||
//! shared across threads (e.g. the TUI event loop and the render task).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use crate::conn::Connection;
|
||||
use anyhow::{Context, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::os::unix::net::UnixStream;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// A thread-safe IPC client connected to a Zesdex daemon over a Unix
|
||||
/// socket.
|
||||
pub struct IpcClient {
|
||||
/// Inner connection protected by a mutex for shared access.
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl IpcClient {
|
||||
/// Connect to the daemon listening at `path` (a Unix socket path).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket path does not exist, the connection
|
||||
/// is refused, or the caller lacks permission.
|
||||
pub fn connect_unix(path: &str) -> Result<Self> {
|
||||
let stream = UnixStream::connect(path)
|
||||
.with_context(|| format!("failed to connect to Unix socket at {path:?}"))?;
|
||||
let conn = Connection::new(stream);
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
/// Serialise `msg` to JSON and send it as a length-prefixed frame.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Delegates to the underlying [`Connection::send`].
|
||||
pub fn send<T: Serialize>(&self, msg: &T) -> Result<()> {
|
||||
let mut guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.expect("IpcClient mutex poisoned — the previous operation panicked");
|
||||
guard.send(msg)
|
||||
}
|
||||
|
||||
/// Read one framed JSON message and deserialise it.
|
||||
///
|
||||
/// Returns `Ok(None)` on clean EOF (daemon closed the connection).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Delegates to the underlying [`Connection::receive`].
|
||||
pub fn receive<T: DeserializeOwned>(&self) -> Result<Option<T>> {
|
||||
let mut guard = self
|
||||
.conn
|
||||
.lock()
|
||||
.expect("IpcClient mutex poisoned — the previous operation panicked");
|
||||
guard.receive()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::os::unix::net::UnixListener;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct Ping {
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connect_and_round_trip() {
|
||||
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let sock_path = dir.join("test.sock");
|
||||
let sock_path_str = sock_path.to_string_lossy().to_string();
|
||||
|
||||
// Start a minimal echo server in a background thread.
|
||||
let listener = UnixListener::bind(&sock_path).unwrap();
|
||||
let server_handle = std::thread::spawn(move || {
|
||||
let (stream, _) = listener.accept().unwrap();
|
||||
let mut conn = Connection::new(stream);
|
||||
// Echo one message back.
|
||||
let req: Ping = conn.receive().unwrap().unwrap();
|
||||
conn.send(&req).unwrap();
|
||||
});
|
||||
|
||||
// Client connects and sends a ping, then receives the echo.
|
||||
let client = IpcClient::connect_unix(&sock_path_str).unwrap();
|
||||
client.send(&Ping { seq: 7 }).unwrap();
|
||||
let resp: Ping = client.receive().unwrap().expect("expected a response");
|
||||
assert_eq!(resp, Ping { seq: 7 });
|
||||
|
||||
server_handle.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Connection wrapper around a Unix socket stream.
|
||||
//!
|
||||
//! [`Connection`] pairs a buffered reader with a raw writer and exposes
|
||||
//! `send` / `receive` for framed JSON messages.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use crate::frame::{read_frame, write_frame};
|
||||
use anyhow::{Context, Result};
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
use std::io::BufReader;
|
||||
use std::os::unix::net::UnixStream;
|
||||
|
||||
/// A framed JSON connection over a Unix socket.
|
||||
///
|
||||
/// Wraps the raw [`UnixStream`] with a [`BufReader`] on the read side and
|
||||
/// direct writes (with explicit flushing) on the write side.
|
||||
pub struct Connection {
|
||||
/// Buffered reader for receiving frames.
|
||||
reader: BufReader<UnixStream>,
|
||||
/// Unbuffered writer (flushed after every frame).
|
||||
writer: UnixStream,
|
||||
}
|
||||
|
||||
impl Connection {
|
||||
/// Create a new `Connection` from an already-connected [`UnixStream`].
|
||||
pub fn new(stream: UnixStream) -> Self {
|
||||
// Clone the stream so that reader and writer can reference separate
|
||||
// file-descriptor handles. `UnixStream::try_clone` is infallible on
|
||||
// Unix (it calls `dup(2)`).
|
||||
let reader = BufReader::new(
|
||||
stream
|
||||
.try_clone()
|
||||
.expect("UnixStream::try_clone should never fail on Linux"),
|
||||
);
|
||||
let writer = stream;
|
||||
Self { reader, writer }
|
||||
}
|
||||
|
||||
/// Serialise `msg` to JSON and send it as a length-prefixed frame.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Delegates to [`serde_json::to_vec`] for serialisation and
|
||||
/// [`write_frame`] for writing.
|
||||
pub fn send<T: Serialize>(&mut self, msg: &T) -> Result<()> {
|
||||
let json =
|
||||
serde_json::to_vec(msg).context("failed to serialise message to JSON")?;
|
||||
write_frame(&mut self.writer, &json)
|
||||
.context("failed to write frame to connection")
|
||||
}
|
||||
|
||||
/// Read one framed JSON message and deserialise it.
|
||||
///
|
||||
/// Returns `Ok(None)` when the remote end has closed the connection
|
||||
/// cleanly (EOF). Returns `Ok(Some(msg))` on a successful read.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Delegates to [`read_frame`] for reading and
|
||||
/// [`serde_json::from_slice`] for deserialisation.
|
||||
pub fn receive<T: DeserializeOwned>(&mut self) -> Result<Option<T>> {
|
||||
let raw = read_frame(&mut self.reader)
|
||||
.context("failed to read frame from connection")?;
|
||||
|
||||
match raw {
|
||||
None => Ok(None),
|
||||
Some(bytes) => {
|
||||
let msg: T = serde_json::from_slice(&bytes)
|
||||
.with_context(|| {
|
||||
format!(
|
||||
"failed to deserialise frame ({} byte(s))",
|
||||
bytes.len()
|
||||
)
|
||||
})?;
|
||||
Ok(Some(msg))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Safety: `UnixStream` is `Send` but not `Sync`. Wrapping `Connection` in
|
||||
// a `Mutex` (as done in `IpcClient`) provides the `Sync` guarantee.
|
||||
// The type itself is `Send` because both fields are `Send`.
|
||||
//
|
||||
// We explicitly assert Send here for clarity:
|
||||
fn _assert_send()
|
||||
where
|
||||
Connection: Send,
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct Ping {
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
/// Helper: create a pair of connected `Connection` values via a
|
||||
/// Unix socket pair.
|
||||
fn pair() -> (Connection, Connection) {
|
||||
let (a, b) = UnixStream::pair().expect("UnixStream::pair failed");
|
||||
(Connection::new(a), Connection::new(b))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip() {
|
||||
let (mut left, mut right) = pair();
|
||||
|
||||
left.send(&Ping { seq: 42 }).unwrap();
|
||||
let received: Ping = right.receive().unwrap().expect("expected a frame");
|
||||
assert_eq!(received, Ping { seq: 42 });
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_detection() {
|
||||
let (left, right) = pair();
|
||||
drop(right); // close remote end
|
||||
|
||||
// Send something first so we can read past it... actually let's
|
||||
// just drop the peer and check that receive returns None.
|
||||
// Since we dropped right, left's reads should eventually get EOF.
|
||||
// But with a socket pair, dropping one end signals EOF on the other.
|
||||
drop(left); // drop left too — we'll test EOF on a fresh pair
|
||||
let (mut a, _b) = pair();
|
||||
drop(_b);
|
||||
let result: Option<Ping> = a.receive().unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
//! Length-prefixed framing for Unix-socket IPC.
|
||||
//!
|
||||
//! Every message on the wire is encoded as:
|
||||
//!
|
||||
//! ```text
|
||||
//! [ 4-byte big-endian payload length ][ payload bytes (JSON) ]
|
||||
//! ```
|
||||
//!
|
||||
//! The length prefix **excludes** itself — it encodes only the number of
|
||||
//! payload bytes that follow.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use std::io::{Read, Write};
|
||||
|
||||
/// Maximum frame payload size (64 MiB).
|
||||
const MAX_PAYLOAD: u32 = 64 * 1024 * 1024;
|
||||
|
||||
/// Read one length-prefixed frame from `reader`.
|
||||
///
|
||||
/// Returns `Ok(None)` when the stream has reached end-of-file (the reader
|
||||
/// returned `Ok(0)` on the first read). Returns `Ok(Some(...))` with the
|
||||
/// raw payload bytes for any successfully decoded frame.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - `UnexpectedEof` if the stream terminates partway through a length
|
||||
/// prefix or payload.
|
||||
/// - `anyhow` error if the payload length exceeds [`MAX_PAYLOAD`].
|
||||
/// - Any I/O error from the underlying reader.
|
||||
pub fn read_frame(reader: &mut impl Read) -> Result<Option<Vec<u8>>> {
|
||||
// --- Read the 4-byte big-endian length prefix ---------------------------
|
||||
let mut len_buf = [0u8; 4];
|
||||
|
||||
match reader.read_exact(&mut len_buf) {
|
||||
Ok(()) => {}
|
||||
Err(ref e) if e.kind() == std::io::ErrorKind::UnexpectedEof => {
|
||||
// Zero bytes available → clean EOF.
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e).context("failed to read frame length prefix"),
|
||||
}
|
||||
|
||||
let payload_len = u32::from_be_bytes(len_buf) as usize;
|
||||
|
||||
if payload_len > MAX_PAYLOAD as usize {
|
||||
anyhow::bail!(
|
||||
"frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"
|
||||
);
|
||||
}
|
||||
|
||||
// --- Read the payload ---------------------------------------------------
|
||||
let mut payload = vec![0u8; payload_len];
|
||||
reader
|
||||
.read_exact(&mut payload)
|
||||
.with_context(|| format!("failed to read {payload_len} byte(s) of frame payload"))?;
|
||||
|
||||
Ok(Some(payload))
|
||||
}
|
||||
|
||||
/// Write one length-prefixed frame to `writer`.
|
||||
///
|
||||
/// Writes the 4-byte big-endian length of `data`, followed by `data` itself.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// - Returns an error if `data` is longer than [`MAX_PAYLOAD`].
|
||||
/// - Any I/O error from the underlying writer.
|
||||
pub fn write_frame(writer: &mut impl Write, data: &[u8]) -> Result<()> {
|
||||
let payload_len: u32 = data
|
||||
.len()
|
||||
.try_into()
|
||||
.context("payload length exceeds u32 range")?;
|
||||
|
||||
if payload_len > MAX_PAYLOAD {
|
||||
anyhow::bail!(
|
||||
"frame payload too large: {payload_len} bytes (max {MAX_PAYLOAD})"
|
||||
);
|
||||
}
|
||||
|
||||
let len_bytes = payload_len.to_be_bytes();
|
||||
writer
|
||||
.write_all(&len_bytes)
|
||||
.context("failed to write frame length prefix")?;
|
||||
writer
|
||||
.write_all(data)
|
||||
.context("failed to write frame payload")?;
|
||||
writer
|
||||
.flush()
|
||||
.context("failed to flush frame writer")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn round_trip_small() {
|
||||
let payload = b"hello world";
|
||||
let mut buf = Vec::new();
|
||||
write_frame(&mut buf, payload).unwrap();
|
||||
|
||||
let mut cursor = std::io::Cursor::new(&buf);
|
||||
let result = read_frame(&mut cursor).unwrap();
|
||||
assert_eq!(result, Some(payload.to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trip_empty() {
|
||||
let payload = b"";
|
||||
let mut buf = Vec::new();
|
||||
write_frame(&mut buf, payload).unwrap();
|
||||
|
||||
let mut cursor = std::io::Cursor::new(&buf);
|
||||
let result = read_frame(&mut cursor).unwrap();
|
||||
assert_eq!(result, Some(payload.to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eof_returns_none() {
|
||||
let mut empty: &[u8] = b"";
|
||||
let result = read_frame(&mut empty).unwrap();
|
||||
assert!(result.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oversized_rejected() {
|
||||
let huge = vec![0u8; (MAX_PAYLOAD as usize) + 1];
|
||||
let mut buf = Vec::new();
|
||||
assert!(write_frame(&mut buf, &huge).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
//! Unix-socket IPC layer for daemon/client communication.
|
||||
//!
|
||||
//! This crate provides the wire protocol, framing, and connection
|
||||
//! wrappers used by both the Zesdex daemon and its TUI client.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
pub mod protocol;
|
||||
pub mod frame;
|
||||
pub mod conn;
|
||||
pub mod client;
|
||||
pub mod server;
|
||||
@@ -0,0 +1,159 @@
|
||||
//! Wire types for the Zesdex IPC protocol.
|
||||
//!
|
||||
//! All types exchanged between the daemon and the TUI client over the
|
||||
//! Unix socket are defined here. Both [`ClientRequest`] and
|
||||
//! [`DaemonFrame`] are serialised as JSON messages framed with a
|
||||
//! length prefix (see [`crate::frame`]).
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// KeyAction
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A resolved key press sent from the daemon to the client (or used inside
|
||||
/// the client event loop for deferred dispatch).
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub enum KeyAction {
|
||||
Char(char),
|
||||
Enter,
|
||||
Escape,
|
||||
Backspace,
|
||||
Delete,
|
||||
Tab,
|
||||
Up,
|
||||
Down,
|
||||
Left,
|
||||
Right,
|
||||
Home,
|
||||
End,
|
||||
PageUp,
|
||||
PageDown,
|
||||
Function(u8),
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ClientRequest
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A message sent from the TUI client to the daemon.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum ClientRequest {
|
||||
/// Periodic heartbeat / tick event.
|
||||
Tick,
|
||||
/// A keyboard event with modifier flags.
|
||||
KeyPress {
|
||||
key: KeyAction,
|
||||
ctrl: bool,
|
||||
alt: bool,
|
||||
shift: bool,
|
||||
},
|
||||
/// A completed text submission (e.g. pressing Enter in the input bar).
|
||||
Submit(String),
|
||||
/// Pasted text content.
|
||||
Paste(String),
|
||||
/// Terminal resize notification.
|
||||
Resize(u16, u16),
|
||||
/// Graceful close / shutdown request.
|
||||
Close,
|
||||
/// Scroll the session view up one page or line.
|
||||
ScrollUp,
|
||||
/// Scroll the session view down one page or line.
|
||||
ScrollDown,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// MessageEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A single chat message within a session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageEntry {
|
||||
/// The role of the message author (e.g. "user", "assistant", "system").
|
||||
pub role: String,
|
||||
/// The text content of the message.
|
||||
pub content: String,
|
||||
/// Unix timestamp (seconds since epoch) when the message was created.
|
||||
pub timestamp: i64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ToastEntry
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A transient toast notification sent to the client.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ToastEntry {
|
||||
/// The kind / category of the toast (e.g. "info", "error", "success").
|
||||
pub kind: String,
|
||||
/// The display message.
|
||||
pub message: String,
|
||||
/// Unix timestamp when the toast was created.
|
||||
pub created_at: i64,
|
||||
/// How long (in milliseconds) the toast should remain visible.
|
||||
pub lifetime_ms: u64,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// StatePayload
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Full UI state snapshot pushed from the daemon to the client after every
|
||||
/// mutation.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StatePayload {
|
||||
/// Opaque session identifier.
|
||||
pub session_id: String,
|
||||
/// Ordered chat messages in the current session.
|
||||
pub messages: Vec<MessageEntry>,
|
||||
/// Monotonically increasing edit counter — used for change detection.
|
||||
pub edit_count: u32,
|
||||
/// Cached length of `messages` (redundant but avoids a deserialisation
|
||||
/// lookup on the client side).
|
||||
pub message_count: usize,
|
||||
/// Name of the currently active overlay, if any.
|
||||
pub overlay: Option<String>,
|
||||
/// Active toast notifications.
|
||||
pub toasts: Vec<ToastEntry>,
|
||||
/// Whether the session has uncommitted changes.
|
||||
pub dirty: bool,
|
||||
/// Current text in the client input buffer (set by the daemon when a
|
||||
/// session is activated so the client restores cursor state).
|
||||
pub input_buffer: String,
|
||||
/// Cursor position within `input_buffer`.
|
||||
pub input_cursor: usize,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DaemonFrame
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A frame sent from the daemon to the client.
|
||||
///
|
||||
/// Every response from the daemon is one of these variants. The client
|
||||
/// dispatches on the variant to update its UI model.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub enum DaemonFrame {
|
||||
/// Full state update — the client should replace its entire local state.
|
||||
StateUpdate(Box<StatePayload>),
|
||||
/// A streaming token for incremental assistant response rendering.
|
||||
StreamToken(String),
|
||||
/// A system-level notification that doesn't alter the session state.
|
||||
SystemNote {
|
||||
/// The kind of system note (e.g. "info", "warning", "error").
|
||||
kind: String,
|
||||
/// The note content.
|
||||
message: String,
|
||||
},
|
||||
/// Instructs the client to place `text` into the system clipboard.
|
||||
ClipboardCopy(String),
|
||||
/// Signals that the daemon has shut down / the session is complete.
|
||||
Closed,
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
//! IPC server — binds a Unix socket and accepts incoming client
|
||||
//! connections.
|
||||
//!
|
||||
//! [`IpcServer`] wraps a [`UnixListener`] and provides a blocking
|
||||
//! `accept` method that returns a [`Connection`] for each new client.
|
||||
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
|
||||
use crate::conn::Connection;
|
||||
use anyhow::{Context, Result};
|
||||
use std::os::unix::net::UnixListener;
|
||||
use std::path::Path;
|
||||
|
||||
/// A Unix-socket IPC server.
|
||||
///
|
||||
/// Each call to [`accept`](Self::accept) blocks until a new client connects
|
||||
/// and returns a [`Connection`] for that client.
|
||||
pub struct IpcServer {
|
||||
listener: UnixListener,
|
||||
}
|
||||
|
||||
impl IpcServer {
|
||||
/// Bind a [`UnixListener`] to `path`.
|
||||
///
|
||||
/// If `path` already exists, it is **removed** first so that a stale
|
||||
/// socket file from a previous run does not prevent binding.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the socket cannot be bound (e.g. insufficient
|
||||
/// permissions or an unreachable parent directory).
|
||||
pub fn bind_unix(path: &str) -> Result<Self> {
|
||||
// Remove stale socket file if present.
|
||||
let p = Path::new(path);
|
||||
if p.exists() {
|
||||
std::fs::remove_file(p)
|
||||
.with_context(|| format!("failed to remove stale socket at {path:?}"))?;
|
||||
}
|
||||
|
||||
let listener = UnixListener::bind(path)
|
||||
.with_context(|| format!("failed to bind Unix socket at {path:?}"))?;
|
||||
|
||||
Ok(Self { listener })
|
||||
}
|
||||
|
||||
/// Block until a client connects and return a [`Connection`] for the new
|
||||
/// client.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the underlying `accept` call fails.
|
||||
pub fn accept(&self) -> Result<Connection> {
|
||||
let (stream, addr) = self
|
||||
.listener
|
||||
.accept()
|
||||
.context("failed to accept client connection")?;
|
||||
|
||||
tracing::debug!("accepted client from {addr:?}");
|
||||
Ok(Connection::new(stream))
|
||||
}
|
||||
}
|
||||
|
||||
/// `UnixListener` is `Send` but not `Sync`. However, `&self`-based
|
||||
/// `accept` is fine because the OS-level listen backlog is inherently
|
||||
/// thread-safe (multiple threads can call `accept` on the same listener).
|
||||
///
|
||||
/// We explicitly assert Send + Sync for clarity:
|
||||
fn _assert_send_sync()
|
||||
where
|
||||
IpcServer: Send + Sync,
|
||||
{
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
struct Ping {
|
||||
seq: u32,
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bind_and_accept_one() {
|
||||
let dir = std::env::temp_dir().join(format!("zesdex-ipc-test-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let sock_path = dir.join("server_test.sock");
|
||||
let sock_path_str = sock_path.to_string_lossy().to_string();
|
||||
|
||||
let server = IpcServer::bind_unix(&sock_path_str).unwrap();
|
||||
|
||||
let server_handle = std::thread::spawn(move || {
|
||||
let mut conn = server.accept().unwrap();
|
||||
let msg: Ping = conn.receive().unwrap().unwrap();
|
||||
assert_eq!(msg, Ping { seq: 1 });
|
||||
conn.send(&Ping { seq: 2 }).unwrap();
|
||||
});
|
||||
|
||||
// Connect a raw client.
|
||||
let stream = std::os::unix::net::UnixStream::connect(&sock_path_str).unwrap();
|
||||
let mut conn = Connection::new(stream);
|
||||
conn.send(&Ping { seq: 1 }).unwrap();
|
||||
let resp: Ping = conn.receive().unwrap().unwrap();
|
||||
assert_eq!(resp, Ping { seq: 2 });
|
||||
|
||||
server_handle.join().unwrap();
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user