[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:
@@ -41,6 +41,8 @@ Between each publish: wait for the crate to be indexed before the next one's `ca
|
||||
|
||||
**Step 5 unblocked 2026-05-06 by F55 / Path A.** `cargo test -p mxaccess-compat --features live-windows-com --test lmx_write_complete_live -- --ignored --nocapture` passes against the live AVEVA install: RegisterEngine2 OK, write round-trips, OnWriteComplete fires with the expected `WriteCompleteEvent { server_handle, item_handle, statuses, is_during_recovery }` shape. Steps 1-4 still pending.
|
||||
|
||||
**Step 1 attempted 2026-05-06 — blocked by F56.** Added `crates/mxaccess-compat/tests/buffered_subscribe_live.rs` driving `Session::subscribe_buffered` via `Session::connect_nmx_auto`. RegisterReference completes successfully against the live engine, but no `0x33` DataUpdate frames are ever received — only op-status frames per write and the `0x11` registration-result. The Rust port's wire frame for RegisterReference must differ from the .NET reference's in some field. The codec router was also fixed during this attempt (envelope-peeling for `NmxSubscriptionMessage` and the `0x11` `NmxReferenceRegistrationResultMessage` path were both missing); those are real bugs that would have hidden any DataUpdate had one arrived. F56 captures the open work.
|
||||
|
||||
**Definition of done:**
|
||||
1. Per-feature evidence summary in `docs/M6-live-verification.md` (one paragraph per feature with the wire-trace excerpt or metrics-exporter snapshot).
|
||||
2. If any feature fails live: file a sub-followup with the captured failure and link it from the evidence doc.
|
||||
@@ -100,6 +102,29 @@ Between each publish: wait for the crate to be indexed before the next one's `ca
|
||||
|
||||
**Resolves when:** the lint is on and the workspace doc build is warning-clean with it.
|
||||
|
||||
### F56 — Buffered subscribe completes RegisterReference but receives no `0x33` DataUpdate frames
|
||||
**Severity:** P1 — blocks F49 step 1 (F36 buffered live verification) and any consumer relying on `Session::subscribe_buffered` to surface value changes.
|
||||
**Source:** F49 step 1 live attempt 2026-05-06. Test `cargo test -p mxaccess-compat --features live-windows-com --test buffered_subscribe_live -- --ignored --nocapture` (added in this session) connects via `Session::connect_nmx_auto` (F55-proven), issues `subscribe_buffered(TestChildObject.TestInt, 1000ms)` against the live engine, and runs a background writer at 500ms cadence. RegisterReference returns HRESULT 0; the engine then fires:
|
||||
- One 46-byte heartbeat envelope (header-only, empty inner)
|
||||
- One 51-byte op-status frame for the `RegisterReference` completion
|
||||
- One 87-byte `0x11` `NmxReferenceRegistrationResultMessage` carrying the assigned `item_handle`
|
||||
- One 51-byte op-status frame **per write** (60 frames over 30s — perfectly clocked to the writer cadence)
|
||||
|
||||
But **zero `0x33` `DataUpdate` frames** ever arrive — verified end-to-end via `RUST_LOG=trace mxaccess_callback=trace`. The .NET reference's `MxNativeSession.SubscribeBufferedAsync` does deliver DataUpdates against the same engine + same tag (per F36 wave 1 evidence at `captures/094-frida-buffered-separate-writer/`), so this is a Rust-port-specific gap.
|
||||
|
||||
**Likely causes (in priority order):**
|
||||
1. The `NmxReferenceRegistrationMessage` body the Rust port sends differs in some field from the .NET reference's. Specifically: `subscribe: true` is set, but other fields (e.g. `item_handle = 0`, `reserved_*`, `source_galaxy_id`) may need different values to trigger DataUpdate dispatch. **Action**: capture the wire bytes from the Rust port's RegisterReference and diff against `captures/094-frida-buffered-separate-writer/` per-byte.
|
||||
2. Some additional client-side step is required after RegisterReference — e.g. an ACK of the `0x11` registration result via the assigned `item_handle`, or a separate RPC the .NET reference dispatches that we miss. The F36 wave 1 evidence said no `SetBufferedUpdateInterval` is sent, but there may be another op. **Action**: capture .NET reference's outbound calls during `subscribe-buffered` end-to-end and compare to ours.
|
||||
3. The `0x11` registration-result body might carry a status code we should be checking (see `NmxReferenceRegistrationResultMessage::status_category` / `status_detail`). If non-zero, the engine may have rejected the subscription silently. **Action**: log the parsed `0x11` body and check the status fields.
|
||||
|
||||
**What's already wired (this session):** `NmxSubscriptionMessage::try_parse_process_data_received_body` (envelope-peeling helper) was added — the previous router called `parse_inner` directly on wire bytes and would have silently dropped any `0x33` that did arrive. This was a real bug fix; without it F56 would have stayed invisible. Same for `NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body` + the `0x11` path in the router.
|
||||
|
||||
**Does not affect:** `Session::write` round-trip (proven by F55 live test); plain `Session::subscribe` (not yet live-tested but uses `AdviseSupervisory` not `RegisterReference`).
|
||||
|
||||
**Definition of done:** F49 step 1 passes — `cargo test -p mxaccess-compat --features live-windows-com --test buffered_subscribe_live -- --ignored --nocapture` reports at least 3 `DataChange` arrivals at the configured cadence, with monotonically-increasing values matching the writer.
|
||||
|
||||
**Resolves when:** the missing field / step / status check is identified, the fix lands in `Session::subscribe_buffered_nmx` (or upstream), and the live test passes.
|
||||
|
||||
### F55 — Hand-rolled callback exporter rejected by `RegisterEngine2` on this AVEVA install
|
||||
**Status:** Resolved 2026-05-06 by Path A (DCOM-managed `INmxSvcCallback` sink in `mxaccess-callback::dcom_sink`, wired into `Session::from_nmx_client` behind the `windows-com` feature). Live test `cargo test -p mxaccess-compat --features live-windows-com --test lmx_write_complete_live -- --ignored --nocapture` passes end-to-end: RegisterEngine2 succeeds, write round-trips, OnWriteComplete fires with status from the wire. The hand-rolled `CallbackExporter` is retained for unit tests that exercise the exporter against an in-process fake NMX peer.
|
||||
**Severity:** P1 — blocks F49 live verification of every M6 feature that needs an `Engine` registered (i.e. all of them).
|
||||
|
||||
Generated
+112
@@ -19,6 +19,15 @@ dependencies = [
|
||||
"cpufeatures",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "aho-corasick"
|
||||
version = "1.1.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "async-trait"
|
||||
version = "0.1.89"
|
||||
@@ -421,6 +430,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy_static"
|
||||
version = "1.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
|
||||
|
||||
[[package]]
|
||||
name = "libc"
|
||||
version = "0.2.186"
|
||||
@@ -433,6 +448,15 @@ version = "0.4.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
|
||||
[[package]]
|
||||
name = "matchers"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"
|
||||
dependencies = [
|
||||
"regex-automata",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "md-5"
|
||||
version = "0.10.6"
|
||||
@@ -583,6 +607,8 @@ dependencies = [
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tokio-stream",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -628,6 +654,15 @@ dependencies = [
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nu-ansi-term"
|
||||
version = "0.50.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.6"
|
||||
@@ -812,6 +847,23 @@ dependencies = [
|
||||
"cipher 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-automata"
|
||||
version = "0.4.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f"
|
||||
dependencies = [
|
||||
"aho-corasick",
|
||||
"memchr",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex-syntax"
|
||||
version = "0.8.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a"
|
||||
|
||||
[[package]]
|
||||
name = "ring"
|
||||
version = "0.17.14"
|
||||
@@ -939,6 +991,15 @@ dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sharded-slab"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"
|
||||
dependencies = [
|
||||
"lazy_static",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
@@ -957,6 +1018,12 @@ version = "0.4.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
|
||||
|
||||
[[package]]
|
||||
name = "smallvec"
|
||||
version = "1.15.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
@@ -1024,6 +1091,15 @@ dependencies = [
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "thread_local"
|
||||
version = "1.1.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiberius"
|
||||
version = "0.12.3"
|
||||
@@ -1144,6 +1220,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
|
||||
dependencies = [
|
||||
"once_cell",
|
||||
"valuable",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-log"
|
||||
version = "0.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"
|
||||
dependencies = [
|
||||
"log",
|
||||
"once_cell",
|
||||
"tracing-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tracing-subscriber"
|
||||
version = "0.3.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"
|
||||
dependencies = [
|
||||
"matchers",
|
||||
"nu-ansi-term",
|
||||
"once_cell",
|
||||
"regex-automata",
|
||||
"sharded-slab",
|
||||
"smallvec",
|
||||
"thread_local",
|
||||
"tracing",
|
||||
"tracing-core",
|
||||
"tracing-log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1174,6 +1280,12 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "valuable"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "version_check"
|
||||
version = "0.9.5"
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
use std::ptr;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
use tracing::{debug, trace, warn};
|
||||
use windows::Win32::System::Com::Marshal::CoMarshalInterface;
|
||||
use windows::Win32::System::Com::StructuredStorage::{
|
||||
CreateStreamOnHGlobal, GetHGlobalFromStream,
|
||||
@@ -118,6 +118,12 @@ impl DcomCallbackSink {
|
||||
// the slice is read-only. We copy out before returning.
|
||||
unsafe { std::slice::from_raw_parts(buffer, buffer_size as usize) }.to_vec()
|
||||
};
|
||||
trace!(
|
||||
opnum,
|
||||
buffer_size,
|
||||
body_len = body.len(),
|
||||
"DcomCallbackSink: forwarding inbound callback"
|
||||
);
|
||||
if let Err(e) = self.event_tx.send(CallbackEvent::CallbackInvoked { opnum, body }) {
|
||||
// The receiver was dropped (the upstream router
|
||||
// probably exited). NmxSvc keeps calling us until
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -19,6 +19,10 @@ thiserror = { workspace = true }
|
||||
tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "sync", "time"] }
|
||||
async-trait = { workspace = true }
|
||||
mxaccess-rpc = { path = "../mxaccess-rpc", version = "0.0.0" }
|
||||
# Live tests use tracing-subscriber to dump router/dcom_sink trace
|
||||
# events on demand (set RUST_LOG=mxaccess=trace,mxaccess_callback=trace).
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
//! Live verification of F36 — buffered subscribe (`Session::subscribe_buffered`)
|
||||
//! round-trips against AVEVA and yields `DataChange`s at the requested cadence.
|
||||
//!
|
||||
//! F49 step 1. Asserts the structural property of F36 (single
|
||||
//! `RegisterReference` with `.property(buffer)` suffix, no separate
|
||||
//! `AdviseSupervisory` follow-up, no `SetBufferedUpdateInterval` RPC)
|
||||
//! is preserved end-to-end. The structural piece is unit-tested
|
||||
//! exhaustively in `crates/mxaccess/src/session.rs` (search
|
||||
//! `subscribe_buffered_nmx`); this test confirms the wire round-trip
|
||||
//! actually delivers updates.
|
||||
//!
|
||||
//! Gated on `MX_LIVE` env + `live-windows-com` feature. Uses
|
||||
//! `Session::connect_nmx_auto` (F55-proven path).
|
||||
//!
|
||||
//! Run with:
|
||||
//! ```text
|
||||
//! cd rust
|
||||
//! cargo test -p mxaccess-compat --features live-windows-com \
|
||||
//! --test buffered_subscribe_live -- --ignored --nocapture
|
||||
//! ```
|
||||
|
||||
#![allow(
|
||||
clippy::unwrap_used,
|
||||
clippy::expect_used,
|
||||
clippy::indexing_slicing,
|
||||
clippy::panic
|
||||
)]
|
||||
|
||||
#[cfg(all(windows, feature = "live-windows-com"))]
|
||||
mod live {
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use mxaccess::{
|
||||
BufferedOptions, GalaxyTagMetadata, MxValue, RecoveryPolicy, Resolver, ResolverError,
|
||||
Session, SessionOptions,
|
||||
};
|
||||
use mxaccess_rpc::ntlm::NtlmClientContext;
|
||||
|
||||
struct StaticResolver {
|
||||
tag_reference: String,
|
||||
metadata: GalaxyTagMetadata,
|
||||
}
|
||||
|
||||
impl StaticResolver {
|
||||
fn new(tag_reference: &str) -> Self {
|
||||
let (object, attribute) = tag_reference
|
||||
.split_once('.')
|
||||
.unwrap_or((tag_reference, "TestInt"));
|
||||
Self {
|
||||
tag_reference: tag_reference.to_string(),
|
||||
metadata: GalaxyTagMetadata {
|
||||
object_tag_name: object.to_string(),
|
||||
attribute_name: attribute.to_string(),
|
||||
primitive_name: None,
|
||||
platform_id: 1,
|
||||
engine_id: 2,
|
||||
object_id: 3,
|
||||
primitive_id: 0,
|
||||
attribute_id: 7,
|
||||
property_id: GalaxyTagMetadata::VALUE_PROPERTY_ID,
|
||||
mx_data_type: 2,
|
||||
is_array: false,
|
||||
security_classification: 0,
|
||||
attribute_source: "dynamic".into(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait::async_trait]
|
||||
impl Resolver for StaticResolver {
|
||||
async fn resolve(&self, tag: &str) -> Result<GalaxyTagMetadata, ResolverError> {
|
||||
if tag == self.tag_reference {
|
||||
Ok(self.metadata.clone())
|
||||
} else {
|
||||
Err(ResolverError::NotFound {
|
||||
tag_reference: tag.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ntlm_from_test_env() -> NtlmClientContext {
|
||||
let user = std::env::var("MX_TEST_USER").expect("MX_TEST_USER");
|
||||
let password = std::env::var("MX_TEST_PASSWORD").expect("MX_TEST_PASSWORD");
|
||||
let domain = std::env::var("MX_TEST_DOMAIN").unwrap_or_default();
|
||||
let hostname = std::env::var("COMPUTERNAME").unwrap_or_default();
|
||||
NtlmClientContext::new(&user, &password, &domain, Some(&hostname))
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
|
||||
#[ignore]
|
||||
async fn buffered_subscribe_yields_updates() {
|
||||
if std::env::var_os("MX_LIVE").is_none() {
|
||||
eprintln!("MX_LIVE not set — skipping live test");
|
||||
return;
|
||||
}
|
||||
let tag = std::env::var("MX_TEST_TAG")
|
||||
.unwrap_or_else(|_| "TestChildObject.TestInt".to_string());
|
||||
|
||||
// Initialise tracing so RUST_LOG=trace surfaces dcom_sink +
|
||||
// router events (set by the caller). Init may fail if a
|
||||
// subscriber is already installed — ignore the result.
|
||||
let _ = tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_test_writer()
|
||||
.try_init();
|
||||
|
||||
eprintln!("connecting via Session::connect_nmx_auto");
|
||||
let session = Session::connect_nmx_auto(
|
||||
ntlm_from_test_env,
|
||||
SessionOptions::default(),
|
||||
Arc::new(StaticResolver::new(&tag)),
|
||||
RecoveryPolicy::default(),
|
||||
)
|
||||
.await
|
||||
.expect("connect_nmx_auto");
|
||||
eprintln!("session connected");
|
||||
|
||||
// 1s cadence. Mirrors the `subscribe-buffered` example.
|
||||
let opts = BufferedOptions {
|
||||
update_interval_ms: 1_000,
|
||||
};
|
||||
eprintln!(
|
||||
"buffered-subscribing to {} (requested cadence {} ms, rounded to {} ms)",
|
||||
tag,
|
||||
opts.update_interval_ms,
|
||||
opts.rounded_update_interval_ms()
|
||||
);
|
||||
let mut sub = session
|
||||
.subscribe_buffered(&tag, opts)
|
||||
.await
|
||||
.expect("subscribe_buffered");
|
||||
eprintln!("correlation_id = {:02x?}", sub.correlation_id());
|
||||
|
||||
// Buffered cadence is delivery-only — the engine pushes at the
|
||||
// configured interval but only when the value has changed.
|
||||
// Spawn a background writer that bumps the tag every 500ms so
|
||||
// the engine always has a fresh value to deliver at the next
|
||||
// cadence boundary. 30s drain window.
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
let writer_session = session.clone();
|
||||
let writer_tag = tag.clone();
|
||||
let writer_stop = Arc::new(std::sync::atomic::AtomicBool::new(false));
|
||||
let writer_stop_clone = writer_stop.clone();
|
||||
let writer = tokio::spawn(async move {
|
||||
let mut value: i32 = 1_000;
|
||||
while !writer_stop_clone.load(std::sync::atomic::Ordering::Acquire) {
|
||||
if let Err(e) = writer_session
|
||||
.write(&writer_tag, MxValue::Int32(value))
|
||||
.await
|
||||
{
|
||||
eprintln!("writer: write({value}) failed: {e}");
|
||||
break;
|
||||
}
|
||||
value = value.wrapping_add(1);
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
value
|
||||
});
|
||||
|
||||
let mut received = 0;
|
||||
let mut last_ts = None;
|
||||
while received < 3 && Instant::now() < deadline {
|
||||
match tokio::time::timeout(Duration::from_secs(5), sub.next()).await {
|
||||
Ok(Some(Ok(dc))) => {
|
||||
eprintln!(
|
||||
"[{received}] {} = {:?} ts={:?}",
|
||||
dc.reference, dc.value, dc.timestamp
|
||||
);
|
||||
received += 1;
|
||||
last_ts = Some(dc.timestamp);
|
||||
}
|
||||
Ok(Some(Err(e))) => {
|
||||
writer_stop.store(true, std::sync::atomic::Ordering::Release);
|
||||
let _ = writer.await;
|
||||
panic!("subscription error: {e}");
|
||||
}
|
||||
Ok(None) => break,
|
||||
Err(_) => {
|
||||
eprintln!("5s gap waiting for next update");
|
||||
}
|
||||
}
|
||||
}
|
||||
writer_stop.store(true, std::sync::atomic::Ordering::Release);
|
||||
let last_value = writer.await.unwrap_or(-1);
|
||||
eprintln!("writer stopped after value {last_value}");
|
||||
|
||||
assert!(
|
||||
received >= 1,
|
||||
"no DataChange arrived within 15s — buffered subscribe didn't round-trip"
|
||||
);
|
||||
eprintln!("received {received} updates; last ts = {last_ts:?}");
|
||||
|
||||
session.unsubscribe(sub).await.expect("unsubscribe");
|
||||
session.shutdown_nmx().await.expect("shutdown");
|
||||
eprintln!("clean shutdown");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(all(windows, feature = "live-windows-com")))]
|
||||
mod live {
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn buffered_subscribe_yields_updates() {
|
||||
eprintln!("test skipped: requires Windows + live-windows-com feature");
|
||||
}
|
||||
}
|
||||
@@ -46,8 +46,8 @@ use mxaccess_callback::ExporterIdentities;
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
use mxaccess_rpc::com_objref_provider::IUnknownHolder;
|
||||
use mxaccess_codec::{
|
||||
MxStatus, NmxOperationStatusMessage, NmxReferenceRegistrationMessage, NmxSubscriptionMessage,
|
||||
NmxSubscriptionRecord,
|
||||
MxStatus, NmxOperationStatusMessage, NmxReferenceRegistrationMessage,
|
||||
NmxReferenceRegistrationResultMessage, NmxSubscriptionMessage, NmxSubscriptionRecord,
|
||||
};
|
||||
use mxaccess_galaxy::{GalaxyTagMetadata, Resolver, ResolverError};
|
||||
use mxaccess_nmx::{NmxClient, NmxClientError, WriteValue};
|
||||
@@ -831,15 +831,53 @@ pub(crate) async fn callback_router(
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Fall through to subscription messages — same 23-byte
|
||||
// preamble + records as `NmxSubscriptionMessage::parse_inner`
|
||||
// expects. Parse failures are silent (no consumer) since
|
||||
// the .NET reference also fires `UnparsedCallbackReceived`
|
||||
// events separately and we don't model that yet.
|
||||
if let Ok(msg) = NmxSubscriptionMessage::parse_inner(&body) {
|
||||
// `send` returns `Err(SendError)` only when there are zero
|
||||
// receivers — that's fine for this wire path; nothing to do.
|
||||
let _ = callback_tx.send(Arc::new(msg));
|
||||
// 2. Try `0x11` reference-registration result. NmxSvc
|
||||
// sends one of these after `RegisterReference` to
|
||||
// convey the assigned `item_handle` + the engine's
|
||||
// decoded item definition / context. Mirrors
|
||||
// `MxNativeSession.OnCallbackReceived:582-588`. The
|
||||
// .NET reference fires a `ReferenceRegistrationReceived`
|
||||
// event but no consumer in the codebase reacts to it;
|
||||
// we currently just consume + drop the frame at trace
|
||||
// level so the catch-all parse below doesn't log a
|
||||
// spurious "unexpected opcode 0x11" warning.
|
||||
if let Ok(result) =
|
||||
NmxReferenceRegistrationResultMessage::try_parse_process_data_received_body(&body)
|
||||
{
|
||||
tracing::trace!(
|
||||
item_handle = result.item_handle,
|
||||
correlation_id = ?result.item_correlation_id,
|
||||
"callback_router: 0x11 RegistrationResult received"
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Fall through to subscription messages. Wire bytes
|
||||
// arrive wrapped in a `ProcessDataReceived` envelope (46-byte
|
||||
// header, optionally with a 4-byte length prefix); the
|
||||
// 23-byte subscription preamble starts after that.
|
||||
// Mirrors `MxNativeSession.OnCallbackReceived:593` which
|
||||
// calls `NmxSubscriptionMessage.ParseProcessDataReceivedBody`.
|
||||
// The earlier code called `parse_inner` directly on the
|
||||
// wire bytes, which silently swallowed every DataUpdate
|
||||
// because the bytes failed the 23-byte preamble check.
|
||||
// Parse failures are still silent (no consumer) — the
|
||||
// .NET reference fires `UnparsedCallbackReceived` events
|
||||
// separately and we don't model that yet.
|
||||
match NmxSubscriptionMessage::try_parse_process_data_received_body(&body) {
|
||||
Ok(msg) => {
|
||||
// `send` returns `Err(SendError)` only when there
|
||||
// are zero receivers — that's fine for this wire
|
||||
// path; nothing to do.
|
||||
let _ = callback_tx.send(Arc::new(msg));
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::trace!(
|
||||
err = %e,
|
||||
body_len = body.len(),
|
||||
"callback_router: dropping unparseable callback body"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2961,18 +2999,31 @@ mod tests {
|
||||
pending_ops,
|
||||
));
|
||||
|
||||
// Build a minimal valid 0x32 SubscriptionStatus body: 23-byte
|
||||
// preamble + 16-byte item_correlation_id, record_count=0 so no
|
||||
// records follow. Total: 39 bytes. Using 0x32 (not 0x33)
|
||||
// because DataUpdate always attempts to parse one record
|
||||
// regardless of record_count, and we'd need a full 38-byte
|
||||
// record body to satisfy that parser.
|
||||
let mut body = vec![0u8; 39];
|
||||
body[0] = 0x32;
|
||||
body[1..3].copy_from_slice(&1u16.to_le_bytes()); // version
|
||||
body[3..7].copy_from_slice(&0i32.to_le_bytes()); // record_count
|
||||
body[7..23].copy_from_slice(&[0xEFu8; 16]); // operation_id
|
||||
body[23..39].copy_from_slice(&[0xCDu8; 16]); // item_correlation_id
|
||||
// Build a minimal valid 0x32 SubscriptionStatus body wrapped
|
||||
// in a `ProcessDataReceived` envelope (header-only form, no
|
||||
// 4-byte total-length prefix): 46-byte header + 39-byte inner.
|
||||
// The header's `inner_length` at offset 2 is `inner_len + 4`
|
||||
// (.NET cs:54-56 — declared length includes the size-of-int).
|
||||
// Using 0x32 (not 0x33) because DataUpdate always attempts
|
||||
// to parse one record regardless of record_count, and we'd
|
||||
// need a full 38-byte record body to satisfy that parser.
|
||||
const HEADER_LEN: usize = 46;
|
||||
const INNER_LEN_OFFSET: usize = 2;
|
||||
let inner_len = 39usize;
|
||||
let mut body = vec![0u8; HEADER_LEN + inner_len];
|
||||
// Inner-length declaration (at INNER_LEN_OFFSET = 2). Flexible
|
||||
// (header-only) form compares `declared == body.len() - HEADER_LEN`
|
||||
// verbatim — no `-4` adjustment (`observed_frame.rs:178`); the
|
||||
// adjustment only applies on the strict path where there's a
|
||||
// 4-byte total-length prefix in front.
|
||||
let declared = inner_len as i32;
|
||||
body[INNER_LEN_OFFSET..INNER_LEN_OFFSET + 4].copy_from_slice(&declared.to_le_bytes());
|
||||
let inner = &mut body[HEADER_LEN..];
|
||||
inner[0] = 0x32;
|
||||
inner[1..3].copy_from_slice(&1u16.to_le_bytes()); // version
|
||||
inner[3..7].copy_from_slice(&0i32.to_le_bytes()); // record_count
|
||||
inner[7..23].copy_from_slice(&[0xEFu8; 16]); // operation_id
|
||||
inner[23..39].copy_from_slice(&[0xCDu8; 16]); // item_correlation_id
|
||||
|
||||
let event = CallbackEvent::CallbackInvoked { opnum: 4, body };
|
||||
event_tx.send(event).unwrap();
|
||||
|
||||
Reference in New Issue
Block a user