Compare commits

...
Author SHA1 Message Date
jaap-jan 8e61fc72b4 Stop an attach's own replay landing in the middle of a test's frames
ci / build and test (pull_request) Successful in 7m43s
ci / desktop nightly (pull_request) Skipped
ci / api image (pull_request) Failing after 11s
ci / android head (pull_request) Successful in 8m7s
TheClipboard_ReachesThePaneThatAskedForIt failed in CI on an opcode of 2
where it wanted 8: a second SessionOpened frame where the clipboard's
should have been. Only one thing sends a session a second SessionOpened,
and that is ReplayAfterAttachAsync, the replay a reattaching page gets.

It arrives there because the replay is fired and forgotten from the
socket-accept thread immediately after the attach is signalled, and the
test helper returned on that signal. Deschedule the accept thread for a
timeslice between the two -- which a loaded CI box does -- and the test
opens its session first, so the replay finds that session live and sends
it a replay-flagged SessionOpened after its real one.

Nothing is wrong with the workspace. The page treats a replay onto a pane
it still has as idempotent, and ReplayAfterAttachAsync's own remarks
already say racing a fresh page's first frames is harmless. What was
wrong is a helper that waited for the attach and then let its callers
count frames as though the attach were finished, so ConnectRendererAsync
now waits for RendererReattached as well -- raised as the last thing the
replay does. The remark there argued a replay could not be sent early; it
missed that it can be sent late, and now says so.

The same late replay could reset the credit window out from under
ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits, which shares the
helper, so that latent flake goes with it.

Verified by delaying the replay's snapshot by a millisecond: the old test
fails under that, and the fixed class passes it eight runs running.
2026-09-15 14:29:08 +02:00
jaap-jan 8a4058ce3a Merge pull request 'Stop a reconnect dialling a different machine of the same name' (#17) from claude/reconnect-wrong-server-9cccfb into main
ci / desktop nightly (push) Skipped
ci / api image (push) Skipped
ci / android head (push) Successful in 3m24s
ci / build and test (push) Failing after 2m20s
Reviewed-on: #17
2026-09-14 11:31:04 +00:00
jaap-jan 2d95366552 Stop a reconnect dialling a different machine of the same name
ci / build and test (pull_request) Successful in 3m31s
ci / desktop nightly (pull_request) Skipped
ci / android head (pull_request) Successful in 7m42s
ci / api image (pull_request) Failing after 13s
A tab remembered what its host was called and nothing else, and the reconnect
under a stopped tab looked the host up by that name: HostFor took the first row
in the keychain whose label matched. Nothing in this application stops two hosts
being called the same thing — a team's vault beside a personal one makes it
ordinary, and "prod-db" is what the database server gets called in both — so the
button dialled whichever namesake sat earliest in the list. A dropped session on
one machine came back as a live shell on another, with the tab, the address and
the status bar all still naming the first. There was nothing on screen to read
the substitution off.

The name match was not an oversight; it was an argument, written into the remark
on HostFor and repeated on RefreshConnectedHosts: a tab outlives the vault that
opened it, so it "cannot hold an entity id that would still mean anything after a
lock". That is not so. EntityId is the item's own sync identity rather than a
reference to a row object, so a vault that is locked and opened again rebuilds
every row around the same ids. The one handle that was rejected as too fragile to
survive a lock is the handle that survives it.

So the id now travels with the connection. ConnectionAttemptEventArgs and
TerminalSessionEventArgs carry it, taken from ConnectionTarget.HostId, which the
connect path has had all along for the log; TerminalTabViewModel keeps it; and
HostFor matches on it. A host renamed since the tab opened is found too, which a
name match by definition cannot. Null is not missing data — it is the whole of
what makes a typed-in address different, and it is why such a tab is refused a
reconnect rather than offered one that would dial a stranger sharing its name.

Three other lookups had grown their own copy of the same match and so had the
same fault, where it is read rather than dialled: the green connected dot, which
lit on every machine answering to the name and claimed a shell on hosts nobody
had connected to; the pinned paths QUICK ACCESS offers, which come from whichever
row that same match found; and the sidebar's "Pin folder" and the SFTP tab row,
which would have edited and browsed the wrong host. All four now go through
HostFor, so "which machine is this tab" has one answer.

Both new tests assert on the address actually dialled rather than on anything the
tab says about itself. The two rows agree on every string a tab carries, which is
exactly why this went unnoticed: a test written against the label would have
passed on the day the wrong machine was being opened.
2026-09-14 10:18:58 +02:00
5 changed files with 209 additions and 41 deletions

No files matched your search

@@ -3761,7 +3761,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return; return;
} }
var tab = new TerminalTabViewModel(e.Label, e.Address); var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
attempts[e.AttemptId] = tab; attempts[e.AttemptId] = tab;
AdoptTab(tab); AdoptTab(tab);
@@ -3825,6 +3825,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
// dropping it would leave a shell running with nothing in the window naming it. // dropping it would leave a shell running with nothing in the window naming it.
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address) var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
{ {
HostId = e.HostId,
StartedAt = clock.GetUtcNow(), StartedAt = clock.GetUtcNow(),
Cipher = NullIfEmpty(e.Cipher), Cipher = NullIfEmpty(e.Cipher),
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm), HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
@@ -4036,13 +4037,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
} }
/// <summary> /// <summary>
/// The host this tab was opened against, or null when the keychain has nothing under that name. /// The host this tab was opened against, or null when the keychain no longer holds it.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for /// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would /// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
/// still mean anything after a lock. /// their database server in both — and this is the lookup <see cref="ReconnectTabAsync"/> dials the
/// answer to. A name match would take a dropped session on one machine and quietly reopen a shell on
/// another, with the tab, the status bar and the address all still naming the first. The id is carried
/// from the attempt that opened the tab; see <see cref="TerminalTabViewModel.HostId"/>.
/// </para>
/// <para>
/// <b>It outlives a lock, which is what the name match was here to survive.</b> The id is the item's own
/// sync identity rather than a reference to a row object, so a vault that is locked and opened again
/// rebuilds its list around the same ids and a tab from before the lock finds its host in it. A host
/// renamed since the tab opened is found too.
/// </para> /// </para>
/// <para> /// <para>
/// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be /// Null is the ordinary answer for two real cases, and both of them are why the reconnect below has to be
@@ -4051,10 +4061,9 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </para> /// </para>
/// </remarks> /// </remarks>
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) => private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
tab is null || Vault is not { } vault tab?.HostId is not { } hostId || Vault is not { } vault
? null ? null
: vault.Hosts.FirstOrDefault( : vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
/// <summary> /// <summary>
/// Whether the selected tab has stopped and there is something to dial again. /// Whether the selected tab has stopped and there is something to dial again.
@@ -4382,10 +4391,15 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// <para> /// <para>
/// Matched on the label, which is what a tab was named after, because that is the only handle the two /// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would /// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller /// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
/// wrong than a dot that goes dark when the vault is reopened. /// they have never connected to — and it is the same match that decides whose pinned paths the sidebar
/// offers, which would then be another host's folders.
/// </para>
/// <para>
/// A tab with no item behind it — a machine reached by typing an address — lights nothing, which is
/// correct: there is no row here that it is a session on.
/// </para> /// </para>
/// <para> /// <para>
/// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription /// <see cref="ActiveTabPinnedPaths"/> is rebuilt here, on the same match, rather than from a subscription
@@ -4408,14 +4422,11 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
foreach (var host in vault.Hosts) foreach (var host in vault.Hosts)
{ {
host.IsConnected = Tabs.Any( host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
// The active tab's host, and only when it is actually connected: the sidebar is for a session // The active tab's host, and only when it is actually connected: the sidebar is for a session
// that is open, not for whichever machine's tab happens to be selected while it is still dialling. // that is open, not for whichever machine's tab happens to be selected while it is still dialling.
if (host.IsConnected if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
{ {
connectedActiveHost = host; connectedActiveHost = host;
} }
@@ -4549,15 +4560,14 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
/// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same /// is drawn and not what it does. It shares <see cref="OnVaultFilesRequested"/>'s plumbing — the same
/// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host /// <see cref="ShowFiles"/> refusal, the same re-found row, the same password-sheet branch for a host
/// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by /// that cannot be dialled unattended — through <see cref="GoToHostFilesAsync"/>, and the host is found by
/// the same label match <see cref="RefreshConnectedHosts"/> used to decide the row is there to click. /// the same <see cref="HostFor"/> match <see cref="RefreshConnectedHosts"/> used to decide the row is
/// there to click — which is the point: the pins on screen and the host they are opened on come from one
/// answer to "which machine is this tab", and two hosts of the same name cannot pull them apart.
/// </remarks> /// </remarks>
[RelayCommand] [RelayCommand]
private async Task OpenPinnedPathAsync(string path) private async Task OpenPinnedPathAsync(string path)
{ {
if (Vault is not { } vault if (HostFor(SelectedTab) is not { } host)
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{ {
return; return;
} }
@@ -4638,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand] [RelayCommand]
private void PinFolderFromSidebar() private void PinFolderFromSidebar()
{ {
if (Vault is not { } vault if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{ {
return; return;
} }
@@ -4674,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand] [RelayCommand]
private async Task SelectFilesHostAsync(TerminalTabViewModel tab) private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
{ {
if (Vault is not { } vault if (HostFor(tab) is not { } host)
|| tab is null
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
{ {
return; return;
} }
@@ -229,6 +229,31 @@ internal sealed partial class TerminalTabViewModel : ObservableObject
internal string Label { get; } internal string Label { get; }
/// <summary>
/// The keychain item this tab was opened from, or null for a machine reached by typing an address.
/// </summary>
/// <remarks>
/// <para>
/// ◆ <b>What the shell matches this tab back to a host row with, because the name will not do it.</b>
/// Nothing stops two hosts being called the same thing, and a team's vault beside a personal one makes it
/// ordinary — so a lookup on <see cref="Label"/> can answer with a different machine that happens to
/// share the name. The reconnect under a stopped tab <em>dials</em> whatever that lookup finds, which is
/// the one place being approximately right is dialling a stranger. See
/// <c>MainWindowViewModel.HostFor</c>.
/// </para>
/// <para>
/// <b>It survives a lock, which the row objects do not.</b> This is the item's sync identity rather than
/// an index into the list on screen — see <c>VaultItem&lt;TSecret&gt;.EntityId</c> — so a vault that is
/// locked and opened again rebuilds every row around the same ids, and a tab that outlived the vault
/// finds its host again. A rename finds it too, which a name match by definition cannot.
/// </para>
/// <para>
/// Null is not missing data: it is the whole of what makes a typed-in address different, and it is why
/// such a tab is honestly refused a reconnect rather than offered one that would dial a namesake.
/// </para>
/// </remarks>
internal Guid? HostId { get; init; }
/// <summary>The account and endpoint, for the pane header and the status bar.</summary> /// <summary>The account and endpoint, for the pane header and the status bar.</summary>
internal string Address { get; } internal string Address { get; }
@@ -947,6 +947,7 @@ internal static class ItemBadge
/// <param name="identityLabel"> /// <param name="identityLabel">
/// The display name of the key or credential that authenticated, or null when a typed password did. /// The display name of the key or credential that authenticated, or null when a typed password did.
/// </param> /// </param>
/// <param name="hostId">The keychain item this was dialled from, or null for a typed-in address.</param>
internal sealed class TerminalSessionEventArgs( internal sealed class TerminalSessionEventArgs(
Guid attemptId, Guid attemptId,
uint sessionId, uint sessionId,
@@ -954,7 +955,8 @@ internal sealed class TerminalSessionEventArgs(
string address, string address,
string cipher, string cipher,
string hostKeyAlgorithm, string hostKeyAlgorithm,
string? identityLabel) : EventArgs string? identityLabel,
Guid? hostId) : EventArgs
{ {
/// <summary>Which attempt this session came out of.</summary> /// <summary>Which attempt this session came out of.</summary>
/// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" /> /// <inheritdoc cref="ConnectionAttemptEventArgs.AttemptId" path="/remarks" />
@@ -971,19 +973,27 @@ internal sealed class TerminalSessionEventArgs(
internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm; internal string HostKeyAlgorithm { get; } = hostKeyAlgorithm;
internal string? IdentityLabel { get; } = identityLabel; internal string? IdentityLabel { get; } = identityLabel;
/// <inheritdoc cref="ConnectionAttemptEventArgs.HostId" />
internal Guid? HostId { get; } = hostId;
} }
/// <summary>A connection that has been asked for, and has not answered yet.</summary> /// <summary>A connection that has been asked for, and has not answered yet.</summary>
/// <param name="attemptId">Identifies this attempt for the whole of its life.</param> /// <param name="attemptId">Identifies this attempt for the whole of its life.</param>
/// <param name="label">The host's name, as the vault has it.</param> /// <param name="label">The host's name, as the vault has it.</param>
/// <param name="address">Who this will be logged in as, and where.</param> /// <param name="address">Who this will be logged in as, and where.</param>
/// <param name="hostId">The keychain item this is being dialled from, or null for a typed-in address.</param>
/// <remarks> /// <remarks>
/// The vault says a connection has started before it says whether it worked, so that the shell can put a /// The vault says a connection has started before it says whether it worked, so that the shell can put a
/// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake /// tab in the strip at the moment the user asks for one rather than however many seconds later a handshake
/// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell /// takes. Everything a tab needs to name itself is here, because the name is a decrypted item and the shell
/// has no vault to read it from. /// has no vault to read it from.
/// </remarks> /// </remarks>
internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, string address) : EventArgs internal sealed class ConnectionAttemptEventArgs(
Guid attemptId,
string label,
string address,
Guid? hostId) : EventArgs
{ {
/// <summary>Identifies this attempt for the whole of its life.</summary> /// <summary>Identifies this attempt for the whole of its life.</summary>
/// <remarks> /// <remarks>
@@ -995,6 +1005,19 @@ internal sealed class ConnectionAttemptEventArgs(Guid attemptId, string label, s
internal string Label { get; } = label; internal string Label { get; } = label;
internal string Address { get; } = address; internal string Address { get; } = address;
/// <summary>
/// The keychain item this connection was made from, or null for a machine that is not in the keychain.
/// </summary>
/// <remarks>
/// ◆ <b>The name is not an identifier and this is.</b> Two hosts may be called the same thing — nothing
/// stops it, and a team vault next to a personal one makes it ordinary — so a tab that remembered only
/// what it was called could be matched back to the wrong machine. That is not a cosmetic mix-up: the
/// reconnect below the tab dials whichever row the match found, so the one thing that must not be
/// guessed is which machine a reconnect goes to. Carried from here because a tab has no vault to ask —
/// see <c>TerminalTabViewModel.HostId</c>.
/// </remarks>
internal Guid? HostId { get; } = hostId;
} }
/// <summary>A connection that has got as far as a named step.</summary> /// <summary>A connection that has got as far as a named step.</summary>
@@ -11326,7 +11349,8 @@ internal sealed partial class VaultViewModel(
var attempt = new ConnectionAttemptEventArgs( var attempt = new ConnectionAttemptEventArgs(
Guid.CreateVersion7(), Guid.CreateVersion7(),
target.Label, target.Label,
Dialled(target, authentication)); Dialled(target, authentication),
target.HostId);
ConnectionStarting?.Invoke(this, attempt); ConnectionStarting?.Invoke(this, attempt);
Status = $"Connecting to {target.Label}…"; Status = $"Connecting to {target.Label}…";
@@ -11784,7 +11808,8 @@ internal sealed partial class VaultViewModel(
Dialled(target, authentication), Dialled(target, authentication),
facts?.Cipher ?? string.Empty, facts?.Cipher ?? string.Empty,
facts?.HostKeyAlgorithm ?? string.Empty, facts?.HostKeyAlgorithm ?? string.Empty,
authentication.IdentityLabel)); authentication.IdentityLabel,
target.HostId));
// Last, and after the tab exists: keeping the password is a favour, and the session the user asked // Last, and after the tab exists: keeping the password is a favour, and the session the user asked
// for must not wait on a vault write to appear. // for must not wait on a vault write to appear.
@@ -2099,6 +2099,82 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
"the session that was replaced should have been released with its pane"); "the session that was replaced should have been released with its pane");
} }
/// <remarks>
/// <para>
/// ◆ THE ONE THIS EXISTS FOR: a reconnect that dialled a different machine of the same name. Nothing in
/// this application stops two hosts being called the same thing — a team's vault beside a personal one
/// makes it ordinary, and "prod-db" is what a database server gets called in both — and a tab used to
/// find its host by that name. A dropped session on one machine then came back as a live shell on the
/// other, with the tab, the address and the status bar all still naming the first.
/// </para>
/// <para>
/// Asserted on the address actually dialled rather than on anything the tab says about itself. The two
/// rows agree on every string a tab carries, which is exactly why this went unnoticed: a test written
/// against the label would have passed on the day the wrong machine was being opened.
/// </para>
/// </remarks>
[Fact]
public async Task ReconnectingATabWhoseHostHasANamesake_DialsTheMachineTheTabWasOpenedOn()
{
var vault = await ReadyToConnectAsync();
await AddKeyAsync(vault, "deploy");
// A second host under the name the fixture's own already has, on a different machine.
await AddHostAsync(vault, "prod-db", "db-replica.internal");
// Both bound to the key, so neither is diverted to the password sheet: what this asserts is which of
// the two gets dialled, not whether either can be. Re-found between the two, because saving a host
// rebuilds the list.
await BindKeyAsync(vault, Machine(vault, "db.internal"), vault.Keys[0].EntityId);
await BindKeyAsync(vault, Machine(vault, "db-replica.internal"), vault.Keys[0].EntityId);
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
await vault.ConnectToRowCommand.ExecuteAsync(Machine(vault, "db-replica.internal"));
var tab = shell.Tabs.ShouldHaveSingleItem();
ssh.Requests.ShouldHaveSingleItem().Host.ShouldBe("db-replica.internal");
ssh.Shells[0].Drop("An established connection was aborted by the server.");
await EventuallyAsync(() => !tab.IsLive, "the dropped shell should have ended the session");
shell.CanReconnectSelectedTab.ShouldBeTrue("the machine this tab was opened on is still there");
await shell.ReconnectTabCommand.ExecuteAsync(tab);
tab.IsLive.ShouldBeTrue(tab.Status);
ssh.Requests.Count.ShouldBe(2);
ssh.Requests[1].Host.ShouldBe(
"db-replica.internal",
"a reconnect dials the machine the tab was opened on, not the one that shares its name");
}
/// <remarks>
/// The same mix-up as the test above, where it is read rather than dialled. A green dot is the host list
/// answering "am I on this machine", and the name two rows share is not an answer to it — a dot on the
/// machine nobody has a shell on claims a session that does not exist, and takes QUICK ACCESS's pins with
/// it, since the sidebar offers the pinned folders of whichever row that same match found.
/// </remarks>
[Fact]
public async Task ConnectingToOneOfTwoHostsOfTheSameName_LightsThatOneAlone()
{
var vault = await ReadyToConnectAsync();
await AddHostAsync(vault, "prod-db", "db-replica.internal");
await using var renderer = await FakeRenderer.AttachAsync(workspace, Token);
vault.SelectedHost = Machine(vault, "db-replica.internal");
await vault.ConnectCommand.ExecuteAsync(null);
shell.Tabs.ShouldHaveSingleItem().IsLive.ShouldBeTrue();
Machine(vault, "db-replica.internal").IsConnected.ShouldBeTrue();
Machine(vault, "db.internal").IsConnected.ShouldBeFalse(
"nobody has a shell on this machine; only its name was connected to");
}
/// <remarks> /// <remarks>
/// A connection that was refused is worth retrying for the same reason a dropped one is, and the /// A connection that was refused is worth retrying for the same reason a dropped one is, and the
/// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later. /// commonest refusal — a machine that was not up yet — is the one most likely to succeed a minute later.
@@ -10377,11 +10453,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
await vault.SaveHostCommand.ExecuteAsync(null); await vault.SaveHostCommand.ExecuteAsync(null);
} }
private static async Task AddHostAsync(VaultViewModel vault, string label) private static async Task AddHostAsync(
VaultViewModel vault,
string label,
string hostname = "db.internal")
{ {
vault.NewHostCommand.Execute(null); vault.NewHostCommand.Execute(null);
vault.EditorLabel = label; vault.EditorLabel = label;
vault.EditorHostname = "db.internal"; vault.EditorHostname = hostname;
vault.EditorUsername = "deploy"; vault.EditorUsername = "deploy";
await vault.SaveHostCommand.ExecuteAsync(null); await vault.SaveHostCommand.ExecuteAsync(null);
@@ -10405,6 +10484,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
private static HostRowViewModel Host(VaultViewModel vault, string label) => private static HostRowViewModel Host(VaultViewModel vault, string label) =>
vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal)); vault.Hosts.Single(row => string.Equals(row.Label, label, StringComparison.Ordinal));
/// <summary>The host row for a given machine, for the tests whose rows deliberately share a name.</summary>
/// <remarks>
/// <see cref="Host"/> cannot be asked when two rows are called the same thing, which is the whole
/// situation those tests set up. Re-found rather than held, because saving any host rebuilds the list.
/// </remarks>
private static HostRowViewModel Machine(VaultViewModel vault, string hostname) =>
vault.Hosts.Single(row => string.Equals(row.Host.Hostname, hostname, StringComparison.Ordinal));
/// <summary>Deletes the selected host: the question, and then the answer to it.</summary> /// <summary>Deletes the selected host: the question, and then the answer to it.</summary>
/// <remarks> /// <remarks>
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing, /// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
@@ -27,6 +27,12 @@ public sealed class TerminalWorkspaceTests
/// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary> /// <summary>How long <see cref="WaitUntilAsync"/> polls before calling it a failure.</summary>
private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10); private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10);
/// <summary>
/// How long <see cref="ConnectRendererAsync"/> waits for the attach's replay before calling it a
/// failure. Generous, because it bounds a hang rather than timing anything.
/// </summary>
private static readonly TimeSpan ReplayTimeout = TimeSpan.FromSeconds(10);
[Fact] [Fact]
public async Task WaitingForARendererThatNeverAttaches_GivesUp() public async Task WaitingForARendererThatNeverAttaches_GivesUp()
{ {
@@ -773,11 +779,20 @@ public sealed class TerminalWorkspaceTests
/// </para> /// </para>
/// <para> /// <para>
/// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call /// <c>WaitForRendererAsync</c> answers only for the first renderer ever to attach, so a second call
/// returns immediately without proving anything about the second socket. That is enough here and is /// returns immediately without proving anything about the second socket — and even for the first it
/// not luck: the only frames a second socket is given are the replay, and the replay is *caused* by the /// answers too early for what the callers here need. It completes when the socket is attached, which
/// attach — <see cref="TerminalWorkspace.RendererReattached"/> and the frames before it cannot be sent /// is before <see cref="TerminalWorkspace.ReplayAfterAttachAsync"/> has run: that is fired and
/// early. The day a test sends something else down a reattached socket, this needs the data plane's own /// forgotten from the accept thread, and a thread-pool that deschedules it for a timeslice lets the
/// <c>SocketAttached</c>, which the workspace does not forward today. /// test open a session in the meantime. The replay then finds that session live and sends it a second,
/// replay-flagged <c>SessionOpened</c> — harmless to the page, which treats it as idempotent, but it
/// lands in the middle of the frames the test is counting on.
/// </para>
/// <para>
/// ◆ SO WAIT FOR THE REPLAY TO BE OVER, NOT ONLY FOR THE ATTACH. <c>RendererReattached</c> is raised
/// as the last thing the replay does, so a socket this returns has had every frame the attach itself
/// causes, and nothing but what the test does next can put another on it. Subscribed before the
/// upgrade, because the replay for a socket that attaches quickly can be over before this method gets
/// to look.
/// </para> /// </para>
/// </remarks> /// </remarks>
private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace) private static async Task<ClientWebSocket> ConnectRendererAsync(TerminalWorkspace workspace)
@@ -795,17 +810,29 @@ public sealed class TerminalWorkspaceTests
"Origin", "Origin",
string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}")); string.Create(CultureInfo.InvariantCulture, $"http://127.0.0.1:{workspace.PageUrl.Port}"));
var replayed = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
void OnReattached(object? sender, EventArgs e) => replayed.TrySetResult();
workspace.RendererReattached += OnReattached;
try try
{ {
await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken); await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken);
await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken); await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken);
await replayed.Task.WaitAsync(ReplayTimeout, TestContext.Current.CancellationToken);
} }
catch catch
{ {
client.Dispose(); client.Dispose();
throw; throw;
} }
finally
{
workspace.RendererReattached -= OnReattached;
}
return client; return client;
} }