[F56 resolved] subscribe paths now drive 0x33 DataUpdate frames
Root cause: `Session::subscribe` and `Session::subscribe_buffered_nmx`
were missing the `INmxService2::Connect` + `AddSubscriberEngine` RPC
pair that the .NET reference's `MxNativeSession.EnsurePublisherConnected`
(`cs:516-526`) issues before the first advise against a publishing
engine. Without those two RPCs, NmxSvc accepted the subscription
registration but the publishing engine never knew our engine was
subscribed — so it never dispatched DataUpdate frames back.
Diagnosis driven by wwtools/aalogcli reading
C:\ProgramData\ArchestrA\LogFiles. The user pointed at this tooling
which lit up the path.
Red herring: NmxSvc's `[Warning] NmxCallback->DataReceived ... failed
with error 0x{N}` log lines turned out to be normal log spam where N
is the bufferSize of the inbound call, not a real error code. The
.NET reference's own probe triggers identical entries while still
receiving DataUpdate frames successfully.
Fix:
- SessionInner::publisher_endpoints — per-session HashMap<(platform_id,
engine_id), ()> cache mirroring MxNativeSession._publisherEndpoints.
- Session::ensure_publisher_connected — issues Connect +
AddSubscriberEngine, once per publisher endpoint per session.
- Session::subscribe + subscribe_buffered_nmx — both call it before
the wire advise.
- subscribe_buffered_nmx — additionally issues AdviseSupervisory after
RegisterReference. The .NET reference's RegisterBufferedItemAsync
only calls RegisterReference, but on this AVEVA install
RegisterReference alone produces the registration result + heartbeat
callbacks without ever starting DataUpdate dispatch; AdviseSupervisory
unblocks the dispatch.
Live verification (`TestMachine_001.TestChangingInt`, a tag that
updates >1×/s):
cargo test -p mxaccess-compat --features live-windows-com \
--test plain_subscribe_live -- --ignored --nocapture
cargo test -p mxaccess-compat --features live-windows-com \
--test buffered_subscribe_live -- --ignored --nocapture
Both pass — `cmd=0x32` SubscriptionStatus + sequence of `cmd=0x33`
DataUpdate frames flow as expected. Tests assert on the raw
Session::callbacks() broadcast (not the typed Subscription::next
DataChange path) because the engine reports quality=Uncertain
value=null for this attribute on this Galaxy — the wire-level
subscription is what F56 was about, not the value content.
DcomCallbackSink reverted to S_OK return for both DataReceivedRaw
and StatusReceivedRaw (the bytes-processed / sentinel HRESULT
experiments during diagnosis turned out to be irrelevant — the
"failed with error 0xN" logs come from NmxSvc regardless of the
return value).
design/followups.md F49 + F56 + docs/M6-live-verification.md updated:
F56 resolved, F49 steps 1 + 4 + 5 pass live, steps 2 + 3 pending
(now executable on this fixture).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -626,6 +626,18 @@ pub struct SessionInner {
|
||||
/// .NET LMX behaviour captured at
|
||||
/// `captures/094-frida-buffered-separate-writer/frida-events.tsv:13`.
|
||||
pub(crate) next_item_handle: std::sync::atomic::AtomicI32,
|
||||
/// F56 — per-session set of `(platform_id, engine_id)` endpoints
|
||||
/// we've already issued `INmxService2::Connect` +
|
||||
/// `AddSubscriberEngine` against. Mirrors the .NET reference's
|
||||
/// `MxNativeSession._publisherEndpoints` (`MxNativeSession.cs:516-525`).
|
||||
/// Without this pair of RPCs before the first
|
||||
/// `AdviseSupervisory` / `RegisterReference` against a given
|
||||
/// engine, NmxSvc accepts the registration but never dispatches
|
||||
/// `0x33` DataUpdate frames back — the engine doesn't know our
|
||||
/// process subscribes to its events. Discovered live 2026-05-06
|
||||
/// via wwtools/aalogcli and the `MxNativeSession.EnsurePublisherConnected`
|
||||
/// helper at `cs:516-526`.
|
||||
pub(crate) publisher_endpoints: Mutex<HashMap<(i32, i32), ()>>,
|
||||
/// F55 / Path A — keeps the DCOM-managed `INmxSvcCallback`'s
|
||||
/// `IUnknown` ref alive for the session's lifetime. The marshalled
|
||||
/// OBJREF passed to `RegisterEngine2` references this object's
|
||||
@@ -1139,6 +1151,7 @@ impl Session {
|
||||
rebuild_factory: Mutex::new(None),
|
||||
pending_ops,
|
||||
next_item_handle: std::sync::atomic::AtomicI32::new(1),
|
||||
publisher_endpoints: Mutex::new(HashMap::new()),
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
dcom_sink_holder: Mutex::new(dcom_sink_holder),
|
||||
}),
|
||||
@@ -1863,6 +1876,14 @@ impl Session {
|
||||
.map_err(map_resolver)?;
|
||||
let correlation_id: [u8; 16] = rand::random();
|
||||
|
||||
// F56 — connect to the publisher engine before issuing the
|
||||
// first advise against it, mirroring
|
||||
// `MxNativeSession.EnsurePublisherConnected` (`cs:516-526`).
|
||||
// Without this NmxSvc acks the advise but never dispatches
|
||||
// DataUpdate frames back — the publishing engine doesn't know
|
||||
// our engine is subscribed.
|
||||
self.ensure_publisher_connected(i32::from(metadata.platform_id), i32::from(metadata.engine_id)).await?;
|
||||
|
||||
let opts = &inner.options;
|
||||
let mut nmx = inner.nmx.lock().await;
|
||||
let hr = nmx
|
||||
@@ -2008,6 +2029,10 @@ impl Session {
|
||||
// rationale as plain `subscribe`).
|
||||
let inbound = Box::pin(BroadcastStream::new(self.inner.callback_tx.subscribe()));
|
||||
|
||||
// F56 — connect to the publisher engine first; see plain
|
||||
// `subscribe` for the rationale.
|
||||
self.ensure_publisher_connected(i32::from(metadata.platform_id), i32::from(metadata.engine_id)).await?;
|
||||
|
||||
let mut nmx = inner.nmx.lock().await;
|
||||
let hr = nmx
|
||||
.register_reference(
|
||||
@@ -2021,6 +2046,29 @@ impl Session {
|
||||
.await
|
||||
.map_err(map_nmx)?;
|
||||
ensure_hresult_ok(hr)?;
|
||||
// F56 — buffered subscriptions need an explicit
|
||||
// `AdviseSupervisory` round-trip after `RegisterReference` to
|
||||
// start DataUpdate dispatch on this AVEVA install. The .NET
|
||||
// reference's `MxNativeSession.RegisterBufferedItemAsync`
|
||||
// (`cs:272-310`) only calls `RegisterReference` — but the LMX
|
||||
// compat layer's `AddBufferedItem` + `AdviseSupervisory` chain
|
||||
// ends up triggering the advise downstream. Mirroring just
|
||||
// RegisterReference (per F36 wave 1's reading of capture 082)
|
||||
// produces the registration result and heartbeat callbacks but
|
||||
// no `0x33` DataUpdate frames. Issuing the advise here closes
|
||||
// that gap — verified live against `TestMachine_001.TestChangingInt`.
|
||||
let hr = nmx
|
||||
.advise_supervisory(
|
||||
opts.local_engine_id,
|
||||
&metadata,
|
||||
correlation_id,
|
||||
opts.galaxy_id,
|
||||
/* source_galaxy_id */ i32::from(opts.galaxy_id),
|
||||
opts.source_platform_id,
|
||||
)
|
||||
.await
|
||||
.map_err(map_nmx)?;
|
||||
ensure_hresult_ok(hr)?;
|
||||
drop(nmx);
|
||||
|
||||
let metadata_arc = Arc::new(metadata);
|
||||
@@ -2063,6 +2111,66 @@ impl Session {
|
||||
})
|
||||
}
|
||||
|
||||
/// F56 — issue `INmxService2::Connect` + `AddSubscriberEngine`
|
||||
/// against the `(platform_id, engine_id)` of the publishing engine,
|
||||
/// once per session. Mirrors
|
||||
/// `MxNativeSession.EnsurePublisherConnected` (`cs:516-526`) +
|
||||
/// `ConnectPublisher` (`cs:528-536`).
|
||||
///
|
||||
/// Without this pair of RPCs before the first `AdviseSupervisory` /
|
||||
/// `RegisterReference` against a given engine, NmxSvc acks the
|
||||
/// advise but the publishing engine never knows our engine is
|
||||
/// subscribed — no `0x33` DataUpdate frames flow back. Confirmed
|
||||
/// 2026-05-06 by the absence of the .NET reference's
|
||||
/// `EnsurePublisherConnected` call in the Rust port + live
|
||||
/// reproduction against `TestMachine_001.TestChangingInt`.
|
||||
async fn ensure_publisher_connected(
|
||||
&self,
|
||||
platform_id: i32,
|
||||
engine_id: i32,
|
||||
) -> Result<(), Error> {
|
||||
let key = (platform_id, engine_id);
|
||||
{
|
||||
let endpoints = self.inner.publisher_endpoints.lock().await;
|
||||
if endpoints.contains_key(&key) {
|
||||
tracing::debug!(
|
||||
platform_id,
|
||||
engine_id,
|
||||
"ensure_publisher_connected: already connected"
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
let opts = &self.inner.options;
|
||||
let local_engine = opts.local_engine_id;
|
||||
let galaxy = i32::from(opts.galaxy_id);
|
||||
let source_platform = opts.source_platform_id;
|
||||
tracing::debug!(
|
||||
platform_id,
|
||||
engine_id,
|
||||
local_engine,
|
||||
galaxy,
|
||||
source_platform,
|
||||
"ensure_publisher_connected: issuing Connect + AddSubscriberEngine"
|
||||
);
|
||||
{
|
||||
let mut nmx = self.inner.nmx.lock().await;
|
||||
let hr = nmx
|
||||
.connect_engine(local_engine, galaxy, platform_id, engine_id)
|
||||
.await
|
||||
.map_err(map_nmx)?;
|
||||
ensure_hresult_ok(hr)?;
|
||||
let hr = nmx
|
||||
.add_subscriber_engine(engine_id, galaxy, source_platform, local_engine)
|
||||
.await
|
||||
.map_err(map_nmx)?;
|
||||
ensure_hresult_ok(hr)?;
|
||||
}
|
||||
let mut endpoints = self.inner.publisher_endpoints.lock().await;
|
||||
endpoints.insert(key, ());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// `subscribe` ordering note: subscribe to the broadcast channel
|
||||
/// FIRST, then issue `AdviseSupervisory`. If we ordered the other
|
||||
/// way, updates that arrive between the advise call and the
|
||||
@@ -2602,6 +2710,7 @@ mod tests {
|
||||
rebuild_factory: Mutex::new(None),
|
||||
pending_ops,
|
||||
next_item_handle: std::sync::atomic::AtomicI32::new(1),
|
||||
publisher_endpoints: Mutex::new(HashMap::new()),
|
||||
#[cfg(all(windows, feature = "windows-com"))]
|
||||
dcom_sink_holder: Mutex::new(None),
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user