Merge pull request 'Desk reads: fork_too_large (list only) replaces DeskForkTooLarge' (#39) from eureka-substrate/fork-too-large into main

This commit is contained in:
Eureka 2026-11-11 01:03:55 +00:00
commit d91985b521
6 changed files with 233 additions and 91 deletions

View file

@ -569,3 +569,217 @@ fn a_busy_pass_is_not_followed_by_the_idle_sleep() {
let gap = answered[4] - answered[3];
assert!(gap < Duration::from_millis(300), "the second batch waited {gap:?}: the loop slept after a busy pass ({answered:?})");
}
// ---- fork_too_large over the real socket ----
fn forks_page(who: &str, cursor: Option<&str>) -> DeskRequest {
let mut value = serde_json::to_value(forks_request(who, &[who])).unwrap();
value["Forks"]["cursor"] = cursor.map_or(serde_json::Value::Null, |text| serde_json::json!(text));
serde_json::from_value(value).unwrap()
}
fn fork_read(who: &str, fork_id: &str) -> DeskRequest {
DeskRequest::Fork { mind: INSTANCE.into(), subject: short(who), deciders: vec![short(who)], fork_id: fork_id.into() }
}
/// The wire spelling of a not-stored refusal's code, and its text.
fn refusal_of(response: &DeskResponse) -> Option<(String, String)> {
let DeskResponse::NotStored(refusal) = response else { return None };
Some((serde_json::to_value(refusal.code).unwrap().as_str().unwrap().to_string(), refusal.error.clone()))
}
/// Three forks, the middle one's record grown over the socket until its card alone is past the page
/// budget. The list names it and its order (`fork_too_large`), the cursor `c<order>` walks on to the
/// older fork and ends at order 0, a READ of it never returns that code (an entry while it fits the
/// response bound, the door's named response-too-large past it), and `authority` on a read is still
/// refused.
#[test]
fn a_fork_too_large_to_page_is_named_by_the_list_and_never_by_the_read() {
let _serial = serial();
assert_ne!(my_uid(), 0, "the door refuses uid 0, so this test cannot run as root");
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let hash = seed(root.path());
{
let mut mind = Mind::open(root.path(), &INSTANCE.into()).unwrap();
let documents = ["Q2", "Q3"]
.into_iter()
.map(|label| PipelineDocument::Question(PipelineQuestion {
campaign: "eureka-state".into(),
label: label.into(),
title: Title("Who owns the state?".into()),
question: "Who owns the state?".into(),
options: vec![
QuestionOption { label: "A".into(), text: "the organ".into() },
QuestionOption { label: "B".into(), text: "the client".into() },
],
recommended: "A".into(),
depends: vec![],
raised_in: None,
asked_on: Date("2026-09-16".into()),
}))
.collect();
let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: short("claude"), session: short("session-2"), tool: short("admit"), imported_from: None };
let outcome = mind.admit(PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents }, Utc.with_ymd_and_hms(2026, 9, 16, 12, 5, 0).unwrap());
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
}
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
let _daemon = spawn(root.path(), &desk, my_uid(), &log);
let (older, big, newer) = (FORK, "eureka-state:question:Q2", "eureka-state:question:Q3");
// Notes of about 300 bytes, until the big fork's list page is the refusal.
let note = "a long reason, ".repeat(20);
let mut sent = 0_u32;
let refused = loop {
let listed = ask(&desk, "m-g", &forks_page("x", Some("c2")));
if refusal_of(&listed).is_some() {
break listed;
}
assert!(matches!(listed, DeskResponse::Forks { .. }), "{listed:?}");
sent += 1;
assert!(sent < 400, "the record does not grow");
let mut grow = answer("x", &hash, &format!("00000000-0000-4000-8000-{sent:012}"));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = big.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(&desk, "m-w", &grow), DeskResponse::Answered { .. }));
};
// (1) The list names the fork and its order, and nothing else is new in the text.
let (code, text) = refusal_of(&refused).unwrap();
assert_eq!(code, "fork_too_large", "{refused:?}");
assert!(text.contains(big) && text.contains("order 1"), "{text}");
// (2) The cursor c<order> resumes to the older fork, and order 0 is the end. The newest fork pages
// alone ahead of it; the big one is named from the first page too.
let first = ask(&desk, "m-p0", &forks_page("x", None));
let DeskResponse::Forks { list, .. } = first else { panic!("{first:?}") };
assert_eq!((list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect::<Vec<_>>(), list.next_cursor.map(|c| c.order())), (vec![newer.to_string()], Some(2)));
let rest = ask(&desk, "m-p1", &forks_page("x", Some("c1")));
let DeskResponse::Forks { list, .. } = rest else { panic!("{rest:?}") };
assert_eq!((list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect::<Vec<_>>(), list.next_cursor.is_none()), (vec![older.to_string()], true));
assert_eq!(list.forks[0].card.order, 0);
// (3) A read of the same fork: its whole entry while it fits the response bound.
let read = ask(&desk, "m-r", &fork_read("x", big));
let DeskResponse::Fork { entry, .. } = &read else { panic!("a read of the oversize fork is its entry, not a refusal: {read:?}") };
assert_eq!((entry.card.fork_id.as_str(), entry.card.order), (big, 1));
// Past DESK_MAX_RESPONSE_BYTES the read is the door's named response-too-large refusal, still not fork_too_large.
let mut more = 0_u32;
let too_big = loop {
let read = ask(&desk, "m-r", &fork_read("x", big));
if matches!(read, DeskResponse::Refused(_)) {
break read;
}
assert!(matches!(read, DeskResponse::Fork { .. }), "{read:?}");
more += 1;
assert!(more < 400, "the record does not grow");
let mut grow = answer("x", &hash, &format!("00000000-0000-4000-8000-{:012}", 1000 + more));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = big.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(&desk, "m-w", &grow), DeskResponse::Answered { .. }));
};
assert!(matches!(too_big, DeskResponse::Refused(huginn_mind::MindRefusal::ResponseTooLarge { .. })), "{too_big:?}");
assert!(refusal_of(&ask(&desk, "m-g", &forks_page("x", Some("c2")))).is_some_and(|(code, _)| code == "fork_too_large"));
// (4) `authority` on a read is still refused whole (the contract's request_invalid is the door's payload-not-a-request).
for (operation, request) in [("forks", forks_request("x", &["x"])), ("fork", fork_read("x", big))] {
let mut value = serde_json::to_value(&request).unwrap();
value[if operation == "forks" { "Forks" } else { "Fork" }]["authority"] = serde_json::json!("Operator");
let message = CultNetMessage::OperationRequest {
message_id: "m-ra".into(),
service_id: huginn_mind::desk_wire::DESK_SERVICE_ID.into(),
operation: operation.into(),
payload_schema: huginn_mind::desk_wire::DESK_REQUEST_SCHEMA.into(),
payload_encoding: "messagepack-base64".into(),
payload: base64::Engine::encode(&base64::engine::general_purpose::STANDARD, rmp_serde::to_vec_named(&value).unwrap()),
source_runtime_id: None,
target_runtime_id: None,
};
let reply = exchange(&desk, &framed(&message)).expect("the door replied");
assert_eq!(decode_desk_response(&reply).unwrap().1.unwrap_err().code, "payload-not-a-request", "{operation}");
}
}
/// Q1 (order 0), Q2, Q3 held; the daemon up. Returns what the tests need.
fn three_forks() -> (tempfile::TempDir, tempfile::TempDir, String, PathBuf, Daemon) {
let (root, run) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
let hash = seed(root.path());
{
let mut mind = Mind::open(root.path(), &INSTANCE.into()).unwrap();
let documents = ["Q2", "Q3"]
.into_iter()
.map(|label| PipelineDocument::Question(PipelineQuestion {
campaign: "eureka-state".into(),
label: label.into(),
title: Title("Who owns the state?".into()),
question: "Who owns the state?".into(),
options: vec![
QuestionOption { label: "A".into(), text: "the organ".into() },
QuestionOption { label: "B".into(), text: "the client".into() },
],
recommended: "A".into(),
depends: vec![],
raised_in: None,
asked_on: Date("2026-09-16".into()),
}))
.collect();
let provenance = PipelineProvenance { faculty: Faculty::Hands, agent: short("claude"), session: short("session-2"), tool: short("admit"), imported_from: None };
let outcome = mind.admit(PipelineAdmissionBatch { instance: INSTANCE.into(), provenance, documents }, Utc.with_ymd_and_hms(2026, 9, 16, 12, 5, 0).unwrap());
assert!(matches!(outcome, PipelineAdmissionOutcome::Committed { .. }), "{outcome:?}");
}
let (desk, log) = (run.path().join("desk.sock"), run.path().join("daemon.log"));
let daemon = spawn(root.path(), &desk, my_uid(), &log);
(root, run, hash, desk, daemon)
}
/// Grows `fork`'s record over the socket until the list page at `cursor` is the refusal; returns it.
fn grown_until_refused(desk: &Path, hash: &str, fork: &str, cursor: Option<&str>) -> DeskResponse {
let note = "a long reason, ".repeat(20);
for sent in 1..400_u32 {
let listed = ask(desk, "m-g", &forks_page("x", cursor));
if refusal_of(&listed).is_some() {
return listed;
}
assert!(matches!(listed, DeskResponse::Forks { .. }), "{listed:?}");
let mut grow = answer("x", hash, &format!("00000000-0000-4000-8000-{sent:012}"));
if let DeskRequest::Answer { request, .. } = &mut grow {
request.fork_id = fork.into();
request.answer = Some(ForkAnswer { choice: None, note: Some(note.clone()) });
}
assert!(matches!(ask(desk, "m-w", &grow), DeskResponse::Answered { .. }));
}
panic!("the record does not grow")
}
fn page_ids(response: DeskResponse) -> (Vec<String>, Option<u64>) {
let DeskResponse::Forks { list, .. } = response else { panic!("{response:?}") };
(list.forks.iter().map(|e| e.card.fork_id.as_str().to_string()).collect(), list.next_cursor.map(|c| c.order()))
}
/// The oldest fork (order 0) oversize: the list names it and its order, and says it is the end, not a cursor.
#[test]
fn the_oldest_fork_too_large_says_the_list_ends_and_names_no_cursor() {
let _serial = serial();
let (_root, _run, hash, desk, _daemon) = three_forks();
let refused = grown_until_refused(&desk, &hash, FORK, Some("c1"));
let (code, text) = refusal_of(&refused).unwrap();
assert_eq!(code, "fork_too_large");
assert!(text.contains(FORK) && text.contains("order 0") && !text.contains("c0"), "{text}");
}
/// The newest fork (order 2) oversize: the first page is the refusal, and c2 resumes to the older forks.
#[test]
fn the_newest_fork_too_large_is_named_and_its_cursor_resumes_to_the_older_forks() {
let _serial = serial();
let (_root, _run, hash, desk, _daemon) = three_forks();
let newest = "eureka-state:question:Q3";
let refused = grown_until_refused(&desk, &hash, newest, None);
let (code, text) = refusal_of(&refused).unwrap();
assert_eq!(code, "fork_too_large");
assert!(text.contains(newest) && text.contains("order 2") && text.contains("c2"), "{text}");
let rest = page_ids(ask(&desk, "m-p", &forks_page("x", Some("c2"))));
assert_eq!(rest, (vec!["eureka-state:question:Q2".to_string(), FORK.to_string()], None));
}

View file

@ -152,13 +152,14 @@ pub struct ForkDeskAnswer {
pub request_id: String,
}
/// The three requests Huginn refuses without storing, by code.
/// The refusals Huginn sends without storing, by code. `ForkTooLarge` is the list's alone.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ForkRefusalCode {
ForkUnknown,
BodyInvalid,
RequestIdConflict,
ForkTooLarge,
}
/// `gamecult.fork.refusal.v0`. `error` is a fixed sentence, never an echo of what was sent;
@ -382,6 +383,13 @@ fn fork_unknown() -> ForkRefusal {
fork_refusal(ForkRefusalCode::ForkUnknown, "fork_unknown: no such fork".into())
}
/// A list page whose first entry alone is past the page budget: names that fork and its `order`. A
/// client continues with the cursor `c<order>`; at order 0 there is no such cursor and the list ends.
fn fork_too_large(fork: &str, order: u64) -> ForkRefusal {
let onward = if order == 0 { "this is the end of the list".to_string() } else { format!("continue with cursor c{order}") };
fork_refusal(ForkRefusalCode::ForkTooLarge, format!("fork_too_large: fork {fork} at order {order} is past the page budget by itself; {onward}"))
}
fn body_invalid(field: &str) -> ForkRefusal {
fork_refusal(ForkRefusalCode::BodyInvalid, format!("body_invalid: the body does not satisfy gamecult.fork.answer_request.v0 ({field})"))
}

View file

@ -326,7 +326,7 @@ impl<S: MindStore> Mind<S> {
/// A page holds at most `limit` entries (never more than 50) and stops short only when the next
/// entry would push the encoded response past `DESK_PAGE_BUDGET` (the one `DESK_MAX_RESPONSE_BYTES`
/// less the envelope margin); counts and stamp are unaffected. A page whose FIRST entry alone is past
/// the budget is `MindRefusal::DeskForkTooLarge`, naming that fork and its order: the caller goes on
/// the budget is the `fork_too_large` refusal, naming that fork and its order: the caller goes on
/// with the cursor `c<order>`, which returns the forks below it, so no older fork is unreachable.
/// The fork itself is still its whole entry on `desk_fork`. Nothing is dropped silently.
///
@ -384,7 +384,7 @@ impl<S: MindStore> Mind<S> {
if bytes > DESK_PAGE_BUDGET {
let list = held(&mut response);
if list.forks.len() == 1 {
return Ok(DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: key.to_string(), order: *order, bytes: bytes as u64, limit: DESK_PAGE_BUDGET as u64 }));
return Ok(DeskResponse::NotStored(fork_too_large(key, *order)));
}
list.forks.pop();
list.next_cursor = before;
@ -1061,9 +1061,11 @@ mod tests {
assert_eq!(ids(&first), [fork(3)]);
assert_eq!(first.next_cursor.map(ForkCursor::order), Some(2));
let response = mind.desk_forks(&list(&LIST), None, first.next_cursor, limit(50));
let DeskResponse::Refused(MindRefusal::DeskForkTooLarge { fork: named, order, bytes, limit: bound }) = response else { panic!("{response:?}") };
assert_eq!((named, order, bound), (fork(2), 1, DESK_PAGE_BUDGET as u64));
assert!(bytes > bound);
let DeskResponse::NotStored(refusal) = response else { panic!("{response:?}") };
assert_eq!(refusal.code, ForkRefusalCode::ForkTooLarge);
let order = 1;
assert_eq!(refusal.error, fork_too_large(&fork(2), order).error);
assert!(refusal.error.contains(&fork(2)) && refusal.error.contains("order 1"), "{refusal:?}");
// The order the refusal names is the order of the fork it names; its cursor goes on past it.
assert_eq!(entry(mind.desk_fork(&list(&LIST), &fork(2))).0.card.order, order);
let (rest, _) = page(mind.desk_forks(&list(&LIST), None, ForkCursor::new(order), limit(50)));

View file

@ -109,11 +109,6 @@ pub enum MindRefusal {
/// truncated or paginated on its behalf. The daemon owns it and its
/// deferral bound decides it; no rule of the mind is involved.
ResponseTooLarge { bytes: u64, limit: u64 },
/// A desk page whose first entry alone is past the page budget: `fork` is that fork, `order` its
/// rank (a caller goes on past it with the cursor `c<order>`, which returns the forks below it),
/// `bytes` what the page holding it would encode to (without the envelope) and `limit` the page
/// budget it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.
DeskForkTooLarge { fork: String, order: u64, bytes: u64, limit: u64 },
/// A selection the substrate or the organ's value door refused: `field`
/// is the selection field the client sent, `value` the offending part.
SelectionInvalid { field: String, value: Option<String>, message: String },

View file

@ -471,12 +471,13 @@
]
},
"ForkRefusalCode": {
"description": "The three requests Huginn refuses without storing, by code.",
"description": "The refusals Huginn sends without storing, by code. `ForkTooLarge` is the list's alone.",
"type": "string",
"enum": [
"fork_unknown",
"body_invalid",
"request_id_conflict"
"request_id_conflict",
"fork_too_large"
]
},
"ListSchema": {
@ -1562,45 +1563,6 @@
"ResponseTooLarge"
]
},
{
"description": "A desk page whose first entry alone is past the page budget: `fork` is that fork, `order` its\nrank (a caller goes on past it with the cursor `c<order>`, which returns the forks below it),\n`bytes` what the page holding it would encode to (without the envelope) and `limit` the page\nbudget it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.",
"type": "object",
"properties": {
"DeskForkTooLarge": {
"type": "object",
"properties": {
"bytes": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"fork": {
"type": "string"
},
"limit": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"order": {
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
"required": [
"fork",
"order",
"bytes",
"limit"
]
}
},
"additionalProperties": false,
"required": [
"DeskForkTooLarge"
]
},
{
"description": "A selection the substrate or the organ's value door refused: `field`\nis the selection field the client sent, `value` the offending part.",
"type": "object",

View file

@ -2059,45 +2059,6 @@
"ResponseTooLarge"
]
},
{
"description": "A desk page whose first entry alone is past the page budget: `fork` is that fork, `order` its\nrank (a caller goes on past it with the cursor `c<order>`, which returns the forks below it),\n`bytes` what the page holding it would encode to (without the envelope) and `limit` the page\nbudget it exceeds. Raised by the mind's page fill, which is why it is not `ResponseTooLarge`.",
"type": "object",
"properties": {
"DeskForkTooLarge": {
"type": "object",
"properties": {
"bytes": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"fork": {
"type": "string"
},
"limit": {
"type": "integer",
"format": "uint64",
"minimum": 0
},
"order": {
"type": "integer",
"format": "uint64",
"minimum": 0
}
},
"required": [
"fork",
"order",
"bytes",
"limit"
]
}
},
"additionalProperties": false,
"required": [
"DeskForkTooLarge"
]
},
{
"description": "A selection the substrate or the organ's value door refused: `field`\nis the selection field the client sent, `value` the offending part.",
"type": "object",