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:
Joseph Doherty
2026-06-17 05:23:14 -04:00
parent 25d04ec37e
commit 6b5fe6aa82
37 changed files with 1049 additions and 211 deletions
+5 -3
View File
@@ -140,19 +140,21 @@ service requires the `metadata:read` scope on the API key.
### 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 loading the full hierarchy with `discover_hierarchy`. Pass an
empty request for root objects; subsequent calls set `parent_gobject_id`,
`parent_tag_name`, or `parent_contained_path`. Filter fields match
`DiscoverHierarchy`. Each response pairs `children` with `child_has_children` so
you know which nodes to expand. See
you know which nodes to expand. Most callers should prefer the higher-level
`browse()` / `LazyBrowseNode` walker below; `browse_children_raw` is the
low-level escape hatch for direct page-token control. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```python
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb2
reply = await galaxy.browse_children(galaxy_pb2.BrowseChildrenRequest())
reply = await galaxy.browse_children_raw(galaxy_pb2.BrowseChildrenRequest())
for child, has_children in zip(reply.children, reply.child_has_children):
print(child.tag_name, "expand=" + str(has_children))
```
@@ -2,7 +2,7 @@
from .auth import ApiKey, auth_metadata
from .client import GatewayClient
from .galaxy import GalaxyRepositoryClient
from .galaxy import GalaxyRepositoryClient, LazyBrowseNode
from .generated.galaxy_repository_pb2 import (
DeployEvent,
GalaxyAttribute,
@@ -19,19 +19,21 @@ from .errors import (
MxGatewayTransportError,
MxGatewayWorkerError,
)
from .options import ClientOptions
from .options import BrowseChildrenOptions, ClientOptions
from .session import Session
from .values import MxValueView, from_mx_value, to_mx_value
from .version import __version__
__all__ = [
"ApiKey",
"BrowseChildrenOptions",
"ClientOptions",
"DeployEvent",
"GalaxyAttribute",
"GalaxyObject",
"GalaxyRepositoryClient",
"GatewayClient",
"LazyBrowseNode",
"MxAccessError",
"MxGatewayAuthenticationError",
"MxGatewayAuthorizationError",
@@ -769,7 +769,7 @@ def _build_write_bulk_entries(kwargs: dict[str, Any]):
"""
handles = _parse_int_list(kwargs["item_handles"])
value_texts = _parse_string_list(kwargs["values"])
value_texts = _parse_string_list(kwargs["values"], param_hint="--values")
if len(handles) != len(value_texts):
raise click.UsageError(
f"item-handles count ({len(handles)}) does not match values count ({len(value_texts)})",
@@ -1045,8 +1045,7 @@ async def _write2(**kwargs: Any) -> dict[str, Any]:
async def _smoke(**kwargs: Any) -> dict[str, Any]:
async with await _connect(kwargs) as client:
session = await client.open_session(client_session_name=kwargs["client_name"])
closed = False
try:
async with session:
server_handle = await session.register(kwargs["client_name"])
item_handle = await session.add_item(server_handle, kwargs["item"])
await session.advise(server_handle, item_handle)
@@ -1061,9 +1060,6 @@ async def _smoke(**kwargs: Any) -> dict[str, Any]:
"itemHandle": item_handle,
"events": [_message_dict(event) for event in events],
}
finally:
if not closed:
await session.close()
async def _galaxy_test_connection(**kwargs: Any) -> dict[str, Any]:
@@ -1487,10 +1483,10 @@ def _parse_datetime(raw_value: str) -> datetime:
return parsed
def _parse_string_list(raw_value: str) -> list[str]:
def _parse_string_list(raw_value: str, param_hint: str = "--items") -> list[str]:
values = [item.strip() for item in raw_value.split(",") if item.strip()]
if not values:
raise click.BadParameter("at least one item is required", param_hint="--items")
raise click.BadParameter("at least one item is required", param_hint=param_hint)
return values
@@ -1498,7 +1494,12 @@ def _parse_int_list(raw_value: str) -> list[int]:
values = [item.strip() for item in raw_value.split(",") if item.strip()]
if not values:
raise click.BadParameter("at least one item handle is required", param_hint="--item-handles")
return [int(item) for item in values]
try:
return [int(item) for item in values]
except ValueError as exc:
raise click.BadParameter(
f"item handles must be integers: {exc}", param_hint="--item-handles"
) from exc
def _message_dict(message: Any) -> dict[str, Any]:
@@ -0,0 +1,131 @@
"""Regression tests for Client.Python-032..036.
Each test corresponds to a finding from the 2026-06-16 re-review. Tests are
TDD-first — they fail against the pre-fix source and pass against the fixed
source.
"""
from __future__ import annotations
import inspect
import re
from pathlib import Path
import click
import pytest
from zb_mom_ww_mxgateway_cli import commands as cli_commands
from zb_mom_ww_mxgateway_cli.commands import _parse_int_list, _parse_string_list
# ---------------------------------------------------------------------------
# Client.Python-032 — `_smoke` must not carry the dead `closed` guard variable.
# ---------------------------------------------------------------------------
def test_smoke_does_not_carry_dead_closed_guard() -> None:
"""`_smoke` must not reintroduce the dead `closed = False` / `if not closed`
guard removed by Client.Python-004. The variable is never reassigned, so the
guard misleads readers into expecting an early-close path that never exists.
"""
source = inspect.getsource(cli_commands._smoke)
assert "closed = False" not in source, (
"_smoke must not reintroduce the dead `closed = False` variable"
)
assert "if not closed:" not in source, (
"_smoke must not reintroduce the dead `if not closed:` guard"
)
# ---------------------------------------------------------------------------
# Client.Python-033 — `_parse_string_list` param_hint must reflect the caller.
# ---------------------------------------------------------------------------
def test_parse_string_list_default_param_hint_is_items() -> None:
with pytest.raises(click.BadParameter) as exc:
_parse_string_list("")
assert exc.value.param_hint == "--items"
def test_parse_string_list_accepts_caller_supplied_param_hint() -> None:
"""The write-bulk family passes `--values`, so an empty value must surface a
`--values` hint, not the irrelevant `--items` default.
"""
with pytest.raises(click.BadParameter) as exc:
_parse_string_list("", param_hint="--values")
assert exc.value.param_hint == "--values"
# ---------------------------------------------------------------------------
# Client.Python-034 — `_parse_int_list` must re-raise non-numeric tokens as
# click.BadParameter, not a raw ValueError traceback.
# ---------------------------------------------------------------------------
def test_parse_int_list_non_numeric_raises_bad_parameter() -> None:
with pytest.raises(click.BadParameter) as exc:
_parse_int_list("10,abc")
assert exc.value.param_hint == "--item-handles"
def test_parse_int_list_happy_path() -> None:
assert _parse_int_list("10, 20 ,30") == [10, 20, 30]
# ---------------------------------------------------------------------------
# Client.Python-035 — public browse types must be re-exported from the package
# root.
# ---------------------------------------------------------------------------
def test_browse_children_options_is_exported_from_package_root() -> None:
import zb_mom_ww_mxgateway as pkg
assert hasattr(pkg, "BrowseChildrenOptions")
assert "BrowseChildrenOptions" in pkg.__all__
def test_lazy_browse_node_is_exported_from_package_root() -> None:
import zb_mom_ww_mxgateway as pkg
assert hasattr(pkg, "LazyBrowseNode")
assert "LazyBrowseNode" in pkg.__all__
# ---------------------------------------------------------------------------
# Client.Python-036 — README "Browsing lazily" example must reference a method
# that actually exists on GalaxyRepositoryClient.
# ---------------------------------------------------------------------------
def _readme_path() -> Path:
return Path(__file__).resolve().parent.parent / "README.md"
def test_galaxy_client_exposes_browse_children_raw() -> None:
"""Guard the method name the README example depends on so future renames
break this test rather than only failing at runtime in user code.
"""
from zb_mom_ww_mxgateway import GalaxyRepositoryClient
assert hasattr(GalaxyRepositoryClient, "browse_children_raw")
def test_readme_browse_example_uses_existing_method() -> None:
"""The README's `galaxy.<method>(...BrowseChildrenRequest...)` call must name
a method that exists on GalaxyRepositoryClient.
"""
from zb_mom_ww_mxgateway import GalaxyRepositoryClient
text = _readme_path().read_text(encoding="utf-8")
called = set(re.findall(r"galaxy\.([A-Za-z_][A-Za-z0-9_]*)\s*\(", text))
assert called, "README must contain at least one galaxy.<method>(...) example"
for method in called:
assert hasattr(GalaxyRepositoryClient, method), (
f"README references galaxy.{method}() but no such method exists"
)