netget/server/named_pipe/mod.rs
1//! Named pipe (POSIX FIFO) server implementation.
2//!
3//! Platform: Unix/Linux/macOS only. The server creates a FIFO with `mkfifo`, reads whatever a
4//! writer process puts on it, hands each chunk to the LLM as a `named_pipe_data_received` event,
5//! and writes the model's reply to an optional second FIFO. NetGet owns the plumbing; the model
6//! owns the payload — the same contract as `socket_file`, addressed by a FIFO path.
7#![cfg(unix)]
8
9pub mod actions;
10
11use anyhow::{Context, Result};
12use std::fs::File;
13use std::io::Write as _;
14use std::os::unix::ffi::OsStrExt;
15use std::os::unix::io::{AsRawFd, FromRawFd};
16use std::path::{Path, PathBuf};
17use std::sync::Arc;
18use std::time::Duration;
19use tokio::io::unix::AsyncFd;
20use tokio::sync::mpsc;
21use tracing::error;
22
23use crate::llm::action_helper::call_llm;
24use crate::llm::ollama_client::OllamaClient;
25use crate::llm::ActionResult;
26use crate::logging::emit::Log;
27use crate::protocol::Event;
28use crate::server::named_pipe::actions::{NamedPipeProtocol, NAMED_PIPE_DATA_RECEIVED_EVENT};
29use crate::state::app_state::AppState;
30use crate::utils::WireFailure;
31
32/// Removes the FIFO node(s) this server created when the read task ends — including when the task
33/// is aborted by `stop_server`, because aborting drops the task future and with it this guard.
34///
35/// Only nodes **this server** created are listed. A FIFO that already existed belongs to whoever
36/// made it — an operator's `mkfifo /tmp/app.fifo`, another process's endpoint — and unlinking it
37/// on stop deletes someone else's object.
38struct FifoCleanup(Vec<PathBuf>);
39
40impl Drop for FifoCleanup {
41 fn drop(&mut self) {
42 for p in &self.0 {
43 let _ = std::fs::remove_file(p);
44 }
45 }
46}
47
48/// Describe a file type for the "refusing to reuse" error message.
49fn describe_file_type(ft: &std::fs::FileType) -> &'static str {
50 use std::os::unix::fs::FileTypeExt;
51 if ft.is_dir() {
52 "directory"
53 } else if ft.is_symlink() {
54 "symlink"
55 } else if ft.is_fifo() {
56 "FIFO"
57 } else if ft.is_socket() {
58 "socket"
59 } else if ft.is_char_device() {
60 "character device"
61 } else if ft.is_block_device() {
62 "block device"
63 } else {
64 "regular file"
65 }
66}
67
68/// Ensure a FIFO exists at `path`, creating it with `mkfifo` if absent.
69///
70/// `path` comes from the LLM or an MCP caller, so — exactly like `socket_file` — an existing node
71/// is only reused when it really is a FIFO. Anything else (a regular file, a symlink, a socket) is
72/// refused with a message naming what was found, rather than being clobbered.
73///
74/// Returns `true` when this call created the node, which is what decides whether the cleanup
75/// guard may unlink it on stop.
76fn ensure_fifo(path: &Path) -> Result<bool> {
77 match std::fs::symlink_metadata(path) {
78 Ok(meta) => {
79 use std::os::unix::fs::FileTypeExt;
80 if meta.file_type().is_fifo() {
81 // Reuse the existing FIFO — and remember we did not make it.
82 return Ok(false);
83 }
84 anyhow::bail!(
85 "Refusing to use {:?}: it exists but is not a FIFO (it is a {}). Delete it \
86 yourself if that is really what you want, or pass a different path.",
87 path,
88 describe_file_type(&meta.file_type())
89 );
90 }
91 Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
92 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
93 .map_err(|_| anyhow::anyhow!("invalid FIFO path: {:?}", path))?;
94 // 0o600: owner read/write only.
95 let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
96 if rc != 0 {
97 return Err(std::io::Error::last_os_error())
98 .with_context(|| format!("Failed to create FIFO {:?}", path));
99 }
100 Ok(true)
101 }
102 Err(e) => Err(e).with_context(|| format!("Failed to stat {:?}", path)),
103 }
104}
105
106/// Open `path` O_RDWR | O_NONBLOCK.
107///
108/// O_RDWR on a FIFO succeeds immediately (no blocking wait for a peer) and, by keeping both a
109/// reader and a writer reference open, prevents `read()` from returning a spurious EOF every time
110/// an external writer closes — which would otherwise busy-loop the AsyncFd read loop. O_NONBLOCK
111/// is required so the fd can be driven by tokio's `AsyncFd`.
112fn open_fifo_rdwr_nonblocking(path: &Path) -> Result<File> {
113 let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
114 .map_err(|_| anyhow::anyhow!("invalid FIFO path: {:?}", path))?;
115 let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDWR | libc::O_NONBLOCK) };
116 if fd < 0 {
117 return Err(std::io::Error::last_os_error())
118 .with_context(|| format!("Failed to open FIFO {:?}", path));
119 }
120 Ok(unsafe { File::from_raw_fd(fd) })
121}
122
123/// How long a write to the response FIFO may wait for the reader to make room.
124///
125/// A FIFO holds 64 KiB; past that, `write()` blocks until a reader drains it. Nothing here
126/// reads the response FIFO, so with no reader attached the model's output eventually fills the
127/// buffer and the write cannot complete — and the reply is stale by then anyway.
128const RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
129
130/// Open the response FIFO O_RDWR | O_NONBLOCK and register it with the runtime.
131///
132/// O_RDWR succeeds immediately even before a reader attaches, and lets us buffer a response into
133/// the kernel FIFO buffer that the reader drains when it opens the path.
134///
135/// **O_NONBLOCK is the important half.** This used to be a blocking fd written with
136/// `std::io::Write::write_all` from inside the async read loop: once the 64 KiB buffer filled
137/// with nobody reading, that `write_all` blocked *a tokio worker thread* — not just this task —
138/// for as long as the condition lasted, which for an unread FIFO is forever. Driven through
139/// `AsyncFd` the same situation parks only this task, and the timeout below unparks it.
140fn open_response_fifo(path: &Path) -> Result<AsyncFd<File>> {
141 let file = open_fifo_rdwr_nonblocking(path)
142 .with_context(|| format!("Failed to open response FIFO {:?}", path))?;
143 AsyncFd::new(file).with_context(|| {
144 format!(
145 "Failed to register response FIFO {:?} with the async runtime",
146 path
147 )
148 })
149}
150
151/// Named pipe server.
152pub struct NamedPipeServer;
153
154impl NamedPipeServer {
155 /// Create the FIFO(s), open the read side, and spawn the read/dispatch loop.
156 ///
157 /// Awaits readiness (both FIFOs created and opened) before returning, so a failure surfaces as
158 /// `Err` and `server_startup` records `ServerStatus::Error` rather than a server that lies
159 /// about being up.
160 pub async fn spawn_with_llm_actions(
161 pipe_path: PathBuf,
162 response_pipe_path: Option<PathBuf>,
163 llm_client: OllamaClient,
164 app_state: Arc<AppState>,
165 status_tx: mpsc::UnboundedSender<String>,
166 server_id: crate::state::ServerId,
167 ) -> Result<()> {
168 let created_read_fifo = ensure_fifo(&pipe_path)?;
169 let read_file = open_fifo_rdwr_nonblocking(&pipe_path)?;
170 let async_read = AsyncFd::new(read_file).with_context(|| {
171 format!(
172 "Failed to register FIFO {:?} with the async runtime",
173 pipe_path
174 )
175 })?;
176
177 // Track only the nodes we actually created, so the cleanup guard never unlinks a FIFO
178 // that was already there when we started.
179 let mut created: Vec<PathBuf> = Vec::new();
180 if created_read_fifo {
181 created.push(pipe_path.clone());
182 }
183
184 let response_file = match &response_pipe_path {
185 Some(rp) => {
186 if ensure_fifo(rp)? {
187 created.push(rp.clone());
188 }
189 Some(open_response_fifo(rp)?)
190 }
191 None => None,
192 };
193 let cleanup = FifoCleanup(created);
194
195 Log::new(Some(&status_tx)).info(format!(
196 "Named pipe server reading FIFO {:?} (response FIFO: {:?})",
197 pipe_path, response_pipe_path
198 ));
199
200 let protocol = Arc::new(NamedPipeProtocol::new());
201 let task_registrar = app_state.clone();
202
203 let handle = tokio::spawn(async move {
204 // Move the cleanup guard into the task so it drops (and unlinks) on abort.
205 let _cleanup = cleanup;
206 let mut response_file = response_file;
207 let mut buffer = vec![0u8; 8192];
208
209 loop {
210 let mut guard = match async_read.readable().await {
211 Ok(g) => g,
212 Err(e) => {
213 error!("Named pipe {:?} readable() error: {}", pipe_path, e);
214 break;
215 }
216 };
217
218 let read_result = guard.try_io(|inner| {
219 let fd = inner.get_ref().as_raw_fd();
220 let n = unsafe {
221 libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len())
222 };
223 if n < 0 {
224 Err(std::io::Error::last_os_error())
225 } else {
226 Ok(n as usize)
227 }
228 });
229
230 let n = match read_result {
231 Ok(Ok(0)) => {
232 // With O_RDWR a real EOF should not occur. `clear_ready()` is what makes
233 // this `continue` safe: `try_io` only drops the cached readiness when the
234 // closure reports WouldBlock, so continuing on any other outcome leaves
235 // readiness set and the loop spins on a core instead of waiting.
236 guard.clear_ready();
237 continue;
238 }
239 Ok(Ok(n)) => n,
240 Ok(Err(e)) => {
241 error!("Named pipe {:?} read error: {}", pipe_path, e);
242 break;
243 }
244 Err(_would_block) => continue,
245 };
246
247 let data = &buffer[..n];
248 let (data_str, encoding) = if data
249 .iter()
250 .all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
251 {
252 (String::from_utf8_lossy(data).to_string(), "utf8")
253 } else {
254 (hex::encode(data), "hex")
255 };
256
257 Log::new(Some(&status_tx)).debug(format!(
258 "Named pipe {:?} received {} bytes ({})",
259 pipe_path, n, encoding
260 ));
261
262 let event = Event::new(
263 &NAMED_PIPE_DATA_RECEIVED_EVENT,
264 serde_json::json!({ "data": data_str, "encoding": encoding }),
265 );
266
267 match call_llm(
268 &llm_client,
269 &app_state,
270 server_id,
271 None,
272 &event,
273 protocol.as_ref(),
274 )
275 .await
276 {
277 Ok(result) => {
278 for msg in result.messages {
279 let _ = status_tx.send(msg);
280 }
281 let mut wrote = 0usize;
282 for pr in result.protocol_results {
283 if let ActionResult::Output(bytes) = pr {
284 wrote += bytes.len();
285 Self::write_response(&mut response_file, &bytes, &status_tx).await;
286 }
287 // CloseConnection / WaitForMore / others are meaningless for a
288 // connectionless FIFO sink and are intentionally ignored.
289 }
290 // Three outcomes must stay apart in the log: the model answered with
291 // bytes, the model deliberately answered with nothing (a real answer for
292 // a one-way sink), and the backend erred (below). Only the last one gets
293 // a netget-authored reply.
294 Log::new(Some(&status_tx)).debug(format!(
295 "Named pipe {:?} decision={} bytes={}",
296 pipe_path,
297 if wrote > 0 {
298 "model_output"
299 } else {
300 "model_no_output"
301 },
302 wrote
303 ));
304 }
305 Err(e) => {
306 // The backend failed. The full error goes to the log and the status
307 // stream; the FIFO reader gets a category only — never the error, the
308 // backend URL, the model name or an anyhow chain (see
309 // `crate::utils::wire_failure`). Non-fatal: the read loop continues to
310 // the next chunk.
311 let failure = WireFailure::classify(&e);
312 let decision = if failure.is_overloaded() {
313 "fail_closed_overloaded"
314 } else {
315 "fail_closed_llm_error"
316 };
317 error!(
318 "Named pipe {:?} decision={} LLM error: {}",
319 pipe_path, decision, e
320 );
321 Log::new(Some(&status_tx)).warn(format!(
322 "Named pipe {:?} decision={}: {}",
323 pipe_path, decision, e
324 ));
325 Self::write_failure_notice(&mut response_file, failure, &status_tx).await;
326 }
327 }
328 }
329 });
330
331 task_registrar.register_server_task(server_id, handle).await;
332 Ok(())
333 }
334
335 /// Tell the FIFO reader that netget itself could not answer this chunk.
336 ///
337 /// A reader parked in `read()` on the response FIFO has no timeout of its own — `cat` blocks
338 /// forever — so silence on a backend failure hangs it indefinitely. It gets one attributed,
339 /// newline-terminated line carrying a *category* and nothing else: `prefixed_text()` is
340 /// `&'static str`, so nothing derived from the error can reach the pipe. The two categories
341 /// have distinct wording (`WireFailure::Overloaded` reads as retryable), which is the only
342 /// distinction a byte-stream FIFO can carry — it has no status codes.
343 ///
344 /// When no `response_pipe_path` was configured there is no reply channel at all, and silence
345 /// is the only correct behaviour; it is logged, not invented.
346 async fn write_failure_notice(
347 response_file: &mut Option<AsyncFd<File>>,
348 failure: WireFailure,
349 status_tx: &mpsc::UnboundedSender<String>,
350 ) {
351 let log = Log::new(Some(status_tx));
352 let Some(f) = response_file else {
353 log.debug(
354 "Named pipe: no response_pipe_path configured; the failure notice has nowhere to \
355 go and the reader sees nothing"
356 .to_string(),
357 );
358 return;
359 };
360 let mut line = String::with_capacity(failure.prefixed_text().len() + 1);
361 line.push_str(failure.prefixed_text());
362 line.push('\n');
363 if let Err(e) = Self::write_fifo(f, line.as_bytes()).await {
364 log.error(format!("Named pipe failure-notice write error: {}", e));
365 }
366 }
367
368 /// Write `bytes` to the response FIFO, or warn if none was configured.
369 async fn write_response(
370 response_file: &mut Option<AsyncFd<File>>,
371 bytes: &[u8],
372 status_tx: &mpsc::UnboundedSender<String>,
373 ) {
374 let log = Log::new(Some(status_tx));
375 match response_file {
376 Some(f) => match Self::write_fifo(f, bytes).await {
377 Ok(()) => {
378 // FileOnly: the write_named_pipe_data action's own log_template already
379 // reports "-> FIFO {data_len}B" to the TUI at INFO.
380 log.debug(format!(
381 "Named pipe wrote {} bytes to response FIFO",
382 bytes.len()
383 ));
384 }
385 Err(e) => {
386 log.error(format!("Named pipe response write error: {}", e));
387 }
388 },
389 None => {
390 log.warn(format!(
391 "Named pipe: model produced {} bytes of output but no response_pipe_path was \
392 configured; dropping it",
393 bytes.len()
394 ));
395 }
396 }
397 }
398
399 /// Write every byte to the non-blocking response FIFO, bounded by
400 /// [`RESPONSE_WRITE_TIMEOUT`].
401 ///
402 /// The bound matters as much as the async-ness: a FIFO nobody reads fills at 64 KiB and then
403 /// never becomes writable again, so an unbounded wait would leave the read loop parked for
404 /// the life of the server, silently ignoring every later write.
405 async fn write_fifo(file: &mut AsyncFd<File>, bytes: &[u8]) -> std::io::Result<()> {
406 let write_all = async {
407 let mut written = 0usize;
408 while written < bytes.len() {
409 let mut guard = file.writable().await?;
410 match guard.try_io(|inner| inner.get_ref().write(&bytes[written..])) {
411 Ok(Ok(0)) => {
412 return Err(std::io::Error::new(
413 std::io::ErrorKind::WriteZero,
414 "response FIFO accepted no bytes",
415 ))
416 }
417 Ok(Ok(n)) => written += n,
418 Ok(Err(e)) => return Err(e),
419 Err(_would_block) => continue,
420 }
421 }
422 Ok(())
423 };
424 match tokio::time::timeout(RESPONSE_WRITE_TIMEOUT, write_all).await {
425 Ok(res) => res,
426 Err(_) => Err(std::io::Error::new(
427 std::io::ErrorKind::TimedOut,
428 format!(
429 "response FIFO did not accept the write within {}s (is anything reading it?)",
430 RESPONSE_WRITE_TIMEOUT.as_secs()
431 ),
432 )),
433 }
434 }
435}