Announce a session's end when it is actually over, and for closes too
ci / android head (push) Successful in 3m17s
ci / desktop nightly (push) Successful in 41s
ci / build and test (push) Successful in 2m27s
ci / api image (push) Successful in 28s

The phone's notification kept saying '1 shell connected' after the
shell was gone, and both close routes were at fault. A shell exiting on
its own raised SessionEnded from inside its run's finally block — where
the run task is by definition not yet complete, so the LiveSessionCount
the keep-alive reads still counted the dead shell, and nothing fired
later to correct it. A tab closed by hand announced nothing at all, by
a recorded decision that assumed every subscriber was the closer; the
keep-alive is not, and a close it never heard about left the
notification claiming a shell over nothing.

The end is now announced from a continuation after the run completes,
and CloseSessionAsync announces after its own drain — every subscriber
was already a reconcile-to-reality handler, so the echo the old remark
feared costs nothing. Shutdown stays silent: it is dismantling the
subscribers along with the sessions.
This commit is contained in:
2026-08-09 13:01:14 +02:00
parent 506d2803a2
commit e936ab4646
6 changed files with 132 additions and 33 deletions
@@ -217,12 +217,15 @@ public sealed class TerminalWorkspaceTests
}
/// <remarks>
/// The event the tab strip listens to, so a dot can go out the moment a shell exits rather than at the
/// next thing that happens to repaint. Raised only when the session ended on its own: a tab the user
/// closed has a caller who already knows, and telling it would turn one close into two.
/// The event the tab strip and the phone's keep-alive listen to, so a dot can go out — and a foreground
/// notification can come down — the moment a shell exits rather than at the next thing that happens to
/// repaint. The count captured inside the handler is the sharper half of this test: the announcement
/// used to fire from inside the run's own finally block, where the run task is not yet complete, so
/// <c>LiveSessionCount</c> read from the handler still said 1 — and the phone's notification went on
/// claiming a shell that was gone, with nothing left to fire and correct it.
/// </remarks>
[Fact]
public async Task ASessionEndingOnItsOwnIsAnnounced()
public async Task ASessionEndingOnItsOwnIsAnnounced_AfterTheCountStoppedIncludingIt()
{
// A shell with no output to give: its first read returns 0, which is a remote closing the channel,
// so the pump finishes with nobody asking it to.
@@ -231,10 +234,12 @@ public sealed class TerminalWorkspaceTests
await using var workspace = CreateWorkspace(connections);
var ended = new List<uint>();
var liveAtAnnouncement = -1;
workspace.SessionEnded += (_, e) =>
{
lock (ended)
{
liveAtAnnouncement = workspace.LiveSessionCount;
ended.Add(e.SessionId);
}
};
@@ -249,18 +254,33 @@ public sealed class TerminalWorkspaceTests
return ended.Contains(sessionId);
}
});
lock (ended)
{
liveAtAnnouncement.ShouldBe(0, "the announcement must wait for the run to actually complete");
}
}
/// <inheritdoc cref="ASessionEndingOnItsOwnIsAnnounced" />
/// <remarks>
/// The reversal of a recorded decision, and the event's own remark carries why: a close used to be
/// announced to nobody, on the theory that the caller already knew — but the phone's keep-alive is not
/// the caller, and a close it never heard about left the foreground notification claiming a shell that
/// was gone. Announced once, after the drain, so the count a handler reads is already honest.
/// </remarks>
[Fact]
public async Task ClosingASessionIsNotAnnouncedBack()
public async Task ClosingASessionIsAnnounced_OnceItHasDrained()
{
var connections = new FakeConnectionFactory();
await using var workspace = CreateWorkspace(connections);
var announcements = 0;
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
var liveAtAnnouncement = -1;
workspace.SessionEnded += (_, _) =>
{
liveAtAnnouncement = workspace.LiveSessionCount;
Interlocked.Increment(ref announcements);
};
var sessionId = await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
@@ -268,12 +288,15 @@ public sealed class TerminalWorkspaceTests
await workspace.CloseSessionAsync(sessionId);
Volatile.Read(ref announcements)
.ShouldBe(0, "a close the caller asked for is not news to report back to it");
.ShouldBe(1, "a close is news to the keep-alive even though it is an echo to the closer");
liveAtAnnouncement.ShouldBe(0, "announced after the drain, so the count already excludes it");
}
/// <remarks>
/// Disposal is the other path that closes sessions, because it is process shutdown. Asserted so
/// that the SSH connections are known to be released rather than assumed to be.
/// that the SSH connections are known to be released rather than assumed to be — and that these closes,
/// unlike a deliberate one, are announced to nobody: shutdown is dismantling every subscriber along
/// with the sessions, and news nobody is left to hear is not news.
/// </remarks>
[Fact]
public async Task DisposingTheWorkspaceClosesEverySession()
@@ -282,6 +305,9 @@ public sealed class TerminalWorkspaceTests
var workspace = CreateWorkspace(connections);
var announcements = 0;
workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements);
await workspace.OpenSessionAsync(
Request(), TerminalSize.Default, TestContext.Current.CancellationToken);
await workspace.OpenSessionAsync(
@@ -292,6 +318,7 @@ public sealed class TerminalWorkspaceTests
workspace.LiveSessionCount.ShouldBe(0);
connections.Connections.Count.ShouldBe(2);
connections.Connections.ShouldAllBe(connection => connection.IsDisposed);
Volatile.Read(ref announcements).ShouldBe(0, "shutdown closes are not announced");
}
// ---- Reattach ----