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    serve_resolved(socket, manifest, read_only, None)
65}
66
67/// `serve_at`, with the **governing manifest** named explicitly (F73 phase 3).
68///
69/// `governing` is the path resolution actually settled on — under
70/// `PUSHKIN_MANIFEST` it is not under the watch root at all, and from a
71/// subdirectory it is not the watch root's own `pushkin.toml`. Only that file
72/// reloads. `None` keeps the historical default of the watch root's manifest,
73/// which is what the in-crate callers and their suites mean.
74///
75/// Resolution stays in the CLI: this crate has no git and no environment
76/// knowledge, and giving it any would put two answers to "which manifest" in
77/// the tree — the F73 defect wearing a different hat.
78///
79/// # Errors
80/// `Io` when the socket cannot be created/bound; `Protocol` when the
81/// runtime cannot be built.
82pub fn serve_resolved(
83    socket: &Path,
84    manifest: Manifest,
85    read_only: bool,
86    governing: Option<&Path>,
87) -> Result<(), ServerError> {
88    let warm = WarmState::new(manifest);
89    // Watch the repo root (the socket's grandparent via .pushkin/, or cwd
90    // for private sockets elsewhere): manifest edits reload, file edits
91    // invalidate. A watcher failure degrades to compute-every-time — the
92    // gate stays correct, only slower.
93    let watch_root = socket
94        .parent()
95        .and_then(Path::parent)
96        .filter(|root| !root.as_os_str().is_empty())
97        .unwrap_or_else(|| Path::new("."));
98    let governing = governing.map_or_else(|| watch_root.join("pushkin.toml"), Path::to_path_buf);
99    let _watch_guard = warm.watch_governing(watch_root, &governing).ok();
100    if let Some(parent) = socket.parent() {
101        std::fs::create_dir_all(parent)?;
102    }
103    // A previous unclean exit leaves a stale socket; bind() would fail.
104    if socket.exists() {
105        std::fs::remove_file(socket)?;
106    }
107
108    let runtime = tokio::runtime::Builder::new_current_thread()
109        .enable_io()
110        .enable_time()
111        .build()
112        .map_err(|e| ServerError::Protocol(format!("runtime build failed: {e}")))?;
113
114    let result = runtime.block_on(serve_inner(socket, &warm, read_only));
115    // Socket removal is part of shutdown's contract regardless of outcome.
116    let _ = std::fs::remove_file(socket);
117    result
118}
119
120async fn serve_inner(socket: &Path, warm: &WarmState, read_only: bool) -> Result<(), ServerError> {
121    let listener = UnixListener::bind(socket)?;
122    let (shutdown_tx, mut shutdown_rx) = tokio::sync::mpsc::channel::<()>(1);
123    let mut connections: JoinSet<()> = JoinSet::new();
124
125    loop {
126        tokio::select! {
127            accepted = listener.accept() => {
128                let Ok((stream, _addr)) = accepted else { continue };
129                let warm = warm.share();
130                let shutdown_tx = shutdown_tx.clone();
131                connections.spawn(async move {
132                    // Per-connection failures are that connection's problem,
133                    // never the accept loop's.
134                    let _ = handle_connection(stream, &warm, read_only, &shutdown_tx).await;
135                });
136            }
137            _ = shutdown_rx.recv() => break,
138            // Reap finished connection tasks so the set doesn't grow.
139            Some(_) = connections.join_next(), if !connections.is_empty() => {}
140        }
141    }
142    // Drain in-flight connections before tearing the socket down.
143    while connections.join_next().await.is_some() {}
144    Ok(())
145}
146
147async fn handle_connection(
148    stream: UnixStream,
149    warm: &WarmState,
150    read_only: bool,
151    shutdown_tx: &tokio::sync::mpsc::Sender<()>,
152) -> Result<(), ServerError> {
153    let (read_half, mut write_half) = stream.into_split();
154    let mut lines = BufReader::with_capacity(64 * 1024, read_half).lines();
155
156    while let Ok(Some(line)) = lines.next_line().await {
157        if line.len() > MAX_LINE_BYTES {
158            let response = Response::Error {
159                message: "request exceeds size cap".to_owned(),
160            };
161            write_response(&mut write_half, &response).await?;
162            continue;
163        }
164        let response = match serde_json::from_str::<Request>(&line) {
165            Ok(request) => {
166                let response = respond(&request, warm, read_only);
167                let stop_serving = !read_only && matches!(request, Request::Shutdown { .. });
168                write_response(&mut write_half, &response).await?;
169                if stop_serving {
170                    let _ = shutdown_tx.send(()).await;
171                    return Ok(());
172                }
173                continue;
174            }
175            Err(error) => Response::Error {
176                message: format!("unrecognized request: {error}"),
177            },
178        };
179        write_response(&mut write_half, &response).await?;
180    }
181    Ok(())
182}
183
184async fn write_response(
185    write_half: &mut tokio::net::unix::OwnedWriteHalf,
186    response: &Response,
187) -> Result<(), ServerError> {
188    let mut payload = serde_json::to_string(response)
189        .map_err(|e| ServerError::Protocol(format!("response encode failed: {e}")))?;
190    payload.push('\n');
191    write_half.write_all(payload.as_bytes()).await?;
192    write_half.flush().await?;
193    Ok(())
194}
195
196/// Version check then dispatch. Wrong-version requests get a typed error
197/// (a stale daemon and a newer shim must fail loudly, not weirdly).
198fn respond(request: &Request, warm: &WarmState, read_only: bool) -> Response {
199    let v = match request {
200        Request::Check { v, .. } | Request::Ping { v } | Request::Shutdown { v } => *v,
201    };
202    if v != PROTOCOL_VERSION {
203        return Response::Error {
204            message: format!("protocol version {v} unsupported (daemon speaks {PROTOCOL_VERSION})"),
205        };
206    }
207    match request {
208        Request::Check {
209            file_path, content, ..
210        } => Response::Check {
211            result: warm.check(&WriteRequest {
212                file_path: file_path.clone(),
213                content: content.clone(),
214            }),
215        },
216        Request::Ping { .. } => Response::Pong {
217            info: DaemonInfo {
218                pid: std::process::id(),
219                version: env!("CARGO_PKG_VERSION").to_owned(),
220                read_only,
221            },
222        },
223        Request::Shutdown { .. } => {
224            if read_only {
225                Response::Error {
226                    message: "read-only daemon: lifecycle mutations are refused over the wire \
227                              (kill the process from the session that spawned it)"
228                        .to_owned(),
229                }
230            } else {
231                Response::ShuttingDown
232            }
233        }
234    }
235}
236
237/// One round trip against the canonical socket of `repo_root`.
238///
239/// # Errors
240/// `NotRunning` when the socket is absent or refuses connection (the
241/// shim's fall-back-to-cold signal); `Io`/`Protocol` for transport and
242/// encoding failures.
243pub fn request(repo_root: &Path, request: &Request) -> Result<Response, ServerError> {
244    request_at(&socket_path(repo_root), request)
245}
246
247/// One round trip against an explicit socket: connect, send one request
248/// line, read one response line. Synchronous std transport — the shim
249/// side has no runtime, and one blocking round trip is exactly its job.
250///
251/// # Errors
252/// `NotRunning` when the socket is absent or refuses connection;
253/// `Io`/`Protocol` for transport and encoding failures.
254pub fn request_at(socket: &Path, request: &Request) -> Result<Response, ServerError> {
255    use std::io::{BufRead, BufReader as StdBufReader, Write};
256
257    let mut stream = match std::os::unix::net::UnixStream::connect(socket) {
258        Ok(stream) => stream,
259        Err(error) => {
260            return Err(match error.kind() {
261                std::io::ErrorKind::NotFound | std::io::ErrorKind::ConnectionRefused => {
262                    ServerError::NotRunning
263                }
264                _ => ServerError::Io(error),
265            })
266        }
267    };
268    let mut payload = serde_json::to_string(request)
269        .map_err(|e| ServerError::Protocol(format!("request encode failed: {e}")))?;
270    payload.push('\n');
271    stream.write_all(payload.as_bytes())?;
272    stream.flush()?;
273
274    let mut line = String::new();
275    StdBufReader::new(&mut stream).read_line(&mut line)?;
276    if line.is_empty() {
277        return Err(ServerError::Protocol(
278            "daemon closed the connection without responding".to_owned(),
279        ));
280    }
281    serde_json::from_str(&line)
282        .map_err(|e| ServerError::Protocol(format!("unrecognized response: {e}")))
283}