fix: resolve code-review findings (locally verified)
Server-054/055/056, Contracts-020/021/022, Tests-036/038/039, IntegrationTests-030/031/032 (+033 deferred to live rig), Client.Dotnet-026/028/029 (+027 won't-fix), Client.Go-030..034, Client.Python-032..036, Client.Rust-033..038. Key fix: SessionEventDistributor orphaned a subscriber that registered after the pump completed but before disposal (Server-056) -> register paths now complete late registrants under _lifecycleLock; regression test added. The racy dashboard-mirror gRPC test made deterministic (Tests-039). Verified green locally: gateway Tests targeted classes (GatewaySession, SessionEventDistributor, GatewayOptionsValidator, ProtobufContractRoundTrip, GatewaySessionDashboardMirror) + dotnet/go/python/rust client suites.
This commit is contained in:
@@ -161,7 +161,7 @@ cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5
|
||||
|
||||
### Browsing lazily
|
||||
|
||||
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
|
||||
For UI trees or OPC UA bridges, use `browse_children_raw` to walk one level at a
|
||||
time instead of paging the full hierarchy. Pass a default request for root
|
||||
objects; subsequent calls set `parent_gobject_id`, `parent_tag_name`, or
|
||||
`parent_contained_path`. Filter fields match `discover_hierarchy`. Each response
|
||||
@@ -172,7 +172,7 @@ request and filter semantics.
|
||||
```rust
|
||||
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;
|
||||
|
||||
let reply = galaxy.browse_children(BrowseChildrenRequest::default()).await?.into_inner();
|
||||
let reply = galaxy.browse_children_raw(BrowseChildrenRequest::default()).await?;
|
||||
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
|
||||
println!("{} expand={}", child.tag_name, has_children);
|
||||
}
|
||||
|
||||
@@ -349,8 +349,16 @@ mxgw bench-read-bulk [--duration-seconds <n>] [--warmup-seconds <n>] [--bulk-siz
|
||||
mxgw smoke --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --item TestChildObject.TestInt
|
||||
mxgw batch
|
||||
mxgw galaxy {test-connection,last-deploy-time,discover-hierarchy,watch}
|
||||
mxgw galaxy browse [--parent-gobject-id <id>] [--category-id <id>...] [--template-contains <s>...] [--tag-name-glob <glob>] [--include-attributes] [--alarm-bearing-only] [--historized-only] [--depth <n>] [--json]
|
||||
```
|
||||
|
||||
`galaxy browse` walks the hierarchy one level at a time over the raw
|
||||
`BrowseChildren` paging path. `--depth 0` (the default) prints only the
|
||||
requested level; `--depth N` eagerly expands N additional levels beneath each
|
||||
returned node. `--parent-gobject-id` makes `--depth` a no-op (the parent's
|
||||
children are returned as a single level). Omit `--parent-gobject-id` to browse
|
||||
root objects.
|
||||
|
||||
`batch` reads commands from stdin one per line and dispatches each through
|
||||
the normal subcommand path; the loop terminates only on stdin EOF (blank
|
||||
lines log an empty-EOR-bracketed result and continue) so accidental empty
|
||||
|
||||
@@ -46,8 +46,6 @@ enum Command {
|
||||
Version {
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
#[arg(long)]
|
||||
jsonl: bool,
|
||||
},
|
||||
Ping {
|
||||
#[command(flatten)]
|
||||
@@ -458,9 +456,16 @@ struct ConnectionArgs {
|
||||
endpoint: String,
|
||||
#[arg(long)]
|
||||
api_key: Option<String>,
|
||||
/// Name of the environment variable holding the gateway API key. The
|
||||
/// variable's value must be a full gateway key of the form
|
||||
/// `mxgw_<key-id>_<secret>`; it is forwarded verbatim as the Bearer
|
||||
/// token, so do not point this at an unrelated credential.
|
||||
#[arg(long, default_value = "MXGATEWAY_API_KEY")]
|
||||
api_key_env: String,
|
||||
#[arg(long)]
|
||||
/// Use an unencrypted (plaintext h2c) channel. Mutually exclusive with
|
||||
/// `--tls`; supplying both is rejected so an explicit `--tls` cannot be
|
||||
/// silently downgraded.
|
||||
#[arg(long, conflicts_with = "tls")]
|
||||
plaintext: bool,
|
||||
#[arg(long)]
|
||||
tls: bool,
|
||||
@@ -545,7 +550,7 @@ async fn dispatch(command: Command) -> Result<(), Error> {
|
||||
detail: "batch cannot be nested inside another batch session".to_owned(),
|
||||
});
|
||||
}
|
||||
Command::Version { json, .. } => print_version(json),
|
||||
Command::Version { json } => print_version(json),
|
||||
Command::Ping {
|
||||
connection,
|
||||
message,
|
||||
@@ -1214,6 +1219,24 @@ const BROWSE_PAGE_SIZE: i32 = 500;
|
||||
/// Drive `BrowseChildren` paging by hand for a single parent and return the
|
||||
/// flattened child list. Used by the `browse --parent-gobject-id` path, which
|
||||
/// surfaces one level of children rather than the lazy root-tree walk.
|
||||
/// Record a non-empty `next_page_token` in `seen` and reject a repeat. A
|
||||
/// server that returns the same continuation token twice would loop forever,
|
||||
/// so the second sighting is converted to an `InvalidArgument` error. Extracted
|
||||
/// from [`browse_children_one_level`] so the guard can be unit-tested without a
|
||||
/// network client.
|
||||
fn register_page_token(
|
||||
seen: &mut std::collections::HashSet<String>,
|
||||
token: &str,
|
||||
) -> Result<(), Error> {
|
||||
if !seen.insert(token.to_owned()) {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "page_token".to_owned(),
|
||||
detail: format!("galaxy browse children returned repeated page token `{token}`"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn browse_children_one_level(
|
||||
client: &mut GalaxyClient,
|
||||
parent_gobject_id: i32,
|
||||
@@ -1254,14 +1277,7 @@ async fn browse_children_one_level(
|
||||
if page_token.is_empty() {
|
||||
return Ok(children);
|
||||
}
|
||||
if !seen.insert(page_token.clone()) {
|
||||
return Err(Error::InvalidArgument {
|
||||
name: "page_token".to_owned(),
|
||||
detail: format!(
|
||||
"galaxy browse children returned repeated page token `{page_token}`"
|
||||
),
|
||||
});
|
||||
}
|
||||
register_page_token(&mut seen, &page_token)?;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2337,7 +2353,18 @@ where
|
||||
mod tests {
|
||||
use clap::Parser;
|
||||
|
||||
use super::Cli;
|
||||
use super::{Cli, Command};
|
||||
|
||||
/// Pull the flattened `ConnectionArgs` out of a parsed `ping` command so
|
||||
/// `ConnectionArgs::options()` can be exercised directly.
|
||||
fn connection_from_ping(args: &[&str]) -> super::ConnectionArgs {
|
||||
let mut full = vec!["mxgw", "ping"];
|
||||
full.extend_from_slice(args);
|
||||
match Cli::try_parse_from(full).expect("ping parse").command {
|
||||
Command::Ping { connection, .. } => connection,
|
||||
other => panic!("expected ping command, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_version_json_command() {
|
||||
@@ -2345,6 +2372,36 @@ mod tests {
|
||||
assert!(parsed.is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_defaults_to_plaintext() {
|
||||
let options = connection_from_ping(&[]).options();
|
||||
assert!(options.plaintext(), "default channel should be plaintext");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_tls_flag_disables_plaintext() {
|
||||
let options = connection_from_ping(&["--tls"]).options();
|
||||
assert!(
|
||||
!options.plaintext(),
|
||||
"--tls must select an encrypted channel"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_plaintext_flag_selects_plaintext() {
|
||||
let options = connection_from_ping(&["--plaintext"]).options();
|
||||
assert!(options.plaintext());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn connection_rejects_tls_and_plaintext_together() {
|
||||
let parsed = Cli::try_parse_from(["mxgw", "ping", "--tls", "--plaintext"]);
|
||||
assert!(
|
||||
parsed.is_err(),
|
||||
"--tls and --plaintext must conflict so TLS cannot be silently downgraded"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_write_command() {
|
||||
let parsed = Cli::try_parse_from([
|
||||
@@ -2513,6 +2570,50 @@ mod tests {
|
||||
assert_eq!(summary.mean, 42.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn register_page_token_accepts_distinct_tokens_and_rejects_repeats() {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
assert!(super::register_page_token(&mut seen, "tok-1").is_ok());
|
||||
assert!(super::register_page_token(&mut seen, "tok-2").is_ok());
|
||||
|
||||
let repeated = super::register_page_token(&mut seen, "tok-1");
|
||||
match repeated {
|
||||
Err(super::Error::InvalidArgument { name, detail }) => {
|
||||
assert_eq!(name, "page_token");
|
||||
assert!(detail.contains("tok-1"), "detail: {detail}");
|
||||
}
|
||||
other => panic!("expected InvalidArgument on repeated token, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_trailing_characters() {
|
||||
let err = super::parse_rfc3339_timestamp("2026-04-28T15:30:00Zextra");
|
||||
assert!(err.is_err(), "trailing chars after Z must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_day_zero() {
|
||||
let err = super::parse_rfc3339_timestamp("2026-04-00T15:30:00Z");
|
||||
assert!(err.is_err(), "day 0 must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_month_thirteen() {
|
||||
let err = super::parse_rfc3339_timestamp("2026-13-01T15:30:00Z");
|
||||
assert!(err.is_err(), "month 13 must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_rejects_day_out_of_range_for_month() {
|
||||
// April has 30 days.
|
||||
let err = super::parse_rfc3339_timestamp("2026-04-31T15:30:00Z");
|
||||
assert!(err.is_err(), "April 31 must be rejected");
|
||||
// February 29 in a non-leap year.
|
||||
let feb = super::parse_rfc3339_timestamp("2025-02-29T00:00:00Z");
|
||||
assert!(feb.is_err(), "Feb 29 in a non-leap year must be rejected");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rfc3339_parser_round_trips_z_and_offset_inputs() {
|
||||
// 2026-04-28T15:30:00Z = 1_777_995_000 (sanity-checked once below)
|
||||
|
||||
Reference in New Issue
Block a user