[M4] mxaccess: RecoveryPolicy fields + SessionOptions config
M4 wave 1 prep — the design-pivotal small types per dependencies.md
("(b) is small but design-pivotal — agree the event shape before
consumers depend on it"). The actual Session implementation lands
next iteration as wave 1 main (the .NET MxNativeSession.cs is ~24 KB).
RecoveryPolicy
- Was a unit struct; now carries max_attempts: u32 + delay: Duration
(port of MxNativeRecoveryPolicy at MxNativeSession.cs:24-43).
- SINGLE_ATTEMPT associated const matches the .NET static at cs:26.
- validate() rejects max_attempts == 0 (cs:33-36); the negative-Delay
branch (cs:38-41) is unreachable in Rust because Duration is
unsigned, so it's elided with a doc note.
- Default impl now returns SINGLE_ATTEMPT (was derive Default which
zero-initialised).
SessionOptions (new — port of MxNativeClientOptions at cs:7-22)
- local_engine_id, engine_name, partner_version, galaxy_id,
source_platform_id, heartbeat_ticks_per_beat: Option<i32>,
heartbeat_max_missed_ticks.
- default_local_engine_id() constructor: 0x7000 + (process_id & 0x0FFF)
per GenerateDefaultLocalEngineId at cs:18-21.
- default_engine_name(): "mxaccess.<pid>" mirroring the .NET
"MxNativeClient.{ProcessId}" at cs:10.
- partner_version=6 default matches design/60-roadmap.md:54 DoD #1.
Test count delta: 468 -> 479 (+11). All four DoD gates green.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -86,8 +86,52 @@ pub struct TransportCapabilities {
|
|||||||
pub operation_complete_frame: bool,
|
pub operation_complete_frame: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Default)]
|
/// Reconnect / recover policy. Mirrors `MxNativeRecoveryPolicy`
|
||||||
pub struct RecoveryPolicy;
|
/// (`MxNativeSession.cs:24-43`).
|
||||||
|
///
|
||||||
|
/// `Default` and [`RecoveryPolicy::SINGLE_ATTEMPT`] both produce one
|
||||||
|
/// attempt with zero delay — same as the .NET reference's default
|
||||||
|
/// `SingleAttempt` static (`cs:26`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub struct RecoveryPolicy {
|
||||||
|
/// Total attempts before giving up (≥ 1). The .NET reference defaults
|
||||||
|
/// to 1 (`cs:28`); `validate()` rejects 0 (`cs:33-36`).
|
||||||
|
pub max_attempts: u32,
|
||||||
|
/// Delay between attempts. Must be non-negative — enforced by
|
||||||
|
/// `Duration` (`cs:38-41`).
|
||||||
|
pub delay: Duration,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RecoveryPolicy {
|
||||||
|
/// Single-attempt policy — `MxNativeRecoveryPolicy.SingleAttempt`
|
||||||
|
/// (`cs:26`).
|
||||||
|
pub const SINGLE_ATTEMPT: RecoveryPolicy = RecoveryPolicy {
|
||||||
|
max_attempts: 1,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Validate the policy. Mirrors `Validate()` (`cs:31-42`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// [`ConfigError::InvalidArgument`] when `max_attempts == 0`. The
|
||||||
|
/// .NET reference also checks `Delay < TimeSpan.Zero`; the Rust
|
||||||
|
/// `Duration` type makes that case unreachable so the check is
|
||||||
|
/// elided.
|
||||||
|
pub fn validate(&self) -> Result<(), ConfigError> {
|
||||||
|
if self.max_attempts < 1 {
|
||||||
|
return Err(ConfigError::InvalidArgument {
|
||||||
|
detail: "max_attempts must be at least 1".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for RecoveryPolicy {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::SINGLE_ATTEMPT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Not `Clone` — `Error` is not `Clone`-able (thiserror chains an
|
/// Not `Clone` — `Error` is not `Clone`-able (thiserror chains an
|
||||||
/// `io::Error` source which is not `Clone`). Consumers that need to clone an
|
/// `io::Error` source which is not `Clone`). Consumers that need to clone an
|
||||||
@@ -110,6 +154,77 @@ pub enum RecoveryEvent {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Session-level configuration. Mirrors `MxNativeClientOptions`
|
||||||
|
/// (`MxNativeSession.cs:7-22`).
|
||||||
|
///
|
||||||
|
/// All defaults match the .NET reference exactly:
|
||||||
|
///
|
||||||
|
/// - `local_engine_id`: `0x7000 + (process_id & 0x0FFF)` per
|
||||||
|
/// `GenerateDefaultLocalEngineId` (`cs:18-21`). The 12-bit PID slot
|
||||||
|
/// keeps the engine id stable across runs of the same process while
|
||||||
|
/// avoiding collisions with other clients on the box.
|
||||||
|
/// - `engine_name`: `"mxaccess.<pid>"` mirroring
|
||||||
|
/// `"MxNativeClient.{Environment.ProcessId}"` (`cs:10`).
|
||||||
|
/// - `partner_version`: `6` (`cs:11`) — matches the value the live
|
||||||
|
/// probe expects from `INmxService2::GetPartnerVersion` per
|
||||||
|
/// `design/60-roadmap.md:54`.
|
||||||
|
/// - `galaxy_id`: `1` (`cs:12`).
|
||||||
|
/// - `source_platform_id`: `1` (`cs:13`).
|
||||||
|
/// - `heartbeat_max_missed_ticks`: `3` (`cs:16`).
|
||||||
|
///
|
||||||
|
/// `heartbeat_ticks_per_beat` defaults to `None` — the .NET reference
|
||||||
|
/// defaults to `null` (`cs:15`) which means "skip the
|
||||||
|
/// `SetHeartbeatSendInterval` call entirely." When `Some(n)`, M4's
|
||||||
|
/// `Session::connect` will issue a heartbeat-config call after
|
||||||
|
/// `RegisterEngine2`.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub struct SessionOptions {
|
||||||
|
pub local_engine_id: i32,
|
||||||
|
pub engine_name: String,
|
||||||
|
pub partner_version: i32,
|
||||||
|
pub galaxy_id: u8,
|
||||||
|
pub source_platform_id: i32,
|
||||||
|
pub heartbeat_ticks_per_beat: Option<i32>,
|
||||||
|
pub heartbeat_max_missed_ticks: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionOptions {
|
||||||
|
/// Build the default `local_engine_id`. Mirrors
|
||||||
|
/// `GenerateDefaultLocalEngineId` (`MxNativeSession.cs:18-21`).
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// Never fails; the platform PID is always representable.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_local_engine_id() -> i32 {
|
||||||
|
// Cast through i32 to mirror the .NET `int` width. PIDs below
|
||||||
|
// 2^31 (effectively all real PIDs) fit losslessly.
|
||||||
|
let pid = std::process::id() as i32;
|
||||||
|
0x7000 + (pid & 0x0FFF)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the default `engine_name`. Mirrors `MxNativeClient.{ProcessId}`
|
||||||
|
/// (`MxNativeSession.cs:10`) but lowercased to match Rust naming
|
||||||
|
/// conventions for client-side advertised names.
|
||||||
|
#[must_use]
|
||||||
|
pub fn default_engine_name() -> String {
|
||||||
|
format!("mxaccess.{}", std::process::id())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for SessionOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
local_engine_id: Self::default_local_engine_id(),
|
||||||
|
engine_name: Self::default_engine_name(),
|
||||||
|
partner_version: 6,
|
||||||
|
galaxy_id: 1,
|
||||||
|
source_platform_id: 1,
|
||||||
|
heartbeat_ticks_per_beat: None,
|
||||||
|
heartbeat_max_missed_ticks: 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- Error taxonomy ------------------------------------------------------
|
// ---- Error taxonomy ------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, thiserror::Error)]
|
#[derive(Debug, thiserror::Error)]
|
||||||
@@ -345,3 +460,132 @@ impl Session {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
#[allow(
|
||||||
|
clippy::unwrap_used,
|
||||||
|
clippy::expect_used,
|
||||||
|
clippy::indexing_slicing,
|
||||||
|
clippy::panic
|
||||||
|
)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
// ---- RecoveryPolicy ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_default_is_single_attempt() {
|
||||||
|
let p = RecoveryPolicy::default();
|
||||||
|
assert_eq!(p, RecoveryPolicy::SINGLE_ATTEMPT);
|
||||||
|
assert_eq!(p.max_attempts, 1);
|
||||||
|
assert_eq!(p.delay, Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_single_attempt_const_matches_dotnet() {
|
||||||
|
// .NET MxNativeRecoveryPolicy.SingleAttempt = new(): max_attempts=1,
|
||||||
|
// Delay=TimeSpan.Zero. cs:26-29.
|
||||||
|
assert_eq!(RecoveryPolicy::SINGLE_ATTEMPT.max_attempts, 1);
|
||||||
|
assert_eq!(RecoveryPolicy::SINGLE_ATTEMPT.delay, Duration::ZERO);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_validate_accepts_valid() {
|
||||||
|
let p = RecoveryPolicy {
|
||||||
|
max_attempts: 5,
|
||||||
|
delay: Duration::from_millis(100),
|
||||||
|
};
|
||||||
|
assert!(p.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_validate_rejects_zero_attempts() {
|
||||||
|
// cs:33-36 throws ArgumentOutOfRangeException for MaxAttempts < 1.
|
||||||
|
let p = RecoveryPolicy {
|
||||||
|
max_attempts: 0,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
let err = p.validate().unwrap_err();
|
||||||
|
assert!(matches!(err, ConfigError::InvalidArgument { .. }));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_policy_accepts_zero_delay() {
|
||||||
|
// The .NET reference allows Delay = TimeSpan.Zero (only negative is
|
||||||
|
// rejected); Rust's Duration is unsigned so this is automatic.
|
||||||
|
let p = RecoveryPolicy {
|
||||||
|
max_attempts: 3,
|
||||||
|
delay: Duration::ZERO,
|
||||||
|
};
|
||||||
|
assert!(p.validate().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- SessionOptions ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_defaults_match_dotnet() {
|
||||||
|
let o = SessionOptions::default();
|
||||||
|
// local_engine_id = 0x7000 + (pid & 0x0FFF) per cs:18-21.
|
||||||
|
let pid = std::process::id() as i32;
|
||||||
|
assert_eq!(o.local_engine_id, 0x7000 + (pid & 0x0FFF));
|
||||||
|
assert_eq!(o.engine_name, format!("mxaccess.{pid}"));
|
||||||
|
assert_eq!(o.partner_version, 6);
|
||||||
|
assert_eq!(o.galaxy_id, 1);
|
||||||
|
assert_eq!(o.source_platform_id, 1);
|
||||||
|
assert_eq!(o.heartbeat_ticks_per_beat, None);
|
||||||
|
assert_eq!(o.heartbeat_max_missed_ticks, 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_default_local_engine_id_is_in_0x7000_range() {
|
||||||
|
let id = SessionOptions::default_local_engine_id();
|
||||||
|
assert!(
|
||||||
|
(0x7000..=0x7FFF).contains(&id),
|
||||||
|
"default local_engine_id 0x{id:X} not in 0x7000..=0x7FFF"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_default_engine_name_starts_with_mxaccess_dot() {
|
||||||
|
let n = SessionOptions::default_engine_name();
|
||||||
|
assert!(n.starts_with("mxaccess."));
|
||||||
|
// Tail must be a valid u32 (the PID).
|
||||||
|
let pid_str = n.trim_start_matches("mxaccess.");
|
||||||
|
assert!(pid_str.parse::<u32>().is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_options_can_be_overridden() {
|
||||||
|
let o = SessionOptions {
|
||||||
|
local_engine_id: 0x5000,
|
||||||
|
engine_name: "test".to_string(),
|
||||||
|
partner_version: 7,
|
||||||
|
galaxy_id: 2,
|
||||||
|
source_platform_id: 99,
|
||||||
|
heartbeat_ticks_per_beat: Some(10),
|
||||||
|
heartbeat_max_missed_ticks: 5,
|
||||||
|
};
|
||||||
|
assert_eq!(o.partner_version, 7);
|
||||||
|
assert_eq!(o.heartbeat_ticks_per_beat, Some(10));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Recovery event smoke tests -----------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_event_started_constructible() {
|
||||||
|
let e = RecoveryEvent::Started { attempt: 1 };
|
||||||
|
match e {
|
||||||
|
RecoveryEvent::Started { attempt } => assert_eq!(attempt, 1),
|
||||||
|
other => panic!("expected Started, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recovery_event_recovered_constructible() {
|
||||||
|
let e = RecoveryEvent::Recovered { attempt: 3 };
|
||||||
|
match e {
|
||||||
|
RecoveryEvent::Recovered { attempt } => assert_eq!(attempt, 3),
|
||||||
|
other => panic!("expected Recovered, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user