Files
boxmaker/crates/gatewayd/tests/state.rs
T
kyle 24cac6a4eb gatewayd: state, what was handled, our threads and turns in flight
Implemented-By: OpenCode session (model recorded in docs/implementer-log.md)
2026-09-23 21:23:49 -07:00

179 lines
6.5 KiB
Rust

//! The state file: a first start, surviving a restart, its limits, and refusing a damaged file
//! instead of guessing (M4a spec, section 9). Do not edit.
#[path = "support/tmp.rs"]
mod tmp;
use std::os::unix::fs::PermissionsExt;
use gatewayd::state::{InFlight, RECENT_KEPT, State, StateError, THREADS_KEPT};
use tmp::TempDir;
const CHAN: &str = "c0000000000000000000000000";
const DM: &str = "d0000000000000000000000000";
fn id(n: usize) -> String {
format!("p{n:025}")
}
fn turn(n: usize) -> InFlight {
InFlight {
session: format!("mm-{}", id(n)),
channel: DM.to_string(),
root: id(n),
}
}
#[test]
fn a_first_start_then_a_restart() {
let dir = TempDir::new("state-restart");
let path = dir.path().join("gateway/state.json");
let mut s = State::load(&path).unwrap();
assert!(!path.exists(), "loading writes nothing");
assert_eq!(
(s.since(CHAN), s.channels().len(), s.seen(&id(1))),
(None, 0, false)
);
s.handled(&id(1), CHAN, 2000).unwrap();
s.handled(&id(2), CHAN, 1500).unwrap();
s.mark(DM, 3000).unwrap();
s.mark(DM, 9000).unwrap();
s.join_thread(&id(1)).unwrap();
s.start_turn(turn(1)).unwrap();
s.start_turn(turn(2)).unwrap();
s.end_turn(&format!("mm-{}", id(2))).unwrap();
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
let dir_mode = std::fs::metadata(path.parent().unwrap())
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!((mode, dir_mode), (0o600, 0o700));
assert!(!dir.path().join("gateway/state.json.tmp").exists());
let mut again = State::load(&path).unwrap();
assert_eq!(again.since(CHAN), Some(2000), "the mark never moves back");
assert_eq!(
again.since(DM),
Some(3000),
"mark only sets a channel without one"
);
assert_eq!(again.channels(), [CHAN, DM]);
assert!(again.seen(&id(1)) && again.seen(&id(2)));
assert!(again.knows_thread(&id(1)) && !again.knows_thread(&id(2)));
assert_eq!(again.take_in_flight().unwrap(), [turn(1)]);
assert!(
State::load(&path)
.unwrap()
.take_in_flight()
.unwrap()
.is_empty(),
"taking is saved"
);
}
#[test]
fn only_the_newest_posts_and_threads_are_kept() {
let dir = TempDir::new("state-limits");
// A full file to start from: posts 0.. and threads 0.., at their limits.
let recent: Vec<String> = (0..RECENT_KEPT).map(id).collect();
let threads: Vec<String> = (0..THREADS_KEPT).map(id).collect();
let full =
serde_json::json!({"channels": {}, "recent": recent, "threads": threads, "in_flight": []});
let path = dir.write("state.json", &full.to_string());
let mut s = State::load(&path).unwrap();
s.handled(&id(RECENT_KEPT), CHAN, 1).unwrap();
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
s.handled(&id(RECENT_KEPT + 1), CHAN, 1).unwrap();
s.join_thread(&id(THREADS_KEPT)).unwrap();
s.join_thread(&id(3)).unwrap();
let s = State::load(&path).unwrap();
assert!(!s.seen(&id(0)) && !s.seen(&id(1)) && s.seen(&id(2)) && s.seen(&id(RECENT_KEPT + 1)));
assert!(!s.knows_thread(&id(0)) && s.knows_thread(&id(1)) && s.knows_thread(&id(THREADS_KEPT)));
let text = std::fs::read_to_string(&path).unwrap();
let v: serde_json::Value = serde_json::from_str(&text).unwrap();
assert_eq!(
v["recent"].as_array().unwrap().len(),
RECENT_KEPT,
"a repeat is not stored twice"
);
assert_eq!(v["threads"].as_array().unwrap().len(), THREADS_KEPT);
}
#[test]
fn a_damaged_file_stops_with_its_pointer() {
let dir = TempDir::new("state-damaged");
let good_turn = r#"{"session":"mm-p0000000000000000000000001","channel":"d0000000000000000000000000","root":"p0000000000000000000000001"}"#;
let cases = [
"".to_string(),
"{".to_string(),
"[]".to_string(),
r#"{"channels":{},"recent":[],"threads":[]}"#.to_string(),
r#"{"channels":{},"recent":[],"threads":[],"in_flight":[],"extra":1}"#.to_string(),
r#"{"channels":{"../x":1},"recent":[],"threads":[],"in_flight":[]}"#.to_string(),
r#"{"channels":{},"recent":["short"],"threads":[],"in_flight":[]}"#.to_string(),
r#"{"channels":{},"recent":[],"threads":["P0000000000000000000000000"],"in_flight":[]}"#
.to_string(),
format!(
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
good_turn.replace("mm-", "xx-")
),
format!(
r#"{{"channels":{{}},"recent":[],"threads":[],"in_flight":[{}]}}"#,
good_turn.replace("\"d0", "\"D0")
),
];
for (n, text) in cases.iter().enumerate() {
let path = dir.write(&format!("s{n}.json"), text);
match State::load(&path) {
Err(e @ StateError::Read(..)) => {
let message = e.to_string();
assert!(
message.starts_with(&path.display().to_string()),
"{message}"
);
assert!(
message.ends_with("\nsee docs/runbook.md#gateway-state-damaged"),
"{message}"
);
}
Err(e) => panic!("{text}: {e}"),
Ok(_) => panic!("accepted: {text}"),
}
}
let ok = dir.write(
"ok.json",
&format!(
r#"{{"channels":{{"{CHAN}":5}},"recent":[],"threads":[],"in_flight":[{good_turn}]}}"#
),
);
assert_eq!(State::load(&ok).unwrap().since(CHAN), Some(5));
std::fs::create_dir(dir.path().join("adir")).unwrap();
assert!(matches!(
State::load(&dir.path().join("adir")),
Err(StateError::Read(..))
));
}
#[test]
fn a_failed_write_is_an_error_and_keeps_the_old_file() {
let dir = TempDir::new("state-readonly");
let sub = dir.path().join("gateway");
let path = sub.join("state.json");
let mut s = State::load(&path).unwrap();
s.handled(&id(1), CHAN, 5).unwrap();
let before = std::fs::read(&path).unwrap();
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o500)).unwrap();
let got = s.handled(&id(2), CHAN, 6);
std::fs::set_permissions(&sub, std::fs::Permissions::from_mode(0o700)).unwrap();
let e = got.unwrap_err();
assert!(matches!(e, StateError::Write(..)), "{e}");
assert!(
e.to_string()
.ends_with("see docs/runbook.md#gateway-state-damaged"),
"{e}"
);
assert_eq!(std::fs::read(&path).unwrap(), before);
}