[M5] live-probe iteration 1 — major wire-byte reconciliation fixes
First live-test cycle against AVEVA on this box. Comparing the .NET
probe's `--dump-messages` XML output against our NBFX-encoded
envelope surfaced six structural bugs in the F25 envelope/operations
layer. All fixed; tests passing (702 workspace).
Fixes (all backed by the .NET dump as ground truth):
1. **`mustUnderstand` attribute name** — NBFS dict id was 116
(`MustUnderstand`, capital-M, a different SOAP token); SOAP 1.2
spec uses lowercase `mustUnderstand` at id 0. Sending the wrong
one triggered a WCF parse fault that surfaced as TCP RST.
2. **Missing `<a:MessageID>` header** — WCF's default binding
requires MessageID for two-way operations. We now auto-generate
`urn:uuid:<v4>` per envelope via a small inline `make_random_uuid_v4`
helper (no `uuid` crate dep).
3. **Missing `<a:ReplyTo>` anonymous header** — WCF's
BinaryMessageEncoder always emits `<a:ReplyTo><a:Address>...
addressing/anonymous</a:Address></a:ReplyTo>` for two-way ops.
4. **ConnectionValidator field names + namespace** — we were
emitting PascalCase `<ConnectionId>` etc. .NET's WCF
DataContractSerializer uses the private backing-field names
(`<connectionIdField xmlns="...ASBContract">guid</connectionIdField>`)
per `[DataMember(Name = "fooField")]`. Added the
`xmlns:i="...XMLSchema-instance"` declaration WCF emits
alongside (even when no `i:nil` is used). Decoder now accepts
both PascalCase (legacy tests) and DataContract field names.
5. **`<ASBIData>` over-wrapping** — we were emitting
`<Items><ASBIData>{bytes}</ASBIData></Items>`. .NET's
`AsbDataCustomSerializer.WriteStartObject` (`AsbContracts.cs:
1561-1572`) REPLACES the field's outer element with `<ASBIData>`
directly — there's no `<Items>` wrapper on the wire. Fixed by
collapsing `BodyField::AsbiDataElement` to emit just `<ASBIData>`
without the named outer element. The `name` field is retained
for self-documentation.
6. **`collect_asbidata_payloads` API** — was keyed by field name
(`Status` / `Values`); now positional (`payloads[0]`,
`payloads.get(1)`) since the wrapper element is gone. All seven
response decoders updated.
Plus tooling for the live-probe loop:
* `tools/Get-AsbPassphrase.ps1` — DPAPI loader that auto-discovers
the solution name + reads the sharedsecret + decrypts it. Sets
$env:MX_ASB_PASSPHRASE / MX_ASB_HOST / MX_ASB_VIA / MX_LIVE.
Lowercase via-host (WCF SMSvcHost is case-sensitive on the URL
host segment).
* `examples/asb-preamble-probe.rs` — diagnostic that connects,
runs the preamble, captures the PreambleAck, then sends a
synthetic ConnectRequest and dumps both directions as hex. Used
to bisect the wire-byte deltas above.
* `examples/asb-subscribe.rs` port default fixed (5074 → 808 —
WCF's NetTcpPortSharing/SMSvcHost listener confirmed via
Get-NetTCPConnection).
**Status**: preamble + PreambleAck round-trip works end-to-end
against the live AVEVA install (verified via probe). The
post-preamble Connect SOAP envelope still gets TCP RST'd — the six
structural fixes above are necessary but not yet sufficient. Next
iteration needs binary wire capture (Wireshark + Npcap loopback,
or a TCP-relay middleman) to compare the .NET probe's BinaryMessageEncoder
output byte-for-byte with ours and find the remaining delta(s).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -517,7 +517,7 @@ pub struct DeleteMonitoredItemsResponse {
|
||||
pub fn decode_delete_monitored_items_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<DeleteMonitoredItemsResponse, OperationError> {
|
||||
let payload = collect_asbidata_payloads(body_tokens, "Status")
|
||||
let payload = collect_asbidata_payloads(body_tokens)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
@@ -625,7 +625,7 @@ pub struct WriteResponse {
|
||||
}
|
||||
|
||||
pub fn decode_write_response(body_tokens: &[NbfxToken]) -> Result<WriteResponse, OperationError> {
|
||||
let payload = collect_asbidata_payloads(body_tokens, "Status")
|
||||
let payload = collect_asbidata_payloads(body_tokens)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
@@ -843,7 +843,7 @@ pub struct AddMonitoredItemsResponse {
|
||||
pub fn decode_add_monitored_items_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<AddMonitoredItemsResponse, OperationError> {
|
||||
let payloads = collect_asbidata_payloads(body_tokens, "Status");
|
||||
let payloads = collect_asbidata_payloads(body_tokens);
|
||||
let status_payload = payloads
|
||||
.into_iter()
|
||||
.next()
|
||||
@@ -868,17 +868,14 @@ pub struct PublishResponse {
|
||||
pub fn decode_publish_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<PublishResponse, OperationError> {
|
||||
let status_payload = collect_asbidata_payloads(body_tokens, "Status")
|
||||
.into_iter()
|
||||
.next()
|
||||
let payloads = collect_asbidata_payloads(body_tokens);
|
||||
let status_payload = payloads
|
||||
.first()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
let status = decode_item_status_array(&status_payload)?;
|
||||
let status = decode_item_status_array(status_payload)?;
|
||||
|
||||
let values = match collect_asbidata_payloads(body_tokens, "Values")
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
Some(payload) => decode_monitored_item_value_array(&payload)?,
|
||||
let values = match payloads.get(1) {
|
||||
Some(payload) => decode_monitored_item_value_array(payload)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
Ok(PublishResponse { status, values })
|
||||
@@ -980,17 +977,14 @@ pub struct ReadResponse {
|
||||
/// [`crate::decode_envelope`]. Both `Status` and `Values` arrive as
|
||||
/// `<ASBIData>` payloads; we decode the binary form of each.
|
||||
pub fn decode_read_response(body_tokens: &[NbfxToken]) -> Result<ReadResponse, OperationError> {
|
||||
let status_payload = collect_asbidata_payloads(body_tokens, "Status")
|
||||
.into_iter()
|
||||
.next()
|
||||
let payloads = collect_asbidata_payloads(body_tokens);
|
||||
let status_payload = payloads
|
||||
.first()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
let status = decode_item_status_array(&status_payload)?;
|
||||
let status = decode_item_status_array(status_payload)?;
|
||||
|
||||
let values = match collect_asbidata_payloads(body_tokens, "Values")
|
||||
.into_iter()
|
||||
.next()
|
||||
{
|
||||
Some(payload) => decode_runtime_value_array(&payload)?,
|
||||
let values = match payloads.get(1) {
|
||||
Some(payload) => decode_runtime_value_array(payload)?,
|
||||
None => Vec::new(),
|
||||
};
|
||||
|
||||
@@ -1059,7 +1053,7 @@ pub struct UnregisterItemsResponse {
|
||||
pub fn decode_register_items_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<RegisterItemsResponse, OperationError> {
|
||||
let payloads = collect_asbidata_payloads(body_tokens, "Status");
|
||||
let payloads = collect_asbidata_payloads(body_tokens);
|
||||
let status_payload = payloads
|
||||
.into_iter()
|
||||
.next()
|
||||
@@ -1076,7 +1070,7 @@ pub fn decode_register_items_response(
|
||||
pub fn decode_unregister_items_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<UnregisterItemsResponse, OperationError> {
|
||||
let payloads = collect_asbidata_payloads(body_tokens, "Status");
|
||||
let payloads = collect_asbidata_payloads(body_tokens);
|
||||
let status_payload = payloads
|
||||
.into_iter()
|
||||
.next()
|
||||
@@ -1087,25 +1081,32 @@ pub fn decode_unregister_items_response(
|
||||
|
||||
/// Walk a SOAP body's NBFX token stream and pull out the
|
||||
/// `<ASBIData>{Bytes}</ASBIData>` payload bytes for any element named
|
||||
/// `field_name`. Returns `Vec<Vec<u8>>` because some response shapes
|
||||
/// have multiple ASBIData payloads (e.g. `ReadResponse` has both
|
||||
/// `Status` and `Values`).
|
||||
/// outer wrapper element. Returns `Vec<Vec<u8>>` ordered by
|
||||
/// declaration position — for shapes with multiple binary fields
|
||||
/// (e.g. `ReadResponse` has both `Status` and `Values`), the caller
|
||||
/// indexes positionally.
|
||||
///
|
||||
/// Operates on token windows rather than tracking element depth — the
|
||||
/// response shapes are shallow enough that name-keyed scanning is
|
||||
/// reliable. Returns whichever payloads it finds; missing fields
|
||||
/// surface as an empty `Vec`.
|
||||
pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec<Vec<u8>> {
|
||||
/// `[F25 step 11 fix]` Previously this took a `field_name` parameter
|
||||
/// and looked for `<{name}><ASBIData>{Bytes}</ASBIData></{name}>`.
|
||||
/// .NET's `AsbDataCustomSerializer.WriteStartObject` actually
|
||||
/// REPLACES the field's outer element with `<ASBIData>` directly
|
||||
/// (`AsbContracts.cs:1561-1572`), so the wrapper element doesn't
|
||||
/// exist on the wire — confirmed via `MxAsbClient.Probe
|
||||
/// --dump-messages`. The function now returns all payloads in
|
||||
/// declaration order; callers use `payloads[0]`, `payloads.get(1)`
|
||||
/// etc.
|
||||
pub fn collect_asbidata_payloads(tokens: &[NbfxToken]) -> Vec<Vec<u8>> {
|
||||
let mut out = Vec::new();
|
||||
let mut idx = 0;
|
||||
while idx < tokens.len() {
|
||||
if let Some(NbfxToken::Element {
|
||||
while let Some(tok) = tokens.get(idx) {
|
||||
if let NbfxToken::Element {
|
||||
name: NbfxName::Inline(local),
|
||||
..
|
||||
}) = tokens.get(idx)
|
||||
} = tok
|
||||
{
|
||||
if local == field_name {
|
||||
// Skip attributes / namespace decls.
|
||||
if local == "ASBIData" {
|
||||
// Skip attributes / namespace decls between Element
|
||||
// and Text.
|
||||
let mut inner = idx + 1;
|
||||
while matches!(
|
||||
tokens.get(inner),
|
||||
@@ -1115,18 +1116,8 @@ pub fn collect_asbidata_payloads(tokens: &[NbfxToken], field_name: &str) -> Vec<
|
||||
) {
|
||||
inner += 1;
|
||||
}
|
||||
if let Some(NbfxToken::Element {
|
||||
name: NbfxName::Inline(asbidata),
|
||||
..
|
||||
}) = tokens.get(inner)
|
||||
{
|
||||
if asbidata == "ASBIData" {
|
||||
if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) =
|
||||
tokens.get(inner + 1)
|
||||
{
|
||||
out.push(payload.clone());
|
||||
}
|
||||
}
|
||||
if let Some(NbfxToken::Text(NbfxText::Bytes(payload))) = tokens.get(inner) {
|
||||
out.push(payload.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1170,7 +1161,7 @@ pub fn build_unregister_items_request_body(items: &[ItemIdentity]) -> Vec<NbfxTo
|
||||
const IOM_NS: &str = "urn:msg.data.asb.iom:2";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[allow(clippy::enum_variant_names)] // every body field is in fact an element; suffix is descriptive.
|
||||
#[allow(clippy::enum_variant_names, dead_code)] // every body field is in fact an element; suffix is descriptive. `name` on AsbiDataElement is retained for self-documentation but no longer emitted on the wire (see `asbidata_request_body`).
|
||||
enum BodyField {
|
||||
/// Plain element with text body.
|
||||
BoolElement { name: &'static str, value: bool },
|
||||
@@ -1178,8 +1169,11 @@ enum BodyField {
|
||||
/// numeric values as Int8/16/32/64 records — we always pick Int64
|
||||
/// for simplicity; the decoder accepts any width.
|
||||
Int64Element { name: &'static str, value: i64 },
|
||||
/// Element wrapping `<ASBIData>` with base64-binary content (NBFX
|
||||
/// represents that as `Bytes` text records).
|
||||
/// `<ASBIData>` element with binary content (NBFX `Bytes` record).
|
||||
/// `name` is the .NET XmlElement attribute name (e.g. "Items",
|
||||
/// "Values") — kept for self-documentation but ignored on the
|
||||
/// wire because WCF's AsbDataCustomSerializer.WriteStartObject
|
||||
/// replaces the field's outer element with `<ASBIData>` directly.
|
||||
AsbiDataElement {
|
||||
name: &'static str,
|
||||
payload: Vec<u8>,
|
||||
@@ -1243,18 +1237,21 @@ fn asbidata_request_body(outer: &str, fields: &[BodyField]) -> Vec<NbfxToken> {
|
||||
tokens.push(NbfxToken::Text(NbfxText::Int64(*value)));
|
||||
tokens.push(NbfxToken::EndElement);
|
||||
}
|
||||
BodyField::AsbiDataElement { name, payload } => {
|
||||
tokens.push(NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline((*name).to_string()),
|
||||
});
|
||||
BodyField::AsbiDataElement { name: _, payload } => {
|
||||
// WCF's AsbDataCustomSerializer.WriteStartObject
|
||||
// (`AsbContracts.cs:1561-1572`) REPLACES the field's
|
||||
// outer element with `<ASBIData>` rather than nesting
|
||||
// inside it. The `name` parameter (e.g. "Items",
|
||||
// "Values") is ignored on the wire — the .NET
|
||||
// XmlElement attribute name is overridden by the
|
||||
// custom serializer. Verified via .NET probe
|
||||
// `--dump-messages` output.
|
||||
tokens.push(NbfxToken::Element {
|
||||
prefix: None,
|
||||
name: NbfxName::Inline("ASBIData".to_string()),
|
||||
});
|
||||
tokens.push(NbfxToken::Text(NbfxText::Bytes(payload.clone())));
|
||||
tokens.push(NbfxToken::EndElement); // </ASBIData>
|
||||
tokens.push(NbfxToken::EndElement); // </{name}>
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1533,11 +1530,11 @@ mod tests {
|
||||
},
|
||||
NbfxToken::EndElement,
|
||||
];
|
||||
assert!(collect_asbidata_payloads(&body, "Status").is_empty());
|
||||
assert!(collect_asbidata_payloads(&body).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_asbidata_payloads_handles_multiple_fields() {
|
||||
fn collect_asbidata_payloads_handles_multiple_fields_positionally() {
|
||||
let body = asbidata_request_body(
|
||||
"ReadResponse",
|
||||
&[
|
||||
@@ -1545,10 +1542,8 @@ mod tests {
|
||||
BodyField::asbidata("Values", vec![4, 5, 6, 7]),
|
||||
],
|
||||
);
|
||||
let status = collect_asbidata_payloads(&body, "Status");
|
||||
let values = collect_asbidata_payloads(&body, "Values");
|
||||
assert_eq!(status, vec![vec![1u8, 2, 3]]);
|
||||
assert_eq!(values, vec![vec![4u8, 5, 6, 7]]);
|
||||
let payloads = collect_asbidata_payloads(&body);
|
||||
assert_eq!(payloads, vec![vec![1u8, 2, 3], vec![4u8, 5, 6, 7]]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user