[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:
Joseph Doherty
2026-05-05 08:52:01 -04:00
parent d59ce3571c
commit 5cbc330f82
+246 -2
View File
@@ -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:?}"),
}
}
}