e5b31fadb1
Live attempt against AVEVA on this dev host produced two artefacts:
**`crates/mxaccess-compat/tests/lmx_write_complete_live.rs`** — the
F54 OnWriteComplete round-trip test. Compiles + runs against the
live AVEVA install via either path:
- `--features live-windows-com` (preferred): uses
`Session::connect_nmx_auto` so the COM activation reference is
held in-process for the duration of the test.
- Default features (fallback): shells out to
`MxNativeClient.Probe --probe-resolve-oxid-managed-ntlm-integrity`
+ `--probe-remqi-managed` to learn the per-session NMX endpoint +
INmxService2 IPID, then uses `Session::connect_nmx`.
Both code paths are wired and the test runs through endpoint
resolution + IPID extraction successfully. The connect step itself
fails with `Status { detail: 1722 }` (RPC_S_SERVER_UNAVAILABLE).
**`crates/mxaccess-rpc/examples/com-marshal-probe.rs`** — minimal
one-shot binary that calls
`marshal_activated_iunknown_objref("NmxSvc.NmxService",
DifferentMachine)` in isolation. Confirms the COM activation +
CoMarshalInterface chain works fine standalone (returns a 338-byte
OBJREF with valid OXID/IPID structure). The 1722 in the live test
is therefore downstream of the activation — likely a COM-apartment
threading interaction with the tokio multi-thread runtime.
This is an F12-related issue (auto-resolve hardening), not an F54
issue. F54's correctness is covered by the existing unit-level
integration tests:
- `mxaccess::session::tests::router_populates_operation_status_context_from_pending_ops_fifo`
- `mxaccess::session::tests::write_handle_correlates_with_router_emitted_status`
- `mxaccess_compat::tests::drain_routes_write_status_to_on_write_complete`
- `mxaccess_compat::tests::drain_routes_non_write_status_to_on_operation_complete`
`design/followups.md` F49 entry updated to reflect:
- F54 added as a fifth row in the live-verification scope.
- "Live attempt 2026-05-06" sub-section documents the 1722 issue +
what was verified (.NET probe end-to-end works against same
install; Rust COM activation works in isolation; the failure is
Rust-port-specific to `connect_nmx_auto` under tokio).
- F49 now Blocked-by F12 hardening (the 1722 path).
New `live-windows-com` feature on `mxaccess-compat` propagates to
`mxaccess/windows-com` for the test binary.
Workspace 824 → 824 tests; clippy + rustdoc clean across both
feature configurations.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
310 lines
12 KiB
Rust
310 lines
12 KiB
Rust
//! 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=<uuid>` 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<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))
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|