Read the selected team once per reload, so its owner is listed once

Creating a team drew its owner twice. ReloadAsync rebuilds the team list and
then reselects, and the selection handler answers that assignment by starting
its own read of the members, invitations and vaults — while ReloadAsync is
awaiting a read of exactly the same thing. Both clear the collections up front
and both append when their round trip answers, so everything below the team list
was drawn twice. On a team nobody has been added to yet, whose only member is its
owner, that read as the owner being in the team twice.

The rows are records, so the reselect only raises a change when something about
the team has actually moved — which is every reload that follows a change:
creating a team, adding a member, renaming one. A plain refresh looked fine, and
the screen doubled precisely after the acts people come to this screen to
perform.

isReselecting suppresses the handler for the length of the assignment rather
than deduplicating rows afterwards, because only one of the two reads is
awaited. A command that reloads and then reads Members has to be looking at the
reload's own read and not at a fire-and-forget one that may not have answered.

The generation counter is the other half, and it is a different bug with the
same cause: selecting a second team before the first has answered leaves two
reads in flight against the same collections and nothing decides which wins, so
team A's members could land in the list under team B's name. A superseded read
now drops its answer instead of appending it.

Why nothing caught this. The fake server answers from memory, so every read
completes before the next begins and the appends can never interleave — the
duplicate needs a round trip to hold two reads open at once. FakeVaultServer
grows a MemberReadGate for that, and the new test holds a read open and counts
the reads in flight, which is the only moment a second one is distinguishable
from the first. It fails against the old behaviour with two. 237 tests pass in
the app suite and 83 in the layout suite.
This commit is contained in:
2026-08-03 16:26:52 +02:00
parent 38d8706784
commit a2c56de1f2
3 changed files with 130 additions and 12 deletions
@@ -281,6 +281,20 @@ internal sealed partial class TeamsViewModel(
[ObservableProperty]
private TeamActionRequest? pendingAction;
/// <summary>Set while <see cref="ReloadAsync"/> reselects, so the handler does not read as well.</summary>
private bool isReselecting;
/// <summary>
/// Which selection read owns the lists below the team list.
/// </summary>
/// <remarks>
/// Selecting a second team before the first one's read has answered leaves two reads in flight
/// against the same collections, and the one that started first can answer last — so the
/// superseded read drops its answer instead of appending another team's members to the list. UI
/// thread only, which is where every selection change and every continuation on this screen runs.
/// </remarks>
private int selectionGeneration;
/// <summary>Whether there is a server to talk to at all.</summary>
internal bool IsOnline => connection() is not null;
@@ -367,8 +381,22 @@ internal sealed partial class TeamsViewModel(
Teams.Add(new TeamRowViewModel(team));
}
SelectedTeam =
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
// The assignment reselects the same team through a new row object, so the selection handler
// would start its own read of the very lists this method is about to read — two reads
// clearing and then appending into the same collections, which draws every member, invitation
// and vault twice. Suppressed rather than deduplicated, because the read below is awaited and
// the handler's is not: this is the one that has to be the reload's.
isReselecting = true;
try
{
SelectedTeam =
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
}
finally
{
isReselecting = false;
}
RaiseState();
@@ -892,6 +920,11 @@ internal sealed partial class TeamsViewModel(
PendingAction = null;
IsEditingTeam = false;
if (isReselecting)
{
return;
}
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
// from a list box, which has no cancellation token and no way to await. Failures land in Status
// through RunAsync exactly as a command's would.
@@ -947,6 +980,8 @@ internal sealed partial class TeamsViewModel(
/// <summary>Reads the selected team's members, invitations and vaults.</summary>
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
{
var generation = ++selectionGeneration;
Members.Clear();
Invitations.Clear();
Vaults.Clear();
@@ -964,6 +999,11 @@ internal sealed partial class TeamsViewModel(
.ListTeamMembersAsync(team.TeamId, cancellationToken)
.ConfigureAwait(true);
if (generation != selectionGeneration)
{
return;
}
foreach (var member in members)
{
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
@@ -973,6 +1013,11 @@ internal sealed partial class TeamsViewModel(
.ListTeamInvitationsAsync(team.TeamId, cancellationToken)
.ConfigureAwait(true);
if (generation != selectionGeneration)
{
return;
}
foreach (var invitation in invitations)
{
Invitations.Add(new TeamInvitationRowViewModel(invitation));
@@ -982,17 +1027,24 @@ internal sealed partial class TeamsViewModel(
OnPropertyChanged(nameof(HasInvitations));
if (open is null)
if (open is not null)
{
return;
ListVaults(open, team.TeamId);
}
}
// Read from the session rather than from a team-vaults endpoint, because the interesting fact
// about a team vault here is whether *this* machine can open it — which is a property of the
// keyring and not something the server can answer.
/// <summary>Fills the vault list for a team, from what this machine can see.</summary>
/// <remarks>
/// Read from the session rather than from a team-vaults endpoint, because the interesting fact
/// about a team vault here is whether <em>this</em> machine can open it — which is a property of the
/// keyring and not something the server can answer. No await, so it needs no generation guard: it
/// runs to completion inside the read that called it.
/// </remarks>
private void ListVaults(VaultSession open, Guid teamId)
{
var readable = open.ReadableVaults.Select(vault => vault.VaultId).ToHashSet();
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == team.TeamId))
foreach (var vault in open.Vaults.Where(vault => vault.TeamId == teamId))
{
Vaults.Add(new TeamVaultRowViewModel(
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));