1222 lines
42 KiB
Rust
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());
|
|
}
|
|
}
|