use std::io;
use std::path::Path;
use std::time::{Duration, Instant};
use http_body_util::BodyExt;
use hyper::body::Bytes;
use hyper::client::conn::http1;
use hyper::Request;
use hyper::StatusCode;
use hyper_util::rt::TokioIo;
use tokio::net::TcpStream;
#[cfg(unix)]
use tokio::net::UnixStream;
use crate::config::{append_spawn_log, ClientConfig, Spawner, Transport};
use crate::error::ClientError;
pub(crate) struct RawResponse {
pub status: StatusCode,
pub body: Bytes,
}
pub(crate) struct OutgoingRequest<'a> {
pub method: &'a str,
pub path: &'a str,
pub project: Option<&'a str>,
pub body: String,
}
pub(crate) async fn request(
cfg: &ClientConfig,
req: OutgoingRequest<'_>,
) -> Result<RawResponse, ClientError> {
match cfg.transport {
#[cfg(unix)]
Transport::Uds => {
let stream = ensure_connected(
&cfg.socket_path,
&cfg.spawn,
cfg.connect_timeout,
cfg.poll_interval,
cfg.effective_spawn_log_path().as_deref(),
)
.await?;
send_http(stream, req).await
}
Transport::Tcp => {
let stream = ensure_connected_tcp(
&cfg.port_path,
&cfg.spawn,
cfg.connect_timeout,
cfg.poll_interval,
cfg.effective_spawn_log_path().as_deref(),
)
.await?;
send_http(stream, req).await
}
}
}
async fn send_http<S>(stream: S, req: OutgoingRequest<'_>) -> Result<RawResponse, ClientError>
where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Send + Unpin + 'static,
{
let io = TokioIo::new(stream);
let OutgoingRequest {
method,
path,
project,
body,
} = req;
let (mut sender, conn) = http1::handshake::<_, String>(io)
.await
.map_err(|e| ClientError::Http(format!("handshake failed: {e}")))?;
let conn_task = tokio::spawn(async move {
let _ = conn.await;
});
let mut builder = Request::builder()
.method(method)
.uri(path)
.header("host", "kindling.local")
.header("content-type", "application/json");
if let Some(p) = project {
builder = builder.header(crate::PROJECT_HEADER, p);
}
let req = builder
.body(body)
.map_err(|e| ClientError::Http(format!("building request: {e}")))?;
let resp = sender
.send_request(req)
.await
.map_err(|e| ClientError::Http(format!("send_request: {e}")))?;
let status = resp.status();
let body = resp
.into_body()
.collect()
.await
.map_err(|e| ClientError::Http(format!("reading body: {e}")))?
.to_bytes();
conn_task.abort();
Ok(RawResponse { status, body })
}
#[cfg(unix)]
async fn ensure_connected(
socket_path: &Path,
spawner: &Spawner,
connect_timeout: Duration,
poll_interval: Duration,
spawn_log_path: Option<&Path>,
) -> Result<UnixStream, ClientError> {
match UnixStream::connect(socket_path).await {
Ok(stream) => return Ok(stream),
Err(e) if is_absent(&e) => { }
Err(e) => {
return Err(ClientError::Unavailable(format!(
"connecting to {}: {e}",
socket_path.display()
)));
}
}
if let Err(e) = spawner.spawn() {
let msg = format!("failed to spawn kindling daemon: {e}");
log_spawn_failure(spawn_log_path, &msg);
return Err(ClientError::Unavailable(msg));
}
let deadline = Instant::now() + connect_timeout;
loop {
let last_err = match UnixStream::connect(socket_path).await {
Ok(stream) => return Ok(stream),
Err(e) if is_absent(&e) => e.to_string(),
Err(e) => {
let msg = format!("connecting to {} after spawn: {e}", socket_path.display());
log_spawn_failure(spawn_log_path, &msg);
return Err(ClientError::Unavailable(msg));
}
};
if Instant::now() >= deadline {
let msg = format!(
"daemon socket {} did not become reachable within {:?} after spawn ({last_err})",
socket_path.display(),
connect_timeout
);
log_spawn_failure(spawn_log_path, &msg);
return Err(ClientError::Unavailable(msg));
}
tokio::time::sleep(poll_interval).await;
}
}
async fn ensure_connected_tcp(
port_path: &Path,
spawner: &Spawner,
connect_timeout: Duration,
poll_interval: Duration,
spawn_log_path: Option<&Path>,
) -> Result<TcpStream, ClientError> {
if let Some(port) = read_port(port_path) {
match TcpStream::connect(("127.0.0.1", port)).await {
Ok(stream) => return Ok(stream),
Err(e) if is_absent(&e) => { }
Err(e) => {
return Err(ClientError::Unavailable(format!(
"connecting to 127.0.0.1:{port}: {e}"
)));
}
}
}
if let Err(e) = spawner.spawn() {
let msg = format!("failed to spawn kindling daemon: {e}");
log_spawn_failure(spawn_log_path, &msg);
return Err(ClientError::Unavailable(msg));
}
let deadline = Instant::now() + connect_timeout;
loop {
let last_err = match read_port(port_path) {
Some(port) => match TcpStream::connect(("127.0.0.1", port)).await {
Ok(stream) => return Ok(stream),
Err(e) if is_absent(&e) => format!("connecting to 127.0.0.1:{port}: {e}"),
Err(e) => {
let msg = format!("connecting to 127.0.0.1:{port} after spawn: {e}");
log_spawn_failure(spawn_log_path, &msg);
return Err(ClientError::Unavailable(msg));
}
},
None => format!("port file {} not yet present", port_path.display()),
};
if Instant::now() >= deadline {
let msg = format!(
"daemon TCP port (via {}) did not become reachable within {:?} after spawn ({last_err})",
port_path.display(),
connect_timeout
);
log_spawn_failure(spawn_log_path, &msg);
return Err(ClientError::Unavailable(msg));
}
tokio::time::sleep(poll_interval).await;
}
}
fn log_spawn_failure(spawn_log_path: Option<&Path>, detail: &str) {
if let Some(path) = spawn_log_path {
append_spawn_log(path, detail);
}
}
fn read_port(port_path: &Path) -> Option<u16> {
std::fs::read_to_string(port_path)
.ok()
.and_then(|s| s.trim().parse::<u16>().ok())
}
fn is_absent(e: &io::Error) -> bool {
matches!(
e.kind(),
io::ErrorKind::NotFound | io::ErrorKind::ConnectionRefused
)
}