test(tst-24): drive the .NET and Python clients against real in-process gRPC servers
ci / nightly-windev (push) Has been skipped
ci / java (push) Successful in 2m19s
ci / portable (push) Successful in 23m59s
ci / windows-x86 (push) Failing after 58m42s

TST-24 asked for per-client wire tests against a fake gateway. An audit first
corrected the finding's premise: Go, Rust, and Java already had them — bufconn,
a loopback tonic server, and InProcessServerBuilder respectively — each already
asserting the round trip, the server-observed bearer header, and the ReplayGap
sentinel. The two genuine gaps were .NET (every test substituted the transport
interface; the test project had no server package at all) and Python (stub
monkeypatching everywhere but one opt-in TLS test).

Both now serve mxaccess_gateway.v1.MxAccessGateway over a real transport —
Kestrel h2c and grpc.aio, each on an ephemeral loopback port — and drive the
ordinary public client API against it. Only the gateway's behaviour is canned;
the framing, serialization, metadata, and status codes are genuine. Four shapes
each: full round trip with every reply field asserted, the authorization header
as received by the server (including on the streaming RPC), the ReplayGap
sentinel surfaced as the client's typed signal, and a real PERMISSION_DENIED
mapping to the typed authorization error.

The .NET client was only ever compiled in CI, never tested, so the portable job
gains a dotnet test step.

Fixes a bug the new tests caught on their first run: Python's connect() built the
grpc.aio channel inside asyncio.to_thread, and a grpc.aio channel binds to the
event loop current on the constructing thread, so every non-stub connection
raised 'There is no current event loop in thread'. No mock-based test could see
it, and the test guarding the off-loop behaviour patched create_channel and so
asserted the bug. Split resolve_channel_security (blocking TOFU probe, off-loop)
from create_channel (on-loop); the guard tests now assert both halves.
This commit is contained in:
Joseph Doherty
2026-08-10 08:22:25 -04:00
parent d4302c6ac4
commit a8f86b5336
16 changed files with 980 additions and 76 deletions
@@ -12,7 +12,7 @@ from .auth import merge_metadata
from .errors import ensure_protocol_success, map_rpc_error
from .generated import mxaccess_gateway_pb2 as pb
from .generated import mxaccess_gateway_pb2_grpc as pb_grpc
from .options import ClientOptions, create_channel
from .options import ClientOptions, create_channel, resolve_channel_security
class GatewayClient:
@@ -58,9 +58,13 @@ class GatewayClient:
if stub is not None:
return cls(options=resolved, stub=stub)
# create_channel may perform a blocking TLS certificate probe (TOFU
# default); run it off the event loop so connect never freezes the loop.
channel = await asyncio.to_thread(create_channel, resolved)
# Resolving security may perform a blocking TLS certificate probe (TOFU
# default); run that off the event loop so connect never freezes it. The
# channel itself must be built on the loop thread — a grpc.aio channel
# binds to the loop current on the constructing thread, and a worker
# thread has none.
security = await asyncio.to_thread(resolve_channel_security, resolved)
channel = create_channel(resolved, security=security)
return cls(
options=resolved,
stub=pb_grpc.MxAccessGatewayStub(channel),
@@ -21,7 +21,12 @@ from .auth import merge_metadata
from .errors import MxGatewayError, map_rpc_error
from .generated import galaxy_repository_pb2 as galaxy_pb
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
from .options import BrowseChildrenOptions, ClientOptions, create_channel
from .options import (
BrowseChildrenOptions,
ClientOptions,
create_channel,
resolve_channel_security,
)
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000
_BROWSE_CHILDREN_PAGE_SIZE = 500
@@ -70,9 +75,13 @@ class GalaxyRepositoryClient:
if stub is not None:
return cls(options=resolved, stub=stub)
# create_channel may perform a blocking TLS certificate probe (TOFU
# default); run it off the event loop so connect never freezes the loop.
channel = await asyncio.to_thread(create_channel, resolved)
# Resolving security may perform a blocking TLS certificate probe (TOFU
# default); run that off the event loop so connect never freezes it. The
# channel itself must be built on the loop thread — a grpc.aio channel
# binds to the loop current on the constructing thread, and a worker
# thread has none.
security = await asyncio.to_thread(resolve_channel_security, resolved)
channel = create_channel(resolved, security=security)
return cls(
options=resolved,
stub=galaxy_pb_grpc.GalaxyRepositoryStub(channel),
@@ -105,7 +105,72 @@ def _split_authority(endpoint: str) -> tuple[str, int]:
return (host or "localhost", int(port))
def create_channel(options: ClientOptions) -> grpc.aio.Channel:
@dataclass(frozen=True)
class ChannelSecurity:
"""Transport security resolved for one channel.
`credentials` is `None` for a plaintext channel. `target_name_override` is
the SNI/authority override the TOFU path needs, kept separate from the
caller's explicit `server_name_override` so the caller always wins.
"""
credentials: grpc.ChannelCredentials | None = None
target_name_override: str | None = None
def resolve_channel_security(options: ClientOptions) -> ChannelSecurity:
"""Resolve transport security for `options`, running any blocking probe.
This is the only blocking part of channel construction: the TOFU path opens
a real TCP+TLS socket to fetch the server's certificate. It is split out of
`create_channel` because a `grpc.aio` channel binds to the event loop
*current on the constructing thread*, so the channel itself must be built on
the loop thread — building it inside `asyncio.to_thread` raises
``RuntimeError: There is no current event loop in thread 'asyncio_N'``. The
async `connect` classmethods therefore run this function off the loop and
then call `create_channel` on it.
"""
if options.plaintext:
return ChannelSecurity()
if options.ca_file:
root_certificates = Path(options.ca_file).read_bytes()
return ChannelSecurity(
credentials=grpc.ssl_channel_credentials(root_certificates=root_certificates)
)
if options.require_certificate_validation:
return ChannelSecurity(credentials=grpc.ssl_channel_credentials())
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
# server's certificate (unverified) and pin it for this channel (TOFU).
# The probe opens a real blocking TCP+TLS socket, so it MUST be bounded —
# a black-holed / firewall-drop host would otherwise hang on the OS default
# connect timeout (minutes). Bound it by call_timeout (or a short fixed
# fallback) so the dial fails fast as a transport error.
host, port = _split_authority(options.endpoint)
probe_timeout = options.call_timeout if options.call_timeout else _TOFU_PROBE_TIMEOUT_SECONDS
try:
presented = ssl.get_server_certificate((host, port), timeout=probe_timeout)
except OSError as error:
raise MxGatewayTransportError(
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
) from error
# The gateway self-signed cert always carries a "localhost" SAN, so default
# the SNI/target-name override to it when none was supplied, tolerating
# dial-by-IP or hostname mismatch.
return ChannelSecurity(
credentials=grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii")),
target_name_override="localhost",
)
def create_channel(
options: ClientOptions,
*,
security: ChannelSecurity | None = None,
) -> grpc.aio.Channel:
"""Create a plaintext or TLS `grpc.aio` channel from client options.
The TLS default is lenient: grpc-python has no per-channel skip-verify, so
@@ -113,48 +178,29 @@ def create_channel(options: ClientOptions) -> grpc.aio.Channel:
as the channel's only trust root (trust-on-first-use). Set
`require_certificate_validation=True` to force system-trust verification, or
pass `ca_file` to verify against a specific CA — both bypass the TOFU path.
Pass *security* to reuse a `ChannelSecurity` already resolved off the event
loop by `resolve_channel_security`; omit it and this call resolves (and may
block) inline. Must run on the thread owning the event loop the channel will
be used from.
"""
security = security if security is not None else resolve_channel_security(options)
channel_options: list[tuple[str, str | int]] = [
("grpc.max_receive_message_length", options.max_grpc_message_bytes),
("grpc.max_send_message_length", options.max_grpc_message_bytes),
]
if options.server_name_override:
channel_options.append(("grpc.ssl_target_name_override", options.server_name_override))
elif security.target_name_override:
channel_options.append(("grpc.ssl_target_name_override", security.target_name_override))
if options.plaintext:
if security.credentials is None:
return grpc.aio.insecure_channel(options.endpoint, options=channel_options)
if options.ca_file:
root_certificates = Path(options.ca_file).read_bytes()
credentials = grpc.ssl_channel_credentials(root_certificates=root_certificates)
elif options.require_certificate_validation:
credentials = grpc.ssl_channel_credentials()
else:
# Lenient default: grpc-python has no per-channel skip-verify, so fetch the
# server's certificate (unverified) and pin it for this channel (TOFU).
# The probe opens a real blocking TCP+TLS socket, so it MUST be bounded —
# a black-holed / firewall-drop host would otherwise hang on the OS default
# connect timeout (minutes). Bound it by call_timeout (or a short fixed
# fallback) so the dial fails fast as a transport error. The async
# `connect` classmethods run this off the event loop (asyncio.to_thread).
host, port = _split_authority(options.endpoint)
probe_timeout = options.call_timeout if options.call_timeout else _TOFU_PROBE_TIMEOUT_SECONDS
try:
presented = ssl.get_server_certificate((host, port), timeout=probe_timeout)
except OSError as error:
raise MxGatewayTransportError(
f"failed to fetch TLS certificate from {options.endpoint}: {error}"
) from error
credentials = grpc.ssl_channel_credentials(root_certificates=presented.encode("ascii"))
# The gateway self-signed cert always carries a "localhost" SAN, so default
# the SNI/target-name override to it when none was supplied, tolerating
# dial-by-IP or hostname mismatch.
if not options.server_name_override:
channel_options.append(("grpc.ssl_target_name_override", "localhost"))
return grpc.aio.secure_channel(
options.endpoint,
credentials,
security.credentials,
options=channel_options,
)