Files
mxaccess/rust/crates/mxaccess-codec/tests/buffered_register_reference_parity.rs
T
Joseph Doherty ad1cf2351c [F36 + F40 + F44] M6 wave 1: subscribe_buffered (NMX) + metrics + evidence
Three M6 sub-followups landed in this wave (sub-agent worktrees +
manual reconciliation in main):

**F36 — Session::subscribe_buffered (NMX) per R2 single-sample**
- `BufferedOptions::rounded_update_interval_ms()` — 100ms rounding
  helper mirroring MxNativeCompatibilityServer.cs:638
  ((updateInterval + 99) / 100) * 100, saturating on overflow.
- `Session::subscribe_buffered` (public, lib.rs:604) delegates to
  the new private `subscribe_buffered_nmx` which uses the buffered
  RegisterReference path: item_definition suffixed with
  `.property(buffer)`, subscribe=true (no separate
  AdviseSupervisory follow-up — verified against capture 082).
- Per R2 verified at wwtools/mxaccesscli/docs/api-notes.md the wire
  semantic is single-sample-per-event with a server-side cadence
  knob; rounded_ms is held client-side only (native MXAccess does
  not emit a separate SetBufferedUpdateInterval RPC, verified by
  absence in 079/082 captures).
- New crates/mxaccess/examples/subscribe-buffered.rs.
- New crates/mxaccess-codec/tests/buffered_register_reference_parity.rs:
  4 tests (capture 079/082 round-trip, suffix helper, constructive
  forward-build vs capture 082).

**F40 — Optional metrics feature**
- New crates/mxaccess/src/metrics.rs (275 lines): `pub(crate)`
  thin wrappers (`record_write_latency`, `record_read_latency`,
  `inc_writes`, `inc_reads`, `inc_advises`, `inc_recovery_*`,
  `set_active_subscriptions`, etc.) that compile to no-ops under
  `#[cfg(not(feature = "metrics"))]`. Call sites in session.rs +
  asb_session.rs invoke them unconditionally; the gate is inside
  the wrapper.
- `metrics = { version = "0.24", optional = true }` added to
  workspace + mxaccess crate Cargo.toml.
- Default build: zero metrics dep, zero runtime cost.

**F44 — Buffered batch + suspend capture decode evidence**
- New docs/M6-buffered-evidence.md: per-capture summary for
  077, 079, 080, 081, 082, 094 — call sequence, key wire bytes,
  R2/R5 verdict.
- R2 confirmed silently as "not a real risk" — single-sample
  observed across 079/080/082/094.
- R5 trigger conditions documented from capture 077: AdviseSupervisory
  + Suspend pair, 1-second intervals, succeeds on enum attributes.
- design/70-risks-and-open-questions.md R2/R5 status updated.

Workspace: 759 → 792 tests, clippy clean, rustdoc -D warnings clean.

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

216 lines
8.5 KiB
Rust

//! Round-trip parity: buffered-subscribe `RegisterReference` (opcode `0x10`)
//! body, captured live with Frida.
//!
//! Closes the F36 DoD bullet 6 (`design/followups.md`): "Round-trip fixture
//! loaded from `captures/079-frida-add-buffered-advise-testint/` validating
//! the wire-byte sequence (call → response)."
//!
//! The .NET reference's [`MxNativeSession.RegisterBufferedItemAsync`]
//! (`MxNativeSession.cs:272-310`) builds a single `RegisterReference` frame
//! with `item_definition` suffixed by `.property(buffer)` and
//! `subscribe = true`. The Rust counterpart is
//! [`mxaccess::Session::subscribe_buffered`], which composes
//! [`mxaccess_codec::NmxReferenceRegistrationMessage::to_buffered_item_definition`]
//! with [`mxaccess_codec::NmxReferenceRegistrationMessage::encode`].
//!
//! Both fixtures below are the **inner LMX `RegisterReference` body** copied
//! verbatim from the corresponding capture's
//! `frida-events.tsv` (the `nmx.enter ... CNmxAdapter.PutRequest` row whose
//! candidate body starts with `10 01 00 ...`):
//!
//! - `082-frida-add-buffered-plain-advise-testint`: 173-byte body for
//! `(itemDefinition = "TestInt", itemContext = "TestChildObject")` with
//! correlation id `fb df 86 dc 1f c4 34 4b bb 26 a9 97 35 e9 b7 57`.
//! - `079-frida-add-buffered-advise-testint`: 173-byte body for the same
//! `(itemDefinition, itemContext)` pair with correlation id
//! `32 c3 d9 6d ed 72 f1 48 84 85 37 0c 66 bc f8 92`. (Capture 079 is the
//! `add-buffered-advise` scenario, which exercises the same wire frame
//! under a slightly different harness mode — both captures land on the
//! same `RegisterReference` shape.)
#![allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::indexing_slicing,
clippy::panic
)]
use mxaccess_codec::NmxReferenceRegistrationMessage;
/// Decode a space-separated hex string into bytes. Mirrors
/// `Convert.FromHexString` from the `.NET` test helper.
fn hex_to_bytes(s: &str) -> Vec<u8> {
s.split_whitespace()
.map(|tok| u8::from_str_radix(tok, 16).expect("malformed hex token in fixture"))
.collect()
}
/// Captured `RegisterReference` (0x10) body from
/// `captures/082-frida-add-buffered-plain-advise-testint/frida-events.tsv`,
/// line 45 (`nmx.enter ... CNmxAdapter.PutRequest`, candidate size 173).
const CAPTURE_082_BODY_HEX: &str = "\
10 01 00 \
01 00 00 00 \
fb df 86 dc 1f c4 34 4b bb 26 a9 97 35 e9 b7 57 \
ff ff \
00 00 \
01 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
32 00 00 81 \
54 00 65 00 73 00 74 00 49 00 6e 00 74 00 \
2e 00 70 00 72 00 6f 00 70 00 65 00 72 00 74 00 79 00 \
28 00 62 00 75 00 66 00 66 00 65 00 72 00 29 00 \
00 00 \
00 00 00 00 00 00 00 00 \
20 00 00 00 \
54 00 65 00 73 00 74 00 43 00 68 00 69 00 6c 00 64 00 \
4f 00 62 00 6a 00 65 00 63 00 74 00 \
00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
01";
/// Captured `RegisterReference` (0x10) body from
/// `captures/079-frida-add-buffered-advise-testint/frida-events.tsv`,
/// line 45 (`nmx.enter ... CNmxAdapter.PutRequest`, candidate size 173).
/// Differs from `CAPTURE_082_BODY_HEX` only in the 16-byte correlation id —
/// the rest of the wire shape is identical because both captures exercise
/// the same `(itemDefinition="TestInt", itemContext="TestChildObject")` pair.
const CAPTURE_079_BODY_HEX: &str = "\
10 01 00 \
01 00 00 00 \
32 c3 d9 6d ed 72 f1 48 84 85 37 0c 66 bc f8 92 \
ff ff \
00 00 \
01 00 00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
32 00 00 81 \
54 00 65 00 73 00 74 00 49 00 6e 00 74 00 \
2e 00 70 00 72 00 6f 00 70 00 65 00 72 00 74 00 79 00 \
28 00 62 00 75 00 66 00 66 00 65 00 72 00 29 00 \
00 00 \
00 00 00 00 00 00 00 00 \
20 00 00 00 \
54 00 65 00 73 00 74 00 43 00 68 00 69 00 6c 00 64 00 \
4f 00 62 00 6a 00 65 00 63 00 74 00 \
00 00 \
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 \
01";
/// Helper — assemble a `NmxReferenceRegistrationMessage` matching the
/// captured fixture and assert it encodes to the same bytes the .NET
/// reference + LMX server emit on the wire. Mirrors the .NET reference's
/// `MxNativeSession.RegisterBufferedItemAsync` request build:
///
/// ```csharp
/// var message = new NmxReferenceRegistrationMessage(
/// itemHandle,
/// subscription.CorrelationId,
/// NmxReferenceRegistrationMessage.ToBufferedItemDefinition(itemDefinition),
/// itemContext,
/// Subscribe: true);
/// ```
fn assert_roundtrip(captured_hex: &str, correlation_id: [u8; 16]) {
let captured = hex_to_bytes(captured_hex);
// Parse the captured bytes — should succeed cleanly.
let parsed = NmxReferenceRegistrationMessage::parse(&captured)
.expect("parse captured RegisterReference body");
// Sanity-check the high-level fields the F36 implementation depends on.
assert_eq!(
parsed.item_handle, 1,
"captured item_handle (LMXProxy harness uses sequential int handles starting at 1)"
);
assert_eq!(parsed.item_correlation_id, correlation_id);
assert_eq!(
parsed.item_definition, "TestInt.property(buffer)",
"buffered suffix preserved"
);
assert!(
parsed.subscribe,
"subscribe flag — buffered RegisterReference always sets it (.NET MxNativeSession.cs:298)"
);
// Re-encode and confirm byte-identical.
let re_encoded = parsed.encode();
assert_eq!(
re_encoded, captured,
"RegisterReference body must round-trip byte-identical"
);
// Also confirm the suffix helper is idempotent on an already-buffered name
// — the .NET reference does the same case-insensitive guard at
// `NmxReferenceRegistrationMessage.cs:96-102`.
let resuffixed =
NmxReferenceRegistrationMessage::to_buffered_item_definition(&parsed.item_definition)
.expect("re-applying buffered suffix");
assert_eq!(resuffixed, parsed.item_definition);
}
#[test]
fn capture_082_register_reference_round_trips() {
assert_roundtrip(
CAPTURE_082_BODY_HEX,
[
0xfb, 0xdf, 0x86, 0xdc, 0x1f, 0xc4, 0x34, 0x4b, 0xbb, 0x26, 0xa9, 0x97, 0x35, 0xe9,
0xb7, 0x57,
],
);
}
#[test]
fn capture_079_register_reference_round_trips() {
assert_roundtrip(
CAPTURE_079_BODY_HEX,
[
0x32, 0xc3, 0xd9, 0x6d, 0xed, 0x72, 0xf1, 0x48, 0x84, 0x85, 0x37, 0x0c, 0x66, 0xbc,
0xf8, 0x92,
],
);
}
#[test]
fn buffered_suffix_helper_matches_captured_definition() {
// F36 DoD bullet 1 verification: the codec helper that the Rust
// `Session::subscribe_buffered` calls must produce the exact suffix
// the captured wire bytes carry.
let suffixed = NmxReferenceRegistrationMessage::to_buffered_item_definition("TestInt").unwrap();
assert_eq!(suffixed, "TestInt.property(buffer)");
}
#[test]
fn buffered_register_reference_constructed_from_session_inputs_matches_capture_082() {
// Forward-build the message from the same inputs `Session::subscribe_buffered`
// gathers (correlation id + already-suffixed item definition + empty
// item context, with subscribe=true) and assert the encoded body
// matches the capture once we plug in the capture's specific
// `(item_context = "TestChildObject")` from the .NET probe harness.
//
// The Rust simple-form `subscribe_buffered(reference, ...)` passes
// the FULL reference as `item_definition` with empty `item_context`;
// capture 082 came from the LMXProxy compatibility surface which
// splits the reference into `(itemDefinition="TestInt", itemContext="TestChildObject")`.
// Both forms are valid on the wire — this test exercises the
// split-context form to confirm the Rust codec produces the identical
// bytes the live LMX server saw.
let captured = hex_to_bytes(CAPTURE_082_BODY_HEX);
let item_definition =
NmxReferenceRegistrationMessage::to_buffered_item_definition("TestInt").unwrap();
let msg = NmxReferenceRegistrationMessage {
item_handle: 1,
item_correlation_id: [
0xfb, 0xdf, 0x86, 0xdc, 0x1f, 0xc4, 0x34, 0x4b, 0xbb, 0x26, 0xa9, 0x97, 0x35, 0xe9,
0xb7, 0x57,
],
item_definition,
item_context: "TestChildObject".to_string(),
subscribe: true,
reserved_25_27: [0; 2],
reserved_31_55: [0; 24],
};
let encoded = msg.encode();
assert_eq!(encoded, captured);
}