Files
lmxopcua/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Pickers/BrowseOpenGuardTests.cs
T
Joseph Doherty e16146fa0c fix(adminui): close a browse session that opens after its picker is gone (R04 L4)
Every picker and RawBrowseModal stored the OpenAsync token after the await; if
the host was disposed (or the modal closed) meanwhile, its teardown had already
run with no token, so the plant-side session lived until the reaper's idle TTL.
BrowseOpenGuard.CloseIfOrphaned closes it at once; each host sets _disposed
first thing in DisposeAsync (RawBrowseModal also treats !Visible as gone).

Refs #745
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 17:51:34 -04:00

170 lines
7.5 KiB
C#

using System.Reflection;
using System.Runtime.ExceptionServices;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging.Abstractions;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Browsing;
using ZB.MOM.WW.OtOpcUa.AdminUI.Components.Shared.Drivers.Pickers;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Pickers;
// BL0006: the test renderer needs RenderTree types (same suppression as RawBrowseModalStaleOpenTests).
#pragma warning disable BL0006
/// <summary>
/// R04 L4: a browse session that finishes opening after its picker (or the browse modal) went away is
/// closed at once instead of living until the reaper's idle TTL.
/// </summary>
[Trait("Category", "Unit")]
public sealed class BrowseOpenGuardTests : IDisposable
{
private readonly List<Renderer> _renderers = new();
public void Dispose()
{
foreach (var r in _renderers) r.Dispose();
}
[Fact]
public async Task Guard_closes_an_orphaned_session_and_leaves_a_live_one()
{
var svc = new RecordingBrowserService();
var token = Guid.NewGuid();
BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, token), hostGone: false, svc).ShouldBeFalse();
svc.Closed.ShouldBeEmpty();
BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, token), hostGone: true, svc).ShouldBeTrue();
await svc.WaitForCloseAsync();
svc.Closed.ShouldBe([token]);
}
[Fact]
public void Guard_never_closes_a_failed_open_and_swallows_a_failing_close()
{
var svc = new RecordingBrowserService();
BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(false, "refused", Guid.Empty), hostGone: true, svc).ShouldBeTrue();
svc.Closed.ShouldBeEmpty();
var throwing = new RecordingBrowserService { ThrowOnClose = true };
Should.NotThrow(() => BrowseOpenGuard.CloseIfOrphaned(new BrowseOpenResult(true, null, Guid.NewGuid()), true, throwing));
}
[Fact]
public async Task Picker_disposed_mid_open_closes_the_session_it_was_handed()
{
var gate = new TaskCompletionSource<BrowseOpenResult>();
var svc = new RecordingBrowserService { Open = () => gate.Task };
var picker = NewPicker(svc);
var open = InvokeOpen(picker);
await picker.DisposeAsync();
var token = Guid.NewGuid();
gate.SetResult(new BrowseOpenResult(true, null, token));
await open;
await svc.WaitForCloseAsync();
svc.Closed.ShouldBe([token]);
Token(picker).ShouldBe(Guid.Empty);
}
[Fact]
public async Task Picker_still_open_adopts_the_session()
{
// Positive control for the test above: without the dispose the token is adopted, not closed.
var token = Guid.NewGuid();
var svc = new RecordingBrowserService { Open = () => Task.FromResult(new BrowseOpenResult(true, null, token)) };
var picker = NewPicker(svc);
await InvokeOpen(picker);
Token(picker).ShouldBe(token);
svc.Closed.ShouldBeEmpty();
}
[Fact]
public void Every_browse_host_guards_its_open()
{
foreach (var path in new[]
{
"AbCipAddressPickerBody.razor", "FOCASAddressPickerBody.razor", "GalaxyAddressPickerBody.razor",
"OpcUaClientAddressPickerBody.razor", "TwinCATAddressPickerBody.razor", "SqlAddressPickerBody.razor",
})
{
var src = AdminUiSource.Read("Components", "Shared", "Drivers", "Pickers", path);
src.ShouldContain("BrowseOpenGuard.CloseIfOrphaned(result, _disposed, BrowserService)", customMessage: path);
src.ShouldContain("_disposed = true;", customMessage: path);
}
var modal = AdminUiSource.Read("Components", "Shared", "Raw", "RawBrowseModal.razor");
modal.ShouldContain("BrowseOpenGuard.CloseIfOrphaned(result, _disposed || !Visible, BrowserService)");
}
private OpcUaClientAddressPickerBody NewPicker(IBrowserSessionService svc)
{
var picker = new OpcUaClientAddressPickerBody();
typeof(OpcUaClientAddressPickerBody).GetProperty("BrowserService", BindingFlags.NonPublic | BindingFlags.Instance)!
.SetValue(picker, svc);
var renderer = new InertRenderer();
_renderers.Add(renderer);
renderer.Attach(picker);
return picker;
}
private static Task InvokeOpen(OpcUaClientAddressPickerBody picker) =>
(Task)typeof(OpcUaClientAddressPickerBody).GetMethod("OpenBrowseAsync", BindingFlags.NonPublic | BindingFlags.Instance)!
.Invoke(picker, null)!;
private static Guid Token(OpcUaClientAddressPickerBody picker) =>
(Guid)typeof(OpcUaClientAddressPickerBody).GetField("_token", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(picker)!;
private sealed class RecordingBrowserService : IBrowserSessionService
{
private readonly TaskCompletionSource _closed = new(TaskCreationOptions.RunContinuationsAsynchronously);
public List<Guid> Closed { get; } = new();
public bool ThrowOnClose { get; init; }
public Func<Task<BrowseOpenResult>> Open { get; init; } = () => Task.FromResult(new BrowseOpenResult(false, "n/a", Guid.Empty));
public Task WaitForCloseAsync() => _closed.Task.WaitAsync(TimeSpan.FromSeconds(10));
public bool CanBrowse(string driverType, string configJson) => true;
public Task<BrowseOpenResult> OpenAsync(string driverType, string configJson, CancellationToken ct) => Open();
public Task<IReadOnlyList<BrowseNode>> RootAsync(Guid token, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
public Task<IReadOnlyList<AttributeInfo>> AttributesAsync(Guid token, string nodeId, CancellationToken ct) => throw new NotImplementedException();
public Task<int> RequestRebirthAsync(Guid token, string scope, CancellationToken ct) => throw new NotImplementedException();
public bool CanRequestRebirth(Guid token) => false;
public Task CloseAsync(Guid token)
{
if (ThrowOnClose) throw new InvalidOperationException("remote gone");
lock (Closed) Closed.Add(token);
_closed.TrySetResult();
return Task.CompletedTask;
}
}
private sealed class InertRenderer()
: Renderer(new ServiceCollection().BuildServiceProvider(), NullLoggerFactory.Instance)
{
public override Dispatcher Dispatcher { get; } = new InlineDispatcher();
public void Attach(IComponent component) => AssignRootComponentId(component);
protected override void HandleException(Exception exception) => ExceptionDispatchInfo.Capture(exception).Throw();
protected override Task UpdateDisplayAsync(in RenderBatch renderBatch) => Task.CompletedTask;
}
private sealed class InlineDispatcher : Dispatcher
{
public override bool CheckAccess() => true;
public override Task InvokeAsync(Action workItem) { workItem(); return Task.CompletedTask; }
public override Task InvokeAsync(Func<Task> workItem) => workItem();
public override Task<TResult> InvokeAsync<TResult>(Func<TResult> workItem) => Task.FromResult(workItem());
public override Task<TResult> InvokeAsync<TResult>(Func<Task<TResult>> workItem) => workItem();
}
}