fix(raw): a stale open can no longer feed the picker another device's config

This commit is contained in:
Joseph Doherty
2026-08-05 03:36:04 -04:00
parent c1c21f3ff8
commit 4e3a6e8497
3 changed files with 55 additions and 2 deletions
@@ -170,6 +170,11 @@
}
var dto = await Svc.LoadTagForEditAsync(TagId);
// Two opens can overlap on one modal instance (the tree's Edit command is synchronous and does
// not disable while this resolves), so every post-await write is discarded unless this open is
// still the displayed one. Last-completion-wins would point the picker at another device.
if (_loadedKey != key || !Visible) { return; }
// RawTagEditDto is a record struct → a missing tag comes back as default (all-zero), not null.
if (dto is not { } d || string.IsNullOrEmpty(d.TagId))
{
@@ -192,6 +197,8 @@
try
{
var merged = await Svc.LoadMergedProbeConfigAsync(d.DeviceId);
if (_loadedKey != key || !Visible) { return; } // superseded open → never install its device
if (merged is { } m)
{
_mergedConfigJson = m.MergedConfigJson;
@@ -20,14 +20,26 @@ internal sealed class FakeRawTreeService : IRawTreeService
public Func<string, (string DriverType, string MergedConfigJson)?> OnLoadMergedProbeConfig { get; set; } =
_ => throw new NotImplementedException();
/// <summary>
/// Optional asynchronous override of <see cref="OnLoadTagForEdit"/>. The sync delegates above
/// complete inline, which cannot express two opens overlapping on one modal instance — set this to
/// gate a load on a <see cref="TaskCompletionSource"/> and interleave the second open.
/// </summary>
public Func<string, Task<RawTagEditDto?>>? OnLoadTagForEditAsync { get; set; }
/// <summary>Optional asynchronous override of <see cref="OnLoadMergedProbeConfig"/>; see <see cref="OnLoadTagForEditAsync"/>.</summary>
public Func<string, Task<(string DriverType, string MergedConfigJson)?>>? OnLoadMergedProbeConfigAsync { get; set; }
/// <inheritdoc/>
public Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default)
=> Task.FromResult(OnLoadTagForEdit(tagId));
=> OnLoadTagForEditAsync is { } async ? async(tagId) : Task.FromResult(OnLoadTagForEdit(tagId));
/// <inheritdoc/>
public Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(
string deviceId, CancellationToken ct = default)
=> Task.FromResult(OnLoadMergedProbeConfig(deviceId));
=> OnLoadMergedProbeConfigAsync is { } async
? async(deviceId)
: Task.FromResult(OnLoadMergedProbeConfig(deviceId));
/// <inheritdoc/>
public Task<IReadOnlyList<RawNode>> LoadRootsAsync(CancellationToken ct = default)
@@ -33,6 +33,9 @@ public sealed class RawTagModalMergedConfigTests
{
private const string MergedConfig = """{"endpoint":"10.0.0.1:44818","slot":0}""";
/// <summary>A second device's config — the blob a superseded open must never install.</summary>
private const string StaleConfig = """{"endpoint":"10.0.0.9:44818","slot":3}""";
[Fact]
public async Task The_editor_receives_the_merged_device_config_not_a_stub()
{
@@ -98,6 +101,37 @@ public sealed class RawTagModalMergedConfigTests
firstAccessor().ShouldBe(MergedConfig);
}
[Fact]
public async Task A_stale_open_never_feeds_the_picker_another_devices_config()
{
// RawTree's OnEditTag is synchronous and does not disable while the modal resolves, so a rapid
// double-click across two tag rows overlaps two opens on ONE modal instance. Every write after an
// await must therefore be discarded if the open it belongs to is no longer the displayed one:
// last-completion-wins would silently point a live browse at the wrong controller.
var gate = new TaskCompletionSource<(string DriverType, string MergedConfigJson)?>();
var svc = new FakeRawTreeService
{
OnLoadTagForEdit = tagId => TagOn(tagId, tagId == "tag-1" ? "dev-A" : "dev-B"),
OnLoadMergedProbeConfigAsync = deviceId => deviceId == "dev-A"
? gate.Task
: Task.FromResult<(string DriverType, string MergedConfigJson)?>(("AbCip", MergedConfig)),
};
var modal = NewModal(svc, "tag-1");
var firstOpen = InvokeParametersSetAsync(modal);
// The second open supersedes the first while the first is still parked on dev-A's resolve.
modal.TagId = "tag-2";
await InvokeParametersSetAsync(modal);
gate.SetResult(("AbCip", StaleConfig));
await firstOpen;
DriverConfigJson(modal).ShouldBe(
MergedConfig,
"the superseded open's dev-A config overwrote the displayed tag's dev-B config");
}
private static RawTagEditDto TagOn(string tagId, string deviceId) => new(
tagId,
deviceId,