use std::sync::Arc;
use std::time::{Duration, Instant};
use axum::Router;
use axum::body::Bytes;
use axum::extract::State;
use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use http_body_util::{BodyExt, Full};
use prost::Message;
use mira_core::identity::resource_key;
use mira_core::query::{self, Cursor};
use mira_proto::collector::logs::v1::{ExportLogsServiceRequest, ExportLogsServiceResponse};
use mira_proto::collector::metrics::v1::{
ExportMetricsServiceRequest, ExportMetricsServiceResponse,
};
use mira_proto::collector::trace::v1::{ExportTraceServiceRequest, ExportTraceServiceResponse};
use crate::api;
#[derive(Clone)]
pub struct Proxy {
replicas: Arc<[String]>,
max_request_bytes: usize,
}
impl Proxy {
pub fn new(replicas: Vec<String>, max_request_bytes: usize) -> Result<Proxy, String> {
if replicas.is_empty() {
return Err("mira proxy needs at least one --replica URI".into());
}
Ok(Proxy {
replicas: replicas.into(),
max_request_bytes,
})
}
}
pub fn router(p: Proxy) -> Router {
let max = p.max_request_bytes;
Router::new()
.route("/health", get(up))
.route("/readyz", get(up))
.route("/api/v1/query", post(query_handler))
.route("/v1/logs", post(logs_handler))
.route("/v1/traces", post(traces_handler))
.route("/v1/metrics", post(metrics_handler))
.route("/api/v1/correlate", post(unmergeable))
.route("/api/v1/map", post(unmergeable))
.route("/api/v1/metrics/query", post(unmergeable))
.route("/api/v1/metrics/names", post(unmergeable))
.route("/api/v1/entities", post(unmergeable))
.layer(axum::extract::DefaultBodyLimit::max(max))
.with_state(p)
}
async fn up() -> Response {
let mut j = mira_core::json::Json::new();
j.obj(|j| {
j.key("status");
j.str("ok");
});
api::json_ok(j.into_string())
}
async fn unmergeable(uri: axum::http::Uri) -> Response {
let path = uri.path().to_owned();
fail(
StatusCode::NOT_IMPLEMENTED,
&format!(
"{path} is answered from one node's blocks and cannot be merged across replicas; \
query a replica directly. See `mira proxy` in the usage."
),
)
}
async fn query_handler(State(p): State<Proxy>, body: String) -> Response {
let t = Instant::now();
let caller = match api::parse(&body) {
Ok(d) => d,
Err(e) => return fail(StatusCode::BAD_REQUEST, &e),
};
let doc = match caller["cursors"].is_badvalue() {
true => with_cursors(&body),
false => body.clone(),
};
let q = match api::parse(&doc).and_then(|d| api::search_doc(&d, api::now_nanos())) {
Ok(q) if q.cursors => q,
Ok(_) => {
return fail(
StatusCode::BAD_REQUEST,
"the query document set `cursors` to false; the proxy needs them to merge",
);
}
Err(e) => return fail(StatusCode::BAD_REQUEST, &e),
};
let wanted = api::search_doc(&caller, api::now_nanos()).is_ok_and(|c: query::Search| c.cursors);
let answers = match fanout(&p, "/api/v1/query", TEXT, doc.into_bytes()).await {
Ok(a) => a,
Err(e) => return fail(StatusCode::BAD_GATEWAY, &e),
};
let bodies: Vec<String> = answers.into_iter().map(|(_, b)| b).collect();
match merge(&bodies, q.limit, wanted) {
Ok(r) => json_ok(api::envelope("rows", &r, t.elapsed())),
Err(e) => fail(StatusCode::BAD_GATEWAY, &e),
}
}
fn merge(bodies: &[String], limit: usize, keep_cursors: bool) -> Result<query::Results, String> {
let mut stats = query::Stats::default();
let mut more = false;
let mut rows: Vec<(Cursor, &str)> = Vec::new();
for body in bodies {
let fields = object(body)?;
let page = pieces(field(&fields, "rows")?)?;
let cursors = match field(&fields, "cursors") {
Ok(cs) => pieces(cs)?,
Err(_) if page.is_empty() => Vec::new(),
Err(_) => {
return Err(
"a replica answered without `cursors`; every node behind one \
proxy has to be the same build"
.into(),
);
}
};
if page.len() != cursors.len() {
return Err("a replica's `rows` and `cursors` disagree in length".into());
}
for (row, c) in page.into_iter().zip(cursors) {
rows.push((unquote(c)?.parse()?, row));
}
let s = object(field(&fields, "stats")?)?;
stats.blocks_total += count(&s, "blocks_total")?;
stats.blocks_scanned += count(&s, "blocks_scanned")?;
stats.rows_scanned += count(&s, "rows_scanned")?;
stats.rows_matched += count(&s, "rows_matched")?;
more |= field(&fields, "next").is_ok();
}
rows.sort_unstable_by_key(|(c, _)| c.key());
rows.dedup_by_key(|(c, _)| c.key());
more |= rows.len() > limit;
rows.truncate(limit);
let mut json = String::from("[");
for (i, (_, row)) in rows.iter().enumerate() {
if i > 0 {
json.push(',');
}
json.push_str(row);
}
json.push(']');
Ok(query::Results {
json,
stats,
next: more.then(|| rows.last().map(|(c, _)| *c)).flatten(),
cursors: match keep_cursors {
true => rows.iter().map(|(c, _)| *c).collect(),
false => Vec::new(),
},
})
}
fn with_cursors(body: &str) -> String {
let b = body.trim();
match b.strip_prefix('{') {
Some(rest) if rest.trim_start().starts_with('}') => {
format!("{{\"cursors\": \"true\"{rest}")
}
Some(rest) => format!("{{\"cursors\": \"true\", {rest}"),
None => format!("{b}\n\"cursors\": \"true\"\n"),
}
}
macro_rules! ingest {
($name:ident, $path:literal, $field:ident, $req:ident, $resp:ident, $json:path) => {
async fn $name(State(p): State<Proxy>, h: HeaderMap, b: Bytes) -> Response {
let (json, req) =
match crate::receiver::decode::<$req>(&h, b, p.max_request_bytes, $json) {
Ok(v) => v,
Err((json, code, e)) => return crate::receiver::fail(json, code, &e),
};
let n = p.replicas.len();
let mut parts: Vec<Vec<_>> = (0..n).map(|_| Vec::new()).collect();
for (i, e) in req.$field.into_iter().enumerate() {
let attrs = e.resource.as_ref().map_or(&[][..], |r| &r.attributes);
parts[slot(attrs, i, n)].push(e);
}
let bodies: Vec<(usize, Vec<u8>)> = parts
.into_iter()
.enumerate()
.filter(|(_, part)| !part.is_empty())
.map(|(i, part)| (i, $req { $field: part }.encode_to_vec()))
.collect();
match scatter(&p, $path, bodies).await {
Ok(()) => accepted(json, &$resp::default()),
Err((code, e)) if code == StatusCode::SERVICE_UNAVAILABLE => (
[(header::RETRY_AFTER, "1")],
crate::receiver::fail(json, code, &e),
)
.into_response(),
Err((code, e)) => crate::receiver::fail(json, code, &e),
}
}
};
}
ingest!(
logs_handler,
"/v1/logs",
resource_logs,
ExportLogsServiceRequest,
ExportLogsServiceResponse,
crate::json::logs
);
ingest!(
traces_handler,
"/v1/traces",
resource_spans,
ExportTraceServiceRequest,
ExportTraceServiceResponse,
crate::json::traces
);
ingest!(
metrics_handler,
"/v1/metrics",
resource_metrics,
ExportMetricsServiceRequest,
ExportMetricsServiceResponse,
crate::json::metrics
);
fn slot(attrs: &[mira_proto::common::v1::KeyValue], i: usize, n: usize) -> usize {
match resource_key(attrs) {
mira_core::identity::NO_IDENTITY => i % n,
k => (k % n as u64) as usize,
}
}
async fn scatter(p: &Proxy, path: &str, bodies: Vec<(usize, Vec<u8>)>) -> Result<(), Status> {
let mut set = tokio::task::JoinSet::new();
for (i, body) in bodies {
let url = format!("{}{path}", p.replicas[i]);
set.spawn(async move {
let r = send(&url, PROTOBUF, body).await;
(url, r)
});
}
let mut worst: Option<Status> = None;
while let Some(joined) = set.join_next().await {
let (url, r) = joined.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
let bad = match r {
Err(e) => Some((StatusCode::SERVICE_UNAVAILABLE, format!("{url}: {e}"))),
Ok((s, _)) if s.is_success() => None,
Ok((s, b)) => Some((
retryable(s),
format!("{url}: HTTP {} {}", s.as_u16(), text(&b)),
)),
};
if let Some(bad) = bad {
worst = Some(match worst {
Some(w) if w.0 == StatusCode::SERVICE_UNAVAILABLE => w,
_ => bad,
});
}
}
match worst {
Some(w) => Err(w),
None => Ok(()),
}
}
type Status = (StatusCode, String);
fn retryable(s: StatusCode) -> StatusCode {
match s.as_u16() {
400 | 413 | 415 => s,
_ => StatusCode::SERVICE_UNAVAILABLE,
}
}
const TEXT: &str = "text/plain; charset=utf-8";
const PROTOBUF: &str = "application/x-protobuf";
async fn fanout(
p: &Proxy,
path: &str,
ct: &'static str,
body: Vec<u8>,
) -> Result<Vec<(String, String)>, String> {
let mut set = tokio::task::JoinSet::new();
for r in p.replicas.iter() {
let url = format!("{r}{path}");
let body = body.clone();
set.spawn(async move {
let out = send(&url, ct, body).await;
(url, out)
});
}
let mut out = Vec::with_capacity(p.replicas.len());
while let Some(joined) = set.join_next().await {
let (url, r) = joined.map_err(|e| e.to_string())?;
let (status, body) = r.map_err(|e| format!("{url}: {e}"))?;
if !status.is_success() {
return Err(format!(
"{url}: HTTP {} {}",
status.as_u16(),
text(body.as_ref())
));
}
out.push((
url,
String::from_utf8(body.into()).map_err(|e| e.to_string())?,
));
}
Ok(out)
}
const REPLICA_TIMEOUT: Duration = Duration::from_secs(60);
async fn send(url: &str, ct: &'static str, body: Vec<u8>) -> Result<(StatusCode, Bytes), String> {
let req = hyper::Request::builder()
.method(hyper::Method::POST)
.uri(url)
.header(hyper::header::CONTENT_TYPE, ct)
.body(Full::new(Bytes::from(body)))
.map_err(|e| e.to_string())?;
let resp = tokio::time::timeout(REPLICA_TIMEOUT, client().request(req))
.await
.map_err(|_| format!("no answer in {}s", REPLICA_TIMEOUT.as_secs()))?
.map_err(|e| e.to_string())?;
let status = resp.status();
let body = resp
.into_body()
.collect()
.await
.map_err(|e| e.to_string())?
.to_bytes();
Ok((status, body))
}
type Client = hyper_util::client::legacy::Client<
hyper_util::client::legacy::connect::HttpConnector,
Full<Bytes>,
>;
fn client() -> &'static Client {
static C: std::sync::OnceLock<Client> = std::sync::OnceLock::new();
C.get_or_init(|| {
hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new())
.build_http()
})
}
fn object(s: &str) -> Result<Vec<(&str, &str)>, String> {
pieces(s)?.into_iter().map(field_of).collect()
}
fn pieces(s: &str) -> Result<Vec<&str>, String> {
let s = s.trim();
let inner = s
.strip_prefix(['[', '{'])
.and_then(|r| r.strip_suffix([']', '}']))
.ok_or_else(|| format!("expected a JSON array or object, got {s:.40}"))?;
let (mut out, mut depth, mut start) = (Vec::new(), 0i32, 0usize);
let (mut quoted, mut escaped) = (false, false);
for (i, b) in inner.bytes().enumerate() {
match b {
_ if escaped => escaped = false,
b'\\' if quoted => escaped = true,
b'"' => quoted = !quoted,
_ if quoted => {}
b'[' | b'{' => depth += 1,
b']' | b'}' => depth -= 1,
b',' if depth == 0 => {
out.push(inner[start..i].trim());
start = i + 1;
}
_ => {}
}
}
if depth != 0 || quoted {
return Err("unbalanced JSON in a replica's answer".into());
}
let last = inner[start..].trim();
if !last.is_empty() || !out.is_empty() {
out.push(last);
}
Ok(out)
}
fn field_of(p: &str) -> Result<(&str, &str), String> {
let bad = || format!("expected a JSON member, got {p:.40}");
let rest = p.strip_prefix('"').ok_or_else(bad)?;
let end = rest.find('"').ok_or_else(bad)?;
let value = rest[end + 1..]
.trim_start()
.strip_prefix(':')
.ok_or_else(bad)?;
Ok((&rest[..end], value.trim()))
}
fn field<'a>(fields: &[(&'a str, &'a str)], key: &str) -> Result<&'a str, String> {
fields
.iter()
.find(|(k, _)| *k == key)
.map(|(_, v)| *v)
.ok_or_else(|| format!("a replica's answer has no {key:?}"))
}
fn count(fields: &[(&str, &str)], key: &str) -> Result<usize, String> {
field(fields, key)?
.parse()
.map_err(|e| format!("{key}: {e}"))
}
fn unquote(s: &str) -> Result<&str, String> {
s.strip_prefix('"')
.and_then(|r| r.strip_suffix('"'))
.ok_or_else(|| format!("expected a quoted cursor, got {s:.40}"))
}
fn text(b: &[u8]) -> String {
String::from_utf8_lossy(b).chars().take(200).collect()
}
fn json_ok(body: String) -> Response {
(
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
body,
)
.into_response()
}
fn accepted<T: Message>(json: bool, ok: &T) -> Response {
match json {
true => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/json")],
"{}",
)
.into_response(),
false => (
StatusCode::OK,
[(header::CONTENT_TYPE, "application/x-protobuf")],
ok.encode_to_vec(),
)
.into_response(),
}
}
fn fail(code: StatusCode, msg: &str) -> Response {
api::error(code, msg)
}
#[cfg(test)]
mod tests {
use super::*;
fn page(rows: &[(i64, u32, u64)], more: bool) -> String {
let cursors: Vec<Cursor> = rows
.iter()
.map(|&(ts, node, seq)| Cursor {
ts,
node,
seq,
row: 0,
})
.collect();
let mut json = String::from("[");
for (i, c) in cursors.iter().enumerate() {
if i > 0 {
json.push(',');
}
json.push_str(&format!("{{\"body\":\"{c}\"}}"));
}
json.push(']');
api::envelope(
"rows",
&query::Results {
json,
stats: query::Stats {
blocks_total: 4,
blocks_scanned: 1,
rows_scanned: 10,
rows_matched: rows.len(),
dropped_series: 0,
},
next: more.then(|| *cursors.last().unwrap()),
cursors,
},
Duration::from_micros(7),
)
}
fn bodies(cursor: &str) -> Vec<String> {
vec![cursor.to_owned()]
}
#[test]
fn a_merged_page_is_the_replicas_rows_in_the_global_cursor_order() {
let a = page(&[(100, 1, 9), (80, 1, 9), (60, 1, 8)], false);
let b = page(&[(90, 2, 3), (70, 2, 3), (50, 2, 1)], false);
let r = merge(&[a, b], 10, false).unwrap();
assert_eq!(
r.json,
"[{\"body\":\"100.1.9.0\"},{\"body\":\"90.2.3.0\"},{\"body\":\"80.1.9.0\"},\
{\"body\":\"70.2.3.0\"},{\"body\":\"60.1.8.0\"},{\"body\":\"50.2.1.0\"}]"
);
assert!(r.next.is_none());
assert_eq!(r.stats.blocks_total, 8);
assert_eq!(r.stats.rows_scanned, 20);
assert_eq!(r.stats.rows_matched, 6);
assert!(r.cursors.is_empty());
}
#[test]
fn one_block_seen_by_two_replicas_is_not_two_copies_of_every_row() {
let shared = [(100, 1, 9), (80, 1, 9), (60, 1, 8)];
let both = || [page(&shared, false), page(&shared, false)];
let r = merge(&both(), 10, false).unwrap();
assert_eq!(
r.json,
"[{\"body\":\"100.1.9.0\"},{\"body\":\"80.1.9.0\"},{\"body\":\"60.1.8.0\"}]"
);
assert!(r.next.is_none());
let cut = merge(&both(), 2, false).unwrap();
assert_eq!(
cut.json, "[{\"body\":\"100.1.9.0\"},{\"body\":\"80.1.9.0\"}]",
"a page of two is two rows, not one row twice"
);
assert_eq!(cut.next.unwrap().to_string(), "80.1.9.0");
}
#[test]
fn rows_sharing_a_nanosecond_on_different_nodes_still_have_one_order() {
let a = page(&[(100, 7, 1)], false);
let b = page(&[(100, 3, 1)], false);
let one = merge(&[a.clone(), b.clone()], 10, false).unwrap();
let other = merge(&[b, a], 10, false).unwrap();
assert_eq!(one.json, other.json);
assert_eq!(
one.json,
"[{\"body\":\"100.7.1.0\"},{\"body\":\"100.3.1.0\"}]"
);
}
#[test]
fn the_page_is_cut_at_limit_and_next_is_the_last_row_emitted() {
let a = page(&[(100, 1, 9), (80, 1, 9)], false);
let b = page(&[(90, 2, 3), (70, 2, 3)], false);
let r = merge(&[a, b], 3, false).unwrap();
assert_eq!(
r.json,
"[{\"body\":\"100.1.9.0\"},{\"body\":\"90.2.3.0\"},{\"body\":\"80.1.9.0\"}]"
);
assert_eq!(r.next.unwrap().to_string(), "80.1.9.0");
}
#[test]
fn a_replica_holding_more_makes_the_merged_page_not_the_last_one() {
let a = page(&[(100, 1, 9)], true);
let b = page(&[(90, 2, 3)], false);
let r = merge(&[a, b], 10, false).unwrap();
assert_eq!(r.json.matches("body").count(), 2);
assert_eq!(r.next.unwrap().to_string(), "90.2.3.0");
}
#[test]
fn cursors_are_passed_on_only_when_the_caller_asked_and_match_the_merged_rows() {
let a = page(&[(100, 1, 9), (60, 1, 8)], false);
let b = page(&[(90, 2, 3)], false);
let r = merge(&[a, b], 10, true).unwrap();
let got: Vec<String> = r.cursors.iter().map(Cursor::to_string).collect();
assert_eq!(got, ["100.1.9.0", "90.2.3.0", "60.1.8.0"]);
}
#[test]
fn an_answer_this_proxy_cannot_place_is_an_error_and_not_a_guess() {
let without = api::envelope(
"rows",
&query::Results {
json: "[{\"body\":\"x\"}]".into(),
stats: query::Stats::default(),
next: None,
cursors: Vec::new(),
},
Duration::ZERO,
);
let why = |body: &str| merge(&bodies(body), 10, false).err().expect("accepted");
assert!(why(&without).contains("the same build"));
let short = page(&[(100, 1, 9), (80, 1, 9)], false).replace(
",\"cursors\":[\"100.1.9.0\",\"80.1.9.0\"]",
",\"cursors\":[\"100.1.9.0\"]",
);
assert!(why(&short).contains("disagree in length"));
let odd =
page(&[(100, 1, 9)], false).replace("\"rows_matched\":1", "\"rows_matched\":\"1\"");
assert!(why(&odd).contains("rows_matched"), "{odd}");
for broken in ["", "{}", "not json", "{\"rows\":[]}"] {
assert!(merge(&bodies(broken), 10, false).is_err(), "{broken:?}");
}
}
#[test]
fn a_replica_with_no_rows_contributes_none_and_breaks_nothing() {
let r = merge(&[page(&[], false), page(&[(90, 2, 3)], false)], 10, false).unwrap();
assert_eq!(r.json, "[{\"body\":\"90.2.3.0\"}]");
assert!(r.next.is_none());
}
#[test]
fn every_document_shape_comes_back_with_cursors_on() {
let docs = [
r#"{"signal":"logs","limit":5}"#,
"{}",
"{ }",
"signal: \"traces\"\nlimit: 5",
"",
" \n",
];
for doc in docs {
let out = with_cursors(doc);
let q = api::parse(&out).and_then(|d| api::search_doc(&d, 0));
assert!(
q.as_ref().is_ok_and(|q| q.cursors),
"{doc:?} became {out:?}: {q:?}"
);
}
let q = api::parse(&with_cursors(r#"{"signal":"traces","limit":7}"#))
.and_then(|d| api::search_doc(&d, 0))
.unwrap();
assert_eq!(q.limit, 7);
assert!(matches!(q.signal, query::Signal::Traces));
}
#[test]
fn one_entity_lands_on_one_replica_from_any_position_in_any_batch() {
let kv = |k: &str, v: &str| mira_proto::common::v1::KeyValue {
key: k.into(),
value: Some(mira_proto::common::v1::AnyValue {
value: Some(mira_proto::common::v1::any_value::Value::StringValue(
v.into(),
)),
}),
};
let pod = [
kv("service.name", "checkout"),
kv("service.instance.id", "7"),
];
let drifted = [
kv("k8s.node.name", "node-4"),
kv("service.instance.id", "7"),
kv("service.name", "checkout"),
];
for n in 1..8 {
let want = slot(&pod, 0, n);
assert!(want < n);
for i in 0..5 {
assert_eq!(slot(&pod, i, n), want, "n={n} i={i}");
assert_eq!(slot(&drifted, i, n), want, "n={n} i={i} drifted");
}
}
let n = 3;
let spread: Vec<usize> = (0..6).map(|i| slot(&[], i, n)).collect();
assert_eq!(spread, [0, 1, 2, 0, 1, 2]);
}
#[test]
fn the_scanner_splits_on_structure_and_not_on_punctuation_inside_strings() {
assert_eq!(pieces("[]").unwrap(), Vec::<&str>::new());
assert_eq!(pieces("{ }").unwrap(), Vec::<&str>::new());
assert_eq!(pieces("[1, 2]").unwrap(), ["1", "2"]);
assert_eq!(
pieces(r#"[{"a":{"b":[1,2]}},{"c":"x,y"}]"#).unwrap(),
[r#"{"a":{"b":[1,2]}}"#, r#"{"c":"x,y"}"#]
);
assert_eq!(
pieces(r#"[{"body":"{\"level\": \"warn\"}, retrying"},{"body":"ok"}]"#).unwrap(),
[
r#"{"body":"{\"level\": \"warn\"}, retrying"}"#,
r#"{"body":"ok"}"#
]
);
for bad in ["", "[1,2", "{\"a\":1", "[\"unterminated]"] {
assert!(pieces(bad).is_err(), "{bad:?}");
}
let o = object(r#"{"rows":[{"a":1}],"stats":{"n":2},"next":"1.2.3.4"}"#).unwrap();
assert_eq!(field(&o, "rows").unwrap(), "[{\"a\":1}]");
assert_eq!(
count(&object(field(&o, "stats").unwrap()).unwrap(), "n").unwrap(),
2
);
assert_eq!(unquote(field(&o, "next").unwrap()).unwrap(), "1.2.3.4");
assert!(field(&o, "cursors").is_err());
assert!(unquote("1.2.3.4").is_err());
assert!(field_of("\"a\" 1").is_err());
}
#[test]
fn a_proxy_with_no_replicas_is_refused_where_the_operator_can_see_it() {
assert!(Proxy::new(Vec::new(), 1).is_err());
assert!(Proxy::new(vec!["http://127.0.0.1:1".into()], 1).is_ok());
}
#[test]
fn only_the_senders_own_fault_is_passed_back_unretryable() {
for pass in [400u16, 413, 415] {
let s = StatusCode::from_u16(pass).unwrap();
assert_eq!(retryable(s), s);
}
for retry in [429u16, 500, 502, 503, 504] {
assert_eq!(
retryable(StatusCode::from_u16(retry).unwrap()),
StatusCode::SERVICE_UNAVAILABLE
);
}
}
#[tokio::test(start_paused = true)]
async fn a_replica_that_answers_badly_is_an_error_and_not_a_short_page() {
let e = send("http://exa mple/v1/logs", PROTOBUF, Vec::new())
.await
.expect_err("a malformed address was accepted");
assert!(!e.is_empty());
let (_deaf, url) = deaf();
let e = send(&url, PROTOBUF, Vec::new())
.await
.expect_err("silence was read as an answer");
assert!(e.contains("no answer in 60s"), "{e}");
let e = send(
&raw(b"HTTP/1.1 200 OK\r\nContent-Length: 10\r\n\r\nabc"),
PROTOBUF,
Vec::new(),
)
.await
.expect_err("a truncated body was read as an answer");
assert!(!e.is_empty());
}
fn deaf() -> (std::net::TcpListener, String) {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
(l, format!("http://{addr}"))
}
fn raw(reply: &'static [u8]) -> String {
use std::io::Write;
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
std::thread::spawn(move || {
let Ok((mut s, _)) = l.accept() else { return };
let _ = s.write_all(reply);
});
format!("http://{addr}")
}
}