Desk door fix batch: one deadline per connection (reads and writes spend what is left), the catalog reply bounded by the response limit, the dead index call and answer_ref deleted

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Eureka 2026-11-10 22:17:04 +00:00
commit 4e43e43a26
4 changed files with 107 additions and 53 deletions

View file

@ -5,7 +5,7 @@
use chrono::{DateTime, Utc};
use cultnet_rs::Selection;
use crate::sources::forge::HttpForge;
use huginn_mind::desk_wire::{DeskRequest, DeskResponse, answer_ref};
use huginn_mind::desk_wire::{DeskRequest, DeskResponse};
use huginn_mind::eureka_pipeline::{PipelineRef, Slug};
use huginn_mind::wire::{ACTIVITY_REPOS_MAX, HuginnMindRequest, HuginnMindResponse, IndexStatus};
use huginn_mind::{Members, Mind, MindRefusal, MindStore, PipelineAdmissionOutcome, SemanticQuery, SourceHits, SourceQuery};
@ -237,7 +237,7 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
/// One desk request, answered by the Mind method it names. The Mind is the instance's own
/// (`mind` is checked against it); `deciders` is this call's list, handed to the Mind and
/// dropped. A fresh answer reaches the index as `Admit`'s writes do; a replay wrote nothing.
/// dropped. A desk answer is not indexed (`index_text` gives it no text), so nothing here tells the index.
/// The reads wait on follow-up desk-door-reads and are refused until they exist.
pub fn handle_desk(&mut self, request: DeskRequest, now: DateTime<Utc>) -> DeskResponse {
if let Err(refusal) = self.mind.require_instance(request.mind()) {
@ -245,15 +245,7 @@ impl<S: MindStore, I: IndexSink<S>> Daemon<S, I> {
}
match request {
DeskRequest::Answer { subject, authority, deciders, request, .. } => {
let (response, replayed) = self.mind.desk_answer(&deciders, subject, authority, request, now);
if !replayed
&& let DeskResponse::Answered { answer, .. } = &response
&& let Some(stored) = answer_ref(answer)
&& let Err(error) = self.index.committed(&self.mind, std::slice::from_ref(&stored))
{
eprintln!("huginn: the index refused the landed write [{}]: {error:#}", stored.id.0);
}
response
self.mind.desk_answer(&deciders, subject, authority, request, now).0
}
DeskRequest::Forks { .. } | DeskRequest::Fork { .. } => DeskResponse::Refused(MindRefusal::Unavailable { detail: DESK_READS_UNBUILT.into() }),
}

View file

@ -5,13 +5,14 @@
//! refused peer has nothing read and nothing written. The socket's life: bind, replace a stale
//! one, refuse a live one or a file that is not a socket, mode 0660, unlink on drop. And the
//! bounds: one request per connection, one reply, a header over `DESK_MAX_REQUEST_BYTES` refused
//! before anything is allocated, `DESK_IO_TIMEOUT` on every read and write, a reply past
//! `DESK_MAX_RESPONSE_BYTES` refused by name.
//! before anything is allocated, one `DESK_CONNECTION_DEADLINE` for the whole connection (every
//! read and write spends what is left of it, so a peer that trickles bytes is cut off at the
//! deadline like one that is silent), a reply past `DESK_MAX_RESPONSE_BYTES` refused by name.
//!
//! It owns no rule. Which Mind method answers is `Daemon::handle_desk`'s; when the door is
//! served is `serve::run`'s, once per pass in the thread that owns the Mind. A peer that is
//! slow costs the loop at most `DESK_ACCEPTS_PER_PASS` connections of `2 * DESK_IO_TIMEOUT`
//! each, and only the configured peer can reach that.
//! slow costs the loop at most `DESK_ACCEPTS_PER_PASS` connections of `DESK_CONNECTION_DEADLINE`
//! each, four seconds, and only the configured peer can reach that.
//!
//! The door keeps no request id: a retry after a lost reply is a new connection with the same
//! request, and the stored answer is what replays it, across a restart as well.
@ -23,7 +24,7 @@ use std::os::fd::AsFd;
use std::os::unix::fs::{FileTypeExt, PermissionsExt};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::{Path, PathBuf};
use std::time::Duration;
use std::time::{Duration, Instant};
use anyhow::{Context, Result, bail, ensure};
use chrono::{DateTime, Utc};
@ -38,12 +39,13 @@ use huginn_mind::desk_wire::{
};
use huginn_mind::envelope::{OperationFailure, decode_desk_request, encode_desk_failure, encode_desk_response};
use huginn_mind::{MindRefusal, MindStore};
use rustix::net::sockopt::{Timeout, set_socket_timeout};
use crate::daemon::{Daemon, IndexSink};
use crate::serve::{MAX_ECHOED_FIELD_BYTES, overlong_echo};
/// How long the door waits on one read or one write of the configured peer.
pub const DESK_IO_TIMEOUT: Duration = Duration::from_millis(500);
/// How long one connection of the configured peer may hold the loop, reading and writing together.
pub const DESK_CONNECTION_DEADLINE: Duration = Duration::from_secs(1);
/// Connections one loop pass serves, so a peer that connects in a stream cannot hold the RUDP door off.
pub const DESK_ACCEPTS_PER_PASS: u32 = 4;
@ -145,11 +147,7 @@ impl DeskDoor {
}
};
taken += 1;
let served = serve_connection(stream, self.peer_uid, daemon, &self.registry, now, |stream| {
stream.set_nonblocking(false)?;
stream.set_read_timeout(Some(DESK_IO_TIMEOUT))?;
stream.set_write_timeout(Some(DESK_IO_TIMEOUT))
});
let served = serve_connection(stream, self.peer_uid, daemon, &self.registry, now, |stream| stream.set_nonblocking(false));
if served != Served::Answered {
eprintln!("huginn: {served}");
}
@ -171,9 +169,49 @@ fn profile() -> CultNetTransportProfile {
)
}
/// One connection: the peer first, before anything is read, then one frame in, one frame out.
/// `ready` readies the stream for blocking, bounded I/O and runs only after the peer is known to
/// be the configured one.
/// A stream whose every read and write is bounded by what is left of one deadline: the socket's
/// timeout is reset to the remainder before each call, so bytes arriving do not extend it.
pub(crate) struct Deadlined<St> {
inner: St,
deadline: Instant,
}
impl<St: AsFd> Deadlined<St> {
pub(crate) fn new(inner: St, within: Duration) -> Self {
Self { inner, deadline: Instant::now() + within }
}
fn remaining(&self, which: Timeout) -> io::Result<()> {
// Whole milliseconds: rustix rounds a sub-microsecond tail up into an out-of-range tv_usec.
let left = Duration::from_millis(self.deadline.saturating_duration_since(Instant::now()).as_millis() as u64);
if left.is_zero() {
return Err(io::ErrorKind::TimedOut.into());
}
set_socket_timeout(&self.inner, which, Some(left)).map_err(io::Error::from)
}
}
impl<St: Read + AsFd> Read for Deadlined<St> {
fn read(&mut self, buf: &mut [u8]) -> io::Result<usize> {
self.remaining(Timeout::Recv)?;
self.inner.read(buf)
}
}
impl<St: Write + AsFd> Write for Deadlined<St> {
fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
self.remaining(Timeout::Send)?;
self.inner.write(buf)
}
fn flush(&mut self) -> io::Result<()> {
self.inner.flush()
}
}
/// One connection: the peer first, before anything is read, then one frame in, one frame out,
/// all inside `DESK_CONNECTION_DEADLINE`. `ready` readies the stream for blocking I/O and runs
/// only after the peer is known to be the configured one.
pub fn serve_connection<St, S, I>(
stream: St,
peer_uid: u32,
@ -197,7 +235,7 @@ where
if let Err(error) = ready(&stream) {
return Served::Dropped(format!("the stream could not be readied: {error}"));
}
let mut transport = TcpFramedTransportConnection::new(stream, profile());
let mut transport = TcpFramedTransportConnection::new(Deadlined::new(stream, DESK_CONNECTION_DEADLINE), profile());
let frame = match transport.receive() {
Ok(frame) => frame,
Err(error) => return Served::Dropped(format!("no whole frame arrived: {error:#}")),
@ -243,7 +281,14 @@ pub fn answer_desk_message<S: MindStore, I: IndexSink<S>>(
Err(failure) => encode_desk_failure(message_id, operation, &failure, &runtime_id),
},
CultNetMessage::SchemaCatalogRequest { .. } => match registry.create_catalog_response(&message) {
Ok(response) => response,
Ok(reply) => bounded(reply, DESK_MAX_RESPONSE_BYTES, |size| CultNetMessage::Error {
error: match size {
Ok(bytes) => format!("{DESK_SERVICE_ID} refuses a catalog reply of {bytes} bytes over {DESK_MAX_RESPONSE_BYTES}"),
Err(detail) => detail,
},
code: None,
details: None,
}),
Err(error) => CultNetMessage::Error { error: format!("{error:#}"), code: None, details: None },
},
_ => CultNetMessage::Error {
@ -263,13 +308,21 @@ pub(crate) fn respond(response: &DeskResponse, message_id: &str, operation: &str
Ok(reply) => reply,
Err(error) => return not_encodable(format!("{error:#}")),
};
let size = match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) {
Ok(bytes) => bytes.len(),
Err(error) => return not_encodable(format!("{error:#}")),
};
if size <= limit {
return reply;
}
let refusal = DeskResponse::Refused(MindRefusal::ResponseTooLarge { bytes: size as u64, limit: limit as u64 });
encode_desk_response(message_id, operation, &refusal, runtime_id).unwrap_or_else(|error| not_encodable(format!("{error:#}")))
bounded(reply, limit, |size| match size {
Ok(bytes) => {
let refusal = DeskResponse::Refused(MindRefusal::ResponseTooLarge { bytes: bytes as u64, limit: limit as u64 });
encode_desk_response(message_id, operation, &refusal, runtime_id).unwrap_or_else(|error| not_encodable(format!("{error:#}")))
}
Err(detail) => not_encodable(detail),
})
}
/// `reply` itself when it encodes to at most `limit` bytes. Otherwise what `refuse` makes of the
/// encoded size, or of the encoding error. Every reply the door sends passes here.
fn bounded(reply: CultNetMessage, limit: usize, refuse: impl FnOnce(Result<usize, String>) -> CultNetMessage) -> CultNetMessage {
match encode_cultnet_message_to_vec(&reply, CultNetWireContract::CultNetSchemaV0) {
Ok(bytes) if bytes.len() <= limit => reply,
Ok(bytes) => refuse(Ok(bytes.len())),
Err(error) => refuse(Err(format!("{error:#}"))),
}
}

View file

@ -30,7 +30,7 @@ use tempfile::TempDir;
use crate::daemon::tests::{INSTANCE, OTHER, RecordingIndex, batch, campaign_seed, now, question_and_ruling, slug};
use crate::daemon::Daemon;
use crate::desk_door::{DeskDoor, Served, answer_desk_message, bind_desk, desk_registry, serve_connection};
use crate::desk_door::{DESK_CONNECTION_DEADLINE, DeskDoor, Served, answer_desk_message, bind_desk, desk_registry, serve_connection};
use crate::index::Backoff;
use crate::serve::{Options, ServeOptions, bind, parse_options, run, schema_registry, startup};
@ -342,17 +342,12 @@ mod door_smoke {
fn a_counted_answer_over_the_socket_reads_back() {
assert_ne!(my_uid(), 0, "the door refuses uid 0, so these tests cannot run as root");
let mut rig = rig(my_uid());
let hash = rig.hash.clone();
let (before, hash) = (rig.state(), rig.hash.clone());
let response = rig.serving(|path| reply_of(path, "m-1", &answer("x", DeskAuthority::Operator, &["x", "y"], wire(&hash, R1))));
let DeskResponse::Answered { answer, counted_answer_id, .. } = &response else { panic!("{response:?}") };
assert_eq!((answer.outcome.as_str(), counted_answer_id.as_deref(), answer.authority), ("Counted", Some("n1"), DeskAuthority::Operator));
let seen = rig.index.seen.lock().unwrap().clone();
assert_eq!(seen.len(), 1, "the fresh answer reached the index once");
let stored = huginn_mind::eureka_pipeline::PipelineRef {
kind: huginn_mind::eureka_pipeline::PipelineKind::DeskAnswer,
id: short(&seen[0].0[0]),
};
assert!(rig.daemon.mind().view(&stored).unwrap().is_some(), "the ref the index was given is the stored answer");
assert_eq!(rig.state().0, before.0 + 1, "the answer is stored");
assert!(rig.index.seen.lock().unwrap().is_empty(), "a desk answer is not indexed");
}
/// A pass takes at most `DESK_ACCEPTS_PER_PASS` waiting connections and says how many it took.
@ -676,7 +671,7 @@ mod door_smoke {
let (_, response) = decode_desk_response(&replayed).unwrap();
assert_eq!(counted(response.unwrap()).0, "n1", "the retry is answered with the stored answer");
assert_eq!(rig.state(), first_state, "the retry stored nothing");
assert_eq!(rig.index.seen.lock().unwrap().len(), 1, "the search index holds the answer once");
assert!(rig.index.seen.lock().unwrap().is_empty(), "a desk answer is not indexed");
}
#[test]
@ -773,6 +768,7 @@ mod door_smoke {
// ---- the bounds, pinned as the numbers the spec states ----
mod bounds {
use crate::desk_door::Deadlined;
use cultnet_rs::{CultNetSchemaKind, CultNetSchemaRegistration, CultNetSchemaRegistry};
use huginn_mind::desk_wire::DESK_MAX_RESPONSE_BYTES;
@ -815,6 +811,25 @@ mod bounds {
});
}
/// The write side of the deadline: a peer that stops reading is let go of at one second, the
/// socket's own send timeout reset to what is left before each write.
#[test]
fn a_peer_that_never_reads_is_cut_off_at_one_second() {
let (_peer, server) = UnixStream::pair().unwrap();
let mut stream = Deadlined::new(server, DESK_CONNECTION_DEADLINE);
let (chunk, started) = (vec![0_u8; 64 * 1024], Instant::now());
let error = loop {
if let Err(error) = stream.write(&chunk) {
break error;
}
};
let took = started.elapsed();
// The kernel's send timeout reads as WouldBlock; the deadline already spent reads as TimedOut.
assert!(matches!(error.kind(), std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut), "{error:?}");
assert!(took >= Duration::from_millis(1000) && took < Duration::from_millis(1250), "the write was cut after {took:?}");
assert_eq!(stream.write(&chunk).unwrap_err().kind(), std::io::ErrorKind::TimedOut, "once spent, nothing more is written");
}
/// A catalog is a reply like any other and is measured against the response bound: one that
/// encodes to exactly the bound is sent, one byte more is refused naming the size.
#[test]

View file

@ -15,7 +15,7 @@
use chrono::{DateTime, Utc};
use eureka_pipeline::{
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, PipelineRef, RequestId, Sha256Hex, Short, Slug, Words,
DeskAuthority, DeskOutcome, DeskRefusal, PipelineDeskAnswer, PipelineDocument, PipelineKind, PipelineQuestion, RequestId, Sha256Hex, Short, Slug, Words,
};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
@ -330,12 +330,6 @@ fn answer_key(fork: &str, n: u32) -> String {
format!("{}{}", prefix(fork), n_text(n))
}
/// The record a projected answer was stored under: the ref the index is told about when the
/// answer is fresh. `None` for an id outside the `n<seq>` form, which no stored answer has.
pub fn answer_ref(answer: &ForkDeskAnswer) -> Option<PipelineRef> {
Some(PipelineRef { kind: PipelineKind::DeskAnswer, id: Short(answer_key(&answer.fork_id, parse_n(&answer.answer_id)?)) })
}
/// The sequence a key of `fork`'s answers names; `None` for another fork's key or any other text.
fn answer_seq(fork: &str, key: &str) -> Option<u32> {
parse_n(key.strip_prefix(prefix(fork).as_str())?)