[F55] Path C investigation: NmxSvc requires SCM-registered OXID for callbacks
rust / build / test / clippy / fmt (push) Has been cancelled
rust / cargo public-api drift check (F41) (push) Has been cancelled

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) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-06 08:55:59 -04:00
parent c5d611d6fa
commit 0a274af76f
2 changed files with 23 additions and 5 deletions
+18 -4
View File
@@ -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[<port>]` 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[<port>]) — **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):**
@@ -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 `"<hostname>[<port>]"`.
///
/// `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<u8> {
let binding = format!("{hostname}[{port}]", port = self.local_addr.port());