hyperhive/hive-sock-client/src/lib.rs
atlas be3411e180 feat(#3245): gate rustdoc in nix flake check, and clear the workspace
Nothing in the gate read doc-comments: clippy doesn't check intra-doc
links, cargo test doesn't, and no check built docs. So a [`Foo`] pointing
at a renamed, moved or deleted item rendered as plain text and had no
discoverer but a human happening to read the comment.

That matters here more than in most repos, because the convention is to
put a thing's authoritative description in one doc-comment and point at
it from everywhere else -- the design leans on the pointers being real,
and a dangling link is worse than no link since it names something and
sends the reader looking.

Adds `docs-rustdoc` to nix/checks.nix: craneLib.cargoDoc over
--workspace --no-deps --document-private-items, denying six rustdoc
lints. Listed explicitly rather than -D warnings so a new lint appearing
upstream cannot red the build on a class nobody has triaged.

--document-private-items is load-bearing rather than thoroughness for
its own sake: most of this workspace's doc-comments live on private
items and //! module headers, so without it rustdoc checks a small
fraction of the links and the gate sits green while the rot continues.

Then fixes every error it reports, 40 to 0 across nine crates. The
classes differ and so do the fixes:

- public item, wrong scope -> qualify. Node and Node::parent are both
  public; the link failed only because scheduler.rs does not import
  Node. Six sites become [`crate::Node::parent`].
- private item -> downgrade to backticks. Nothing was made public to
  satisfy a lint; changing API surface to appease a doc check would be
  the tail wagging the dog.
- genuinely dead -> [`JobBuilder::insert_into`] names a method that does
  not exist. Insertion is Scheduler::insert_job.
- prose that looks like markup -> argv[0] parsed as a link, and
  <args>/<hex>/<name> parsed as HTML tags.

Note for future fixes: pub(crate) resolves in an intra-doc link, a plain
private fn in a binary crate does not (wait_for_nodes resolved,
connect_hint did not, same crate, same shape).

The check does not ride the clippy/test artifact cache. It takes
cargoArtifacts, but rustdoc needs its own flavour of dependency
metadata, which cargo build does not produce, so a --no-deps docs build
still compiles dependencies it never documents. Measured at 6m47s cold;
that reasoning is recorded in the check's own comment so the next reader
does not re-derive it.

Verified by running the check's exact command against the pre-cleanup
tree first: 40 errors, build failed. A gate that cannot fail is not
evidence, and building it before the cleanup makes that proof free.
2026-08-14 02:30:55 +02:00

308 lines
11 KiB
Rust

//! JSON-line-over-unix-socket client, shared by every daemon that talks to
//! a hyperhive socket.
//!
//! The wire protocol is identical everywhere — connect, write one line of
//! JSON, read one line of JSON back — so this crate is generic over the
//! request and response types and knows nothing about either protocol. The
//! host-served control socket and the harness's in-agent socket both use
//! it with their own wire-type crates.
//!
//! Two axes of behaviour are real and stay caller-selectable; everything
//! else is shared:
//!
//! - **retry**: [`Retry::RideOutRestart`] for callers with no natural
//! retry of their own, [`Retry::None`] for callers already inside a poll
//! loop whose interval *is* the retry.
//! - **response**: [`request`] decodes it, [`notify`] drains and discards
//! it.
//!
//! Whether a failure propagates or is logged and swallowed is the caller's
//! choice and stays at the call site — it is not a property of the
//! transport.
use std::path::Path;
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use serde::Serialize;
use serde::de::DeserializeOwned;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
/// Backoff schedule for [`Retry::RideOutRestart`]. Five entries → up to 5
/// retries on top of the initial attempt; total wall-clock cap =
/// 2+4+8+16+30 = 60s. Sized to ride out a service restart (systemd usually
/// has the unix socket back inside ~5s) without the caller having to
/// handle the transient itself.
const RIDE_OUT_RESTART_BACKOFFS_MS: &[u64] = &[2_000, 4_000, 8_000, 16_000, 30_000];
/// What to do when a connect or I/O attempt fails.
///
/// Deliberately two named policies rather than a configurable schedule:
/// only two behaviours exist in the tree, and naming them keeps the
/// *reason* for each choice readable at the call site.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Retry {
/// Fail on the first failure. For callers inside a poll loop, where
/// the poll interval already is the retry — a second, in-request
/// backoff would stack sleeps on top of it and delay the rest of the
/// batch.
None,
/// Back off on `RIDE_OUT_RESTART_BACKOFFS_MS` (~60s total). For
/// callers with no natural retry of their own, where a surfaced
/// transient costs more than the wait.
RideOutRestart,
}
impl Retry {
/// Sleep schedule between attempts; its length is the retry budget.
fn backoffs(self) -> &'static [u64] {
match self {
Self::None => &[],
Self::RideOutRestart => RIDE_OUT_RESTART_BACKOFFS_MS,
}
}
}
/// Send `req` over `socket` and decode the single-line JSON response.
///
/// # Errors
///
/// Returns an error if `req` cannot be serialised, if the socket is
/// unreachable after the retry budget is spent, if the server closes
/// without responding, or if the response does not deserialise into
/// `Resp`.
pub async fn request<Req, Resp>(socket: &Path, req: &Req, retry: Retry) -> Result<Resp>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
request_retried(socket, req, retry)
.await
.map(|(resp, _)| resp)
}
/// Same as [`request`], but also reports how many retries it took past the
/// initial attempt (0 = succeeded first try).
///
/// MCP tool handlers use this to append a one-line hint to the tool result
/// when retries happened, so claude reads the earlier socket flake as a
/// transient rather than a content error worth an LLM-level retry.
///
/// # Errors
///
/// Same as [`request`].
pub async fn request_retried<Req, Resp>(
socket: &Path,
req: &Req,
retry: Retry,
) -> Result<(Resp, u32)>
where
Req: Serialize + ?Sized,
Resp: DeserializeOwned,
{
let payload = encode(req)?;
let (line, retries) = with_retry(socket, &payload, Mode::Decode, retry).await?;
let resp = serde_json::from_str(line.trim())
.with_context(|| format!("decode response from {}", socket.display()))?;
Ok((resp, retries))
}
/// Send `req` over `socket`, half-close, and drain the response line
/// without decoding it.
///
/// For fire-and-forget ops whose reply carries nothing the caller acts on.
/// The drain is not optional: without it the server's write-back lands on
/// a closed socket.
///
/// # Errors
///
/// Returns an error if `req` cannot be serialised, or if the socket is
/// unreachable / the write fails after the retry budget is spent. A
/// failure to read the discarded response is not an error — the request
/// was already delivered.
pub async fn notify<Req>(socket: &Path, req: &Req, retry: Retry) -> Result<()>
where
Req: Serialize + ?Sized,
{
let payload = encode(req)?;
with_retry(socket, &payload, Mode::Drain, retry).await?;
Ok(())
}
/// What to do with the server's response line.
#[derive(Clone, Copy)]
enum Mode {
/// Flush the write half, read the response, hand it back to be parsed.
Decode,
/// Half-close the write half, read-and-discard the response.
Drain,
}
/// Serialise `req` into one newline-terminated JSON line.
///
/// Kept outside the retry loop on purpose: a serialisation failure is
/// deterministic, so retrying it would only reproduce it.
fn encode<Req>(req: &Req) -> Result<Vec<u8>>
where
Req: Serialize + ?Sized,
{
let mut payload = serde_json::to_string(req).context("serialise request")?;
payload.push('\n');
Ok(payload.into_bytes())
}
/// Run [`try_once`] until it succeeds or the retry budget is spent,
/// returning the response line and the number of retries it took.
async fn with_retry(
socket: &Path,
payload: &[u8],
mode: Mode,
retry: Retry,
) -> Result<(String, u32)> {
let backoffs = retry.backoffs();
let mut attempt: usize = 0;
loop {
match try_once(socket, payload, mode).await {
Ok(line) => return Ok((line, u32::try_from(attempt).unwrap_or(u32::MAX))),
Err(e) => {
let Some(&sleep_ms) = backoffs.get(attempt) else {
return Err(exhausted(e, backoffs));
};
tracing::warn!(
attempt = attempt + 1,
sleep_ms,
socket = %socket.display(),
error = %e,
"hive socket attempt failed; retrying"
);
tokio::time::sleep(Duration::from_millis(sleep_ms)).await;
attempt += 1;
}
}
}
}
/// Annotate the final failure with how long the retry schedule tried, so a
/// surfaced error says whether it was one shot or a full ride-out.
fn exhausted(err: anyhow::Error, backoffs: &[u64]) -> anyhow::Error {
if backoffs.is_empty() {
return err;
}
let total_s = backoffs.iter().sum::<u64>() / 1_000;
err.context(format!(
"gave up after {} retries over ~{total_s}s",
backoffs.len()
))
}
/// One connect / write / read cycle. Every error it returns is retryable
/// by construction — the deterministic failures (serialise, deserialise)
/// happen outside the retry loop.
async fn try_once(socket: &Path, payload: &[u8], mode: Mode) -> Result<String> {
let stream = UnixStream::connect(socket).await.map_err(|e| {
// A refused or missing socket usually means the listener is
// mid-restart (operator redeploy, harness restart) — it is
// recreated on boot and a retrying caller rides it out. When the
// error does surface (budget spent, or a fail-fast caller) the
// hint marks it as a likely transient rather than a hard failure
// worth escalating. The path stays in the error either way: a
// permission problem that reads as "is the daemon running?" sends
// the operator to fix the wrong thing.
let restarting = matches!(
e.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
);
let err = anyhow::Error::new(e).context(format!("connect to {}", socket.display()));
if restarting {
err.context("the listener may be restarting (e.g. an operator redeploy)")
} else {
err
}
})?;
let (read, mut write) = stream.into_split();
write
.write_all(payload)
.await
.with_context(|| format!("write to {}", socket.display()))?;
match mode {
Mode::Decode => write
.flush()
.await
.with_context(|| format!("flush {}", socket.display()))?,
Mode::Drain => write
.shutdown()
.await
.with_context(|| format!("shutdown write to {}", socket.display()))?,
}
let mut reader = BufReader::new(read);
let mut line = String::new();
match mode {
Mode::Decode => {
let read_bytes = reader
.read_line(&mut line)
.await
.with_context(|| format!("read from {}", socket.display()))?;
if read_bytes == 0 || line.is_empty() {
return Err(anyhow!(
"{} closed the connection without responding",
socket.display()
));
}
}
Mode::Drain => {
// Discarded, including any error reading it: the request is
// already delivered and the caller acts on nothing here.
let _ = reader.read_line(&mut line).await;
line.clear();
}
}
Ok(line)
}
#[cfg(test)]
mod tests {
use super::{Retry, notify, request};
/// A connect to a non-existent socket path (ENOENT → `NotFound`) is
/// annotated with both the socket path and the "may be restarting"
/// hint, so a surfaced tool error reads as the expected transient.
#[tokio::test]
async fn missing_socket_names_the_path_and_hints_restart() {
let bogus = std::path::Path::new("/nonexistent/hive/mcp.sock");
let err = request::<(), serde_json::Value>(bogus, &(), Retry::None)
.await
.expect_err("connect to a non-existent socket must fail");
let msg = format!("{err:#}");
assert!(msg.contains("restarting"), "missing restart hint: {msg}");
assert!(msg.contains("connect to"), "missing connect context: {msg}");
}
/// `Retry::None` returns promptly rather than sleeping the ride-out
/// schedule — its callers are poll ticks that need the rest of the
/// batch to still run this cycle.
#[tokio::test]
async fn no_retry_fails_fast() {
let bogus = std::path::Path::new("/nonexistent/hive/agent.sock");
let started = std::time::Instant::now();
notify(bogus, &(), Retry::None)
.await
.expect_err("connect to a non-existent socket must fail");
assert!(
started.elapsed() < std::time::Duration::from_secs(1),
"Retry::None slept: {:?}",
started.elapsed()
);
}
/// The ride-out schedule is a full minute of patience — the value the
/// harness's tool callers depend on to not surface a restart.
#[test]
fn ride_out_restart_budget_is_a_minute() {
let total_ms: u64 = Retry::RideOutRestart.backoffs().iter().sum();
assert_eq!(total_ms, 60_000);
assert!(Retry::None.backoffs().is_empty());
}
}