use std::io;
use std::path::Path;
use std::time::Duration;
use anyhow::{Context as _, anyhow};
use argonauts_core::control::{ClientKind, ControlReply, ControlRequest, Frame};
use argonauts_core::events::RunEvent;
use tokio::io::BufReader;
use tokio::net::UnixStream;
use tokio::net::unix::{OwnedReadHalf, OwnedWriteHalf};
use crate::run_files;
use crate::wire::{read_frame, write_frame};
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
const READ_TIMEOUT: Duration = Duration::from_secs(10);
const CHANGE_TIMEOUT: Duration = Duration::from_secs(60);
const TAKE_OVER_TIMEOUT: Duration = Duration::from_secs(10 * 60);
pub(crate) struct Connection {
reader: BufReader<OwnedReadHalf>,
writer: OwnedWriteHalf,
next_id: u64,
pub(crate) pid: u32,
}
impl Connection {
pub(crate) async fn connect(run_dir: &Path, kind: ClientKind) -> anyhow::Result<Option<Self>> {
let path = match run_files::socket_address(run_dir) {
Ok(path) => path,
Err(err) if nobody_listens(&err) => return Ok(None),
Err(err) => return Err(err).context("finding the run's control socket"),
};
let stream = match UnixStream::connect(&path).await {
Ok(stream) => stream,
Err(err) if nobody_listens(&err) => return Ok(None),
Err(err) => {
return Err(err).with_context(|| format!("connecting to {}", path.display()));
}
};
let (reader, writer) = stream.into_split();
let mut connection = Self {
reader: BufReader::new(reader),
writer,
next_id: 0,
pid: 0,
};
let hello = ControlRequest::Hello {
client: kind,
version: env!("CARGO_PKG_VERSION").to_owned(),
};
let greeting = tokio::time::timeout(HANDSHAKE_TIMEOUT, connection.request(hello))
.await
.map_err(|_| anyhow!("the run's run host accepted but didn't answer"))?;
match greeting {
Ok(ControlReply::Welcome { pid, .. }) => {
connection.pid = pid;
Ok(Some(connection))
}
Ok(ControlReply::Error { message }) => {
Err(anyhow!("the run's run host refused this client: {message}"))
}
Ok(other) => Err(anyhow!("the run's run host greeted with {other:?}")),
Err(err) if closed(&err) => Ok(None),
Err(err) => Err(err),
}
}
pub(crate) async fn request(
&mut self,
request: ControlRequest,
) -> anyhow::Result<ControlReply> {
let within = reply_within(&request);
let id = self.send(request).await?;
tokio::time::timeout(within, self.reply(id))
.await
.map_err(|_| {
anyhow!(
"the run's run host didn't answer within {}s",
within.as_secs()
)
})?
}
async fn reply(&mut self, id: u64) -> anyhow::Result<ControlReply> {
loop {
let frame: Frame<ControlReply> = read_frame(&mut self.reader)
.await?
.ok_or_else(|| anyhow::Error::new(io::Error::from(io::ErrorKind::UnexpectedEof)))
.context("the run's run host closed the connection")?;
if frame.id == id {
return Ok(frame.body);
}
}
}
pub(crate) async fn subscribe(
mut self,
from_seq: u64,
from_snapshot: bool,
) -> anyhow::Result<Subscription> {
let id = self
.send(ControlRequest::Subscribe {
from_seq,
from_snapshot,
})
.await?;
Ok(Subscription {
reader: self.reader,
_writer: self.writer,
id,
})
}
async fn send(&mut self, request: ControlRequest) -> anyhow::Result<u64> {
let id = self.next_id;
self.next_id += 1;
let frame = Frame { id, body: request };
write_frame(&mut self.writer, &frame)
.await
.context("writing to the run's run host")?;
Ok(id)
}
}
pub(crate) struct Subscription {
reader: BufReader<OwnedReadHalf>,
_writer: OwnedWriteHalf,
id: u64,
}
impl Subscription {
pub(crate) async fn next(&mut self) -> anyhow::Result<Option<RunEvent>> {
loop {
let frame: Option<Frame<ControlReply>> = match read_frame(&mut self.reader).await {
Ok(frame) => frame,
Err(err) if err.kind() == io::ErrorKind::ConnectionReset => None,
Err(err) => return Err(err).context("reading from the run's run host"),
};
let Some(frame) = frame else {
return Ok(None);
};
if frame.id != self.id {
continue;
}
match frame.body {
ControlReply::Event { event } => return Ok(Some(*event)),
ControlReply::Error { message } => {
return Err(anyhow!("the run's run host ended the stream: {message}"));
}
_ => continue,
}
}
}
}
fn reply_within(request: &ControlRequest) -> Duration {
match request {
ControlRequest::Hello { .. } => HANDSHAKE_TIMEOUT,
ControlRequest::Status | ControlRequest::Diff { .. } | ControlRequest::Raw { .. } => {
READ_TIMEOUT
}
ControlRequest::TakeOver { .. } => TAKE_OVER_TIMEOUT,
ControlRequest::Subscribe { .. }
| ControlRequest::Steer { .. }
| ControlRequest::Mention { .. }
| ControlRequest::Guidance { .. }
| ControlRequest::Pause { .. }
| ControlRequest::CancelPause
| ControlRequest::Stop
| ControlRequest::StopSession { .. }
| ControlRequest::TakeBack { .. }
| ControlRequest::ApprovalMode { .. }
| ControlRequest::Reassign { .. }
| ControlRequest::TakeOverEnded { .. }
| ControlRequest::Answer { .. }
| ControlRequest::Rewind { .. }
| ControlRequest::SpotCheck { .. }
| ControlRequest::Goal { .. } => CHANGE_TIMEOUT,
}
}
fn nobody_listens(err: &io::Error) -> bool {
matches!(
err.kind(),
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
)
}
fn closed(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause.downcast_ref::<io::Error>().is_some_and(|err| {
matches!(
err.kind(),
io::ErrorKind::UnexpectedEof
| io::ErrorKind::ConnectionReset
| io::ErrorKind::BrokenPipe
)
})
})
}