Skip to main content

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}