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.
This commit is contained in:
jaap-jan committed 2026-09-14 10:18:58 +02:00
1 parent 9ead42171c
commit 2d95366552
4 files changed
+177 -36

No files matched your search

@@ -3761,7 +3761,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
return;
}
var tab = new TerminalTabViewModel(e.Label, e.Address);
var tab = new TerminalTabViewModel(e.Label, e.Address) { HostId = e.HostId };
attempts[e.AttemptId] = 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.
var adopted = new TerminalTabViewModel(e.SessionId, e.Label, e.Address)
{
HostId = e.HostId,
StartedAt = clock.GetUtcNow(),
Cipher = NullIfEmpty(e.Cipher),
HostKeyAlgorithm = NullIfEmpty(e.HostKeyAlgorithm),
@@ -4036,13 +4037,22 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
}
/// <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>
/// <remarks>
/// <para>
/// Matched on the label, which is the same handle <see cref="RefreshConnectedHosts"/> matches on and for
/// the same reason: a tab outlives the vault that opened it, so it cannot hold an entity id that would
/// still mean anything after a lock.
/// ◆ <b>Matched on the keychain item's id, never on the name.</b> Nothing stops two hosts being called
/// the same thing — a team's vault beside a personal one makes it ordinary, and "db" is what people call
/// 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>
/// 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>
/// </remarks>
private HostRowViewModel? HostFor(TerminalTabViewModel? tab) =>
tab is null || Vault is not { } vault
tab?.HostId is not { } hostId || Vault is not { } vault
? null
: vault.Hosts.FirstOrDefault(
row => string.Equals(row.Label, tab.Label, StringComparison.Ordinal));
: vault.Hosts.FirstOrDefault(row => row.EntityId == hostId);
/// <summary>
/// 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>
/// <remarks>
/// <para>
/// Matched on the label, which is what a tab was named after, because that is the only handle the two
/// lists share — a tab outlives the vault that opened it, so it cannot hold an entity id that would
/// still mean anything after a lock. Two hosts sharing a name would light both dots, which is a smaller
/// wrong than a dot that goes dark when the vault is reopened.
/// Matched on the keychain item's id, the same handle <see cref="HostFor"/> matches on and for the
/// reason given there: two hosts may share a name, and this list is read as "which machines am I on".
/// Lighting both dots for a name two machines answer to would say the user has a shell open on a host
/// 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>
/// <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)
{
host.IsConnected = Tabs.Any(
tab => tab.IsLive && string.Equals(tab.Label, host.Label, StringComparison.Ordinal));
host.IsConnected = Tabs.Any(tab => tab.IsLive && tab.HostId == host.EntityId);
// 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.
if (host.IsConnected
&& SelectedTab is { } selected
&& string.Equals(host.Label, selected.Label, StringComparison.Ordinal))
if (host.IsConnected && SelectedTab is { } selected && selected.HostId == host.EntityId)
{
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
/// <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
/// 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>
[RelayCommand]
private async Task OpenPinnedPathAsync(string path)
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
if (HostFor(SelectedTab) is not { } host)
{
return;
}
@@ -4638,10 +4648,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private void PinFolderFromSidebar()
{
if (Vault is not { } vault
|| SelectedTab is not { } tab
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
if (Vault is not { } vault || HostFor(SelectedTab) is not { } host)
{
return;
}
@@ -4674,10 +4681,7 @@ internal sealed partial class MainWindowViewModel : ObservableObject, IAsyncDisp
[RelayCommand]
private async Task SelectFilesHostAsync(TerminalTabViewModel tab)
{
if (Vault is not { } vault
|| tab is null
|| vault.Hosts.FirstOrDefault(
host => string.Equals(host.Label, tab.Label, StringComparison.Ordinal)) is not { } host)
if (HostFor(tab) is not { } host)
{
return;
}