From 0a274af76f0ced289f03a7f6219adf61bcf741eb Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 6 May 2026 08:55:59 -0400 Subject: [PATCH] [F55] Path C investigation: NmxSvc requires SCM-registered OXID for callbacks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Captured OBJREF byte structures from both paths via the .NET probe: - `--probe-callback-marshal`: DCOM-marshalled, 338 bytes, succeeds (when used inside `MxNativeSession.Open` → `CreateRegisteredService`). - `--probe-register-managed-callback`: hand-rolled, 162 bytes, fails with `RegisterEngine2 → 0x800706BA RPC_S_SERVER_UNAVAILABLE`. The structural diff: - `std_flags`: DCOM=`0x0A80` (SORF_OXRES4+6+8) vs hand-rolled=`0x280` (SORF_OXRES4+6). Bit `0x0800` (SORF_OXRES8) only set in DCOM. - ncacn_ip_tcp bindings: DCOM=4 with no ports; hand-rolled=1 with explicit `[port]`. - Total size: 338 vs 162 bytes. Tested the simplest fix (hand-rolled `std_flags = 0x0A80` to match DCOM): **still fails with the same 1722.** Reverted. **Diagnosis updated in F55:** NmxSvc on receiving RegisterEngine2 appears to call `IObjectExporter::ResolveOxid` against the local SCM (`127.0.0.1:135`) to resolve the callback OBJREF's OXID, then dial the resulting bindings. Our hand-rolled OXID is never registered with RPCSS, so the SCM-side resolution fails and NmxSvc returns RPC_S_SERVER_UNAVAILABLE — matching: - the symptom (1722), - the sub-second timing (no TCP dial-back to our listener attempted), - the fact that the .NET `ManagedCallbackExporter` (same hand-rolled approach) ALSO fails identically. DCOM marshalling fixes this because `CoMarshalInterface` internally registers the OXID with RPCSS. The bindings have no port because RPCSS returns the dynamic port from the DCOM stub layer. **Conclusion: Path A is the architecturally correct fix** — the callback exporter must be a DCOM-managed object (e.g. via `windows-rs` `#[implement]`) for NmxSvc to accept the callback. The hand-rolled-listener-with-explicit-port approach is fundamentally incompatible with NmxSvc's callback validation, in both Rust and the .NET reference. Path C (cheap investigation) is exhausted; F55 verdict updated to recommend Path A explicitly. `cargo test --workspace` 824 passing; clippy `-D warnings` clean across both feature configurations. Co-Authored-By: Claude Opus 4.7 (1M context) --- design/followups.md | 22 +++++++++++++++---- rust/crates/mxaccess-callback/src/exporter.rs | 6 ++++- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/design/followups.md b/design/followups.md index b715102..589f4ee 100644 --- a/design/followups.md +++ b/design/followups.md @@ -111,10 +111,24 @@ Between each publish: wait for the crate to be indexed before the next one's `ca **The OBJREF binding is correct:** `DESKTOP-6JL3KKO[]` with `port` from `tokio::net::TcpListener::bind(0.0.0.0:0)`. Windows Firewall is OFF on all profiles. The hand-rolled exporter accepts connections; NmxSvc just refuses to use it. -**Hypotheses (each needs verification):** -1. NmxSvc validates callback OBJREFs through Windows DCOM (`CoUnmarshalInterface` or similar) before registering them — and the hand-rolled blob fails that validation, surfacing as `RPC_S_SERVER_UNAVAILABLE` because COM interprets it as "the named server is unreachable". -2. The OBJREF carries fields (e.g. specific `STDOBJREF.flags`, security bindings, or authn-hint values) NmxSvc requires that the hand-rolled builder doesn't set correctly. Comparing the byte-by-byte structure shows identical layout to .NET's hand-rolled OBJREF — but the same .NET hand-rolled OBJREF also fails. So this isn't a Rust-vs-.NET layout drift, it's an architecture-vs-NmxSvc gap. -3. The NmxSvc version on this dev machine has stricter callback validation than the reference development version targeted by `MxNativeClient`'s original architecture. (NmxSvc release notes / version unknown at this point.) +**Path C investigation (2026-05-06).** Captured the OBJREF byte structure from both paths via the .NET probe: + +| Field | DCOM-marshalled (works) | Hand-rolled (fails) | +|---|---|---| +| Total size | 338 bytes | 162 bytes | +| `std_flags` | `0x0A80` (SORF_OXRES4+OXRES6+OXRES8) | `0x280` (SORF_OXRES4+OXRES6) | +| `std_public_refs` | 5 | 5 | +| `std_oxid` / `std_oid` / `std_ipid` | random per session | random per session | +| ncacn_ip_tcp bindings | 4 (DESKTOP-6JL3KKO, 10.100.0.48, 2x IPv6 link-local) — **no ports** | 1 (DESKTOP-6JL3KKO[]) — **with port** | +| Security bindings | 7 | 7 | + +Tried setting `std_flags = 0x0A80` on the hand-rolled OBJREF (matching the DCOM-marshalled flag bits): **RegisterEngine2 still fails with the same 1722.** Reverted. + +**Updated diagnosis.** The likely cause is that NmxSvc, on receiving RegisterEngine2 with a callback OBJREF, does its own SCM-side OXID resolution: it calls `IObjectExporter::ResolveOxid` against the local SCM at `127.0.0.1:135` to get the bindings for the OBJREF's OXID, then dials those bindings. Our hand-rolled OXID is **never registered with the local SCM**, so the resolution fails and NmxSvc returns `RPC_S_SERVER_UNAVAILABLE` (1722) — matching the symptom and the sub-second timing (no TCP-dial-back attempt to our listener happens at all). + +DCOM marshalling fixes this because `CoMarshalInterface` internally registers the OXID with RPCSS, so NmxSvc's SCM-side ResolveOxid succeeds. The bindings carry no port because RPCSS-side resolution returns the dynamic port from the Windows DCOM stub layer. + +This makes Path A the architecturally correct fix: the callback exporter must be a DCOM-managed object (registered with RPCSS) for NmxSvc to accept the callback. The hand-rolled-listener-with-explicit-port-in-OBJREF approach used by both the Rust port and the .NET reference's `ManagedCallbackExporter` doesn't satisfy NmxSvc's callback validation. **Three resolution paths (each substantial):** diff --git a/rust/crates/mxaccess-callback/src/exporter.rs b/rust/crates/mxaccess-callback/src/exporter.rs index 33594f0..903e73d 100644 --- a/rust/crates/mxaccess-callback/src/exporter.rs +++ b/rust/crates/mxaccess-callback/src/exporter.rs @@ -210,9 +210,13 @@ impl CallbackExporter { /// Build a callback OBJREF to publish back to the AVEVA service. /// /// Mirrors `ManagedCallbackExporter.CreateCallbackObjRef` - /// (`cs:44-54`): the IID is `INmxSvcCallback`, `std_flags = 0x280`, + /// (`cs:44-54`): the IID is `INmxSvcCallback`, /// `public_refs = 5`, OXID/OID/IPID come from `self.identities`, and /// the single string binding is `"[]"`. + /// + /// `std_flags = 0x280` — `SORF_OXRES4 | SORF_OXRES6` (= `0x80 | + /// 0x200`). Mirrors the .NET reference's `ManagedCallbackExporter` + /// (`cs:48`). #[must_use] pub fn create_callback_objref(&self, hostname: &str) -> Vec { let binding = format!("{hostname}[{port}]", port = self.local_addr.port());