[F49 step 1 + F56] callback router: peel envelope before parsing subscription / 0x11 frames

The router used to call NmxSubscriptionMessage::parse_inner directly
on the COM-stub-delivered body, but the wire bytes arrive wrapped in
a ProcessDataReceived envelope (46-byte header + optional 4-byte
length prefix); parse_inner expects post-envelope bytes. Result:
every 0x33 DataUpdate that ever arrived was silently dropped.

Mirrors the .NET reference's MxNativeSession.OnCallbackReceived flow
at cs:582-606 — three sequential parse attempts:
  1. NmxOperationStatusMessage::try_parse_process_data_received_body  (already wired)
  2. NmxReferenceRegistrationResultMessage::try_parse_...              (NEW — was missing)
  3. NmxSubscriptionMessage::try_parse_process_data_received_body      (NEW — was wrong)

Adds:
- NmxSubscriptionMessage::try_parse_process_data_received_body — peels
  envelope via NmxObservedEnvelope::parse_process_data_received_body_flexible,
  then dispatches to existing parse_inner.
- NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body —
  same shape, for the 0x11 registration-result frame.
- Router branch for 0x11 — currently traces the assigned item_handle and
  drops the frame (matches the .NET reference, which fires a
  ReferenceRegistrationReceived event with no consumer in the codebase).
- Router fall-through trace! when neither path matches, so future
  unparseable bodies surface in RUST_LOG=trace instead of vanishing.
- DcomCallbackSink::forward — trace! per inbound callback so
  RUST_LOG=mxaccess_callback=trace surfaces opnum + size.
- crates/mxaccess-compat/tests/buffered_subscribe_live.rs — F49 step 1
  live test that drives subscribe_buffered + a 500ms-cadence writer.
  Also pulls tracing-subscriber as a dev-dep so the test can dump
  router activity.

Existing router_task_decodes_callback_invoked_into_broadcast unit test
updated to wrap its synthetic 0x32 body in an envelope so the new
parse path actually accepts it.

Live result: F56 — the buffered round-trip *registers* successfully
(RegisterReference returns HRESULT 0; engine sends one 0x11
RegistrationResult + one 51-byte op-status per write, perfectly
clocked) but the engine never sends a 0x33 DataUpdate. Rust-port-
specific gap vs the .NET reference's working buffered path; root
cause is likely a field-level difference in the RegisterReference
body or a missing post-RegisterReference step. Captured as F56 in
design/followups.md, blocking F49 step 1; F56's DoD is the same
live test reporting >=3 DataChange arrivals.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-06 09:50:57 -04:00
parent 2fc327a8d5
commit af15fe7587
8 changed files with 477 additions and 24 deletions
@@ -572,6 +572,25 @@ impl NmxReferenceRegistrationResultMessage {
})
}
/// Peel the `ProcessDataReceived` envelope and parse the inner
/// `0x11` registration-result body. Mirrors
/// `NmxReferenceRegistrationResultMessage.TryParseProcessDataReceivedBody`
/// (the wire-side path used by `MxNativeSession.OnCallbackReceived`
/// at `cs:582`).
///
/// # Errors
///
/// - [`CodecError::ShortRead`] / [`CodecError::InnerLengthMismatch`]
/// surfaced from the envelope parse.
/// - Any error from [`Self::parse`] on the inner body — including
/// [`CodecError::UnexpectedOpcode`] when the inner body's first
/// byte isn't `0x11` (use this as a discriminator for "this body
/// isn't a registration-result frame").
pub fn try_parse_process_data_received_body(body: &[u8]) -> Result<Self, CodecError> {
let envelope = crate::NmxObservedEnvelope::parse_process_data_received_body_flexible(body)?;
Self::parse(&envelope.inner_body)
}
/// Encode the result body. The .NET reference does not provide an
/// `Encode` (the result is server-emitted); the Rust port supplies one
/// for round-trip testing and for synthetic-server use cases. The
@@ -215,6 +215,29 @@ impl NmxSubscriptionMessage {
_ => Err(CodecError::UnexpectedOpcode(command)),
}
}
/// Peel the `ProcessDataReceived` envelope and parse the inner
/// subscription body. Mirrors the .NET reference's
/// `NmxSubscriptionMessage.ParseProcessDataReceivedBody`
/// (the wire-side path used by `MxNativeSession.OnCallbackReceived`
/// at `cs:593`).
///
/// Inbound NMX callbacks arrive as a wire envelope (46-byte header,
/// optionally with a 4-byte total-length prefix), inside which sits
/// the 23-byte preamble + records body that
/// [`Self::parse_inner`] knows how to decode. Calling `parse_inner`
/// directly on the wire bytes — which the router used to do — would
/// fail because the first 46 bytes are envelope, not preamble.
///
/// # Errors
///
/// - [`CodecError::ShortRead`] / [`CodecError::InnerLengthMismatch`]
/// surfaced from the envelope parse.
/// - Any error from [`Self::parse_inner`] on the inner body.
pub fn try_parse_process_data_received_body(body: &[u8]) -> Result<Self, CodecError> {
let envelope = crate::NmxObservedEnvelope::parse_process_data_received_body_flexible(body)?;
Self::parse_inner(&envelope.inner_body)
}
}
/// `0x33` DataUpdate. Mirrors `NmxSubscriptionMessage.ParseDataUpdate`