From 5cbc330f82e51e5f6566a7306ea54755d2e89962 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 5 May 2026 08:52:01 -0400 Subject: [PATCH] [M4] mxaccess: RecoveryPolicy fields + SessionOptions config MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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, heartbeat_max_missed_ticks. - default_local_engine_id() constructor: 0x7000 + (process_id & 0x0FFF) per GenerateDefaultLocalEngineId at cs:18-21. - default_engine_name(): "mxaccess." 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) --- rust/crates/mxaccess/src/lib.rs | 248 +++++++++++++++++++++++++++++++- 1 file changed, 246 insertions(+), 2 deletions(-) diff --git a/rust/crates/mxaccess/src/lib.rs b/rust/crates/mxaccess/src/lib.rs index 0838291..50f624b 100644 --- a/rust/crates/mxaccess/src/lib.rs +++ b/rust/crates/mxaccess/src/lib.rs @@ -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."` 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, + 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::().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:?}"), + } + } +}