Files
smarm/tests/cluster_envelope.rs
T
claude-asm-audit 4c0e42152f feat(cluster): RFC 010 c10–c16, follow-ups and Phase 6 (squash of 16ef583..d9c62a8)
Tree snapshot of d9c62a8 (2026-08-18). The 20 source commits between
16ef583 (c9) and d9c62a8 were never pushed and the clone that held them
was lost; this commit carries their combined tree verbatim so the build
history stays auditable from the c1–c9 commits below it. Original
hashes as recorded in the session handoff:

  c10  f03e94d  pid targeting + auto-serialization (RemotePid, D14 name
                on the wire); Phase 3 gate
  c11  7ef4bad  DownReason::Disconnected, wire tag 5
  c12  d124162  remote monitors (Monitor/Demonitor/Down frames)
  c13  9de967b  connection-loss synthesis (A+B: Monitors::teardown +
                unread-command Disconnected); Phase 4 gate
  c14  7e822b7  eager pg eviction (reaper actor, ReaperInboxes)
       dbe1a22  InboundVerdict::label(), trace::Event::ClusterInbound
       31a9877  tests/channel.rs monitor-churn target gated on `go`
       653559e  Discovery::Withdrawn{name, addr}
  c15  b41d76e  distributed pg: Sync on NodeUp, Join/Leave broadcast,
                NodeDown sweep, members_all; PgMsg wire type
  c16  fafa881  pick_any / dispatch_any; Phase 5 complete
  Phase 6 Tier A:
       195c73e  p4  NodeEvent::NodeDown(NodeInfo)
       48fd766  p1  connector Candidate{name, addr, state}
       ce8cf99  p2+p7 conn.rs select arms as Vec<Arm>; Outbound::Drained
       bf24988  p6  RemotePid::from_local -> Option
       9ae0380  p3  PeerStanding{Free, Claimed, Dialing}
       c7d62a1  p11 cluster::Timing knobs, threaded by value
       46f171d  p11 cluster_disconnect un-ignored on SMARM_FAST_TIMING
  Phase 6 Tier B:
       391a9ae  p5  cluster::RemoteDownReason{Local, Disconnected};
                    DownReason::Disconnected removed from core
       7ddd908  p9  pg ctl channel unconditional, one cfg seam at spawn
       d9c62a8      PeerNameMismatch parks the candidate; ClusterDial trace

Verified at d9c62a8: default 361/0, cluster 448/0, clippy --lib on
default / cluster / cluster+smarm-trace, fmt, 10x flake on
cluster_dial_mismatch, 5x on cluster_pg.
2026-08-18 16:00:00 +02:00

277 lines
7.9 KiB
Rust

//! RFC 010 c2 — owned envelope tests (roadmap: per-frame roundtrip,
//! truncation mid-field, unknown tag, length prefix lying long and short,
//! zero-length payload, adversarial lengths).
#![cfg(feature = "cluster")]
use serde::{Deserialize, Serialize};
use smarm::cluster::envelope::{
decode_payload, encode_payload, DecodeError, Frame, NodeMeta, RejectReason, MAX_FRAME_LEN,
PROTO_VERSION,
};
use smarm::cluster::RemoteDownReason;
use smarm::monitor::DownReason;
use smarm::pg::Incarnation;
fn meta() -> NodeMeta {
NodeMeta {
role: "worker".into(),
region: "eu-west".into(),
}
}
fn all_frames() -> Vec<Frame> {
vec![
Frame::Hello {
proto_version: PROTO_VERSION,
build_hash: 0xDEAD_BEEF_CAFE_F00D,
node_name: "alpha".into(),
incarnation: Incarnation::new(7),
meta: meta(),
},
Frame::HelloAck {
node_name: "beta".into(),
incarnation: Incarnation::new(9),
meta: meta(),
},
Frame::HelloReject {
reason: RejectReason::NameTaken,
},
Frame::Heartbeat,
Frame::Send {
index: 42,
generation: 3,
type_hash: 0x1234_5678_9ABC_DEF0,
payload: vec![1, 2, 3, 4, 5],
},
Frame::SendNamed {
name: "the_counter".into(),
type_hash: 0xFFFF_0000_FFFF_0000,
payload: vec![],
},
Frame::Monitor {
monitor_id: 77,
index: 42,
generation: 3,
},
Frame::Demonitor { monitor_id: 77 },
Frame::Down {
monitor_id: 77,
reason: RemoteDownReason::Local(DownReason::Panic),
},
Frame::Down {
monitor_id: 78,
reason: RemoteDownReason::Disconnected,
},
]
}
fn encode_one(f: &Frame) -> Vec<u8> {
let mut buf = Vec::new();
f.encode(&mut buf).unwrap();
buf
}
#[test]
fn per_frame_roundtrip() {
for f in all_frames() {
let buf = encode_one(&f);
let (decoded, consumed) = Frame::decode(&buf).unwrap().unwrap();
assert_eq!(decoded, f, "roundtrip mismatch");
assert_eq!(consumed, buf.len(), "consumed != buffer length for {f:?}");
}
}
#[test]
fn back_to_back_frames_decode_sequentially() {
let mut buf = Vec::new();
for f in all_frames() {
f.encode(&mut buf).unwrap();
}
let mut off = 0;
let mut decoded = Vec::new();
while off < buf.len() {
let (f, n) = Frame::decode(&buf[off..]).unwrap().unwrap();
decoded.push(f);
off += n;
}
assert_eq!(decoded, all_frames());
assert_eq!(off, buf.len());
}
#[test]
fn heartbeat_golden_bytes() {
// Locks the layout: u32 LE length prefix, then the tag byte.
let buf = encode_one(&Frame::Heartbeat);
assert_eq!(buf, vec![1, 0, 0, 0, 4]);
}
#[test]
fn zero_length_payload_roundtrips() {
let f = Frame::Send {
index: 0,
generation: 0,
type_hash: 0,
payload: vec![],
};
let buf = encode_one(&f);
let (decoded, consumed) = Frame::decode(&buf).unwrap().unwrap();
assert_eq!(decoded, f);
assert_eq!(consumed, buf.len());
}
#[test]
fn incomplete_is_none_not_error() {
let buf = encode_one(&all_frames()[0]);
// Every strict prefix short of the full frame must report "need more".
for cut in 0..buf.len() {
assert_eq!(
Frame::decode(&buf[..cut]).unwrap(),
None,
"cut at {cut} should be incomplete"
);
}
}
#[test]
fn unknown_frame_tag() {
let buf = vec![1, 0, 0, 0, 250];
assert_eq!(Frame::decode(&buf), Err(DecodeError::UnknownTag(250)));
}
#[test]
fn unknown_enum_tags() {
// HelloReject with a bogus reason tag.
let buf = vec![2, 0, 0, 0, 3, 99];
assert_eq!(
Frame::decode(&buf),
Err(DecodeError::UnknownEnumTag {
what: "RejectReason",
tag: 99
})
);
// Down with a bogus reason tag (id = 0u64).
let mut buf = vec![10, 0, 0, 0, 9];
buf.extend_from_slice(&0u64.to_le_bytes());
buf.push(200);
assert_eq!(
Frame::decode(&buf),
Err(DecodeError::UnknownEnumTag {
what: "RemoteDownReason",
tag: 200
})
);
}
#[test]
fn length_prefix_lying_long_with_bytes_present_is_trailing() {
let mut buf = encode_one(&Frame::Heartbeat);
// Declare 3 extra body bytes and actually supply them.
let declared = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) + 3;
buf[0..4].copy_from_slice(&declared.to_le_bytes());
buf.extend_from_slice(&[0xAA, 0xBB, 0xCC]);
assert_eq!(Frame::decode(&buf), Err(DecodeError::Trailing { extra: 3 }));
}
#[test]
fn length_prefix_lying_long_without_bytes_is_incomplete() {
// Indistinguishable from a partial read — must be None, not an error.
let mut buf = encode_one(&Frame::Heartbeat);
let declared = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]) + 3;
buf[0..4].copy_from_slice(&declared.to_le_bytes());
assert_eq!(Frame::decode(&buf).unwrap(), None);
}
#[test]
fn length_prefix_lying_short_truncates_a_field() {
let f = &all_frames()[0]; // Hello: plenty of fields to cut into
let mut buf = encode_one(f);
let declared = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]);
let lie = declared - 4; // cut mid-field
buf[0..4].copy_from_slice(&lie.to_le_bytes());
assert_eq!(Frame::decode(&buf), Err(DecodeError::Truncated));
}
#[test]
fn truncation_mid_string_field() {
// A frame whose declared length is intact but whose inner string length
// runs past the body: SendNamed claiming a 1000-byte name in a tiny body.
let mut body = vec![6u8]; // TAG_SEND_NAMED
body.extend_from_slice(&1000u16.to_le_bytes());
body.extend_from_slice(b"short");
let mut buf = (body.len() as u32).to_le_bytes().to_vec();
buf.extend_from_slice(&body);
assert_eq!(Frame::decode(&buf), Err(DecodeError::Truncated));
}
#[test]
fn adversarial_lengths() {
// Length prefix of u32::MAX: reject as oversized, do not wait for 4 GiB.
let buf = [0xFF, 0xFF, 0xFF, 0xFF, 0];
assert_eq!(
Frame::decode(&buf),
Err(DecodeError::FrameTooLarge {
declared: u32::MAX as usize
})
);
// Just over the cap: also rejected.
let over = (MAX_FRAME_LEN as u32 + 1).to_le_bytes();
assert!(matches!(
Frame::decode(&over),
Err(DecodeError::FrameTooLarge { .. })
));
// Zero-length frame: there is no tag byte; corrupt, not incomplete.
let buf = [0, 0, 0, 0];
assert_eq!(Frame::decode(&buf), Err(DecodeError::EmptyFrame));
}
#[test]
fn invalid_utf8_in_string_field() {
let mut buf = encode_one(&Frame::SendNamed {
name: "abcd".into(),
type_hash: 0,
payload: vec![],
});
// name bytes start after: 4 (len) + 1 (tag) + 2 (str len) = offset 7
buf[7] = 0xFF;
assert_eq!(Frame::decode(&buf), Err(DecodeError::Utf8));
}
#[derive(Debug, PartialEq, Serialize, Deserialize)]
struct Ping {
seq: u64,
label: String,
}
#[test]
fn payload_seam_roundtrip() {
let ping = Ping {
seq: 31337,
label: "hello".into(),
};
let blob = encode_payload(&ping).unwrap();
// Carry it through a real frame, as it will travel in c9.
let f = Frame::Send {
index: 1,
generation: 1,
type_hash: 0xABCD,
payload: blob,
};
let buf = encode_one(&f);
let (decoded, _) = Frame::decode(&buf).unwrap().unwrap();
let Frame::Send { payload, .. } = decoded else {
panic!("wrong frame");
};
let back: Ping = decode_payload(&payload).unwrap();
assert_eq!(back, ping);
}
#[test]
fn payload_seam_rejects_truncated_blob() {
let blob = encode_payload(&Ping {
seq: 1,
label: "x".into(),
})
.unwrap();
assert!(decode_payload::<Ping>(&blob[..blob.len() - 1]).is_err());
}