[F33] mxaccess-asb: complete InvalidConnectionId tolerance propagation
rust / build / test / clippy / fmt (push) Has been cancelled
rust / build / test / clippy / fmt (push) Has been cancelled
Closes F33. Final commit in the three-step F33 closure (218f4c4→7a5f251→ this) — propagates the F31 InvalidConnectionId tolerance pattern to every remaining response decoder + adds publish-loop detection so the F26 stream terminates cleanly on server-side rejections instead of spinning silently. Decoders updated to tolerate empty / missing payloads + surface result_code/success: - decode_publish_response (the F26 stream's hot path) - decode_unregister_items_response - decode_delete_monitored_items_response - decode_write_response - decode_publish_write_complete_response Shared `extract_result_status(body_tokens)` helper in operations.rs consolidates the per-decoder find_text_in_named_element calls for resultCodeField + successField — a single source of truth for the F31-pattern wrapper extraction. Public response structs gain `result_code: Option<u32>` and `success: Option<bool>`: - PublishResponse - UnregisterItemsResponse - DeleteMonitoredItemsResponse - WriteResponse - PublishWriteCompleteResponse asb_session.rs::publish_loop: when PublishResponse.result_code is Some(non_zero), the loop now sends Err(ConnectionError::TransportFailure { detail: "publish returned result_code 0xXX (server-side rejection)" }) as the stream's terminal item, then returns. Without this, an InvalidConnectionId-poisoned subscription would generate empty PublishResponse forever. 5 new tests synthesise the InvalidConnectionId wire shape (`<Result><resultCodeField>1</><successField>false</></><ASBIData/><ASBIData/>`) for each decoder via the shared synthesise_invalid_connection_id_body helper — pin the tolerance for Publish, Unregister, Delete*, Write, and PublishWriteComplete. Updated obsolete write_response_missing_status_fails test to write_response_missing_status_returns_empty_with_no_result_code since the decoder no longer errors. Live read regression test: TestChildObject.TestInt = 99 returned end-to-end after all changes (cargo run -p mxaccess --example asb-subscribe). Workspace: mxaccess-asb 82 → 87 tests (+5). All other crates unchanged. Default-feature clippy clean. design/followups.md: F33 moved to Resolved with the full three-commit audit trail. M5 status block stable: F32 + F33 closed, only F28 (canonical XML for the remaining 8 ops) remains as P2 latent — works in practice under empty hashAlgorithm. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -466,6 +466,10 @@ pub fn build_publish_write_complete_request_body() -> Vec<NbfxToken> {
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct PublishWriteCompleteResponse {
|
||||
pub complete_writes_count: usize,
|
||||
/// `Result.resultCodeField` per the F31 InvalidConnectionId pattern.
|
||||
pub result_code: Option<u32>,
|
||||
/// `Result.successField` per the F31 pattern.
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn decode_publish_write_complete_response(
|
||||
@@ -480,8 +484,11 @@ pub fn decode_publish_write_complete_response(
|
||||
)
|
||||
})
|
||||
.count();
|
||||
let (result_code, success) = extract_result_status(body_tokens);
|
||||
Ok(PublishWriteCompleteResponse {
|
||||
complete_writes_count: count,
|
||||
result_code,
|
||||
success,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -553,17 +560,25 @@ pub fn build_delete_monitored_items_request_body(
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct DeleteMonitoredItemsResponse {
|
||||
pub status: Vec<ItemStatus>,
|
||||
/// `Result.resultCodeField` per the F31 InvalidConnectionId pattern.
|
||||
pub result_code: Option<u32>,
|
||||
/// `Result.successField` per the F31 pattern.
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn decode_delete_monitored_items_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<DeleteMonitoredItemsResponse, OperationError> {
|
||||
let payload = collect_asbidata_payloads(body_tokens)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
let status = decode_item_status_array(&payload)?;
|
||||
Ok(DeleteMonitoredItemsResponse { status })
|
||||
let status = match collect_asbidata_payloads(body_tokens).into_iter().next() {
|
||||
Some(payload) if !payload.is_empty() => decode_item_status_array(&payload)?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (result_code, success) = extract_result_status(body_tokens);
|
||||
Ok(DeleteMonitoredItemsResponse {
|
||||
status,
|
||||
result_code,
|
||||
success,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Write operation (F25 step 9) ---------------------------------------
|
||||
@@ -663,15 +678,23 @@ pub fn build_write_request_body(
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct WriteResponse {
|
||||
pub status: Vec<ItemStatus>,
|
||||
/// `Result.resultCodeField` per the F31 InvalidConnectionId pattern.
|
||||
pub result_code: Option<u32>,
|
||||
/// `Result.successField` per the F31 pattern.
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn decode_write_response(body_tokens: &[NbfxToken]) -> Result<WriteResponse, OperationError> {
|
||||
let payload = collect_asbidata_payloads(body_tokens)
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
let status = decode_item_status_array(&payload)?;
|
||||
Ok(WriteResponse { status })
|
||||
let status = match collect_asbidata_payloads(body_tokens).into_iter().next() {
|
||||
Some(payload) if !payload.is_empty() => decode_item_status_array(&payload)?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (result_code, success) = extract_result_status(body_tokens);
|
||||
Ok(WriteResponse {
|
||||
status,
|
||||
result_code,
|
||||
success,
|
||||
})
|
||||
}
|
||||
|
||||
// ---- Subscription operations (F25 step 8) -------------------------------
|
||||
@@ -940,22 +963,42 @@ pub fn decode_add_monitored_items_response(
|
||||
pub struct PublishResponse {
|
||||
pub status: Vec<ItemStatus>,
|
||||
pub values: Vec<MonitoredItemValue>,
|
||||
/// `Result.resultCodeField` per the F31 InvalidConnectionId pattern.
|
||||
/// On the F26 stream's hot path: when this is `Some(non_zero)` the
|
||||
/// publish-loop should terminate the stream with an error rather
|
||||
/// than silently delivering empty value arrays forever.
|
||||
pub result_code: Option<u32>,
|
||||
/// `Result.successField` per the F31 pattern.
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
pub fn decode_publish_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<PublishResponse, OperationError> {
|
||||
let payloads = collect_asbidata_payloads(body_tokens);
|
||||
// Tolerate empty/missing Status payload — that's the
|
||||
// InvalidConnectionId short-circuit shape captured live in F33.
|
||||
let status_payload = payloads
|
||||
.first()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
let status = decode_item_status_array(status_payload)?;
|
||||
.map(Vec::as_slice)
|
||||
.unwrap_or(&[]);
|
||||
let status = if status_payload.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
decode_item_status_array(status_payload)?
|
||||
};
|
||||
|
||||
let values = match payloads.get(1) {
|
||||
Some(payload) => decode_monitored_item_value_array(payload)?,
|
||||
None => Vec::new(),
|
||||
Some(payload) if !payload.is_empty() => decode_monitored_item_value_array(payload)?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
Ok(PublishResponse { status, values })
|
||||
let (result_code, success) = extract_result_status(body_tokens);
|
||||
Ok(PublishResponse {
|
||||
status,
|
||||
values,
|
||||
result_code,
|
||||
success,
|
||||
})
|
||||
}
|
||||
|
||||
/// Decoded `DeleteSubscriptionResponse`. Empty body per
|
||||
@@ -1162,6 +1205,23 @@ pub const RESULT_CODE_INVALID_CONNECTION_ID: u32 = 1;
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct UnregisterItemsResponse {
|
||||
pub status: Vec<ItemStatus>,
|
||||
/// `Result.resultCodeField` per the F31 InvalidConnectionId pattern.
|
||||
pub result_code: Option<u32>,
|
||||
/// `Result.successField` per the F31 pattern.
|
||||
pub success: Option<bool>,
|
||||
}
|
||||
|
||||
/// Shared helper for the F31 InvalidConnectionId tolerance pattern.
|
||||
/// Extracts `Result.resultCodeField` and `Result.successField` from
|
||||
/// the response body when the server returns the Result wrapper for
|
||||
/// an operation-level failure. Returns `(None, None)` for the success
|
||||
/// path where the wrapper isn't emitted.
|
||||
fn extract_result_status(body_tokens: &[NbfxToken]) -> (Option<u32>, Option<bool>) {
|
||||
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"));
|
||||
(result_code, success)
|
||||
}
|
||||
|
||||
/// Decode a `RegisterItemsResponse` SOAP body from the NBFX token
|
||||
@@ -1180,10 +1240,7 @@ pub fn decode_register_items_response(
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let item_capabilities_present = find_element_named(body_tokens, "ItemCapabilities").is_some();
|
||||
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"));
|
||||
let (result_code, success) = extract_result_status(body_tokens);
|
||||
Ok(RegisterItemsResponse {
|
||||
status,
|
||||
item_capabilities_present,
|
||||
@@ -1234,17 +1291,21 @@ fn find_text_in_named_element(tokens: &[NbfxToken], name: &str) -> Option<String
|
||||
None
|
||||
}
|
||||
|
||||
/// Decode an `UnregisterItemsResponse` SOAP body.
|
||||
/// Decode an `UnregisterItemsResponse` SOAP body. Tolerates empty/
|
||||
/// missing Status payload per the F31 pattern.
|
||||
pub fn decode_unregister_items_response(
|
||||
body_tokens: &[NbfxToken],
|
||||
) -> Result<UnregisterItemsResponse, OperationError> {
|
||||
let payloads = collect_asbidata_payloads(body_tokens);
|
||||
let status_payload = payloads
|
||||
.into_iter()
|
||||
.next()
|
||||
.ok_or(OperationError::MissingField { field: "Status" })?;
|
||||
let status = decode_item_status_array(&status_payload)?;
|
||||
Ok(UnregisterItemsResponse { status })
|
||||
let status = match collect_asbidata_payloads(body_tokens).into_iter().next() {
|
||||
Some(payload) if !payload.is_empty() => decode_item_status_array(&payload)?,
|
||||
_ => Vec::new(),
|
||||
};
|
||||
let (result_code, success) = extract_result_status(body_tokens);
|
||||
Ok(UnregisterItemsResponse {
|
||||
status,
|
||||
result_code,
|
||||
success,
|
||||
})
|
||||
}
|
||||
|
||||
/// Walk a SOAP body's NBFX token stream and pull out the
|
||||
@@ -2272,13 +2333,60 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_response_missing_status_fails() {
|
||||
fn write_response_missing_status_returns_empty_with_no_result_code() {
|
||||
// Post-F33 the decoder is tolerant of missing Status — it
|
||||
// returns empty status with result_code/success unset.
|
||||
let body = asbidata_request_body("WriteResponse", &[]);
|
||||
let err = decode_write_response(&body).unwrap_err();
|
||||
assert!(matches!(
|
||||
err,
|
||||
OperationError::MissingField { field: "Status" }
|
||||
));
|
||||
let response = decode_write_response(&body).unwrap();
|
||||
assert!(response.status.is_empty());
|
||||
assert_eq!(response.result_code, None);
|
||||
assert_eq!(response.success, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_response_surfaces_invalid_connection_id() {
|
||||
let body = synthesise_invalid_connection_id_body("WriteResponse");
|
||||
let response = decode_write_response(&body).unwrap();
|
||||
assert!(response.status.is_empty());
|
||||
assert_eq!(response.result_code, Some(1));
|
||||
assert_eq!(response.success, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_response_surfaces_invalid_connection_id() {
|
||||
let body = synthesise_invalid_connection_id_body("PublishResponse");
|
||||
let response = decode_publish_response(&body).unwrap();
|
||||
assert!(response.status.is_empty());
|
||||
assert!(response.values.is_empty());
|
||||
assert_eq!(response.result_code, Some(1));
|
||||
assert_eq!(response.success, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unregister_items_response_surfaces_invalid_connection_id() {
|
||||
let body = synthesise_invalid_connection_id_body("UnregisterItemsResponse");
|
||||
let response = decode_unregister_items_response(&body).unwrap();
|
||||
assert!(response.status.is_empty());
|
||||
assert_eq!(response.result_code, Some(1));
|
||||
assert_eq!(response.success, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn delete_monitored_items_response_surfaces_invalid_connection_id() {
|
||||
let body = synthesise_invalid_connection_id_body("DeleteMonitoredItemsResponse");
|
||||
let response = decode_delete_monitored_items_response(&body).unwrap();
|
||||
assert!(response.status.is_empty());
|
||||
assert_eq!(response.result_code, Some(1));
|
||||
assert_eq!(response.success, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn publish_write_complete_response_surfaces_invalid_connection_id() {
|
||||
let body = synthesise_invalid_connection_id_body("PublishWriteCompleteResponse");
|
||||
let response = decode_publish_write_complete_response(&body).unwrap();
|
||||
assert_eq!(response.complete_writes_count, 0);
|
||||
assert_eq!(response.result_code, Some(1));
|
||||
assert_eq!(response.success, Some(false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -363,6 +363,25 @@ async fn publish_loop<F, Fut>(
|
||||
loop {
|
||||
match publish_fn().await {
|
||||
Ok(response) => {
|
||||
// F33: if the server short-circuited with a non-zero
|
||||
// resultCodeField (e.g. InvalidConnectionId), terminate
|
||||
// the stream rather than silently delivering empty
|
||||
// value batches forever. Caller can still inspect the
|
||||
// error via the final stream item.
|
||||
if let Some(code) = response.result_code {
|
||||
if code != 0 {
|
||||
let _ = tx
|
||||
.send(Err(Error::Connection(
|
||||
ConnectionError::TransportFailure {
|
||||
detail: format!(
|
||||
"publish returned result_code 0x{code:08X} (server-side rejection)"
|
||||
),
|
||||
},
|
||||
)))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
for value in response.values {
|
||||
if tx.send(Ok(value)).await.is_err() {
|
||||
return; // consumer dropped the stream
|
||||
@@ -471,6 +490,8 @@ mod tests {
|
||||
PublishResponse {
|
||||
status: Vec::<ItemStatus>::new(),
|
||||
values,
|
||||
result_code: None,
|
||||
success: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user