feat(galaxy.browser): add transient gateway-connection factory

GalaxyDriverBrowser opens an ad-hoc GalaxyRepositoryClient from the
AdminUI's persisted Galaxy options and hands it to a GalaxyBrowseSession
for the address picker. Mirrors GalaxyDriver.BuildClientOptions field-
for-field so the gateway sees an identical option shape, with API-key
resolution inlined (env:/file:/dev: prefixes) so the Browser project
needn't take a hard reference on Driver.Galaxy.

Connect phase runs under a 30s budget linked to the caller's CT and
includes a TestConnectionAsync call so auth/TLS/DNS failures surface
inside the budget instead of waiting for the first DiscoverHierarchy
round-trip. On any post-Create exception the client is disposed before
the throw propagates.

Refactored GalaxyBrowseSession to take only GalaxyRepositoryClient —
browse never needs MxGatewaySession (that's only for live subscribe/
write paths), and constructing one outside the runtime driver isn't
straightforward. The session now disposes _client in DisposeAsync; the
_session field/parameter is gone.
This commit is contained in:
Joseph Doherty
2026-05-28 15:59:57 -04:00
parent 641b2ecbcf
commit 1a143beeb9
2 changed files with 202 additions and 10 deletions
@@ -12,11 +12,12 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
/// caches it by <c>TagName</c> (and <c>GobjectId</c> for parent lookup), and serves
/// subsequent <see cref="ExpandAsync"/> calls in-memory. Attribute fetches are
/// per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
/// Owns the supplied <see cref="MxGatewaySession"/> and disposes it best-effort.
/// Owns the supplied <see cref="GalaxyRepositoryClient"/> and disposes it
/// best-effort. (Browse does not need an <c>MxGatewaySession</c> — that's only
/// required for live subscribe/write paths handled by the runtime driver.)
/// </summary>
internal sealed class GalaxyBrowseSession : IBrowseSession
{
private readonly MxGatewaySession _session;
private readonly GalaxyRepositoryClient _client;
private readonly ConcurrentDictionary<string, GalaxyObject> _byTagName = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<int, GalaxyObject> _byGobjectId = new();
@@ -31,15 +32,14 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <summary>
/// Initializes a new session wrapping a connected gateway client. The factory
/// in <c>GalaxyDriverBrowser</c> (Task 9) constructs both the session and the
/// repository client and hands them off here for the session's lifetime.
/// Initializes a new session wrapping a connected repository client. The factory
/// in <c>GalaxyDriverBrowser</c> (Task 9) constructs the client via
/// <see cref="GalaxyRepositoryClient.Create"/> and hands it off here for the
/// session's lifetime.
/// </summary>
/// <param name="session">Gateway session to dispose when the browse closes.</param>
/// <param name="client">Galaxy repository client to query for hierarchy and attributes.</param>
internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client)
internal GalaxyBrowseSession(GalaxyRepositoryClient client)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_client = client ?? throw new ArgumentNullException(nameof(client));
}
@@ -186,7 +186,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
};
/// <summary>
/// Idempotently tears down the underlying gateway session. Swallows exceptions
/// Idempotently tears down the underlying repository client. Swallows exceptions
/// on shutdown — the registry's reaper may be racing a client-initiated close.
/// </summary>
public async ValueTask DisposeAsync()
@@ -196,7 +196,7 @@ internal sealed class GalaxyBrowseSession : IBrowseSession
_rootGate.Dispose();
try
{
await _session.DisposeAsync().ConfigureAwait(false);
await _client.DisposeAsync().ConfigureAwait(false);
}
catch
{