From 218f4c4ec819c6959dce0de5ee879fd2ffb26818 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Wed, 6 May 2026 01:25:41 -0400 Subject: [PATCH] mxaccess-asb: extend F31 InvalidConnectionId tolerance to Read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live MX_ASB_TRACE_REPLY capture against MxDataProvider during the F33 investigation showed Read hitting the same InvalidConnectionId race that F31 fixed for register_items: server replies with a Result wrapper carrying resultCodeField=1 + successField=false plus two empty payloads. The decoder bailed with MissingField "Status" instead of surfacing result_code. Two changes: 1. ReadResponse gains result_code: Option and success: Option fields, matching the RegisterItemsResponse shape. decode_read_response tolerates empty / missing payloads (returns empty status + values arrays) and surfaces the wrapper's result_code / success via find_text_in_named_element. 2. AsbClient::read gets a retry loop mirroring register_items: MAX_ATTEMPTS=10, BACKOFF_BASE_MS=200, total worst-case ~11s. Internal read_once helper does a single attempt; the public read() walks the retry budget on RESULT_CODE_INVALID_CONNECTION_ID. Live verification: cargo run -p mxaccess --example asb-subscribe returned `TestChildObject.TestInt = AsbVariant { type_id: 4, length: 4, payload: [99, 0, 0, 0] }` after presumably one or more transient retries (the previous run without the retry hit "MissingField Status" against the same server state). 1 new test (read_response_tolerates_empty_asbidata_when_invalid_connection_id) plus a synthesise_invalid_connection_id_body helper that builds the canonical wire shape captured live (Result wrapper + resultCodeField=1 + successField=false + two empty elements). Workspace 718 → 722 tests... wait, mxaccess-asb went 79 → 80 (+1). Tests still all green; clippy clean on default and windows-com features. This is foundation for closing F33: the same tolerance pattern needs to apply to the subscribe decoders (decode_create_subscription_response, decode_add_monitored_items_response, decode_publish_response) once a similar live-trace capture confirms their wire shapes. Co-Authored-By: Claude Opus 4.7 (1M context) --- rust/crates/mxaccess-asb/src/client.rs | 28 +++++++ rust/crates/mxaccess-asb/src/operations.rs | 90 +++++++++++++++++++--- 2 files changed, 109 insertions(+), 9 deletions(-) diff --git a/rust/crates/mxaccess-asb/src/client.rs b/rust/crates/mxaccess-asb/src/client.rs index 19964a5..e273935 100644 --- a/rust/crates/mxaccess-asb/src/client.rs +++ b/rust/crates/mxaccess-asb/src/client.rs @@ -433,7 +433,35 @@ impl AsbClient { /// `Read` operation — sends a signed `ReadIn` SOAP envelope and /// decodes the `ReadResponse` (Status array + Values array). + /// + /// Retries up to 10 times with `200 * attempt` ms backoff on + /// `Result.resultCodeField == InvalidConnectionId (1)` — same + /// transient race that affects `register_items` (F31). Without + /// the retry, a Read issued shortly after `register_items` + /// exhausts its own retry budget can land in the same + /// pre-authenticated-state window and fail. Total worst-case + /// wait ~11s. pub async fn read(&mut self, items: &[ItemIdentity]) -> Result { + const MAX_ATTEMPTS: u32 = 10; + const BACKOFF_BASE_MS: u64 = 200; + + let mut response = self.read_once(items).await?; + let mut attempt = 1u32; + while attempt < MAX_ATTEMPTS + && response.result_code + == Some(crate::operations::RESULT_CODE_INVALID_CONNECTION_ID) + { + tokio::time::sleep(std::time::Duration::from_millis( + BACKOFF_BASE_MS * u64::from(attempt), + )) + .await; + response = self.read_once(items).await?; + attempt += 1; + } + Ok(response) + } + + async fn read_once(&mut self, items: &[ItemIdentity]) -> Result { let body = build_read_request_body(items); let response = self .send_signed_envelope(actions::READ, body, None, false) diff --git a/rust/crates/mxaccess-asb/src/operations.rs b/rust/crates/mxaccess-asb/src/operations.rs index 9bffcff..cd89c4c 100644 --- a/rust/crates/mxaccess-asb/src/operations.rs +++ b/rust/crates/mxaccess-asb/src/operations.rs @@ -1012,24 +1012,47 @@ const MESSAGES_NS: &str = "http://asb.contracts.messages/20111111"; pub struct ReadResponse { pub status: Vec, pub values: Vec, + /// `Result.resultCodeField` from the response wrapper. `Some(1)` = + /// `InvalidConnectionId` (transient race — see [`RESULT_CODE_INVALID_CONNECTION_ID`] + /// and `AsbClient::read`'s retry loop). `None` if the field wasn't + /// present (e.g. the server wrapped Read differently). + pub result_code: Option, + /// `Result.successField` — `false` means the operation failed + /// server-side and the per-item Status / Values arrays are empty. + pub success: Option, } /// Decode a `ReadResponse` SOAP body from the NBFX tokens returned by /// [`crate::decode_envelope`]. Both `Status` and `Values` arrive as /// `` payloads; we decode the binary form of each. +/// +/// Tolerates empty / missing `` payloads — that's how the +/// server signals an operation-level failure (`successField=false` +/// with a non-zero `resultCodeField`). Mirrors the tolerance pattern +/// applied to [`decode_register_items_response`] under F31. The +/// caller inspects `result_code` / `success` for transient failures +/// and retries. pub fn decode_read_response(body_tokens: &[NbfxToken]) -> Result { 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 values = match payloads.get(1) { - Some(payload) => decode_runtime_value_array(payload)?, - None => Vec::new(), + let status = match payloads.first() { + Some(payload) if !payload.is_empty() => decode_item_status_array(payload)?, + _ => Vec::new(), }; + let values = match payloads.get(1) { + Some(payload) if !payload.is_empty() => decode_runtime_value_array(payload)?, + _ => Vec::new(), + }; + let result_code = find_text_in_named_element(body_tokens, "resultCodeField") + .and_then(|s| s.parse().ok()); + let success = find_text_in_named_element(body_tokens, "successField") + .map(|s| s.eq_ignore_ascii_case("true")); - Ok(ReadResponse { status, values }) + Ok(ReadResponse { + status, + values, + result_code, + success, + }) } /// Decode a `RuntimeValue[]` array from the WCF custom-serializer @@ -1970,6 +1993,55 @@ mod tests { assert!(decoded.values.is_empty()); } + #[test] + fn read_response_tolerates_empty_asbidata_when_invalid_connection_id() { + // Mirrors the live wire capture from F33 — the server returns + // empty `` Status + empty `` Values + // when it short-circuits on InvalidConnectionId. Decode must + // surface result_code/success rather than erroring with + // MissingField "Status". + let body = synthesise_invalid_connection_id_body("ReadResponse"); + let decoded = decode_read_response(&body).unwrap(); + assert!(decoded.status.is_empty()); + assert!(decoded.values.is_empty()); + assert_eq!(decoded.result_code, Some(1)); + assert_eq!(decoded.success, Some(false)); + } + + /// Build a body shaped like the live `InvalidConnectionId` response + /// captured via `MX_ASB_TRACE_REPLY` against MxDataProvider: + /// Result wrapper with `resultCodeField=1`, `successField=false`, + /// then two empty `` payloads (Status + the second + /// payload, e.g. Values for Read or absent for plain Register). + fn synthesise_invalid_connection_id_body(wrapper: &str) -> Vec { + use mxaccess_asb_nettcp::nbfx::{NbfxName, NbfxText, NbfxToken}; + fn elem(name: &str) -> NbfxToken { + NbfxToken::Element { + prefix: None, + name: NbfxName::Inline(name.to_string()), + } + } + let mut tokens = vec![ + elem(wrapper), + // Result wrapper + elem("Result"), + elem("resultCodeField"), + NbfxToken::Text(NbfxText::One), + NbfxToken::EndElement, + elem("successField"), + NbfxToken::Text(NbfxText::Bool(false)), + NbfxToken::EndElement, + NbfxToken::EndElement, // + ]; + // Two empty payloads. + for _ in 0..2 { + tokens.push(elem("ASBIData")); + tokens.push(NbfxToken::EndElement); + } + tokens.push(NbfxToken::EndElement); // + tokens + } + #[test] fn publish_write_complete_body_is_empty_wrapper() { let body = build_publish_write_complete_request_body();