#![cfg(unix)]
pub mod actions;
use anyhow::{Context, Result};
use std::fs::File;
use std::io::Write as _;
use std::os::unix::ffi::OsStrExt;
use std::os::unix::io::{AsRawFd, FromRawFd};
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use tokio::io::unix::AsyncFd;
use tokio::sync::mpsc;
use tracing::error;
use crate::llm::action_helper::call_llm;
use crate::llm::ollama_client::OllamaClient;
use crate::llm::ActionResult;
use crate::logging::emit::Log;
use crate::protocol::Event;
use crate::server::named_pipe::actions::{NamedPipeProtocol, NAMED_PIPE_DATA_RECEIVED_EVENT};
use crate::state::app_state::AppState;
use crate::utils::WireFailure;
struct FifoCleanup(Vec<PathBuf>);
impl Drop for FifoCleanup {
fn drop(&mut self) {
for p in &self.0 {
let _ = std::fs::remove_file(p);
}
}
}
fn describe_file_type(ft: &std::fs::FileType) -> &'static str {
use std::os::unix::fs::FileTypeExt;
if ft.is_dir() {
"directory"
} else if ft.is_symlink() {
"symlink"
} else if ft.is_fifo() {
"FIFO"
} else if ft.is_socket() {
"socket"
} else if ft.is_char_device() {
"character device"
} else if ft.is_block_device() {
"block device"
} else {
"regular file"
}
}
fn ensure_fifo(path: &Path) -> Result<bool> {
match std::fs::symlink_metadata(path) {
Ok(meta) => {
use std::os::unix::fs::FileTypeExt;
if meta.file_type().is_fifo() {
return Ok(false);
}
anyhow::bail!(
"Refusing to use {:?}: it exists but is not a FIFO (it is a {}). Delete it \
yourself if that is really what you want, or pass a different path.",
path,
describe_file_type(&meta.file_type())
);
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|_| anyhow::anyhow!("invalid FIFO path: {:?}", path))?;
let rc = unsafe { libc::mkfifo(c_path.as_ptr(), 0o600) };
if rc != 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("Failed to create FIFO {:?}", path));
}
Ok(true)
}
Err(e) => Err(e).with_context(|| format!("Failed to stat {:?}", path)),
}
}
fn open_fifo_rdwr_nonblocking(path: &Path) -> Result<File> {
let c_path = std::ffi::CString::new(path.as_os_str().as_bytes())
.map_err(|_| anyhow::anyhow!("invalid FIFO path: {:?}", path))?;
let fd = unsafe { libc::open(c_path.as_ptr(), libc::O_RDWR | libc::O_NONBLOCK) };
if fd < 0 {
return Err(std::io::Error::last_os_error())
.with_context(|| format!("Failed to open FIFO {:?}", path));
}
Ok(unsafe { File::from_raw_fd(fd) })
}
const RESPONSE_WRITE_TIMEOUT: Duration = Duration::from_secs(5);
fn open_response_fifo(path: &Path) -> Result<AsyncFd<File>> {
let file = open_fifo_rdwr_nonblocking(path)
.with_context(|| format!("Failed to open response FIFO {:?}", path))?;
AsyncFd::new(file).with_context(|| {
format!(
"Failed to register response FIFO {:?} with the async runtime",
path
)
})
}
pub struct NamedPipeServer;
impl NamedPipeServer {
pub async fn spawn_with_llm_actions(
pipe_path: PathBuf,
response_pipe_path: Option<PathBuf>,
llm_client: OllamaClient,
app_state: Arc<AppState>,
status_tx: mpsc::UnboundedSender<String>,
server_id: crate::state::ServerId,
) -> Result<()> {
let created_read_fifo = ensure_fifo(&pipe_path)?;
let read_file = open_fifo_rdwr_nonblocking(&pipe_path)?;
let async_read = AsyncFd::new(read_file).with_context(|| {
format!(
"Failed to register FIFO {:?} with the async runtime",
pipe_path
)
})?;
let mut created: Vec<PathBuf> = Vec::new();
if created_read_fifo {
created.push(pipe_path.clone());
}
let response_file = match &response_pipe_path {
Some(rp) => {
if ensure_fifo(rp)? {
created.push(rp.clone());
}
Some(open_response_fifo(rp)?)
}
None => None,
};
let cleanup = FifoCleanup(created);
Log::new(Some(&status_tx)).info(format!(
"Named pipe server reading FIFO {:?} (response FIFO: {:?})",
pipe_path, response_pipe_path
));
let protocol = Arc::new(NamedPipeProtocol::new());
let task_registrar = app_state.clone();
let handle = tokio::spawn(async move {
let _cleanup = cleanup;
let mut response_file = response_file;
let mut buffer = vec![0u8; 8192];
loop {
let mut guard = match async_read.readable().await {
Ok(g) => g,
Err(e) => {
error!("Named pipe {:?} readable() error: {}", pipe_path, e);
break;
}
};
let read_result = guard.try_io(|inner| {
let fd = inner.get_ref().as_raw_fd();
let n = unsafe {
libc::read(fd, buffer.as_mut_ptr() as *mut libc::c_void, buffer.len())
};
if n < 0 {
Err(std::io::Error::last_os_error())
} else {
Ok(n as usize)
}
});
let n = match read_result {
Ok(Ok(0)) => {
guard.clear_ready();
continue;
}
Ok(Ok(n)) => n,
Ok(Err(e)) => {
error!("Named pipe {:?} read error: {}", pipe_path, e);
break;
}
Err(_would_block) => continue,
};
let data = &buffer[..n];
let (data_str, encoding) = if data
.iter()
.all(|&b| b.is_ascii_graphic() || b.is_ascii_whitespace())
{
(String::from_utf8_lossy(data).to_string(), "utf8")
} else {
(hex::encode(data), "hex")
};
Log::new(Some(&status_tx)).debug(format!(
"Named pipe {:?} received {} bytes ({})",
pipe_path, n, encoding
));
let event = Event::new(
&NAMED_PIPE_DATA_RECEIVED_EVENT,
serde_json::json!({ "data": data_str, "encoding": encoding }),
);
match call_llm(
&llm_client,
&app_state,
server_id,
None,
&event,
protocol.as_ref(),
)
.await
{
Ok(result) => {
for msg in result.messages {
let _ = status_tx.send(msg);
}
let mut wrote = 0usize;
for pr in result.protocol_results {
if let ActionResult::Output(bytes) = pr {
wrote += bytes.len();
Self::write_response(&mut response_file, &bytes, &status_tx).await;
}
}
Log::new(Some(&status_tx)).debug(format!(
"Named pipe {:?} decision={} bytes={}",
pipe_path,
if wrote > 0 {
"model_output"
} else {
"model_no_output"
},
wrote
));
}
Err(e) => {
let failure = WireFailure::classify(&e);
let decision = if failure.is_overloaded() {
"fail_closed_overloaded"
} else {
"fail_closed_llm_error"
};
error!(
"Named pipe {:?} decision={} LLM error: {}",
pipe_path, decision, e
);
Log::new(Some(&status_tx)).warn(format!(
"Named pipe {:?} decision={}: {}",
pipe_path, decision, e
));
Self::write_failure_notice(&mut response_file, failure, &status_tx).await;
}
}
}
});
task_registrar.register_server_task(server_id, handle).await;
Ok(())
}
async fn write_failure_notice(
response_file: &mut Option<AsyncFd<File>>,
failure: WireFailure,
status_tx: &mpsc::UnboundedSender<String>,
) {
let log = Log::new(Some(status_tx));
let Some(f) = response_file else {
log.debug(
"Named pipe: no response_pipe_path configured; the failure notice has nowhere to \
go and the reader sees nothing"
.to_string(),
);
return;
};
let mut line = String::with_capacity(failure.prefixed_text().len() + 1);
line.push_str(failure.prefixed_text());
line.push('\n');
if let Err(e) = Self::write_fifo(f, line.as_bytes()).await {
log.error(format!("Named pipe failure-notice write error: {}", e));
}
}
async fn write_response(
response_file: &mut Option<AsyncFd<File>>,
bytes: &[u8],
status_tx: &mpsc::UnboundedSender<String>,
) {
let log = Log::new(Some(status_tx));
match response_file {
Some(f) => match Self::write_fifo(f, bytes).await {
Ok(()) => {
log.debug(format!(
"Named pipe wrote {} bytes to response FIFO",
bytes.len()
));
}
Err(e) => {
log.error(format!("Named pipe response write error: {}", e));
}
},
None => {
log.warn(format!(
"Named pipe: model produced {} bytes of output but no response_pipe_path was \
configured; dropping it",
bytes.len()
));
}
}
}
async fn write_fifo(file: &mut AsyncFd<File>, bytes: &[u8]) -> std::io::Result<()> {
let write_all = async {
let mut written = 0usize;
while written < bytes.len() {
let mut guard = file.writable().await?;
match guard.try_io(|inner| inner.get_ref().write(&bytes[written..])) {
Ok(Ok(0)) => {
return Err(std::io::Error::new(
std::io::ErrorKind::WriteZero,
"response FIFO accepted no bytes",
))
}
Ok(Ok(n)) => written += n,
Ok(Err(e)) => return Err(e),
Err(_would_block) => continue,
}
}
Ok(())
};
match tokio::time::timeout(RESPONSE_WRITE_TIMEOUT, write_all).await {
Ok(res) => res,
Err(_) => Err(std::io::Error::new(
std::io::ErrorKind::TimedOut,
format!(
"response FIFO did not accept the write within {}s (is anything reading it?)",
RESPONSE_WRITE_TIMEOUT.as_secs()
),
)),
}
}
}