Skip to main content

pushkin_daemon/
server.rs

1//! The daemon server: accepts UDS connections under `.pushkin/`, answers
2//! protocol requests with the shared pipeline (spec §4.3). One tokio
3//! runtime per `serve` call; connections are tracked in a `JoinSet` so no
4//! task is fire-and-forget (AGENTS.md tokio rule). The daemon is a
5//! transport for the cold pipeline, never a second brain: `Check` calls
6//! `pushkin_core::pipeline::check_write` — the same function, the same
7//! envelope. Read-only daemons (spec §8.4: the offer made to non-canonical
8//! binaries) serve on private sockets and refuse wire mutations.
9
10use crate::protocol::{DaemonInfo, Request, Response, PROTOCOL_VERSION};
11use crate::warm::WarmState;
12use pushkin_core::manifest::Manifest;
13use pushkin_core::pipeline::WriteRequest;
14use std::path::{Path, PathBuf};
15use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
16use tokio::net::{UnixListener, UnixStream};
17use tokio::task::JoinSet;
18
19/// Socket location relative to the repo root — inside `.pushkin/` so the
20/// gate surface owns its own transport.
21pub const SOCKET_FILE: &str = ".pushkin/daemon.sock";
22
23/// One request line must fit comfortably; a whole-file write payload can
24/// be large, so the cap is generous but bounded (no unbounded reads from
25/// an untrusted local peer).
26const MAX_LINE_BYTES: usize = 16 * 1024 * 1024;
27
28#[derive(Debug, thiserror::Error)]
29pub enum ServerError {
30    #[error("daemon io failure: {0}")]
31    Io(#[from] std::io::Error),
32    #[error("daemon not running")]
33    NotRunning,
34    #[error("protocol failure: {0}")]
35    Protocol(String),
36}
37
38#[must_use]
39pub fn socket_path(repo_root: &Path) -> PathBuf {
40    repo_root.join(SOCKET_FILE)
41}
42
43/// Serve requests on the canonical socket until a `Shutdown` request
44/// arrives. Blocking: owns a current-thread tokio runtime for its
45/// lifetime. The socket file is created on bind and removed before
46/// returning.
47///
48/// # Errors
49/// `Io` when the socket cannot be created/bound; `Protocol` when the
50/// runtime cannot be built.
51pub fn serve(repo_root: &Path, manifest: Manifest) -> Result<(), ServerError> {
52    serve_at(&socket_path(repo_root), manifest, false)
53}
54
55/// Serve on an explicit socket path. `read_only` daemons answer checks
56/// and pings (flagged in `DaemonInfo`) but refuse `Shutdown` over the
57/// wire — their lifecycle belongs to the OS session that spawned them
58/// (spec §8.4), so they run until killed.
59///
60/// # Errors
61/// `Io` when the socket cannot be created/bound; `Protocol` when the
62/// runtime cannot be built.
63pub fn serve_at(socket: &Path, manifest: Manifest, read_only: bool) -> Result<(), ServerError> {
64    let warm = WarmState::new(manifest);
65    // Watch the repo root (the socket's grandparent via .pushkin/, or cwd
66    // for private sockets elsewhere): manifest edits reload, file edits
67    // invalidate. A watcher failure degrades to compute-every-time — the
68    // gate stays correct, only slower.
69    let watch_root = socket
70        .parent()
71        .and_then(Path::parent)
72        .filter(|root| !root.as_os_str().is_empty())
73        .unwrap_or_else(|| Path::new("."));
74    let _watch_guard = warm.watch(watch_root).ok();
75    if let Some(parent) = socket.parent() {
76        std::fs::create_dir_all(parent)?;
77    }
78    // A previous unclean exit leaves a stale socket; bind() would fail.
79    if socket.exists() {
80        std::fs::remove_file(socket)?;
81    }
82
83    let runtime = tokio::runtime::Builder::new_current_thread()
84        .enable_io()
85        .enable_time()
86        .build()
87        .map_err(|e| ServerError::Protocol(format!("runtime build failed: {e}")))?;
88
89    let result = runtime.block_on(serve_inner(socket, &warm, read_only));
90    // Socket removal is part of shutdown's contract regardless of outcome.
91    let _ = std::fs::remove_file(socket);
92    result
93}
94
95async fn serve_inner(socket: &Path, warm: &WarmState, read_only: bool) -> Result<(), ServerError> {
96    let listener = UnixListener::bind(socket)?;
97    let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
98    let mut connections: JoinSet<()> = JoinSet::new();
99
100    loop {
101        tokio::select! {
102            accepted = listener.accept() => {
103                let Ok((stream, _addr)) = accepted else { continue };
104                let warm = warm.share();
105                let shutdown_tx = shutdown_tx.clone();
106                connections.spawn(async move {
107                    // Per-connection failures are that connection's problem,
108                    // never the accept loop's.
109                    let _ = handle_connection(stream, &warm, read_only, &shutdown_tx).await;
110                });
111            }
112            _ = shutdown_rx.recv() => break,
113            // Reap finished connection tasks so the set doesn't grow.
114            Some(_) = connections.join_next(), if !connections.is_empty() => {}
115        }
116    }
117    // Drain in-flight connections before tearing the socket down.
118    while connections.join_next().await.is_some() {}
119    Ok(())
120}
121
122async fn handle_connection(
123    stream: UnixStream,
124    warm: &WarmState,
125    read_only: bool,
126    shutdown_tx: &tokio::sync::mpsc::Sender<()>,
127) -> Result<(), ServerError> {
128    let (read_half, mut write_half) = stream.into_split();
129    let mut lines = BufReader::with_capacity(64 * 1024, read_half).lines();
130
131    while let Ok(Some(line)) = lines.next_line().await {
132        if line.len() > MAX_LINE_BYTES {
133            let response = Response::Error {
134                message: "request exceeds size cap".to_owned(),
135            };
136            write_response(&mut write_half, &response).await?;
137            continue;
138        }
139        let response = match serde_json::from_str::<Request>(&line) {
140            Ok(request) => {
141                let response = respond(&request, warm, read_only);
142                let stop_serving = !read_only && matches!(request, Request::Shutdown { .. });
143                write_response(&mut write_half, &response).await?;
144                if stop_serving {
145                    let _ = shutdown_tx.send(()).await;
146                    return Ok(());
147                }
148                continue;
149            }
150            Err(error) => Response::Error {
151                message: format!("unrecognized request: {error}"),
152            },
153        };
154        write_response(&mut write_half, &response).await?;
155    }
156    Ok(())
157}
158
159async fn write_response(
160    write_half: &mut tokio::net::unix::OwnedWriteHalf,
161    response: &Response,
162) -> Result<(), ServerError> {
163    let mut payload = serde_json::to_string(response)
164        .map_err(|e| ServerError::Protocol(format!("response encode failed: {e}")))?;
165    payload.push('\n');
166    write_half.write_all(payload.as_bytes()).await?;
167    write_half.flush().await?;
168    Ok(())
169}
170
171/// Version check then dispatch. Wrong-version requests get a typed error
172/// (a stale daemon and a newer shim must fail loudly, not weirdly).
173fn respond(request: &Request, warm: &WarmState, read_only: bool) -> Response {
174    let v = match request {
175        Request::Check { v, .. } | Request::Ping { v } | Request::Shutdown { v } => *v,
176    };
177    if v != PROTOCOL_VERSION {
178        return Response::Error {
179            message: format!("protocol version {v} unsupported (daemon speaks {PROTOCOL_VERSION})"),
180        };
181    }
182    match request {
183        Request::Check {
184            file_path, content, ..
185        } => Response::Check {
186            result: warm.check(&WriteRequest {
187                file_path: file_path.clone(),
188                content: content.clone(),
189            }),
190        },
191        Request::Ping { .. } => Response::Pong {
192            info: DaemonInfo {
193                pid: std::process::id(),
194                version: env!("CARGO_PKG_VERSION").to_owned(),
195                read_only,
196            },
197        },
198        Request::Shutdown { .. } => {
199            if read_only {
200                Response::Error {
201                    message: "read-only daemon: lifecycle mutations are refused over the wire \
202                              (kill the process from the session that spawned it)"
203                        .to_owned(),
204                }
205            } else {
206                Response::ShuttingDown
207            }
208        }
209    }
210}
211
212/// One round trip against the canonical socket of `repo_root`.
213///
214/// # Errors
215/// `NotRunning` when the socket is absent or refuses connection (the
216/// shim's fall-back-to-cold signal); `Io`/`Protocol` for transport and
217/// encoding failures.
218pub fn request(repo_root: &Path, request: &Request) -> Result<Response, ServerError> {
219    request_at(&socket_path(repo_root), request)
220}
221
222/// One round trip against an explicit socket: connect, send one request
223/// line, read one response line. Synchronous std transport — the shim
224/// side has no runtime, and one blocking round trip is exactly its job.
225///
226/// # Errors
227/// `NotRunning` when the socket is absent or refuses connection;
228/// `Io`/`Protocol` for transport and encoding failures.
229pub fn request_at(socket: &Path, request: &Request) -> Result<Response, ServerError> {
230    use std::io::{BufRead, BufReader as StdBufReader, Write};
231
232    let mut stream = match std::os::unix::net::UnixStream::connect(socket) {
233        Ok(stream) => stream,
234        Err(error) => {
235            return Err(match error.kind() {
236                std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
237                    ServerError::NotRunning
238                }
239                _ => ServerError::Io(error),
240            })
241        }
242    };
243    let mut payload = serde_json::to_string(request)
244        .map_err(|e| ServerError::Protocol(format!("request encode failed: {e}")))?;
245    payload.push('\n');
246    stream.write_all(payload.as_bytes())?;
247    stream.flush()?;
248
249    let mut line = String::new();
250    StdBufReader::new(&mut stream).read_line(&mut line)?;
251    if line.is_empty() {
252        return Err(ServerError::Protocol(
253            "daemon closed the connection without responding".to_owned(),
254        ));
255    }
256    serde_json::from_str(&line)
257        .map_err(|e| ServerError::Protocol(format!("unrecognized response: {e}")))
258}