Public Access
Stop a reconnect dialling a different machine of the same name
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.
This commit is contained in:
1 parent
9ead42171c
commit
2d95366552
4 files changed
+177
-36
No files matched your search
@@ -2099,6 +2099,82 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
"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>
|
||||
/// 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.
|
||||
@@ -10377,11 +10453,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
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.EditorLabel = label;
|
||||
vault.EditorHostname = "db.internal";
|
||||
vault.EditorHostname = hostname;
|
||||
vault.EditorUsername = "deploy";
|
||||
|
||||
await vault.SaveHostCommand.ExecuteAsync(null);
|
||||
@@ -10405,6 +10484,14 @@ public sealed partial class ShellFlowTests : IAsyncLifetime
|
||||
private static HostRowViewModel Host(VaultViewModel vault, string label) =>
|
||||
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>
|
||||
/// <remarks>
|
||||
/// Both halves, because both are what deleting anything now takes — arming on its own changes nothing,
|
||||
|
||||
Reference in new issue
Block a user