Files
kaptan/firmware/libs/kaptan-config-mavlink/src/service/session.rs
T

1222 lines
42 KiB
Rust

//! Lease-bound command service over the transport-neutral configuration core.
use core::num::NonZeroU32;
use kaptan_config::{
BeginError, CommitError, ConfigManager, ConfigModel, ConfigState, ConfigStore, FieldWriteError,
SetError,
};
#[cfg(test)]
use super::query::{ConfigInfo, DescriptorResponse, EnumOptionResponse, ValueResponse};
use super::{query::ServiceResponse, replay::RememberedResult};
#[cfg(test)]
use crate::command::Authorized;
use crate::{
command::{ConfigCommand, Principal, Target},
protocol::{ConfigReply, Operation, ProtocolError, ResultKind},
};
#[derive(Clone, Copy)]
pub(super) struct Session {
pub(super) id: u32,
pub(super) revision: u32,
}
/// Owns only the one GCS transaction lease and bounded protocol state.
///
/// This protocol never accepts raw frames or interprets transport authority
/// evidence. The composition owner admits a command, binds its principal, and
/// may expire the protocol on lease or link loss. The same owner retains the
/// one [`ConfigManager`] and one runtime log sink queue.
pub struct ConfigProtocol {
pub(super) session: Option<Session>,
/// Sole configuration principal admitted until explicit lease expiry.
pub(super) principal: Option<Principal>,
pub(super) next_session_id: Option<NonZeroU32>,
pub(super) target: Target,
/// Sole most-recent result retained for the admitted stop-and-wait client.
pub(super) remembered: Option<RememberedResult>,
pub(super) state: ConfigState,
}
impl ConfigProtocol {
/// Creates a custom configuration-protocol state machine.
pub const fn new(target: Target) -> Self {
Self {
session: None,
principal: None,
next_session_id: Some(NonZeroU32::MIN),
target,
remembered: None,
state: ConfigState::Active,
}
}
/// Dispatches one composition-admitted configuration command. The
/// `principal` isolates transaction ownership; the first accepted `Begin`
/// binds this protocol to that exact source until [`Self::expire`]. This
/// adapter neither authenticates the source nor decides whether the
/// transport may configure the board. The authoritative
/// `KAPTAN_CONFIG_RESULT` response remains the complete configuration
/// audit; generic human logging is intentionally independent.
///
/// An emitter error is a delivery failure, not an operation rollback. A
/// mutating command may already be durable and its sole most-recent result
/// is remembered before `emit` returns an error. Binding one stop-and-wait
/// client prevents another source from evicting that result; expiry clears
/// both the binding and replay state. Descriptor and enum-option queries
/// each emit exactly one response and are safe to retry by index.
pub fn dispatch_from_composition<C, S, F, E>(
&mut self,
manager: &mut ConfigManager<C, S>,
principal: Principal,
command: ConfigCommand,
mut emit: F,
) -> Result<(), E>
where
C: ConfigModel,
S: ConfigStore<C>,
F: FnMut(ServiceResponse) -> Result<(), E>,
{
self.refresh(manager);
self.dispatch_inner(manager, principal, command, &mut emit)
}
/// Dispatches a host/SITL test command through the same protocol path.
#[cfg(test)]
pub fn dispatch<C, S, F, E>(
&mut self,
manager: &mut ConfigManager<C, S>,
authorized: Authorized<ConfigCommand>,
emit: F,
) -> Result<(), E>
where
C: ConfigModel,
S: ConfigStore<C>,
F: FnMut(ServiceResponse) -> Result<(), E>,
{
let (principal, command) = authorized.into_parts();
self.dispatch_from_composition(manager, principal, command, emit)
}
/// Expires the admitted principal and drops its RAM transaction, if any.
pub fn expire<C, S>(&mut self, manager: &mut ConfigManager<C, S>) -> bool
where
C: ConfigModel,
S: ConfigStore<C>,
{
let had_session = self.session.take().is_some();
if had_session {
manager.abort();
}
let expired = self.principal.take().is_some();
self.remembered = None;
expired
}
fn dispatch_inner<C, S, F, E>(
&mut self,
manager: &mut ConfigManager<C, S>,
owner: Principal,
command: ConfigCommand,
emit: &mut F,
) -> Result<(), E>
where
C: ConfigModel,
S: ConfigStore<C>,
F: FnMut(ServiceResponse) -> Result<(), E>,
{
if command.target() != self.target {
return emit(ServiceResponse::Result(
self.rejected(command, ProtocolError::BadTarget),
));
}
if self.principal.is_some_and(|principal| principal != owner) {
return emit(ServiceResponse::Result(
self.rejected(command, ProtocolError::SessionBusy),
));
}
if let Some(cached) = self.cached(owner, command) {
return emit(ServiceResponse::Result(cached));
}
let response = match command {
ConfigCommand::InfoRequest { request_id, .. } => {
ServiceResponse::Info(self.info(manager, request_id))
}
ConfigCommand::DescriptorRequest {
request_id, index, ..
} => self
.descriptor_response(manager, request_id, index)
.map_or_else(
|| ServiceResponse::Result(self.rejected(command, ProtocolError::InvalidQuery)),
ServiceResponse::Descriptor,
),
ConfigCommand::EnumOptionRequest {
request_id,
descriptor_index,
option_index,
..
} => self
.enum_option_response(manager, request_id, descriptor_index, option_index)
.map_or_else(
|| ServiceResponse::Result(self.rejected(command, ProtocolError::InvalidQuery)),
ServiceResponse::EnumOption,
),
ConfigCommand::ValueRequest {
request_id,
session_id,
field,
source,
..
} => self.value_response(manager, owner, request_id, session_id, field, source),
ConfigCommand::Begin { .. } => {
ServiceResponse::Result(self.begin(manager, owner, command))
}
ConfigCommand::Set { .. }
| ConfigCommand::Validate { .. }
| ConfigCommand::Commit { .. }
| ConfigCommand::Abort { .. } => {
ServiceResponse::Result(self.in_session(manager, owner, command))
}
};
self.refresh(manager);
let response = match response {
ServiceResponse::Result(reply) => {
self.remember(owner, command, reply);
ServiceResponse::Result(reply)
}
response => response,
};
emit(response)
}
fn begin<C, S>(
&mut self,
manager: &mut ConfigManager<C, S>,
owner: Principal,
command: ConfigCommand,
) -> ConfigReply
where
C: ConfigModel,
S: ConfigStore<C>,
{
let ConfigCommand::Begin { request_id, .. } = command else {
return self.rejected(command, ProtocolError::Internal);
};
if let Some(session) = self.session {
return self.reply(
request_id,
session.id,
session.revision,
Operation::Begin,
ResultKind::Rejected,
Some(ProtocolError::SessionBusy),
);
}
let Some(id) = self.allocate_session_id() else {
return self.rejected(command, ProtocolError::Internal);
};
match manager.begin() {
Ok(()) => {
self.principal = Some(owner);
self.session = Some(Session { id, revision: 0 });
self.reply(
request_id,
id,
0,
Operation::Begin,
ResultKind::Accepted,
None,
)
}
Err(BeginError::TransactionActive) => {
self.rejected(command, ProtocolError::SessionBusy)
}
}
}
fn in_session<C, S>(
&mut self,
manager: &mut ConfigManager<C, S>,
owner: Principal,
command: ConfigCommand,
) -> ConfigReply
where
C: ConfigModel,
S: ConfigStore<C>,
{
let Some(session) = self.session else {
return self.rejected(command, ProtocolError::InvalidSession);
};
let session_id = command_session_id(command);
if session.id != session_id || self.principal != Some(owner) {
return self.rejected(command, ProtocolError::InvalidSession);
}
if let Some(expected_revision) = command_expected_revision(command)
&& expected_revision != session.revision
{
return self.rejected(command, ProtocolError::StaleRevision);
}
match command {
ConfigCommand::Set {
request_id,
field,
value,
..
} => {
let Some(next_revision) = session.revision.checked_add(1) else {
return self.rejected(command, ProtocolError::Internal);
};
match manager.set(field, value) {
Ok(_) => {
let Some(session) = self.session.as_mut() else {
return self.rejected(command, ProtocolError::Internal);
};
session.revision = next_revision;
let (session_id, revision) = (session.id, session.revision);
self.reply(
request_id,
session_id,
revision,
Operation::Set,
ResultKind::Accepted,
None,
)
}
Err(SetError::NoTransaction) => self.rejected(command, ProtocolError::BadState),
Err(SetError::Field(error)) => self.rejected(command, field_error(error)),
}
}
ConfigCommand::Validate { request_id, .. } => match manager.validate() {
Ok(()) => self.reply(
request_id,
session.id,
session.revision,
Operation::Validate,
ResultKind::Accepted,
None,
),
Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState),
Err(CommitError::InvalidCandidate(violation)) => {
self.rejected(command, ProtocolError::Violation(violation))
}
Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::Internal),
},
ConfigCommand::Commit { request_id, .. } => match manager.commit() {
Ok(()) => {
self.session = None;
self.refresh(manager);
self.reply(
request_id,
0,
session.revision,
Operation::Commit,
ResultKind::CommittedRestartRequired,
None,
)
}
Err(CommitError::NoTransaction) => self.rejected(command, ProtocolError::BadState),
Err(CommitError::InvalidCandidate(violation)) => {
self.rejected(command, ProtocolError::Violation(violation))
}
Err(CommitError::Store(_)) => self.rejected(command, ProtocolError::StorageFailure),
},
ConfigCommand::Abort { request_id, .. } => {
manager.abort();
self.session = None;
self.refresh(manager);
self.reply(
request_id,
0,
session.revision,
Operation::Abort,
ResultKind::Accepted,
None,
)
}
ConfigCommand::InfoRequest { .. }
| ConfigCommand::DescriptorRequest { .. }
| ConfigCommand::EnumOptionRequest { .. }
| ConfigCommand::ValueRequest { .. }
| ConfigCommand::Begin { .. } => self.rejected(command, ProtocolError::Internal),
}
}
fn allocate_session_id(&mut self) -> Option<u32> {
let id = self.next_session_id?;
self.next_session_id = id.get().checked_add(1).and_then(NonZeroU32::new);
Some(id.get())
}
fn refresh<C, S>(&mut self, manager: &ConfigManager<C, S>)
where
C: ConfigModel,
S: ConfigStore<C>,
{
self.state = manager.state();
}
pub(super) fn reply(
&self,
request_id: u32,
session_id: u32,
revision: u32,
operation: Operation,
result: ResultKind,
error: Option<ProtocolError>,
) -> ConfigReply {
ConfigReply {
request_id,
session_id,
revision,
operation,
result,
error,
state: self.state,
}
}
pub(super) fn rejected(&self, command: ConfigCommand, error: ProtocolError) -> ConfigReply {
self.reply(
command.request_id(),
command_session_id(command),
self.session.map_or(0, |session| session.revision),
operation(command),
ResultKind::Rejected,
Some(error),
)
}
pub(super) fn rejected_for(
&self,
request_id: u32,
session_id: u32,
error: ProtocolError,
) -> ConfigReply {
self.reply(
request_id,
session_id,
self.session.map_or(0, |session| session.revision),
Operation::Query,
ResultKind::Rejected,
Some(error),
)
}
}
fn command_session_id(command: ConfigCommand) -> u32 {
match command {
ConfigCommand::InfoRequest { .. }
| ConfigCommand::DescriptorRequest { .. }
| ConfigCommand::EnumOptionRequest { .. }
| ConfigCommand::Begin { .. } => 0,
ConfigCommand::ValueRequest { session_id, .. } => session_id,
ConfigCommand::Set { session_id, .. }
| ConfigCommand::Validate { session_id, .. }
| ConfigCommand::Commit { session_id, .. }
| ConfigCommand::Abort { session_id, .. } => session_id,
}
}
fn command_expected_revision(command: ConfigCommand) -> Option<u32> {
match command {
ConfigCommand::Set {
expected_revision, ..
}
| ConfigCommand::Validate {
expected_revision, ..
}
| ConfigCommand::Commit {
expected_revision, ..
} => Some(expected_revision),
ConfigCommand::InfoRequest { .. }
| ConfigCommand::DescriptorRequest { .. }
| ConfigCommand::EnumOptionRequest { .. }
| ConfigCommand::ValueRequest { .. }
| ConfigCommand::Begin { .. }
| ConfigCommand::Abort { .. } => None,
}
}
const fn operation(command: ConfigCommand) -> Operation {
match command {
ConfigCommand::InfoRequest { .. }
| ConfigCommand::DescriptorRequest { .. }
| ConfigCommand::EnumOptionRequest { .. }
| ConfigCommand::ValueRequest { .. } => Operation::Query,
ConfigCommand::Begin { .. } => Operation::Begin,
ConfigCommand::Set { .. } => Operation::Set,
ConfigCommand::Validate { .. } => Operation::Validate,
ConfigCommand::Commit { .. } => Operation::Commit,
ConfigCommand::Abort { .. } => Operation::Abort,
}
}
fn field_error(error: FieldWriteError) -> ProtocolError {
match error {
FieldWriteError::UnknownField(field) => ProtocolError::UnknownField(field),
FieldWriteError::WrongType { field, .. } => ProtocolError::TypeMismatch(field),
FieldWriteError::Violation(violation) => ProtocolError::Violation(violation),
}
}
#[cfg(test)]
mod tests {
use std::{cell::Cell, rc::Rc};
use kaptan_config::{
ConfigManager, ConfigModel, ConfigStore, ConfigValue, ConfigViolation, EnumOption,
FieldDescriptor, FieldId, FieldWriteError, ValidationRule,
};
use super::*;
use crate::{Authorized, ConfigCommand, ProtocolError, ResultKind, Target, ValueSource};
const VALUE: FieldId = FieldId::new(1);
const ENUM_OPTIONS: [EnumOption; 3] = [(0, "Zero"), (1, "One"), (2, "Two")];
const DESCRIPTOR: FieldDescriptor = FieldDescriptor {
id: VALUE,
display_key: "test.value",
kind: kaptan_config::ValueKind::U32,
minimum: Some(ConfigValue::U32(0)),
maximum: Some(ConfigValue::U32(10)),
enum_options: &ENUM_OPTIONS,
unit: "count",
};
#[derive(Clone)]
struct Candidate(u32);
impl ConfigModel for Candidate {
type Active = u32;
type Context = ();
const SCHEMA_ID: [u8; 16] = *b"CONFIGSERVICE-01";
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
(index == 0).then_some(&DESCRIPTOR)
}
fn field_count() -> u16 {
1
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
(id == VALUE).then_some(ConfigValue::U32(self.0))
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
(id == VALUE).then_some(ConfigValue::U32(*active))
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
if id != VALUE {
return Err(FieldWriteError::UnknownField(id));
}
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: kaptan_config::ValueKind::U32,
actual: value.kind(),
});
};
if value > 10 {
return Err(FieldWriteError::Violation(ConfigViolation::field(
id,
ValidationRule::AboveMaximum,
)));
}
self.0 = value;
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(self.0)
}
}
struct Store;
impl ConfigStore<Candidate> for Store {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
Ok(())
}
}
const fn multi_descriptor(id: u16) -> FieldDescriptor {
FieldDescriptor {
id: FieldId::new(id),
display_key: "test.multi",
kind: kaptan_config::ValueKind::U32,
minimum: None,
maximum: None,
enum_options: &[],
unit: "count",
}
}
const MULTI_DESCRIPTORS: [FieldDescriptor; 6] = [
multi_descriptor(1),
multi_descriptor(2),
multi_descriptor(3),
multi_descriptor(4),
multi_descriptor(5),
multi_descriptor(6),
];
#[derive(Clone)]
struct MultiCandidate([u32; 6]);
impl ConfigModel for MultiCandidate {
type Active = [u32; 6];
type Context = ();
const SCHEMA_ID: [u8; 16] = *b"CONFIGSERVICE-02";
fn descriptor(index: u16) -> Option<&'static FieldDescriptor> {
MULTI_DESCRIPTORS.get(usize::from(index))
}
fn field_count() -> u16 {
MULTI_DESCRIPTORS.len() as u16
}
fn field_value(&self, id: FieldId) -> Option<ConfigValue> {
id.get()
.checked_sub(1)
.and_then(|index| self.0.get(usize::from(index)))
.copied()
.map(ConfigValue::U32)
}
fn active_field_value(active: &Self::Active, id: FieldId) -> Option<ConfigValue> {
id.get()
.checked_sub(1)
.and_then(|index| active.get(usize::from(index)))
.copied()
.map(ConfigValue::U32)
}
fn set_field(&mut self, id: FieldId, value: ConfigValue) -> Result<(), FieldWriteError> {
let Some(index) = id.get().checked_sub(1).map(usize::from) else {
return Err(FieldWriteError::UnknownField(id));
};
let Some(field) = self.0.get_mut(index) else {
return Err(FieldWriteError::UnknownField(id));
};
let ConfigValue::U32(value) = value else {
return Err(FieldWriteError::WrongType {
field: id,
expected: kaptan_config::ValueKind::U32,
actual: value.kind(),
});
};
*field = value;
Ok(())
}
fn validate(&self, _: &Self::Context) -> Result<Self::Active, ConfigViolation> {
Ok(self.0)
}
}
struct MultiStore;
impl ConfigStore<MultiCandidate> for MultiStore {
type Error = ();
fn load(&mut self) -> Result<Option<MultiCandidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &MultiCandidate) -> Result<(), Self::Error> {
Ok(())
}
}
struct CountingStore(Rc<Cell<u32>>);
impl ConfigStore<Candidate> for CountingStore {
type Error = ();
fn load(&mut self) -> Result<Option<Candidate>, Self::Error> {
Ok(None)
}
fn persist(&mut self, _: &Candidate) -> Result<(), Self::Error> {
self.0.set(self.0.get() + 1);
Ok(())
}
}
/// Test-only composition root. Production code must retain these two
/// objects separately; this wrapper keeps protocol examples compact while
/// exercising the same externally-owned-manager dispatch API.
struct TestService {
manager: ConfigManager<Candidate, Store>,
protocol: ConfigProtocol,
}
impl TestService {
fn new(manager: ConfigManager<Candidate, Store>, target: Target) -> Self {
Self {
manager,
protocol: ConfigProtocol::new(target),
}
}
fn dispatch_all(
&mut self,
command: Authorized<ConfigCommand>,
) -> std::vec::Vec<ServiceResponse> {
let mut responses = std::vec::Vec::new();
self.protocol
.dispatch(&mut self.manager, command, |response| {
responses.push(response);
Ok::<(), core::convert::Infallible>(())
})
.unwrap();
responses
}
fn dispatch(&mut self, command: Authorized<ConfigCommand>) -> ServiceResponse {
let mut responses = self.dispatch_all(command);
assert_eq!(responses.len(), 1, "test expected one service response");
responses.pop().unwrap()
}
}
fn result(response: ServiceResponse) -> ConfigReply {
match response {
ServiceResponse::Result(reply) => reply,
ServiceResponse::Info(_)
| ServiceResponse::Descriptor(_)
| ServiceResponse::EnumOption(_)
| ServiceResponse::Value(_) => {
panic!("test expected a result response")
}
}
}
#[test]
fn retries_are_idempotent_and_wrong_owner_cannot_mutate() {
let manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let begin = ConfigCommand::Begin {
request_id: 1,
target: Target::new(1, 1),
};
let started = result(service.dispatch(Authorized::host(7, begin)));
assert_eq!(started.result, ResultKind::Accepted);
let set = ConfigCommand::Set {
request_id: 2,
session_id: started.session_id,
expected_revision: 0,
field: VALUE,
value: ConfigValue::U32(3),
target: Target::new(1, 1),
};
let accepted = result(service.dispatch(Authorized::host(7, set)));
assert_eq!(accepted.result, ResultKind::Accepted);
assert_eq!(result(service.dispatch(Authorized::host(7, set))), accepted);
let conflict = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Set {
request_id: 2,
session_id: started.session_id,
expected_revision: 0,
field: VALUE,
value: ConfigValue::U32(4),
target: Target::new(1, 1),
},
)));
assert_eq!(conflict.error, Some(ProtocolError::RequestConflict));
assert_eq!(
service.manager.transaction_value(VALUE),
Some(ConfigValue::U32(3))
);
let foreign = result(service.dispatch(Authorized::host(
8,
ConfigCommand::Set {
request_id: 3,
session_id: started.session_id,
expected_revision: 1,
field: VALUE,
value: ConfigValue::U32(3),
target: Target::new(1, 1),
},
)));
assert_eq!(foreign.error, Some(ProtocolError::SessionBusy));
}
#[test]
fn only_the_most_recent_stop_and_wait_result_is_replayed() {
let manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let begin = ConfigCommand::Begin {
request_id: 1,
target: Target::new(1, 1),
};
let started = result(service.dispatch(Authorized::host(7, begin)));
let set = ConfigCommand::Set {
request_id: 2,
session_id: started.session_id,
expected_revision: 0,
field: VALUE,
value: ConfigValue::U32(3),
target: Target::new(1, 1),
};
assert_eq!(
result(service.dispatch(Authorized::host(7, set))).result,
ResultKind::Accepted
);
// Advancing a stop-and-wait client replaces the preceding result.
// Reusing that older command is handled from current state instead of
// retaining an unbounded request history.
assert_eq!(
result(service.dispatch(Authorized::host(7, begin))).error,
Some(ProtocolError::SessionBusy)
);
}
#[test]
fn busy_owner_can_identify_and_abort_its_abandoned_session() {
let manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let started = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Begin {
request_id: 1,
target: Target::new(1, 1),
},
)));
let owned_busy = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Begin {
request_id: 2,
target: Target::new(1, 1),
},
)));
assert_eq!(owned_busy.error, Some(ProtocolError::SessionBusy));
assert_eq!(owned_busy.session_id, started.session_id);
let foreign_busy = result(service.dispatch(Authorized::host(
8,
ConfigCommand::Begin {
request_id: 3,
target: Target::new(1, 1),
},
)));
assert_eq!(foreign_busy.error, Some(ProtocolError::SessionBusy));
assert_eq!(foreign_busy.session_id, 0);
let aborted = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Abort {
request_id: 4,
session_id: owned_busy.session_id,
target: Target::new(1, 1),
},
)));
assert_eq!(aborted.result, ResultKind::Accepted);
}
#[test]
fn exact_validation_issue_reaches_result() {
let manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let begin = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Begin {
request_id: 1,
target: Target::new(1, 1),
},
)));
let rejected = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Set {
request_id: 2,
session_id: begin.session_id,
expected_revision: 0,
field: VALUE,
value: ConfigValue::U32(11),
target: Target::new(1, 1),
},
)));
assert!(
matches!(rejected.error, Some(ProtocolError::Violation(issue)) if issue.rule() == ValidationRule::AboveMaximum)
);
}
#[test]
fn discovery_and_value_reads_are_service_responses() {
let manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let descriptors = service.dispatch(Authorized::host(
7,
ConfigCommand::DescriptorRequest {
request_id: 1,
index: 0,
target: Target::new(1, 1),
},
));
assert!(matches!(
descriptors,
ServiceResponse::Descriptor(DescriptorResponse {
index: 0,
descriptor: DESCRIPTOR,
..
})
));
let value = service.dispatch(Authorized::host(
7,
ConfigCommand::ValueRequest {
request_id: 2,
session_id: 0,
field: VALUE,
source: ValueSource::Active,
target: Target::new(1, 1),
},
));
assert!(matches!(
value,
ServiceResponse::Value(ValueResponse {
value: ConfigValue::U32(2),
..
})
));
let info = service.dispatch(Authorized::host(
7,
ConfigCommand::InfoRequest {
request_id: 3,
target: Target::new(1, 1),
},
));
assert!(matches!(
info,
ServiceResponse::Info(ConfigInfo {
request_id: 3,
field_count: 1,
..
})
));
}
#[test]
fn descriptors_advertise_the_composition_factory_not_a_schema_fallback() {
let manager = ConfigManager::new(Candidate(7), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let response = service.dispatch(Authorized::host(
7,
ConfigCommand::DescriptorRequest {
request_id: 1,
index: 0,
target: Target::new(1, 1),
},
));
let ServiceResponse::Descriptor(response) = response else {
panic!("expected descriptor response");
};
assert_eq!(response.default, ConfigValue::U32(7));
}
#[test]
fn enum_options_are_explicitly_indexed_and_retry_the_rejected_request() {
let mut manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut protocol = ConfigProtocol::new(Target::new(1, 1));
let command = ConfigCommand::EnumOptionRequest {
request_id: 10,
descriptor_index: 0,
option_index: 0,
target: Target::new(1, 1),
};
let mut rejected = None;
assert_eq!(
protocol.dispatch(&mut manager, Authorized::host(7, command), |response| {
rejected = Some(response);
Err(())
}),
Err(())
);
assert!(matches!(
rejected,
Some(ServiceResponse::EnumOption(EnumOptionResponse {
option_index: 0,
option: (0, "Zero"),
..
}))
));
let mut retry = std::vec::Vec::new();
protocol
.dispatch(&mut manager, Authorized::host(7, command), |response| {
retry.push(response);
Ok::<(), core::convert::Infallible>(())
})
.unwrap();
assert_eq!(retry.len(), 1, "one request identifies one option");
assert!(matches!(
retry[0],
ServiceResponse::EnumOption(EnumOptionResponse {
option_index: 0,
option: (0, "Zero"),
..
})
));
let next = ConfigCommand::EnumOptionRequest {
request_id: 11,
descriptor_index: 0,
option_index: 1,
target: Target::new(1, 1),
};
let mut second = None;
protocol
.dispatch(&mut manager, Authorized::host(7, next), |response| {
second = Some(response);
Ok::<(), core::convert::Infallible>(())
})
.unwrap();
assert!(matches!(
second,
Some(ServiceResponse::EnumOption(EnumOptionResponse {
option_index: 1,
option: (1, "One"),
..
}))
));
}
#[test]
fn descriptor_queries_are_bounded_ordered_and_stop_at_the_schema_end() {
let mut manager = ConfigManager::new(MultiCandidate([0; 6]), (), MultiStore).unwrap();
let mut protocol = ConfigProtocol::new(Target::new(1, 1));
let mut indices = std::vec::Vec::new();
protocol
.dispatch(
&mut manager,
Authorized::host(
7,
ConfigCommand::DescriptorRequest {
request_id: 1,
index: 1,
target: Target::new(1, 1),
},
),
|response| {
let ServiceResponse::Descriptor(response) = response else {
panic!("expected a descriptor response");
};
indices.push(response.index);
Ok::<(), core::convert::Infallible>(())
},
)
.unwrap();
assert_eq!(indices, [1]);
indices.clear();
protocol
.dispatch(
&mut manager,
Authorized::host(
7,
ConfigCommand::DescriptorRequest {
request_id: 2,
index: 4,
target: Target::new(1, 1),
},
),
|response| {
let ServiceResponse::Descriptor(response) = response else {
panic!("expected a descriptor response");
};
indices.push(response.index);
Ok::<(), core::convert::Infallible>(())
},
)
.unwrap();
assert_eq!(indices, [4]);
}
#[test]
fn descriptor_request_retries_the_same_index_after_delivery_fails() {
let mut manager = ConfigManager::new(MultiCandidate([0; 6]), (), MultiStore).unwrap();
let mut protocol = ConfigProtocol::new(Target::new(1, 1));
let command = ConfigCommand::DescriptorRequest {
request_id: 1,
index: 0,
target: Target::new(1, 1),
};
let mut indices = std::vec::Vec::new();
let failure = protocol.dispatch(&mut manager, Authorized::host(7, command), |response| {
let ServiceResponse::Descriptor(response) = response else {
panic!("expected a descriptor response");
};
indices.push(response.index);
Err(())
});
assert_eq!(failure, Err(()));
assert_eq!(indices, [0]);
indices.clear();
protocol
.dispatch(&mut manager, Authorized::host(7, command), |response| {
let ServiceResponse::Descriptor(response) = response else {
panic!("expected a descriptor response");
};
indices.push(response.index);
Ok::<(), core::convert::Infallible>(())
})
.unwrap();
assert_eq!(indices, [0]);
indices.clear();
protocol
.dispatch(
&mut manager,
Authorized::host(
7,
ConfigCommand::DescriptorRequest {
request_id: 2,
index: 1,
target: Target::new(1, 1),
},
),
|response| {
let ServiceResponse::Descriptor(response) = response else {
panic!("expected a descriptor response");
};
indices.push(response.index);
Ok::<(), core::convert::Infallible>(())
},
)
.unwrap();
assert_eq!(indices, [1]);
}
#[test]
fn invalid_descriptor_index_is_a_structured_query_error() {
let manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let reply = result(service.dispatch(Authorized::host(
7,
ConfigCommand::DescriptorRequest {
request_id: 1,
index: 1,
target: Target::new(1, 1),
},
)));
assert_eq!(reply.error, Some(ProtocolError::InvalidQuery));
}
#[test]
fn a_lost_commit_response_does_not_repeat_flash_persistence() {
let persists = Rc::new(Cell::new(0));
let store = CountingStore(Rc::clone(&persists));
let mut manager = ConfigManager::new(Candidate(2), (), store).unwrap();
let mut protocol = ConfigProtocol::new(Target::new(1, 1));
let mut begin_reply = None;
protocol
.dispatch(
&mut manager,
Authorized::host(
7,
ConfigCommand::Begin {
request_id: 1,
target: Target::new(1, 1),
},
),
|response| {
begin_reply = Some(result(response));
Ok::<(), core::convert::Infallible>(())
},
)
.unwrap();
let begin_reply = begin_reply.unwrap();
let commit = ConfigCommand::Commit {
request_id: 2,
session_id: begin_reply.session_id,
expected_revision: 0,
target: Target::new(1, 1),
};
assert_eq!(
protocol.dispatch(&mut manager, Authorized::host(7, commit), |_| Err(())),
Err(())
);
assert_eq!(persists.get(), 1);
let foreign = ConfigCommand::Begin {
request_id: 3,
target: Target::new(1, 1),
};
let mut foreign_reply = None;
protocol
.dispatch(&mut manager, Authorized::host(8, foreign), |response| {
foreign_reply = Some(result(response));
Ok::<(), core::convert::Infallible>(())
})
.unwrap();
assert_eq!(
foreign_reply.unwrap().error,
Some(ProtocolError::SessionBusy)
);
let mut retry_reply = None;
protocol
.dispatch(&mut manager, Authorized::host(7, commit), |response| {
retry_reply = Some(result(response));
Ok::<(), core::convert::Infallible>(())
})
.unwrap();
assert_eq!(persists.get(), 1);
assert_eq!(
retry_reply.unwrap().result,
ResultKind::CommittedRestartRequired
);
// Expiry releases the principal even though COMMIT already closed its
// RAM transaction, allowing the next direct-link configurator in.
assert!(protocol.expire(&mut manager));
let mut next_owner = None;
protocol
.dispatch(&mut manager, Authorized::host(8, foreign), |response| {
next_owner = Some(result(response));
Ok::<(), core::convert::Infallible>(())
})
.unwrap();
assert_eq!(next_owner.unwrap().result, ResultKind::Accepted);
}
#[test]
fn configuration_result_remains_the_structured_failure_authority() {
let manager = ConfigManager::new(Candidate(2), (), Store).unwrap();
let mut service = TestService::new(manager, Target::new(1, 1));
let begin = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Begin {
request_id: 1,
target: Target::new(1, 1),
},
)));
let rejected = result(service.dispatch(Authorized::host(
7,
ConfigCommand::Set {
request_id: 2,
session_id: begin.session_id,
expected_revision: 0,
field: FieldId::new(2),
value: ConfigValue::U32(3),
target: Target::new(1, 1),
},
)));
assert_eq!(
rejected.error,
Some(ProtocolError::UnknownField(FieldId::new(2)))
);
assert_eq!(rejected.request_id, 2);
assert_eq!(rejected.primary_field(), FieldId::new(2).get());
}
}