af15fe7587
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>
214 lines
7.9 KiB
Rust
214 lines
7.9 KiB
Rust
//! 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");
|
|
}
|
|
}
|