Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/socket_server/mod.rs
atlas 58ef299c3c hive-c0re: remove the approvals subsystem
Nothing submits an approval since scheduled prompts left hive-c0re, so the
queue only acted on rows written before that.

- hive-c0re: the approvals store and its migrations, approve/deny/
  resolve actions, the ResolveApproval tail node (and meta_update's
  approval_id), POST /api/approve and /api/deny, the approval views on
  /api/state, the approval_resolved dashboard event, the orphan GC,
  destroy/purge failing pending rows, and the approval rows in
  get_loose_ends. DestroyBookkeeping loses the purge flag, which only
  worded the approval-failure note.
- The CancelLooseEnd request goes: its only hive-c0re arm was the
  approval one. cancel_loose_end handles reminder and todo in the harness.
- Wire types: hive_sh4re::approvals (ReminderStats moves to inbox),
  LooseEnd::Approval, CancelLooseEndKind, HelperEvent::ApprovalResolved,
  ToolGroup::Approvals, HostRequest::{Pending,Approve,Deny} and
  HostResponse.approvals. HOST_SOCK_VERSION goes to 2 for the removed
  field.
- hivectl loses the approvals verbs; docs/tools/hivectl-cli.md is
  regenerated.
- The system prompt drops approval_resolved.
- The approvals table stays in broker.sqlite, unread, the same way the
  operator_questions table was left when its store was removed.
- An `approvals` entry still in tool-groups.json is skipped with a warning
  by ToolGroup::parse_list, like any unknown group.
- docs/agent-lifecycle/approvals.md is renamed to config-changes.md so the
  code's doc pointers resolve; its content follows in the docs commit.
2026-10-04 23:55:32 +02:00

714 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Unix-socket request server, shared by the per-agent sockets and the
//! (pure-transport) manager socket. The socket file's existence on disk
//! authenticates the caller: connecting to `<.../agents/foo/mcp.sock>` means
//! you are `foo`; the manager socket simply serves as `ruth`. There is no
//! privilege flag — both transports run the same [`serve`] / [`dispatch`]
//! code, and authority derives uniformly from the caller's identity. An
//! agent-targeting verb may name any agent, so `ruth` reaches every agent
//! exactly the way every other agent does, not via any hardcoded name match.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::{Context, Result};
use hive_core_agent_sock::{Request, Response};
use hive_sh4re::inbox::Message;
use hive_sh4re::manager::MANAGER_AGENT;
use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
use tokio::task::JoinHandle;
use crate::coordinator::Coordinator;
pub struct AgentSocket {
pub path: PathBuf,
pub handle: JoinHandle<()>,
}
pub fn start(agent: &str, socket_path: &Path, coord: Arc<Coordinator>) -> Result<AgentSocket> {
use std::os::unix::fs::PermissionsExt as _;
let agent = agent.to_owned();
if let Some(parent) = socket_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create agent socket dir {}", parent.display()))?;
}
if socket_path.exists() {
std::fs::remove_file(socket_path).context("remove stale agent socket")?;
}
let listener = UnixListener::bind(socket_path)
.with_context(|| format!("bind agent socket {}", socket_path.display()))?;
// The socket is bind-mounted into exactly one container as
// `/run/hive/mcp.sock` (`lifecycle::set_nspawn_flags`); the
// in-container harness connects as the per-agent unix user,
// not root, so the default `tokio::net::UnixListener::bind`
// perms (0755) lock it out. 0666 lets the agent user connect;
// the bind source dir is per-agent on host so blast radius is
// unchanged.
std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o666))
.with_context(|| format!("chmod agent socket {}", socket_path.display()))?;
tracing::info!(%agent, socket = %socket_path.display(), "agent socket listening");
let path = socket_path.to_path_buf();
let handle = tokio::spawn(async move {
let err = accept_until_fatal(
&agent,
|| async { listener.accept().await.map(|(stream, _)| stream) },
|stream| {
let agent = agent.clone();
let coord = coord.clone();
tokio::spawn(async move {
if let Err(e) = serve(stream, agent, coord).await {
tracing::warn!(error = ?e, "agent connection failed");
}
});
},
)
.await;
exit_on_dead_listener(&agent, &err);
});
Ok(AgentSocket { path, handle })
}
/// Bind + serve the manager socket. This is now **pure transport**: it grants
/// no authority of its own — it just serves requests as `agent = MANAGER_AGENT`
/// ("ruth"), and ruth's reach comes entirely from the capabilities /
/// tool-groups it holds, identical to connecting on a per-agent socket — ruth
/// uses the standard per-agent runtime dir + socket, with no dedicated helpers.
pub fn start_manager(coord: Arc<Coordinator>) -> Result<()> {
use std::os::unix::fs::PermissionsExt as _;
let dir = crate::paths::agent_runtime_dir(crate::lifecycle::MANAGER_NAME);
std::fs::create_dir_all(&dir)
.with_context(|| format!("create manager dir {}", dir.display()))?;
let socket = Coordinator::socket_path(crate::lifecycle::MANAGER_NAME);
if socket.exists() {
std::fs::remove_file(&socket).context("remove stale manager socket")?;
}
let listener = UnixListener::bind(&socket)
.with_context(|| format!("bind manager socket {}", socket.display()))?;
// 0666 so the in-container root user (non-root) can connect; the bind
// source dir is manager-only on host (see the per-agent socket above).
std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o666))
.with_context(|| format!("chmod manager socket {}", socket.display()))?;
tracing::info!(socket = %socket.display(), "manager socket listening");
tokio::spawn(async move {
let err = accept_until_fatal(
MANAGER_AGENT,
|| async { listener.accept().await.map(|(stream, _)| stream) },
|stream| {
let coord = coord.clone();
tokio::spawn(async move {
// Pure transport: serve as `ruth`, no privilege grant.
if let Err(e) = serve(stream, MANAGER_AGENT.to_owned(), coord).await {
tracing::warn!(error = ?e, "manager connection failed");
}
});
},
)
.await;
exit_on_dead_listener(MANAGER_AGENT, &err);
});
Ok(())
}
/// Pause after an accept error that is not about a single connection. EMFILE
/// and ENFILE leave the listener readable, so retrying at once would spin until
/// some fd is closed. Same policy as axum's `serve`, which the dashboard runs.
const ACCEPT_RETRY_DELAY: std::time::Duration = std::time::Duration::from_secs(1);
/// Accept errors that mean the listening fd itself is unusable: retrying it
/// returns the same error forever.
fn accept_error_is_fatal(e: &std::io::Error) -> bool {
matches!(
e.raw_os_error(),
Some(libc::EBADF | libc::EFAULT | libc::EINVAL | libc::ENOTSOCK | libc::EOPNOTSUPP)
)
}
/// Hand each accepted connection to `on_conn`, retrying every accept error
/// except a fatal one, which is returned. `agent` labels the log lines.
async fn accept_until_fatal<A, F>(
agent: &str,
mut accept: A,
mut on_conn: impl FnMut(UnixStream),
) -> std::io::Error
where
A: FnMut() -> F,
F: std::future::Future<Output = std::io::Result<UnixStream>>,
{
loop {
match accept().await {
Ok(stream) => on_conn(stream),
Err(e) if accept_error_is_fatal(&e) => return e,
Err(e) => {
tracing::warn!(%agent, error = ?e, "socket accept failed; retrying");
if !matches!(
e.kind(),
std::io::ErrorKind::ConnectionAborted
| std::io::ErrorKind::ConnectionReset
| std::io::ErrorKind::ConnectionRefused
) {
tokio::time::sleep(ACCEPT_RETRY_DELAY).await;
}
}
}
}
}
/// Exit on a listener that can no longer accept. A running hive-c0re re-binds
/// an agent socket only on that agent's next start, and the manager socket
/// never; on restart (`Restart = "on-failure"` on the unit) `start_manager` and
/// `mcp_sockets::sync_on_start` re-bind every one.
fn exit_on_dead_listener(agent: &str, e: &std::io::Error) -> ! {
tracing::error!(
%agent,
error = ?e,
"socket listener cannot accept; exiting so hive-c0re restarts and re-binds all sockets"
);
std::process::exit(1)
}
/// Longest request line accepted on the agent and manager sockets, newline
/// included. The largest request a client sends is an `OperatorMsg` from the
/// agent web UI's `/send` (`hive-agent/src/web_ui/actions.rs:21`), whose body
/// axum's default limit caps at 2 MiB; JSON escaping can roughly double that to
/// 4 MiB, and this is 4× that.
const REQUEST_LINE_MAX_BYTES: usize = 16 * 1024 * 1024;
enum RequestLine {
Eof,
Complete,
TooLong,
}
/// Read one request line into `buf`, consuming at most
/// [`REQUEST_LINE_MAX_BYTES`] from `reader`.
async fn read_request_line<R: AsyncBufRead + Unpin>(
reader: &mut R,
buf: &mut Vec<u8>,
) -> std::io::Result<RequestLine> {
buf.clear();
let n = (&mut *reader)
.take(REQUEST_LINE_MAX_BYTES as u64)
.read_until(b'\n', buf)
.await?;
Ok(if n == 0 {
RequestLine::Eof
} else if n == REQUEST_LINE_MAX_BYTES && !buf.ends_with(b"\n") {
RequestLine::TooLong
} else {
RequestLine::Complete
})
}
/// Answer each request line on `stream` until EOF. An over-long line gets a
/// `Response::Err` and then the connection is closed, because the rest of the
/// line is still unread.
async fn serve(stream: UnixStream, agent: String, coord: Arc<Coordinator>) -> Result<()> {
let (read, mut write) = stream.into_split();
let mut reader = BufReader::new(read);
let mut line = Vec::new();
loop {
let (resp, too_long) = match read_request_line(&mut reader, &mut line).await? {
RequestLine::Eof => return Ok(()),
RequestLine::TooLong => (
Response::Err {
message: format!(
"request too long: no newline within {REQUEST_LINE_MAX_BYTES} bytes; \
connection closed"
),
},
true,
),
RequestLine::Complete => match serde_json::from_slice::<Request>(&line) {
Ok(req) => (dispatch(&req, &agent, &coord).await, false),
Err(e) => (
Response::Err {
message: format!("parse error: {e}"),
},
false,
),
},
};
let mut payload = serde_json::to_string(&resp)?;
payload.push('\n');
write.write_all(payload.as_bytes()).await?;
write.flush().await?;
if too_long {
anyhow::bail!(
"`{agent}` sent a request line over {REQUEST_LINE_MAX_BYTES} bytes; closed"
);
}
}
}
/// Max long-poll window the caller can ask for; values above the
/// cap are clamped. 180s keeps us under typical TCP/proxy idle
/// limits while still letting agents park their turn until a
/// message arrives. Omitting `wait_seconds` (or passing `0`) means
/// "peek, don't wait" — claude can call recv whenever it wants a
/// cheap "is there anything pending?" check without blocking the
/// turn for 30 seconds. To actually park, the caller passes a
/// positive `wait_seconds`.
pub(crate) const RECV_LONG_POLL_MAX: std::time::Duration = std::time::Duration::from_mins(3);
/// Server-side hard cap on `Recv.max` — canonical value lives in
/// `hive_sh4re::inbox::RECV_BATCH_MAX` so the harness's wake-prompt hint and
/// this enforcement site can't drift apart.
pub(crate) const RECV_BATCH_MAX: u32 = hive_sh4re::inbox::RECV_BATCH_MAX;
pub(crate) fn recv_timeout(wait_seconds: Option<u64>) -> std::time::Duration {
match wait_seconds {
Some(s) => std::time::Duration::from_secs(s).min(RECV_LONG_POLL_MAX),
None => std::time::Duration::ZERO,
}
}
/// Unified dispatch for every socket connection — per-agent sockets and the
/// (now pure-transport) manager socket alike. There is no privilege bit;
/// authority derives uniformly from the caller's identity.
async fn dispatch(
req: &hive_core_agent_sock::Request,
agent: &str,
coord: &Arc<Coordinator>,
) -> hive_core_agent_sock::Response {
match req {
hive_core_agent_sock::Request::Send {
to,
body,
in_reply_to,
} => handle_send(coord, agent, to, body, *in_reply_to),
hive_core_agent_sock::Request::Recv { wait_seconds, max } => {
handle_recv(coord, agent, *wait_seconds, *max).await
}
hive_core_agent_sock::Request::Status => handle_status(coord, agent),
hive_core_agent_sock::Request::OperatorMsg { body } => {
handle_operator_msg(coord, agent, body)
}
hive_core_agent_sock::Request::Wake { from, body } => handle_wake(coord, agent, from, body),
hive_core_agent_sock::Request::Recent { limit } => handle_recent(coord, agent, *limit),
hive_core_agent_sock::Request::SetStatus { text } => handle_set_status(coord, text),
hive_core_agent_sock::Request::GetAgentMeta { name } => {
handle_get_agent_meta(coord, agent, name.as_ref()).await
}
hive_core_agent_sock::Request::AckTurn => handle_ack_turn(coord, agent),
hive_core_agent_sock::Request::AckUntil { up_to } => handle_ack_until(coord, agent, *up_to),
hive_core_agent_sock::Request::RequeueInflight => handle_requeue_inflight(coord, agent),
hive_core_agent_sock::Request::GracefulStopComplete => {
// Harness drained + is exiting: clear the fence so the
// `GracefulStop` orchestration (which polls this flag) proceeds
// to stop the container without waiting out its timeout.
coord.clear_graceful_stop(agent);
hive_core_agent_sock::Response::Ok
}
hive_core_agent_sock::Request::PauseAcknowledged => {
// Harness saw its own pause marker flip between turns: clear
// the fence so the pause DAG's drain node (which polls this
// flag) resolves without waiting out its timeout.
coord.clear_pause_pending(agent);
hive_core_agent_sock::Response::Ok
}
hive_core_agent_sock::Request::GetLooseEnds => handle_get_loose_ends(coord, agent),
}
}
/// `Recv` — long-poll the broker for up to `max` messages (capped at
/// `RECV_BATCH_MAX`), mapping deliveries onto the wire response.
async fn handle_recv(
coord: &Arc<Coordinator>,
agent: &str,
wait_seconds: Option<u64>,
max: Option<u32>,
) -> hive_core_agent_sock::Response {
// Graceful-stop fence: while a graceful stop is pending for this agent,
// return `GracefulStop` instead of polling the broker. The harness runs
// one stop-checkpoint turn then exits; new sends keep queueing in the
// broker for the agent's next start. Checked before the (blocking) poll
// so a flag set between polls is seen on the next Recv — the orchestration
// also fires a transient wake to break an in-flight long-poll.
if coord.is_graceful_stop_pending(agent) {
return hive_core_agent_sock::Response::GracefulStop;
}
let cap = max.unwrap_or(1).min(RECV_BATCH_MAX) as usize;
match coord
.broker
.recv_blocking_batch(agent, recv_timeout(wait_seconds), cap)
.await
{
Ok(deliveries) => {
// `recv_batch` stamps `delivered_at` on every popped row, so a
// `count_pending` here excludes the just-popped batch and reports
// exactly how many still-pending messages remain to drain. A count
// error is non-fatal — fall back to 0 rather than fail the recv.
let remaining = coord.broker.count_pending(agent).unwrap_or(0);
hive_core_agent_sock::Response::Messages {
messages: deliveries
.into_iter()
.map(|d| hive_sh4re::inbox::DeliveredMessage {
from: d.message.from.to_string(),
body: d.message.body,
id: d.id,
redelivered: d.redelivered,
in_reply_to: d.message.in_reply_to,
})
.collect(),
remaining,
}
}
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `Wake` — inject a wake into `agent`'s own inbox. Persisted through
/// the sqlite broker like any other message so the agent can ack it
/// via `AckUntil` and it appears in message history for post-mortem.
fn handle_wake(
coord: &Arc<Coordinator>,
agent: &str,
from: &str,
body: &str,
) -> hive_core_agent_sock::Response {
match coord.broker.send(&Message {
from: hive_sh4re::manager::trusted_sender(from),
to: agent.to_owned(),
body: body.to_owned(),
in_reply_to: None,
}) {
Ok(()) => hive_core_agent_sock::Response::Ok,
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `SetStatus` — validate the status text, then trigger a dashboard
/// rescan. The harness has already written the status file to its own
/// `state/` dir (it runs as the agent user), so this only refreshes the
/// dashboard's view.
fn handle_set_status(coord: &Arc<Coordinator>, text: &str) -> hive_core_agent_sock::Response {
if let Err(message) = crate::limits::check_status_text(text) {
return hive_core_agent_sock::Response::Err { message };
}
let coord2 = Arc::clone(coord);
tokio::spawn(async move { coord2.rescan_containers_and_emit().await });
hive_core_agent_sock::Response::Ok
}
/// `GetAgentMeta` — identity + live status for `name` (defaults to the
/// caller). Reads the live container-view status and the hive/swarm
/// display names.
async fn handle_get_agent_meta(
coord: &Arc<Coordinator>,
agent: &str,
name: Option<&hive_types::Ident>,
) -> hive_core_agent_sock::Response {
// `name` arrives pre-validated by serde (wire field is `Ident`). The
// `None` default (target == caller) still needs a parse since `agent`
// is a plain `&str` here — but it's the caller's own authenticated
// name, already valid in practice.
let target_id = match name {
Some(id) => id.clone(),
None => match hive_types::Ident::parse(agent) {
Ok(id) => id,
Err(reason) => {
return hive_core_agent_sock::Response::Err {
message: format!("get_agent_meta: invalid agent name {agent:?}: {reason}"),
};
}
},
};
let (status_text, status_set_at, running) =
crate::container_view::read_agent_status_live(&target_id).await;
let (hive_name, swarm_name) = crate::container_view::hive_swarm_names();
// Matrix identities are public handles (`name` / `user_id`
// `@user:server` / `homeserver`) — the access token lives separately
// in the agent's `matrix-token` and is never part of this response.
// Peer visibility is intentional: it lets an agent verify/contact
// another on a public matrix instance. The `Ident::parse` gate
// above is what closes the real vector here (path traversal via `../`
// in an agent-supplied name).
let matrix_accounts = read_agent_matrix_identities(&target_id);
hive_core_agent_sock::Response::AgentMeta {
name: target_id.into_string(),
running,
hyperhive_rev: crate::auto_update::current_flake_rev(&coord.hyperhive_flake),
status_text,
status_set_at,
hive_name,
swarm_name,
matrix_accounts,
}
}
/// Read the target agent's matrix identities from the daemon's
/// `matrix-accounts.json` snapshot (under the agent's state dir).
/// Best-effort: an absent / unparseable snapshot (no matrix provisioning,
/// or the daemon not up yet) yields an empty list. The `MatrixIdentity`
/// serde shape matches the snapshot entries; the snapshot's `live` field is
/// ignored (only live accounts are written).
fn read_agent_matrix_identities(
agent: &hive_types::Ident,
) -> Vec<hive_sh4re::container::MatrixIdentity> {
let path = Coordinator::agent_notes_dir(agent).join("matrix-accounts.json");
std::fs::read_to_string(&path)
.ok()
.and_then(|s| serde_json::from_str::<Vec<hive_sh4re::container::MatrixIdentity>>(&s).ok())
.unwrap_or_default()
}
/// `Status` — count of pending (unread) inbox messages for `agent`.
fn handle_status(coord: &Arc<Coordinator>, agent: &str) -> hive_core_agent_sock::Response {
match coord.broker.count_pending(agent) {
Ok(unread) => hive_core_agent_sock::Response::Status { unread },
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `OperatorMsg` — deliver an operator-authored message into `agent`'s
/// inbox (from the `operator` recipient).
fn handle_operator_msg(
coord: &Arc<Coordinator>,
agent: &str,
body: &str,
) -> hive_core_agent_sock::Response {
match coord.broker.send(&Message {
from: hive_sh4re::manager::trusted_sender(hive_sh4re::manager::OPERATOR_RECIPIENT),
to: agent.to_owned(),
body: body.to_owned(),
in_reply_to: None,
}) {
Ok(()) => hive_core_agent_sock::Response::Ok,
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `Recent` — the last `limit` inbox rows for `agent` (read-only,
/// doesn't consume).
fn handle_recent(
coord: &Arc<Coordinator>,
agent: &str,
limit: u64,
) -> hive_core_agent_sock::Response {
match coord.broker.recent_for(agent, limit) {
Ok(rows) => hive_core_agent_sock::Response::Recent { rows },
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `AckTurn` — mark `agent`'s in-flight delivered messages acked so
/// they don't redeliver on the next turn.
fn handle_ack_turn(coord: &Arc<Coordinator>, agent: &str) -> hive_core_agent_sock::Response {
match coord.broker.ack_turn(agent) {
Ok(_n) => hive_core_agent_sock::Response::Ok,
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `AckUntil` — bulk-ack every message addressed to `agent` with row
/// id `<= up_to` (the agent-side backlog-triage escape hatch).
fn handle_ack_until(
coord: &Arc<Coordinator>,
agent: &str,
up_to: i64,
) -> hive_core_agent_sock::Response {
match coord.broker.ack_until(agent, up_to) {
Ok(count) => hive_core_agent_sock::Response::Acked { count },
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `RequeueInflight` — resurface `agent`'s unacked in-flight messages
/// (crash recovery on harness boot).
fn handle_requeue_inflight(
coord: &Arc<Coordinator>,
agent: &str,
) -> hive_core_agent_sock::Response {
match coord.broker.requeue_inflight(agent) {
Ok(n) => {
if n > 0 {
tracing::info!(%agent, requeued = %n, "requeued in-flight messages");
}
hive_core_agent_sock::Response::Ok
}
Err(e) => hive_core_agent_sock::Response::Err {
message: format!("{e:#}"),
},
}
}
/// `GetLooseEnds` — always the caller's own loose ends.
fn handle_get_loose_ends(coord: &Arc<Coordinator>, agent: &str) -> Response {
match crate::loose_ends::for_agent(coord, agent) {
Ok(loose_ends) => Response::LooseEnds { loose_ends },
Err(e) => Response::Err {
message: format!("{e:#}"),
},
}
}
/// `Send` handler. Applies the
/// 4 KiB body cap, then routes broadcast (`to == "*"`) / unicast through
/// their respective broker calls.
pub(crate) fn handle_send(
coord: &Arc<Coordinator>,
agent: &str,
to: &str,
body: &str,
in_reply_to: Option<i64>,
) -> Response {
if let Err(message) = crate::limits::check_size("send", body) {
return Response::Err { message };
}
if to == "*" {
let errors = coord.broadcast_send(agent, body);
return if errors.is_empty() {
Response::Ok
} else {
Response::Err {
message: format!("broadcast failed for agents: {}", errors.join(", ")),
}
};
}
// Validate that the recipient is a known local agent or the
// special "operator" recipient. Without this check a typo in `to`
// silently queues a message nobody will ever read.
//
// Cross-hive messaging (`name@hive` qualified names) is not routed
// through the broker — use the Matrix MCP tools for that instead.
if to.contains('@') {
return Response::Err {
message: format!(
"send failed: cross-hive recipient `{to}` is not supported \
via the broker — use Matrix MCP tools for cross-hive messaging"
),
};
}
if to != hive_sh4re::manager::OPERATOR_RECIPIENT {
// A name that doesn't parse as an Ident can't be a local agent, so
// it collapses into the same "unknown recipient" error as a valid
// name with no state dir.
let exists = hive_types::Ident::parse(to)
.is_ok_and(|id| crate::paths::agent_state_dir(&id).exists());
if !exists {
return Response::Err {
message: format!(
"send failed: unknown recipient `{to}` \
(no agent with that name exists on this hive)"
),
};
}
}
match coord.broker.send(&Message {
from: hive_sh4re::manager::trusted_sender(agent),
to: to.to_owned(),
body: body.to_owned(),
in_reply_to,
}) {
Ok(()) => Response::Ok,
Err(e) => Response::Err {
message: format!("{e:#}"),
},
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A real `Coordinator` over a throwaway sqlite dir. `serve` takes an
/// `Arc<Coordinator>`, so there is no lighter way in; `open` touches
/// nothing outside the db path it is handed.
fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
let dir = tempfile::tempdir().expect("tempdir");
let coord = Coordinator::open(
&dir.path().join("broker.sqlite"),
crate::coordinator::HiveEnv::default(),
crate::hive_stats::PriceTable::new(),
1,
)
.expect("open coordinator");
(dir, Arc::new(coord))
}
#[tokio::test]
async fn over_long_line_is_rejected_after_reading_only_the_bound() {
let total = 2 * REQUEST_LINE_MAX_BYTES as u64;
let mut reader = BufReader::new(tokio::io::repeat(b'x').take(total));
let mut buf = Vec::new();
let outcome = read_request_line(&mut reader, &mut buf)
.await
.expect("read");
// At most one `BufReader` fill past the bound left the source.
let unread = reader.into_inner().limit();
let consumed = total - unread;
assert!(
consumed <= (REQUEST_LINE_MAX_BYTES + 8 * 1024) as u64,
"consumed {consumed} of {total} bytes"
);
assert_eq!(buf.len(), REQUEST_LINE_MAX_BYTES);
assert!(matches!(outcome, RequestLine::TooLong));
}
#[tokio::test]
async fn serve_answers_an_over_long_line_with_an_error_and_closes() {
let (_dir, coord) = coordinator();
let (client, server) = UnixStream::pair().expect("socketpair");
let handler = tokio::spawn(serve(server, "iris".to_owned(), coord));
let (read, mut write) = client.into_split();
let writer = tokio::spawn(async move {
let chunk = vec![b'x'; 64 * 1024];
for _ in 0..2 * REQUEST_LINE_MAX_BYTES / chunk.len() {
if write.write_all(&chunk).await.is_err() {
return;
}
}
let _ = write.shutdown().await;
});
let mut reply = String::new();
BufReader::new(read)
.read_line(&mut reply)
.await
.expect("reply");
let resp: Response = serde_json::from_str(&reply).expect("response json");
assert!(
matches!(&resp, Response::Err { message } if message.starts_with("request too long")),
"{reply}"
);
assert!(
handler.await.expect("join").is_err(),
"connection not closed"
);
writer.await.expect("join");
}
#[tokio::test]
async fn accept_loop_survives_a_transient_error() {
let (client, _peer) = UnixStream::pair().expect("socketpair");
let mut script = std::collections::VecDeque::from([
Err(std::io::Error::from_raw_os_error(libc::EMFILE)),
Ok(client),
Err(std::io::Error::from_raw_os_error(libc::EBADF)),
]);
let mut served = 0;
let err = accept_until_fatal(
"iris",
|| std::future::ready(script.pop_front().expect("accept after a fatal error")),
|_stream| served += 1,
)
.await;
assert_eq!(served, 1, "connection after EMFILE not served");
assert_eq!(err.raw_os_error(), Some(libc::EBADF));
}
}