Public Access
Merge branch 'claude/fingerprint-enrollment-support-a6c2f3'
This commit is contained in:
@@ -281,6 +281,20 @@ internal sealed partial class TeamsViewModel(
|
|||||||
[ObservableProperty]
|
[ObservableProperty]
|
||||||
private TeamActionRequest? pendingAction;
|
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>
|
/// <summary>Whether there is a server to talk to at all.</summary>
|
||||||
internal bool IsOnline => connection() is not null;
|
internal bool IsOnline => connection() is not null;
|
||||||
|
|
||||||
@@ -367,8 +381,22 @@ internal sealed partial class TeamsViewModel(
|
|||||||
Teams.Add(new TeamRowViewModel(team));
|
Teams.Add(new TeamRowViewModel(team));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 =
|
SelectedTeam =
|
||||||
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
Teams.FirstOrDefault(row => row.TeamId == selectedId) ?? Teams.FirstOrDefault();
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isReselecting = false;
|
||||||
|
}
|
||||||
|
|
||||||
RaiseState();
|
RaiseState();
|
||||||
|
|
||||||
@@ -892,6 +920,11 @@ internal sealed partial class TeamsViewModel(
|
|||||||
PendingAction = null;
|
PendingAction = null;
|
||||||
IsEditingTeam = false;
|
IsEditingTeam = false;
|
||||||
|
|
||||||
|
if (isReselecting)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Fire-and-forget on purpose, and the only place in this class that is: selection changes come
|
// 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
|
// 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.
|
// 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>
|
/// <summary>Reads the selected team's members, invitations and vaults.</summary>
|
||||||
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
private async Task LoadSelectedAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var generation = ++selectionGeneration;
|
||||||
|
|
||||||
Members.Clear();
|
Members.Clear();
|
||||||
Invitations.Clear();
|
Invitations.Clear();
|
||||||
Vaults.Clear();
|
Vaults.Clear();
|
||||||
@@ -964,6 +999,11 @@ internal sealed partial class TeamsViewModel(
|
|||||||
.ListTeamMembersAsync(team.TeamId, cancellationToken)
|
.ListTeamMembersAsync(team.TeamId, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (generation != selectionGeneration)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var member in members)
|
foreach (var member in members)
|
||||||
{
|
{
|
||||||
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
|
Members.Add(new TeamMemberRowViewModel(member, member.UserId == selfId));
|
||||||
@@ -973,6 +1013,11 @@ internal sealed partial class TeamsViewModel(
|
|||||||
.ListTeamInvitationsAsync(team.TeamId, cancellationToken)
|
.ListTeamInvitationsAsync(team.TeamId, cancellationToken)
|
||||||
.ConfigureAwait(true);
|
.ConfigureAwait(true);
|
||||||
|
|
||||||
|
if (generation != selectionGeneration)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
foreach (var invitation in invitations)
|
foreach (var invitation in invitations)
|
||||||
{
|
{
|
||||||
Invitations.Add(new TeamInvitationRowViewModel(invitation));
|
Invitations.Add(new TeamInvitationRowViewModel(invitation));
|
||||||
@@ -982,17 +1027,24 @@ internal sealed partial class TeamsViewModel(
|
|||||||
|
|
||||||
OnPropertyChanged(nameof(HasInvitations));
|
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
|
/// <summary>Fills the vault list for a team, from what this machine can see.</summary>
|
||||||
// about a team vault here is whether *this* machine can open it — which is a property of the
|
/// <remarks>
|
||||||
// keyring and not something the server can answer.
|
/// 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();
|
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(
|
Vaults.Add(new TeamVaultRowViewModel(
|
||||||
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
|
vault.VaultId, vault.Name, readable.Contains(vault.VaultId), vault.RekeyRequired));
|
||||||
|
|||||||
@@ -212,12 +212,34 @@ internal sealed partial class FakeVaultServer : ITeamApi, IDirectoryApi, IVaultG
|
|||||||
return Task.CompletedTask;
|
return Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When set, a member read waits on it before answering.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Every other method here answers from memory and therefore completes before its caller's await
|
||||||
|
/// ever suspends, which hides anything the screen only gets wrong while a read is in flight — the
|
||||||
|
/// state a real server leaves it in for the length of a round trip. A test that wants that state
|
||||||
|
/// holds the gate.
|
||||||
|
/// </remarks>
|
||||||
|
internal TaskCompletionSource? MemberReadGate { get; set; }
|
||||||
|
|
||||||
|
/// <summary>How many member reads have been asked for, for a test to assert on.</summary>
|
||||||
|
internal int MemberReads { get; private set; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
public async Task<IReadOnlyList<TeamMemberSummary>> ListTeamMembersAsync(
|
||||||
Guid teamId,
|
Guid teamId,
|
||||||
CancellationToken cancellationToken) =>
|
CancellationToken cancellationToken)
|
||||||
Task.FromResult<IReadOnlyList<TeamMemberSummary>>(
|
{
|
||||||
members.TryGetValue(teamId, out var list) ? [.. list] : []);
|
MemberReads++;
|
||||||
|
|
||||||
|
if (MemberReadGate is { } gate)
|
||||||
|
{
|
||||||
|
await gate.Task.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
return members.TryGetValue(teamId, out var list) ? [.. list] : [];
|
||||||
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
public Task<TeamMemberSummary> AddTeamMemberAsync(
|
||||||
|
|||||||
@@ -527,6 +527,50 @@ public sealed class TeamSharingTests : IAsyncLifetime
|
|||||||
teams.Status.ShouldContain("Withdrew the invitation");
|
teams.Status.ShouldContain("Withdrew the invitation");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// A reload rebuilds the team list and reselects, so a reload that changed the selection — creating
|
||||||
|
/// the first team is exactly that — used to leave two reads of the same team in flight: the one the
|
||||||
|
/// reload awaits, and one the selection handler started on its own. Both clear the member list and
|
||||||
|
/// then both append to it, so every member 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.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// Counted rather than inferred from the list, and the gate is why: against a fake that answers from
|
||||||
|
/// memory each read finishes before the next begins, so the duplicate never appears and the bug
|
||||||
|
/// survives the test. Holding the read open is what makes this behave like a server.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
[Fact]
|
||||||
|
public async Task CreatingATeam_ReadsItsMembersOnce()
|
||||||
|
{
|
||||||
|
await UnlockedAsync();
|
||||||
|
|
||||||
|
var teams = shell.Teams;
|
||||||
|
|
||||||
|
await teams.LoadAsync(Token);
|
||||||
|
|
||||||
|
teams.NewTeamCommand.Execute(null);
|
||||||
|
teams.NewTeamName = "Platform";
|
||||||
|
teams.NewTeamSlug = "platform";
|
||||||
|
|
||||||
|
var gate = new TaskCompletionSource();
|
||||||
|
|
||||||
|
server.MemberReadGate = gate;
|
||||||
|
|
||||||
|
var create = teams.CreateTeamCommand.ExecuteAsync(null);
|
||||||
|
|
||||||
|
// Asserted while the read is still in flight: that is the only moment at which a second read
|
||||||
|
// started by the selection handler is distinguishable from the reload's own.
|
||||||
|
server.MemberReads.ShouldBe(1, "a reload reads the selected team's members once");
|
||||||
|
|
||||||
|
gate.SetResult();
|
||||||
|
|
||||||
|
await create;
|
||||||
|
|
||||||
|
teams.Members.ShouldHaveSingleItem().Role.ShouldBe("OWNER");
|
||||||
|
}
|
||||||
|
|
||||||
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
private async Task CreateTeamAsync(TeamsViewModel teams, string name, string slug)
|
||||||
{
|
{
|
||||||
await teams.LoadAsync(Token);
|
await teams.LoadAsync(Token);
|
||||||
|
|||||||
Reference in New Issue
Block a user