//! Live verification of F54 — the `LMX_OnWriteComplete(hServer, hItem, //! ref MXSTATUS_PROXY[])` callback shape end-to-end against AVEVA. //! //! Gated on `MX_LIVE` env. Resolves the per-session NMX `INmxService2` //! IPID by shelling out to the .NET probe //! (`MxNativeClient.Probe --probe-remqi-managed --objref-only`) and //! parsing the `remqi_managed_inmxservice2_ipid=` line. Then uses //! `Session::connect_nmx` (the proven path; `connect_nmx_auto` returns //! RPC_S_SERVER_UNAVAILABLE in some local-COM activation paths and //! isn't needed for this test). //! //! Run with: //! ```text //! cd rust //! cargo test -p mxaccess-compat --test lmx_write_complete_live -- --ignored --nocapture //! ``` //! //! Required env (populate via `tools/Setup-LiveProbeEnv.ps1`): //! - `MX_LIVE=1` //! - `MX_TEST_USER` / `MX_TEST_DOMAIN` / `MX_TEST_PASSWORD` //! - `MX_NMX_HOST` (default `localhost`) //! - `MX_TEST_TAG` (default `TestChildObject.TestInt`) //! //! Asserts: after a `LmxClient::write(h_server, h_item, value, user_id)` //! the `client.on_write_complete()` stream yields a `WriteCompleteEvent` //! with `(server_handle, item_handle, statuses, is_during_recovery)` //! populated correctly. F49 sweep's core OnWriteComplete row. #![allow( clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing, clippy::panic )] #[cfg(windows)] mod live { use std::process::Command; use std::sync::Arc; use std::time::Duration; use futures_util::StreamExt; use mxaccess::{ GalaxyTagMetadata, MxValue, RecoveryPolicy, Resolver, ResolverError, Session, SessionOptions, }; use mxaccess_compat::LmxClient; use mxaccess_rpc::guid::Guid; use mxaccess_rpc::ntlm::NtlmClientContext; /// Minimal `Resolver` impl. Mirrors the inline shim every NMX /// example uses. 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, // Integer (Int32) 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 { 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)) } #[cfg_attr(feature = "live-windows-com", allow(dead_code))] /// Shell out to the .NET probe to resolve both the /// `INmxService2` IPID and the `(host, port)` of the NMX /// endpoint. Returns `(addr, ipid)` ready for `connect_nmx`. /// /// Two probe runs: /// 1. `--probe-resolve-oxid-managed-ntlm-integrity` → parses the /// first `ncacn_ip_tcp` binding from the `bindings=` line for /// host + port. /// 2. `--probe-remqi-managed` → parses the /// `remqi_managed_inmxservice2_ipid=` line for the IPID. /// /// Per-session live resolution; for production the consumer calls /// `Session::connect_nmx_auto` (windows-com feature) instead. fn resolve_endpoint_via_dotnet_probe() -> (std::net::SocketAddr, Guid) { let project = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) .ancestors() .nth(3) .expect("repo root") .join("src") .join("MxNativeClient.Probe") .join("MxNativeClient.Probe.csproj"); let resolve_out = run_probe(&project, "--probe-resolve-oxid-managed-ntlm-integrity"); let bindings_line = resolve_out .lines() .find(|l| l.starts_with("resolve_oxid_managed_ntlm_integrity_bindings=")) .expect("bindings line in probe output"); let bindings = bindings_line .split_once('=') .map(|(_, v)| v) .unwrap_or_default(); // First `ncacn_ip_tcp:HOST[PORT]` token. Pattern: // string:0x0007:ncacn_ip_tcp:DESKTOP-6JL3KKO[64311]|... let tcp_binding = bindings .split('|') .find(|tok| tok.contains(":ncacn_ip_tcp:")) .expect("at least one ncacn_ip_tcp binding"); let host_port = tcp_binding .rsplit_once(":ncacn_ip_tcp:") .map(|(_, v)| v) .unwrap_or_default(); let bracket_start = host_port.find('[').expect("[port] in binding"); let host = &host_port[..bracket_start]; let port: u16 = host_port[bracket_start + 1..] .trim_end_matches(']') .parse() .expect("parse port"); let addr = std::net::ToSocketAddrs::to_socket_addrs(&(host, port)) .expect("DNS") .find(|a| a.is_ipv4()) // prefer IPv4 — Rust transport stack is happier .or_else(|| { std::net::ToSocketAddrs::to_socket_addrs(&(host, port)) .expect("DNS") .next() }) .expect("at least one address"); eprintln!("resolved NMX endpoint: {host}:{port} -> {addr}"); let remqi_out = run_probe(&project, "--probe-remqi-managed"); let ipid = remqi_out .lines() .find_map(|l| l.strip_prefix("remqi_managed_inmxservice2_ipid=")) .expect("ipid in probe output"); let ipid = Guid::parse_str(ipid.trim()).expect("parse IPID"); eprintln!("resolved INmxService2 IPID: {ipid:?}"); (addr, ipid) } #[cfg_attr(feature = "live-windows-com", allow(dead_code))] fn run_probe(project: &std::path::Path, mode: &str) -> String { eprintln!("running .NET probe: {mode}"); let output = Command::new("dotnet") .args([ "run", "--project", project.to_str().unwrap(), "-c", "Release", "--", mode, "--objref-only", ]) .output() .expect("dotnet run"); if !output.status.success() { panic!( "dotnet probe ({mode}) failed: stderr={}", String::from_utf8_lossy(&output.stderr) ); } String::from_utf8_lossy(&output.stdout).into_owned() } #[tokio::test(flavor = "multi_thread", worker_threads = 2)] #[ignore] async fn lmx_write_fires_on_write_complete_event() { 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()); // F54 live test: prefer `connect_nmx_auto` so the COM // activation reference is held in-process for the duration of // the run. Probe-style external IPID resolution doesn't work // because the per-session IPID expires when the probe exits. #[cfg(feature = "live-windows-com")] let session = { eprintln!("connecting via Session::connect_nmx_auto"); Session::connect_nmx_auto( ntlm_from_test_env, SessionOptions::default(), Arc::new(StaticResolver::new(&tag)), RecoveryPolicy::default(), ) .await .expect("connect_nmx_auto") }; #[cfg(not(feature = "live-windows-com"))] let session = { // Fallback: probe-resolve the endpoint, then connect_nmx. // Subject to the per-session-IPID expiry caveat above — // this branch is mainly for visibility. let _ = (resolve_endpoint_via_dotnet_probe, run_probe); let (addr, service_ipid) = resolve_endpoint_via_dotnet_probe(); eprintln!("connecting via Session::connect_nmx ({addr}, ipid={service_ipid:?})"); Session::connect_nmx( addr, SessionOptions::default(), ntlm_from_test_env(), service_ipid, Arc::new(StaticResolver::new(&tag)), RecoveryPolicy::default(), ) .await .expect("connect_nmx") }; eprintln!("session connected"); let client = LmxClient::register("F54-live-test", session); let server_handle = 1; // LmxClient::from_backend assigns 1. let item_handle = client .add_item(server_handle, &tag) .await .expect("add_item"); eprintln!("add_item({tag}) -> h_item={item_handle}"); // Subscribe to the OnWriteComplete stream BEFORE issuing the // write so we don't race the broadcast channel. let mut on_write_complete = client.on_write_complete(); eprintln!("write({tag}, 42)"); client .write(server_handle, item_handle, MxValue::Int32(42), 0) .await .expect("write"); // Wait for OnWriteComplete to fire. The 5-byte WRITE_COMPLETE_OK // status word arrives via NMX callback typically within // 50-200ms on a healthy local install. let evt = tokio::time::timeout(Duration::from_secs(10), on_write_complete.next()) .await .expect("OnWriteComplete didn't fire within 10s") .expect("on_write_complete stream closed"); eprintln!( "OnWriteComplete fired: server={} item={} statuses_len={} is_during_recovery={}", evt.server_handle, evt.item_handle, evt.statuses.len(), evt.is_during_recovery ); // F54 contract — match the C# `LMX_OnWriteComplete(int hServer, // int hItem, ref MXSTATUS_PROXY[] pVars)` signature shape. assert_eq!(evt.server_handle, server_handle, "hServer matches"); assert_eq!(evt.item_handle, item_handle, "hItem matches"); assert!( !evt.statuses.is_empty(), "MXSTATUS_PROXY[] should carry at least one element" ); assert!(!evt.is_during_recovery); eprintln!("first status: {:?}", evt.statuses[0]); client.unregister(server_handle).await.expect("unregister"); eprintln!("unregistered cleanly"); } } #[cfg(not(windows))] mod live { #[test] #[ignore] fn lmx_write_fires_on_write_complete_event() { eprintln!("test skipped: requires Windows"); } }