[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,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default)]
|
||||
pub struct RecoveryPolicy;
|
||||
/// Reconnect / recover policy. Mirrors `MxNativeRecoveryPolicy`
|
||||
/// (`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
|
||||
/// `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 ------------------------------------------------------
|
||||
|
||||
#[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