use std::{
io,
pin::Pin,
task::{Context, Poll},
};
use tokio::io::{AsyncRead, AsyncWrite, ReadBuf};
pub fn stdio() -> (tokio::io::Stdin, tokio::io::Stdout) {
(tokio::io::stdin(), tokio::io::stdout())
}
pub fn stdio_pipes() -> (StdioReader, StdioWriter) {
#[cfg(target_os = "linux")]
{
use std::os::fd::AsRawFd;
if let Some(pair) =
reopen_fifo_pair(std::io::stdin().as_raw_fd(), std::io::stdout().as_raw_fd())
{
tracing::debug!("stdio_pipes: using the non-blocking Linux pipe transport");
return pair;
}
tracing::debug!("stdio_pipes: falling back to the blocking stdio handles");
}
(
StdioReader {
imp: StdioReaderImp::Blocking(tokio::io::stdin()),
},
StdioWriter {
imp: StdioWriterImp::Blocking(tokio::io::stdout()),
},
)
}
#[cfg(target_os = "linux")]
fn reopen_fifo_pair(
read_fd: std::os::fd::RawFd,
write_fd: std::os::fd::RawFd,
) -> Option<(StdioReader, StdioWriter)> {
let read = reopen_nonblocking(&format!("/proc/self/fd/{read_fd}"), false)?;
if !is_fifo(&read) {
return None;
}
let write = reopen_nonblocking(&format!("/proc/self/fd/{write_fd}"), true)?;
if !is_fifo(&write) {
return None;
}
pipe_transport(read, write)
}
#[cfg(target_os = "linux")]
fn reopen_nonblocking(path: &str, write: bool) -> Option<std::os::fd::OwnedFd> {
use std::os::unix::fs::OpenOptionsExt;
let mut options = std::fs::OpenOptions::new();
options.read(!write).write(write);
options.custom_flags(libc::O_NONBLOCK);
options.open(path).ok().map(Into::into)
}
#[cfg(target_os = "linux")]
fn pipe_transport(
read: std::os::fd::OwnedFd,
write: std::os::fd::OwnedFd,
) -> Option<(StdioReader, StdioWriter)> {
let read = tokio::net::unix::pipe::Receiver::from_owned_fd(read).ok()?;
let write = tokio::net::unix::pipe::Sender::from_owned_fd(write).ok()?;
Some((
StdioReader {
imp: StdioReaderImp::Pipe(read),
},
StdioWriter {
imp: StdioWriterImp::Pipe(write),
},
))
}
#[cfg(target_os = "linux")]
fn is_fifo(fd: &std::os::fd::OwnedFd) -> bool {
use std::os::unix::fs::FileTypeExt;
fd.try_clone()
.map(std::fs::File::from)
.and_then(|file| file.metadata())
.is_ok_and(|metadata| metadata.file_type().is_fifo())
}
#[derive(Debug)]
pub struct StdioReader {
imp: StdioReaderImp,
}
#[derive(Debug)]
enum StdioReaderImp {
Blocking(tokio::io::Stdin),
#[cfg(target_os = "linux")]
Pipe(tokio::net::unix::pipe::Receiver),
}
impl AsyncRead for StdioReader {
fn poll_read(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &mut ReadBuf<'_>,
) -> Poll<io::Result<()>> {
match &mut self.get_mut().imp {
StdioReaderImp::Blocking(reader) => Pin::new(reader).poll_read(cx, buf),
#[cfg(target_os = "linux")]
StdioReaderImp::Pipe(reader) => Pin::new(reader).poll_read(cx, buf),
}
}
}
#[derive(Debug)]
pub struct StdioWriter {
imp: StdioWriterImp,
}
#[derive(Debug)]
enum StdioWriterImp {
Blocking(tokio::io::Stdout),
#[cfg(target_os = "linux")]
Pipe(tokio::net::unix::pipe::Sender),
}
impl AsyncWrite for StdioWriter {
fn poll_write(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
buf: &[u8],
) -> Poll<io::Result<usize>> {
match &mut self.get_mut().imp {
StdioWriterImp::Blocking(writer) => Pin::new(writer).poll_write(cx, buf),
#[cfg(target_os = "linux")]
StdioWriterImp::Pipe(writer) => Pin::new(writer).poll_write(cx, buf),
}
}
fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match &mut self.get_mut().imp {
StdioWriterImp::Blocking(writer) => Pin::new(writer).poll_flush(cx),
#[cfg(target_os = "linux")]
StdioWriterImp::Pipe(writer) => Pin::new(writer).poll_flush(cx),
}
}
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
match &mut self.get_mut().imp {
StdioWriterImp::Blocking(writer) => Pin::new(writer).poll_shutdown(cx),
#[cfg(target_os = "linux")]
StdioWriterImp::Pipe(writer) => Pin::new(writer).poll_shutdown(cx),
}
}
fn poll_write_vectored(
self: Pin<&mut Self>,
cx: &mut Context<'_>,
bufs: &[io::IoSlice<'_>],
) -> Poll<io::Result<usize>> {
match &mut self.get_mut().imp {
StdioWriterImp::Blocking(writer) => Pin::new(writer).poll_write_vectored(cx, bufs),
#[cfg(target_os = "linux")]
StdioWriterImp::Pipe(writer) => Pin::new(writer).poll_write_vectored(cx, bufs),
}
}
fn is_write_vectored(&self) -> bool {
match &self.imp {
StdioWriterImp::Blocking(writer) => writer.is_write_vectored(),
#[cfg(target_os = "linux")]
StdioWriterImp::Pipe(writer) => writer.is_write_vectored(),
}
}
}
#[cfg(test)]
mod tests {
#[cfg(target_os = "linux")]
fn fifo_pair() -> (
std::process::Child,
std::os::fd::OwnedFd,
std::os::fd::OwnedFd,
) {
use std::process::{Command, Stdio};
let mut child = Command::new("cat")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.spawn()
.expect("failed to spawn `cat`");
let write = child.stdin.take().expect("child stdin").into();
let read = child.stdout.take().expect("child stdout").into();
(child, read, write)
}
#[cfg(target_os = "linux")]
fn open_flags(fd: std::os::fd::RawFd) -> i32 {
unsafe { libc::fcntl(fd, libc::F_GETFL) }
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn pipe_transport_round_trips_over_fifos() {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::{StdioReaderImp, StdioWriterImp, pipe_transport};
let (mut child, read, write) = fifo_pair();
let (mut reader, mut writer) = pipe_transport(read, write).expect("child pipes are FIFOs");
assert!(matches!(reader.imp, StdioReaderImp::Pipe(_)));
assert!(matches!(writer.imp, StdioWriterImp::Pipe(_)));
let message = b"{\"jsonrpc\":\"2.0\",\"id\":1}\n";
writer.write_all(message).await.unwrap();
writer.flush().await.unwrap();
let mut echoed = vec![0; message.len()];
reader.read_exact(&mut echoed).await.unwrap();
assert_eq!(echoed, message);
drop(writer);
assert!(child.wait().unwrap().success());
}
#[cfg(target_os = "linux")]
#[test]
fn reopening_a_fifo_keeps_the_original_flags() {
use std::os::fd::AsRawFd;
use super::{is_fifo, reopen_nonblocking};
let path = std::env::temp_dir().join(format!("rmcp-fifo-reopen-{}", std::process::id()));
let _ = std::fs::remove_file(&path);
assert!(
std::process::Command::new("mkfifo")
.arg(&path)
.status()
.expect("failed to run `mkfifo`")
.success()
);
let original = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(&path)
.expect("failed to open the fifo");
assert_eq!(open_flags(original.as_raw_fd()) & libc::O_NONBLOCK, 0);
let reopened =
reopen_nonblocking(&format!("/proc/self/fd/{}", original.as_raw_fd()), false)
.expect("failed to reopen the fifo");
assert!(is_fifo(&reopened));
assert_ne!(open_flags(reopened.as_raw_fd()) & libc::O_NONBLOCK, 0);
assert_eq!(open_flags(original.as_raw_fd()) & libc::O_NONBLOCK, 0);
drop(reopened);
drop(original);
let _ = std::fs::remove_file(&path);
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn reopened_fifo_pair_round_trips_and_keeps_originals_blocking() {
use std::os::fd::AsRawFd;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use super::{StdioReaderImp, StdioWriterImp, reopen_fifo_pair};
let (mut child, read, write) = fifo_pair();
let read_flags = open_flags(read.as_raw_fd());
let write_flags = open_flags(write.as_raw_fd());
assert_eq!(read_flags & libc::O_NONBLOCK, 0);
assert_eq!(write_flags & libc::O_NONBLOCK, 0);
let (mut reader, mut writer) =
reopen_fifo_pair(read.as_raw_fd(), write.as_raw_fd()).expect("child pipes are FIFOs");
assert!(matches!(reader.imp, StdioReaderImp::Pipe(_)));
assert!(matches!(writer.imp, StdioWriterImp::Pipe(_)));
let message = b"{\"jsonrpc\":\"2.0\",\"id\":2}\n";
writer.write_all(message).await.unwrap();
writer.flush().await.unwrap();
let mut echoed = vec![0; message.len()];
reader.read_exact(&mut echoed).await.unwrap();
assert_eq!(echoed, message);
assert_eq!(open_flags(read.as_raw_fd()), read_flags);
assert_eq!(open_flags(write.as_raw_fd()), write_flags);
drop(reader);
drop(writer);
drop(read);
drop(write);
assert!(child.wait().unwrap().success());
}
#[cfg(target_os = "linux")]
#[test]
fn fifo_detection_rejects_character_devices() {
use super::is_fifo;
let dev_null: std::os::fd::OwnedFd = std::fs::File::open("/dev/null")
.expect("failed to open /dev/null")
.into();
assert!(!is_fifo(&dev_null));
}
#[cfg(feature = "server")]
#[tokio::test]
async fn stdio_pipes_builds_an_async_rw_transport() {
use super::stdio_pipes;
use crate::{RoleServer, transport::async_rw::AsyncRwTransport};
let (reader, writer) = stdio_pipes();
let _transport: AsyncRwTransport<RoleServer, _, _> = AsyncRwTransport::new(reader, writer);
}
#[cfg(not(target_os = "linux"))]
#[tokio::test]
async fn stdio_pipes_falls_back_to_blocking_handles_off_linux() {
use super::{StdioReaderImp, StdioWriterImp, stdio_pipes};
let (reader, writer) = stdio_pipes();
assert!(matches!(reader.imp, StdioReaderImp::Blocking(_)));
assert!(matches!(writer.imp, StdioWriterImp::Blocking(_)));
}
}