Files
mxaccess/rust/crates/mxaccess-codec/src/operation_status.rs
T
Joseph Doherty c73a33edd8
rust / build / test / clippy / fmt (push) Has been cancelled
rust / cargo public-api drift check (F41) (push) Has been cancelled
[R3/R4 Path A] mxaccess: port Lmx.dll FUN_10100ce0 synthesizer kernel
Path A landed for R3/R4. The byte->MxStatus synthesizer in Lmx.dll is
FUN_10100ce0 (`analysis/ghidra/exports/Lmx.dll.synthesizer-helpers2-decompile.md`),
a 4-byte u32 LE -> 4-tuple MxStatus decoder used by every NMX-frame
parser in Lmx.dll. The kernel is byte-deterministic and context-free,
so it ports as a pure function -- the operation-tracking state
machine the original verdict deferred is NOT required for synthesis.

Bit layout (per FUN_10100ce0 lines 21-24):
  bit 31:        success    (-1 if set, 0 if clear)
  bits 27..24:   category   (4 bits)
  bits 23..20:   detected_by (4 bits)
  bits 15..0:    detail     (i16 -- low 16 bits, signed)
  bits 30..28, 19..16: reserved/padding

Codec changes:
- MxStatus::from_packed_u32() / ::to_packed_u32() -- the kernel +
  inverse for round-trip parity.
- MxStatus::from_nmx_response_code() -- the constructed-from-response-
  code switch in FUN_1010bd10:741-770 (six proven mappings: 0x01, 0x02
  -> CommunicationError + RequestingNmx; 0x03 -> ConfigurationError +
  RequestingNmx; 0x04 -> ConfigurationError + RespondingNmx; 0x05 ->
  CommunicationError + RespondingNmx; 0x1A -> CommunicationError +
  RequestingNmx).
- MxStatusCategory / MxStatusSource: from_i16/to_i16 promoted to const
  fn so MxStatus::from_packed_u32 can be const.
- NmxOperationStatusMessage::try_parse_process_data_received_body() --
  thin wrapper that peels the outer NmxObservedEnvelope before
  delegating to try_parse_inner. Mirrors
  NmxOperationStatusMessage.TryParseProcessDataReceivedBody (.NET cs:20-32).
- NmxOperationStatusMessage::promote_to_typed() -- entry point that
  returns the existing Status field. Documented as a no-op pass-through
  for now (the 5-byte inner-body wire shape is NOT the same field as
  the 4-byte packed-u32 the kernel decodes); kept for API symmetry.
- 22 new round-trip tests covering the kernel, the response-code
  switch, the proven 0x00/0x41/0xEF completion bytes, and round-trip
  for every canonical sentinel.

mxaccess (Session) changes:
- New OperationKind enum (Write/WriteSecured/Read/Subscribe/
  Unsubscribe/Activate/Suspend/Other).
- New OperationContext struct (correlation_id, op_kind, reference,
  retry_count) -- ground for the F54 follow-on per-operation
  correlation work.
- New OperationStatus event type {raw, status, context,
  is_during_recovery}, mirroring MxNativeOperationStatusEvent (cs:73-78)
  with the typed-MxStatus addition.
- Session::operation_status_events() -> broadcast::Receiver<Arc<
  OperationStatus>> + operation_status_stream() Stream variant.
- callback_router() now tries operation-status parsing first, falling
  through to subscription messages -- matches MxNativeSession
  .OnCallbackReceived dispatch order (cs:574,582,590).
- recover_connection() flips a recovery_active counter (Arc<AtomicU32>
  shared with the router) so OperationStatus.is_during_recovery is
  populated correctly. Mirrors MxNativeSession._recoveryActive
  Volatile.Read at cs:573.
- 3 new router tests covering: status-word frame dispatch + typed
  promotion to WriteCompleteOk; completion-only frames stay verbatim;
  is_during_recovery is stamped from the live counter.

Per-operation context tracking (correlating completion frames back to
outstanding writes/subscribes via the correlation_id) is filed as F54
in design/followups.md. The synthesizer kernel itself is byte-
deterministic, so the kernel and the correlation work are decoupled.

Ghidra evidence (the next-ring xref walk beyond FUN_10114a90):
- analysis/ghidra/exports/Lmx.dll.set-attribute-result-xrefs.md --
  xrefs to OnSetAttributeResult / CancelWithStatus / OperationComplete.
- analysis/ghidra/exports/Lmx.dll.vtable-data-xrefs.md -- vtable-slot
  data xrefs for the virtual-dispatch path.
- analysis/ghidra/exports/Lmx.dll.synthesizer-decompile.md --
  ScanOnDemandCallback::OperationComplete/MultipleOperationComplete
  (FUN_1010b990), RemotePlatformResolver::OperationComplete
  (FUN_1010dc80), and the constructed-from-responseCode synthesizer
  in FUN_1010bd10 (lines 698-770). FUN_1010bd10 is the wire-frame
  receiver that drives the synthesis.
- analysis/ghidra/exports/Lmx.dll.synthesizer-helpers-decompile.md --
  FUN_10003fc0 (the <success %d category %d ...> formatter; confirms
  the 4-tuple layout), FUN_1008f150 (dispatch helper).
- analysis/ghidra/exports/Lmx.dll.synthesizer-helpers2-decompile.md --
  FUN_10100ce0 (the kernel itself), FUN_10100bc0 (3xu16 reader),
  FUN_1005e580 (4-byte stream reader), FUN_1010ee00 (sister NMX-frame
  parser using the same kernel).
- analysis/ghidra/exports/Lmx.dll.synthesizer-callers-xrefs.md --
  caller graph; confirms the kernel is called from many wire-frame
  parsers but each parser shares the single 4-byte decoder.

R3/R4 verdict updated in design/70-risks-and-open-questions.md from
"settled at verbatim-preserve" to "settled per Path A". F54 filed in
design/followups.md for the per-operation correlation work.

cargo build / test / clippy -D warnings / RUSTDOCFLAGS=-D warnings doc
all clean. cargo public-api baselines regenerated for mxaccess and
mxaccess-codec.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-06 07:08:36 -04:00

382 lines
17 KiB
Rust

//! `NmxOperationStatusMessage` — completion / status-word frames.
//!
//! Direct port of `src/MxNativeCodec/NmxOperationStatusMessage.cs`.
//!
//! Two on-the-wire shapes are recognised by the inner-body parser:
//!
//! 1. **5-byte status-word frame** — `00 00 SS SS CC` where `SS SS` is a u16
//! LE status code and `CC` is a completion code. The single proven mapping
//! is `00 00 50 80 00` → [`MxStatus::WRITE_COMPLETE_OK`]
//! (`NmxOperationStatusMessage.cs:48-62`,
//! `design/40-protocol-invariants.md:346`).
//! 2. **1-byte completion-only frame** — a single byte `CC`. Three values are
//! observed in the wild (`0x00`, `0x41`, `0xEF`) but the byte→status
//! mapping is unproven; they are preserved verbatim per
//! `design/70-risks-and-open-questions.md` R3/R4 and
//! `NmxOperationStatusMessage.cs:36-46,69-76`.
//!
//! The .NET reference also exposes `TryParseProcessDataReceivedBody`, which
//! peels an outer `NmxObservedEnvelope` before delegating to the inner-body
//! parser. The outer envelope codec has not yet been ported to Rust; only
//! [`NmxOperationStatusMessage::try_parse_inner`] is provided here. When
//! `NmxObservedEnvelope` lands, add `try_parse_process_data_received_body` as
//! a thin wrapper.
//!
//! ## Typed promotion and the synthesizer kernel
//!
//! [`NmxOperationStatusMessage::promote_to_typed`] returns the same
//! [`MxStatus`] the parser already attached to the message — the
//! verbatim-preserve placeholder for unknown shapes, the
//! [`MxStatus::WRITE_COMPLETE_OK`] sentinel for the proven
//! `(status_code=0x8050, completion_code=0x00)` shape. The 5-byte
//! `00 00 SS SS CC` inner body is **not** the same wire field as the
//! 4-byte packed status word `Lmx.dll!FUN_10100ce0` decodes
//! ([`MxStatus::from_packed_u32`]) — that kernel applies one layer up,
//! to the `INmxService.GetResponse2` payload's `status: i32` field
//! (carried e.g. in subscription records). See
//! `analysis/ghidra/exports/Lmx.dll.synthesizer-helpers2-decompile.md`
//! and `design/70-risks-and-open-questions.md` R3/R4 Path A for the
//! evidence chain.
//!
//! `promote_to_typed` is therefore a thin convenience over the existing
//! `status` field: callers that want the canonical bit-layout decoder
//! should reach for [`MxStatus::from_packed_u32`] directly when they
//! have a 4-byte packed value in hand.
// Direct byte indexing — see reference_handle.rs for rationale.
#![allow(clippy::indexing_slicing)]
use crate::error::CodecError;
use crate::observed_frame::NmxObservedEnvelope;
use crate::status::{MxStatus, MxStatusCategory, MxStatusSource};
/// Which of the two recognised inner-frame shapes was decoded
/// (`NmxOperationStatusMessage.cs:3-7`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum NmxOperationStatusFormat {
/// Single-byte completion frame (`NmxOperationStatusMessage.cs:5,36-46`).
CompletionOnly,
/// 5-byte `00 00 SS SS CC` status-word frame
/// (`NmxOperationStatusMessage.cs:6,48-62`).
StatusWord,
}
/// Decoded operation-status frame
/// (`NmxOperationStatusMessage.cs:9-15` — record fields).
///
/// The four payload fields preserve the raw on-wire bytes; [`Self::status`]
/// carries the promoted [`MxStatus`] when a known mapping exists, or an
/// unpromoted placeholder otherwise (see `CompletionOnly` and the fallback
/// branch of `StatusWord`).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub struct NmxOperationStatusMessage {
/// Which inner frame shape was observed.
pub format: NmxOperationStatusFormat,
/// First byte of the 5-byte frame (`NmxOperationStatusMessage.cs:54`).
/// `0` for `CompletionOnly` frames.
pub command: u8,
/// `inner[2..4]` u16 LE for `StatusWord` (`NmxOperationStatusMessage.cs:50`).
/// `0` for `CompletionOnly` frames.
pub status_code: u16,
/// Completion byte. `inner[0]` for `CompletionOnly`
/// (`NmxOperationStatusMessage.cs:38`); `inner[4]` for `StatusWord`
/// (`NmxOperationStatusMessage.cs:51`).
pub completion_code: u8,
/// Promoted status. The only proven promotion is
/// `status_code == 0x8050 && completion_code == 0x00 → WRITE_COMPLETE_OK`
/// (`NmxOperationStatusMessage.cs:57`,
/// `design/40-protocol-invariants.md:346`). Every other shape is wrapped
/// in an `Unknown`/`Unknown` placeholder with the raw byte preserved in
/// `detail`.
pub status: MxStatus,
}
impl NmxOperationStatusMessage {
/// `true` for the proven `00 00 50 80 00` frame
/// (`NmxOperationStatusMessage.cs:16-18`).
pub fn is_mx_access_write_complete(&self) -> bool {
self.format == NmxOperationStatusFormat::StatusWord
&& self.status_code == 0x8050
&& self.completion_code == 0x00
}
/// Return the typed [`MxStatus`] for this frame.
///
/// This is a thin convenience over [`Self::status`] — same value,
/// no transformation. Provided for API symmetry with
/// [`MxStatus::from_packed_u32`] (the canonical 4-byte synthesizer
/// kernel) and to give consumers a single entry point that can
/// be extended in future revisions if new evidence pins additional
/// `(status_code, completion_code)` shapes.
///
/// **What this method does NOT do:** apply the
/// `Lmx.dll!FUN_10100ce0` synthesizer to the 5-byte inner body.
/// The 5-byte `00 00 SS SS CC` shape and the 4-byte packed-u32
/// shape are different wire fields at different layers — see the
/// module docs and
/// `design/70-risks-and-open-questions.md` R3/R4 Path A. Callers
/// holding a 4-byte packed `MxStatus` (e.g. extracted from a
/// subscription record's `status: i32`) should call
/// [`MxStatus::from_packed_u32`] directly.
#[must_use]
pub const fn promote_to_typed(&self) -> MxStatus {
self.status
}
/// Peel the outer [`NmxObservedEnvelope`] off a `ProcessDataReceived`
/// payload and parse the inner body. Mirrors
/// `NmxOperationStatusMessage.TryParseProcessDataReceivedBody`
/// (`NmxOperationStatusMessage.cs:20-32`).
///
/// # Errors
///
/// Returns `Err` when the outer envelope cannot be parsed or the
/// inner body matches no recognised shape (1- or 5-byte completion
/// frame). The .NET reference returns `false` and a `null!`
/// out-param in both cases; the Rust port surfaces a typed
/// [`CodecError`] so callers can distinguish "not a process-data
/// frame" from "successfully parsed".
pub fn try_parse_process_data_received_body(body: &[u8]) -> Result<Self, CodecError> {
let envelope = NmxObservedEnvelope::parse_process_data_received_body_flexible(body)?;
Self::try_parse_inner(&envelope.inner_body)
}
/// Parse an inner body — either 1 byte (`CompletionOnly`) or 5 bytes
/// (`StatusWord` with leading `00 00`).
///
/// Mirrors `NmxOperationStatusMessage.TryParseInner`
/// (`NmxOperationStatusMessage.cs:34-67`).
///
/// # Errors
///
/// Returns [`CodecError::ShortRead`] when the buffer length matches no
/// recognised shape. The .NET reference returns `false` and a `null!`
/// out-param; the Rust port surfaces the failure as a typed error so
/// callers can distinguish "not an operation-status frame" from
/// "successfully parsed". Match on the error to mirror the bool API.
pub fn try_parse_inner(inner: &[u8]) -> Result<Self, CodecError> {
if inner.len() == 1 {
// CompletionOnly — `NmxOperationStatusMessage.cs:36-46`.
let completion_code = inner[0];
return Ok(Self {
format: NmxOperationStatusFormat::CompletionOnly,
command: 0,
status_code: 0,
completion_code,
status: create_unpromoted_completion_status(completion_code),
});
}
if inner.len() == 5 && inner[0] == 0x00 && inner[1] == 0x00 {
// StatusWord — `NmxOperationStatusMessage.cs:48-62`.
let status_code = u16::from(inner[2]) | (u16::from(inner[3]) << 8);
let completion_code = inner[4];
// Only the (0x8050, 0x00) shape is promoted to a typed status.
// Every other (status_code, completion_code) pair is preserved as
// an Unknown/Unknown placeholder with the raw byte in `detail`,
// mirroring `NmxOperationStatusMessage.cs:57-61`.
//
// The .NET fallback packs `detail` as:
// completion_code == 0x00 ? (short)status_code : completion_code
// We replicate the same selection here, including the
// `unchecked((short)statusCode)` reinterpretation (i.e. the u16's
// bit pattern as i16).
let status = if status_code == 0x8050 && completion_code == 0x00 {
MxStatus::WRITE_COMPLETE_OK
} else {
let detail = if completion_code == 0x00 {
// Reinterpret the u16 status_code as i16 (two's complement).
status_code as i16
} else {
i16::from(completion_code)
};
MxStatus {
success: 0,
category: MxStatusCategory::Unknown,
detected_by: MxStatusSource::Unknown,
detail,
}
};
return Ok(Self {
format: NmxOperationStatusFormat::StatusWord,
command: inner[0],
status_code,
completion_code,
status,
});
}
Err(CodecError::ShortRead {
// 1 or 5 are the two valid lengths; report the smaller for the
// diagnostic. Callers that need the strict bool API should pattern
// match on `Err(_) => false`.
expected: 1,
actual: inner.len(),
})
}
}
/// Build the unpromoted placeholder status used by `CompletionOnly` frames
/// (`NmxOperationStatusMessage.cs:69-76`).
fn create_unpromoted_completion_status(completion_code: u8) -> MxStatus {
MxStatus {
success: 0,
category: MxStatusCategory::Unknown,
detected_by: MxStatusSource::Unknown,
detail: i16::from(completion_code),
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
mod tests {
use super::*;
#[test]
fn write_complete_ok_frame() {
// The proven 5-byte mapping (`design/40-protocol-invariants.md:346`).
let frame = [0x00, 0x00, 0x50, 0x80, 0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord);
assert_eq!(msg.command, 0x00);
assert_eq!(msg.status_code, 0x8050);
assert_eq!(msg.completion_code, 0x00);
assert_eq!(msg.status, MxStatus::WRITE_COMPLETE_OK);
assert!(msg.is_mx_access_write_complete());
}
#[test]
fn status_word_unknown_with_completion_zero_packs_status_code_as_i16() {
// status_code 0x8051, completion 0x00 — not the proven mapping; falls
// through to the unpromoted branch with detail = (i16)0x8051 = -32687.
// Mirrors `NmxOperationStatusMessage.cs:61` (`unchecked((short)statusCode)`).
let frame = [0x00, 0x00, 0x51, 0x80, 0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::StatusWord);
assert_eq!(msg.status_code, 0x8051);
assert_eq!(msg.completion_code, 0x00);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
assert_eq!(msg.status.detected_by, MxStatusSource::Unknown);
assert_eq!(msg.status.detail, 0x8051u16 as i16);
assert!(!msg.is_mx_access_write_complete());
}
#[test]
fn status_word_unknown_with_nonzero_completion_packs_completion_in_detail() {
// completion_code != 0 — detail = completion_code as i16
// (`NmxOperationStatusMessage.cs:61`).
let frame = [0x00, 0x00, 0x50, 0x80, 0x42];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.completion_code, 0x42);
assert_eq!(msg.status.detail, 0x42);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
assert!(!msg.is_mx_access_write_complete());
}
#[test]
fn completion_only_zero_byte() {
// 1-byte 0x00 — preserved verbatim per design R3/R4.
let frame = [0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
assert_eq!(msg.command, 0);
assert_eq!(msg.status_code, 0);
assert_eq!(msg.completion_code, 0x00);
assert_eq!(msg.status.detail, 0x00);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
// `CompletionOnly` is never promoted to WriteCompleteOk.
assert!(!msg.is_mx_access_write_complete());
}
#[test]
fn completion_only_0x41_byte() {
// 1-byte 0x41 — observed in the wild, mapping unproven
// (`design/70-risks-and-open-questions.md` R4).
let frame = [0x41];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
assert_eq!(msg.completion_code, 0x41);
assert_eq!(msg.status.detail, 0x41);
assert_eq!(msg.status.category, MxStatusCategory::Unknown);
}
#[test]
fn completion_only_0xef_byte() {
// 1-byte 0xEF — observed in the wild, mapping unproven
// (`design/70-risks-and-open-questions.md` R4).
let frame = [0xEF];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.format, NmxOperationStatusFormat::CompletionOnly);
assert_eq!(msg.completion_code, 0xEF);
// 0xEF as i16 is 0x00EF (zero-extended), not -17.
assert_eq!(msg.status.detail, 0xEF);
}
#[test]
fn rejects_unknown_length() {
// 0 / 2 / 3 / 4 / 6 bytes — all non-recognised shapes.
for len in [0_usize, 2, 3, 4, 6, 16] {
let buf = vec![0u8; len];
assert!(
NmxOperationStatusMessage::try_parse_inner(&buf).is_err(),
"length {len} should be rejected"
);
}
}
#[test]
fn rejects_5_byte_frame_without_leading_zeros() {
// 5 bytes with non-zero leading bytes — not a StatusWord frame
// (`NmxOperationStatusMessage.cs:48` requires `inner[0] == 0 && inner[1] == 0`).
let frame = [0x01, 0x00, 0x50, 0x80, 0x00];
assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err());
let frame = [0x00, 0x01, 0x50, 0x80, 0x00];
assert!(NmxOperationStatusMessage::try_parse_inner(&frame).is_err());
}
#[test]
fn status_code_is_little_endian() {
// `inner[2..4]` is read as u16 LE — `inner[2] | (inner[3] << 8)`.
// 0xAA at [2], 0xBB at [3] → 0xBBAA.
let frame = [0x00, 0x00, 0xAA, 0xBB, 0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.status_code, 0xBBAA);
}
#[test]
fn promote_to_typed_returns_existing_status_for_status_word() {
// The proven shape — must keep returning the canonical sentinel.
let frame = [0x00, 0x00, 0x50, 0x80, 0x00];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
assert_eq!(msg.promote_to_typed(), MxStatus::WRITE_COMPLETE_OK);
assert_eq!(msg.promote_to_typed(), msg.status);
}
#[test]
fn promote_to_typed_returns_verbatim_status_for_completion_only() {
// 1-byte frames: no synthesizer evidence — must stay verbatim.
for byte in [0x00_u8, 0x41, 0xEF] {
let msg = NmxOperationStatusMessage::try_parse_inner(&[byte]).unwrap();
let promoted = msg.promote_to_typed();
assert_eq!(promoted, msg.status);
assert_eq!(promoted.category, MxStatusCategory::Unknown);
assert_eq!(promoted.detected_by, MxStatusSource::Unknown);
assert_eq!(promoted.detail, i16::from(byte));
}
}
#[test]
fn promote_to_typed_does_not_change_existing_status_field() {
// promote_to_typed must not mutate the verbatim-preserve `status`
// field. This guards the byte-for-byte parity contract with the
// .NET reference.
let frame = [0x00, 0x00, 0x55, 0xAA, 0x33];
let msg = NmxOperationStatusMessage::try_parse_inner(&frame).unwrap();
let original_status = msg.status;
let _typed = msg.promote_to_typed();
assert_eq!(msg.status, original_status);
}
}