test(tst-24): drive the .NET and Python clients against real in-process gRPC servers
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:
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user