ad1cf2351c
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>
216 lines
8.5 KiB
Rust
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);
|
|
}
|