Resolve Client.Rust-022..029: MalformedReply, correlation ids, clippy

Client.Rust-022  Restored Error::MalformedReply for register / add_item /
                 add_item2 and the bulk-subscribe / read-bulk / write-bulk
                 dispatch arms so malformed-but-OK replies fail loudly
                 instead of returning Vec::new().
Client.Rust-023  Restored next_correlation_id and routed every CLI close /
                 stream-alarms / acknowledge-alarm / bench-read-bulk call
                 through it so each call carries a unique opaque token.
Client.Rust-024  Added round-trip tests for read_bulk / write_bulk /
                 write2_bulk / write_secured_bulk / write_secured2_bulk
                 plus stream_alarms and percentile_summary unit tests.
Client.Rust-025  RustClientDesign.md re-synced — new bulk SDK, alarms
                 surface, Error variants, CLI command list, and the
                 Windows stack workaround.
Client.Rust-026  Session::read_bulk now borrows a tag slice; bench-read-
                 bulk binds tags once outside the warm-up / steady-state
                 loops.
Client.Rust-027  .cargo/config.toml selector tightened to
                 cfg(all(windows, target_env = "msvc")) and comment
                 rewritten to match reality (release + debug ship the
                 8 MB reservation).
Client.Rust-028  run_batch removed the empty-line break; stdin EOF is
                 the only terminator.
Client.Rust-029  Re-applied Client.Rust-001 / 002 / 012 — added the
                 missing doc comments, renamed BulkReplyKind variants,
                 and replaced the clone-on-copy with a deref under lock
                 so cargo clippy -D warnings is clean.

All resolved at 2026-05-24; cargo fmt + check + clippy + test all green
(55 tests).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Joseph Doherty
2026-05-24 08:50:15 -04:00
parent 82996aa8e6
commit 4a0f88b17d
10 changed files with 922 additions and 120 deletions
+63 -18
View File
@@ -26,8 +26,8 @@ use zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::{
WriteBulkEntry, WriteSecured2BulkEntry, WriteSecuredBulkEntry,
};
use zb_mom_ww_mxgateway_client::{
ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue, MxValueProjection,
CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
next_correlation_id, ApiKey, ClientOptions, Error, GalaxyClient, GatewayClient, MxValue,
MxValueProjection, CLIENT_VERSION, GATEWAY_PROTOCOL_VERSION, WORKER_PROTOCOL_VERSION,
};
const MAX_AGGREGATE_EVENTS: usize = 10_000;
@@ -359,8 +359,9 @@ enum Command {
/// write `__MXGW_BATCH_EOR__` to stdout after every result. Errors are
/// written as `{"error":"…","type":"error"}` JSON to stdout (not stderr)
/// so the harness can parse them without interleaving stderr. The loop
/// never terminates on command error; only stdin EOF (or an empty line)
/// ends the session.
/// never terminates on command error or accidental blank lines; only
/// stdin EOF ends the session — empty lines log an empty-EOR-bracketed
/// result and continue, matching the other four language CLIs.
Batch,
#[command(subcommand)]
Galaxy(GalaxyCommand),
@@ -503,7 +504,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
let client = connect(connection).await?;
let reply = client
.invoke(MxCommandRequest {
client_correlation_id: "rust-cli-ping".to_owned(),
client_correlation_id: next_correlation_id("cli-ping"),
command: Some(MxCommand {
kind: MxCommandKind::Ping as i32,
payload: Some(zb_mom_ww_mxgateway_client::generated::mxaccess_gateway::v1::mx_command::Payload::Ping(
@@ -550,7 +551,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
let reply = client
.close_session_raw(CloseSessionRequest {
session_id,
client_correlation_id: "rust-cli-close-session".to_owned(),
client_correlation_id: next_correlation_id("cli-close-session"),
})
.await?;
if json {
@@ -624,7 +625,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
json,
} => {
let session = session_for(connection, session_id).await?;
let results = session.read_bulk(server_handle, items, timeout_ms).await?;
let results = session.read_bulk(server_handle, &items, timeout_ms).await?;
print_read_bulk_results("read-bulk", &results, json);
}
Command::WriteBulk {
@@ -832,7 +833,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
let client = connect(connection).await?;
let mut stream = client
.stream_alarms(StreamAlarmsRequest {
client_correlation_id: "rust-cli-stream-alarms".to_owned(),
client_correlation_id: next_correlation_id("cli-stream-alarms"),
alarm_filter_prefix: filter_prefix.unwrap_or_default(),
})
.await?;
@@ -869,7 +870,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
let client = connect(connection).await?;
let reply = client
.acknowledge_alarm(AcknowledgeAlarmRequest {
client_correlation_id: "rust-cli-acknowledge-alarm".to_owned(),
client_correlation_id: next_correlation_id("cli-acknowledge-alarm"),
alarm_full_reference: reference,
comment,
operator_user: operator,
@@ -1113,8 +1114,15 @@ const BATCH_EOR: &str = "__MXGW_BATCH_EOR__";
/// each through the normal [`dispatch`] path, and write [`BATCH_EOR`] to
/// stdout after every result. Errors are serialised as JSON to stdout so
/// the harness can parse them without interleaving stderr. The loop never
/// terminates on command error; only stdin EOF or an empty line ends the
/// session.
/// terminates on command error or accidental blank lines; only stdin EOF
/// ends the session — empty lines log an empty-EOR-bracketed result and
/// continue.
///
/// `std::io::Stdin::lock().lines()` is a blocking iterator and the dispatch
/// future is spawned on a separate tokio task so the runtime's main worker
/// stays free. When the runtime is multi-threaded the blocking read keeps
/// one worker parked on `ReadFile`; that is acceptable here because no other
/// future on the main task needs to run while we wait for the next command.
async fn run_batch() -> Result<(), Error> {
let stdin = io::stdin();
let stdout = io::stdout();
@@ -1125,12 +1133,11 @@ async fn run_batch() -> Result<(), Error> {
detail: e.to_string(),
})?;
if line.is_empty() {
break;
}
let parts: Vec<&str> = line.split_ascii_whitespace().collect();
if parts.is_empty() {
// Empty / whitespace-only line: log an empty-EOR-bracketed
// result and continue so accidental blank lines from the
// PowerShell e2e harness do not silently end the session.
println!("{BATCH_EOR}");
stdout.lock().flush().ok();
continue;
@@ -1388,6 +1395,7 @@ async fn run_bench_read_bulk(
let bench_outcome = async {
let server_handle = session.register(&client_name).await?;
let subscribe_results = session.subscribe_bulk(server_handle, tags.clone()).await?;
let tags_ref: &[String] = &tags;
let item_handles: Vec<i32> = subscribe_results
.iter()
.filter(|r| r.was_successful)
@@ -1401,7 +1409,7 @@ async fn run_bench_read_bulk(
let warmup_deadline = Instant::now() + Duration::from_secs(warmup_seconds);
while Instant::now() < warmup_deadline {
let _ = session
.read_bulk(server_handle, tags.clone(), timeout_ms_param)
.read_bulk(server_handle, tags_ref, timeout_ms_param)
.await;
}
@@ -1419,7 +1427,7 @@ async fn run_bench_read_bulk(
while Instant::now() < steady_deadline {
let call_start = Instant::now();
let result = session
.read_bulk(server_handle, tags.clone(), timeout_ms_param)
.read_bulk(server_handle, tags_ref, timeout_ms_param)
.await;
let elapsed = call_start.elapsed();
latencies_ms.push(elapsed.as_secs_f64() * 1000.0);
@@ -1473,7 +1481,7 @@ async fn run_bench_read_bulk(
let close_result = client
.close_session_raw(CloseSessionRequest {
session_id: session_id.clone(),
client_correlation_id: "rust-cli-bench-read-bulk-close".to_owned(),
client_correlation_id: next_correlation_id("cli-bench-read-bulk-close"),
})
.await;
@@ -2100,6 +2108,43 @@ mod tests {
assert_eq!(super::BATCH_EOR, "__MXGW_BATCH_EOR__");
}
#[test]
fn bench_percentile_summary_matches_hand_built_sample() {
// Hand-built sample with 5 values: 1, 2, 3, 4, 5.
let sample: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let summary = super::percentile_summary(&sample);
assert_eq!(summary.max, 5.0);
// Mean = 15/5 = 3.0
assert!((summary.mean - 3.0).abs() < f64::EPSILON);
// p50: rank = 0.5 * 4 = 2 -> sorted[2] = 3.0
assert!((summary.p50 - 3.0).abs() < f64::EPSILON);
// p95: rank = 0.95 * 4 = 3.8 -> 4.0 + 0.8 * (5.0 - 4.0) = 4.8
assert!((summary.p95 - 4.8).abs() < f64::EPSILON);
// p99: rank = 0.99 * 4 = 3.96 -> 4.0 + 0.96 * 1.0 = 4.96
assert!((summary.p99 - 4.96).abs() < f64::EPSILON);
}
#[test]
fn bench_percentile_summary_handles_empty_sample() {
let summary = super::percentile_summary(&[]);
assert_eq!(summary.p50, 0.0);
assert_eq!(summary.p95, 0.0);
assert_eq!(summary.p99, 0.0);
assert_eq!(summary.max, 0.0);
assert_eq!(summary.mean, 0.0);
}
#[test]
fn bench_percentile_summary_handles_single_value_sample() {
let summary = super::percentile_summary(&[42.0]);
assert_eq!(summary.p50, 42.0);
assert_eq!(summary.p95, 42.0);
assert_eq!(summary.p99, 42.0);
assert_eq!(summary.max, 42.0);
assert_eq!(summary.mean, 42.0);
}
#[test]
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)