using System.Globalization; using System.Net.WebSockets; using System.Text; using DodoSSH.Client.Ssh; namespace DodoSSH.Client.Terminal.Tests; /// /// What the workspace waits for outside the process, and how long a session lives once it has one. /// /// /// /// The renderer gate itself is not in question. drops frames when /// nothing is attached, so a session opened before the renderer arrives loses its SessionOpened frame /// and streams output at a terminal that was never created — and that it opens when a renderer does attach /// is covered by . What is worth a test here is the /// half that used to be missing: waiting for a renderer that never arrives has to end. /// /// /// The session count is here for a different reason. It is shown to a user on the unlock screen, as the /// disclosure that locking the vault leaves shells running, and a wrong number there is not a cosmetic bug: /// it either hides a live connection or invents one. /// /// public sealed class TerminalWorkspaceTests { /// How long polls before calling it a failure. private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10); /// /// How long waits for the attach's replay before calling it a /// failure. Generous, because it bounds a hang rather than timing anything. /// private static readonly TimeSpan ReplayTimeout = TimeSpan.FromSeconds(10); [Fact] public async Task WaitingForARendererThatNeverAttaches_GivesUp() { // The shipped failure this stands in for is a WebView2 that never initialises — no Evergreen // runtime, an install blocked by policy, an AppContainer that cannot reach loopback. From this // side they are identical and all look like the listener being up with nothing ever connecting // to it. Before the wait was bounded this test would have hung instead of failing. await using var workspace = CreateWorkspace( new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMilliseconds(250)); workspace.Start(); await Should.ThrowAsync(async () => await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken)); } [Fact] public async Task WaitingForARenderer_ObeysItsCancellationToken() { // The timeout is the backstop; the caller's token is what makes a Connect the user gave up on // return at once rather than sitting out the rest of the wait. The timeout here is long enough // that only cancellation can end this. await using var workspace = CreateWorkspace( new FakeConnectionFactory(), rendererTimeout: TimeSpan.FromMinutes(5)); workspace.Start(); using var cancellation = new CancellationTokenSource(); var wait = workspace.WaitForRendererAsync(cancellation.Token); await cancellation.CancelAsync(); await Should.ThrowAsync(async () => await wait); } [Fact] public async Task AnOpenSessionIsReportedAsLive() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); workspace.LiveSessionCount.ShouldBe(0); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); workspace.LiveSessionCount.ShouldBe(1); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); workspace.LiveSessionCount.ShouldBe(2); } /// /// The case a naive sessions.Count gets wrong. Nothing removes the entry when the remote closes /// the channel by itself — what it held is given back, but the entry stays so the tab can go on saying /// the session ended — so counting entries would report a shell that exited as still running. A user /// deciding whether it is safe to walk away is the person that lie is told to. /// [Fact] public async Task ASessionWhoseRemoteHasExitedIsNotReportedAsLive() { // A shell with no output to give: its first read returns 0, which is a remote closing the // channel, so the pump finishes on its own with nobody asking it to. var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => workspace.LiveSessionCount == 0); } /// /// /// is the one phase no connection factory can report, /// because by the time it happens the factory has handed back a connection and gone. If this layer did /// not report it the card's last step would light only when the whole session opened, which is the one /// moment the card is already being taken down — a step nobody would ever see lit. /// /// /// Asserted as the whole sequence rather than as "contains OpeningShell", because the order is the part /// that matters: a step list is only readable if what it is told arrives in the order it draws. /// /// [Fact] public async Task OpeningASession_ReportsTheShellPhaseTheFactoryCannot() { var connections = new FakeConnectionFactory(); var reported = new List(); await using var workspace = CreateWorkspace(connections); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, new DelegateProgress(reported.Add), TestContext.Current.CancellationToken); reported.ShouldBe( [ SshConnectionPhase.Reaching, SshConnectionPhase.Authenticating, SshConnectionPhase.OpeningShell, ], "the factory's own phases, then the one this layer performs itself"); } /// /// Nobody watching is the ordinary case — every caller but the connecting card passes null — so it is /// worth one test that the null is a null and not a null reference. /// [Fact] public async Task OpeningASession_WorksWithNobodyWatchingItsPhases() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); workspace.IsSessionLive(sessionId).ShouldBeTrue(); } [Fact] public async Task ClosingASessionEndsItAndDisposesItsConnection() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await workspace.CloseSessionAsync(sessionId); workspace.LiveSessionCount.ShouldBe(0); connections.Connections.ShouldHaveSingleItem().IsDisposed.ShouldBeTrue(); } /// /// /// Per-session liveness, which the tab strip's status dot is. The aggregate count answers "may I walk /// away"; this answers "is this tab still connected", and a tab that went on claiming a shell /// it no longer has would be the same lie one row down. /// /// /// The unknown-id case is asserted because it is what a stale tab asks. A session id this workspace /// never issued, or has already closed, is not live — it must not throw and must not be optimistic. /// /// [Fact] public async Task LivenessIsAnsweredPerSession() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var first = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); var second = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); workspace.IsSessionLive(first).ShouldBeTrue(); workspace.IsSessionLive(second).ShouldBeTrue(); workspace.IsSessionLive(9999).ShouldBeFalse("this workspace never issued that id"); await workspace.CloseSessionAsync(first); workspace.IsSessionLive(first).ShouldBeFalse(); workspace.IsSessionLive(second).ShouldBeTrue("closing one tab must not disturb another"); } /// /// The shell's connect path reads these back once, right after /// returns, to fill in the status bar's cipher and host-key facts — see VaultViewModel.ConnectAndAnnounceAsync. /// Asserted the same way asserts liveness: per session, and /// null rather than thrown for an id this workspace never issued. /// [Fact] public async Task SessionFactsAreReadPerSession() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); var facts = workspace.GetSessionFacts(sessionId).ShouldNotBeNull(); var connection = connections.Connections.ShouldHaveSingleItem(); facts.Cipher.ShouldBe(connection.Cipher); facts.HostKeyAlgorithm.ShouldBe(connection.HostKey.Algorithm); workspace.GetSessionFacts(9999).ShouldBeNull("this workspace never issued that id"); } /// /// /// Inserting a snippet has to be able to say whether it arrived, and the transport cannot: it drops /// frames for a session nothing is listening to, so a send at a dead tab succeeds exactly as loudly as a /// send at a live one. That is why PasteAsync answers rather than returning void — and why the /// answer is a and not an exception, since a tab whose remote hung up an hour ago is /// still on screen and still clickable. /// /// /// What the renderer does with the frame — bracketed paste, and the Enter deliberately outside it — /// cannot be reached from here at all. It is JavaScript inside a WebView, and it is in /// docs/manual-checks.md for that reason. /// /// [Fact] public async Task PastingIntoADeadSessionSaysSoRatherThanDroppingIt() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); (await workspace.PasteAsync( sessionId, "uptime", execute: false, TestContext.Current.CancellationToken)) .ShouldBeTrue("the session is live"); (await workspace.PasteAsync( 9999, "uptime", execute: false, TestContext.Current.CancellationToken)) .ShouldBeFalse("this workspace never issued that id"); await workspace.CloseSessionAsync(sessionId); (await workspace.PasteAsync( sessionId, "uptime", execute: false, TestContext.Current.CancellationToken)) .ShouldBeFalse("the tab it names is gone"); } /// /// /// The right-click paste's answer, and it is a separate frame from the snippet one above rather than the /// same frame with a flag: the page brackets this unconditionally and types a single-line snippet, which /// is a difference of behaviour and not of degree. See . /// /// /// The payload is the clipboard's text and nothing else — no leading flag byte, because there is no /// Enter to ask for. A frame that grew one would put a stray character at the front of every paste, and /// this is what would catch it. /// /// [Fact] public async Task TheClipboard_ReachesThePaneThatAskedForIt() { var connections = new FakeConnectionFactory(blockShellReads: true); await using var workspace = CreateWorkspace(connections); workspace.Start(); using var renderer = await ConnectRendererAsync(workspace); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); // The session's opening frame, read and discarded so the clipboard frame below is the next one. await ReceiveFrameAsync(renderer); (await workspace.SendClipboardAsync( sessionId, "sudo systemctl restart sshd", TestContext.Current.CancellationToken)) .ShouldBeTrue("the session is live"); var frame = await ReceiveFrameAsync(renderer); frame.Opcode.ShouldBe((byte)TerminalServerOpcode.Clipboard); frame.SessionId.ShouldBe(sessionId); Encoding.UTF8.GetString(frame.Payload).ShouldBe("sudo systemctl restart sshd"); } /// /// Refused rather than sent and dropped, for the reason PasteAsync is — with the difference that /// nothing on screen would have shown it either way, so this answer exists to be honest rather than to /// be reported. See . /// [Fact] public async Task SendingTheClipboardToADeadSession_SaysSoRatherThanDroppingIt() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); (await workspace.SendClipboardAsync(9999, "uptime", TestContext.Current.CancellationToken)) .ShouldBeFalse("this workspace never issued that id"); await workspace.CloseSessionAsync(sessionId); (await workspace.SendClipboardAsync(sessionId, "uptime", TestContext.Current.CancellationToken)) .ShouldBeFalse("the tab it names is gone"); } /// /// 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 /// LiveSessionCount 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. /// [Fact] 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. var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); var ended = new List(); var liveAtAnnouncement = -1; workspace.SessionEnded += (_, e) => { lock (ended) { liveAtAnnouncement = workspace.LiveSessionCount; ended.Add(e.SessionId); } }; var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => { lock (ended) { return ended.Contains(sessionId); } }); lock (ended) { liveAtAnnouncement.ShouldBe(0, "the announcement must wait for the run to actually complete"); } } /// /// 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. /// [Fact] public async Task ClosingASessionIsAnnounced_OnceItHasDrained() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var announcements = 0; var liveAtAnnouncement = -1; workspace.SessionEnded += (_, _) => { liveAtAnnouncement = workspace.LiveSessionCount; Interlocked.Increment(ref announcements); }; var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await workspace.CloseSessionAsync(sessionId); Volatile.Read(ref announcements) .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"); } /// /// ◆ The announcement carries how it ended, not just that it did. A subscriber cannot work that out for /// itself afterwards — by the time it hears, there is no transport left to interrogate — and the /// difference between a shell that exited and a connection that broke is the difference between a tab /// that is finished and one worth offering to dial again. /// [Fact] public async Task ASessionWhoseConnectionBroke_IsAnnouncedAsALostConnection() { var connections = new FakeConnectionFactory(bytesPerShell: 0) { TransportFailure = "An established connection was aborted by the server.", }; await using var workspace = CreateWorkspace(connections); TerminalSessionOutcome? outcome = null; workspace.SessionEnded += (_, e) => Volatile.Write(ref outcome, e.Outcome); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => Volatile.Read(ref outcome) is not null); var announced = Volatile.Read(ref outcome)!; announced.End.ShouldBe(SshSessionEnd.ConnectionLost); announced.Description.ShouldBe("An established connection was aborted by the server."); } /// /// The same shell ending with nothing wrong underneath it, which is what exit looks like from /// here. Its own test rather than an assertion tacked onto the one above, because the pair is the point: /// one fake differs from the other by a latched failure and nothing else, and the two must not report /// the same thing. /// [Fact] public async Task ASessionWhoseShellExited_IsAnnouncedAsTheRemoteClosingIt() { var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); TerminalSessionOutcome? outcome = null; workspace.SessionEnded += (_, e) => Volatile.Write(ref outcome, e.Outcome); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => Volatile.Read(ref outcome) is not null); Volatile.Read(ref outcome)!.End.ShouldBe(SshSessionEnd.RemoteClosed); } /// /// A tab being closed, announced as this client's own doing however the pump unwound. It matters for the /// shell reusing a tab: a reconnect closes the session it is replacing, and an announcement blaming the /// remote for that would draw a dropped connection over an attempt that is already under way. /// [Fact] public async Task ClosingASession_IsAnnouncedAsThisClientsDoing() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); TerminalSessionOutcome? outcome = null; workspace.SessionEnded += (_, e) => outcome = e.Outcome; var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await workspace.CloseSessionAsync(sessionId); outcome.ShouldNotBeNull(); outcome.End.ShouldBe(SshSessionEnd.ClosedLocally); } /// /// 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 — 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. /// [Fact] public async Task DisposingTheWorkspaceClosesEverySession() { var connections = new FakeConnectionFactory(); var workspace = CreateWorkspace(connections); var announcements = 0; workspace.SessionEnded += (_, _) => Interlocked.Increment(ref announcements); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await workspace.DisposeAsync(); 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 ---- /// /// /// The scenario the whole fix exists for: a page that lost its socket — killed WebView renderer, or /// simply a reload — reattaches, and the session that was already running has to come back rather than /// sit there forever with its output going nowhere. /// /// /// The session's shell blocks on every read rather than producing output, which is what an idle prompt /// looks like and — for this test — is what keeps its Run live without a background read loop /// competing with this test over the credit window's exact value. /// /// /// Neither assertion below polls, deliberately. The "before" one does not need to: reserving credit is /// a synchronous call, so it is true the instant it returns. The "after" one does not need to either, /// for a subtler reason — calls /// Credits.Reset() and only then awaits sending the replay frame for that same session, with no /// suspension between the two, so by the time this test has received that frame the reset has /// necessarily already happened. A poll here would only have hidden a real ordering bug behind a /// generous timeout instead of catching it. /// /// [Fact] public async Task ANewRenderer_ReplaysTheLiveSessionAndResetsItsCredits() { var connections = new FakeConnectionFactory(blockShellReads: true); await using var workspace = CreateWorkspace(connections); workspace.Start(); using var first = await ConnectRendererAsync(workspace); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); // The session's own opening frame, sent as soon as the pump starts running. Not a replay, and not // what this test is about — read and discarded so it cannot be confused for one below. await ReceiveFrameAsync(first); var credits = workspace.CreditsFor(sessionId).ShouldNotBeNull(); credits.TryReserve(4096); credits.Outstanding.ShouldBeGreaterThanOrEqualTo( 4096, "the pump's own read loop may have reserved a buffer's worth on top of this"); using var second = await ConnectRendererAsync(workspace); var replay = await ReceiveFrameAsync(second); replay.Opcode.ShouldBe((byte)TerminalServerOpcode.SessionOpened); replay.SessionId.ShouldBe(sessionId); replay.Payload.ShouldBe(new byte[] { 1 }, "a replay is flagged so the page can tell it apart from a fresh open"); credits.Outstanding.ShouldBe(0, "the replay frame above cannot have been sent before the reset that precedes it"); } /// /// The other half of a reattach: the workspace has replayed what it owns, and this is the seam the /// shell uses to replay what it owns instead — the font size and the selected tab, neither of which a /// terminal session knows anything about. MainWindowViewModel's subscription is what actually /// does that; this only asserts that the workspace hands it the chance to. /// [Fact] public async Task ANewRenderer_RaisesRendererReattached() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); workspace.Start(); using var first = await ConnectRendererAsync(workspace); var reattachedCount = 0; workspace.RendererReattached += (_, _) => Interlocked.Increment(ref reattachedCount); using var second = await ConnectRendererAsync(workspace); await WaitUntilAsync(() => Volatile.Read(ref reattachedCount) > 0); } // ---- Port forwards ---- [Fact] public async Task AForward_RunsOverTheConnectionOfTheSessionItNames() { var connections = new FakeConnectionFactory(blockShellReads: true); await using var workspace = CreateWorkspace(connections); await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); var second = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); var spec = new SshPortForwardSpec(SshPortForwardKind.Local, 5432, "db.internal", 5432); await using var forward = await workspace.StartForwardAsync( second, spec, TestContext.Current.CancellationToken); forward.Spec.ShouldBe(spec); connections.Connections[0].Forwards.ShouldBeEmpty(); connections.Connections[1].Forwards.ShouldBe([spec]); } [Fact] public async Task AForwardOnASessionThatIsNotRunning_IsRefused() { var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); var ended = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => !workspace.IsSessionLive(ended)); var spec = new SshPortForwardSpec(SshPortForwardKind.Dynamic, 1080); await Should.ThrowAsync(async () => await workspace.StartForwardAsync(ended, spec, TestContext.Current.CancellationToken)); await Should.ThrowAsync(async () => await workspace.StartForwardAsync(999, spec, TestContext.Current.CancellationToken)); connections.Connections[0].Forwards.ShouldBeEmpty(); } // ---- Helpers ---- /// /// /// Input that did not come from the keyboard. The Android head's accessory key row is what needs this — /// a software keyboard has no Ctrl, Esc, Tab or arrows — and what it sends has to arrive at the remote /// byte for byte, because an escape sequence that loses a byte is not a degraded arrow key, it is a /// stray character in somebody's shell. /// /// /// Ordinary typing does not come this way and is not what is being tested: that goes from the renderer /// down the socket, which the pump's own tests cover. /// /// [Fact] public async Task SendingInput_ReachesTheRemoteUnchanged() { var connections = new FakeConnectionFactory(); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); // The up-arrow, as a PTY expects it. Three bytes, and all three matter. byte[] upArrow = [0x1B, (byte)'[', (byte)'A']; await workspace.SendInputAsync(sessionId, upArrow, TestContext.Current.CancellationToken); await WaitUntilAsync(() => connections.Connections.SingleOrDefault()?.Shell?.Written.SequenceEqual(upArrow) == true); } /// /// A tab can close while a key is still in flight, which on a phone is one mis-tap rather than a rare /// race — the close cross sits inside the tab and the accessory row is directly under it. Throwing /// would turn that into a crash on a keystroke that no longer matters. /// [Fact] public async Task SendingInputToASessionThatIsGone_IsIgnored() { await using var workspace = CreateWorkspace(new FakeConnectionFactory()); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await workspace.CloseSessionAsync(sessionId); await Should.NotThrowAsync(async () => await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken)); } /// /// A shell that ended on its own gives its connection back at once, without waiting for the tab. /// /// /// ◆ THE HALF THAT WAS MISSING. Closing a tab disposed the connection and always had; a remote hanging /// up did not, so a session that ended went on holding an authenticated socket, a keep-alive putting a /// packet on the wire every thirty seconds, and SSH.NET's read buffer at whatever size the session's /// busiest moment had grown it to — for as long as the user left the tab open, which for a tab showing /// the last thing a job said is indefinitely. /// [Fact] public async Task ASessionWhoseRemoteHasExited_GivesBackItsConnectionWithoutWaitingForTheTab() { // A shell with no output to give: its first read returns 0, which is the remote closing the // channel, so the pump finishes with nobody having asked it to. var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => connections.Connections.TrueForAll(connection => connection.IsDisposed)); var connection = connections.Connections.ShouldHaveSingleItem(); connection.IsConnected.ShouldBeFalse(); connection.Shell.ShouldNotBeNull().IsOpen.ShouldBeFalse(); // The entry itself stays, and has to: the tab is still on screen, still says it ended, and still // offers a reconnect — all of which read this workspace rather than the tab strip's own memory. workspace.CreditsFor(sessionId).ShouldNotBeNull(); workspace.IsSessionLive(sessionId).ShouldBeFalse(); } /// /// The consequence of the release above, and the reason SendInputAsync asks about liveness /// rather than presence: the shell channel is disposed the moment the remote hangs up, so an accessory /// key pressed at a tab that ended reaches a disposed stream. It has to be as ordinary as pressing one /// at a tab that has closed, which is to say nothing happens. /// [Fact] public async Task SendingInputToASessionWhoseRemoteHasExited_IsIgnored() { var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId)); await Should.NotThrowAsync(async () => await workspace.SendInputAsync(sessionId, "x"u8.ToArray(), TestContext.Current.CancellationToken)); } /// /// Closing a tab over a session that already released itself must still work, because that is the /// ordinary way a dead tab goes away. Everything the close does is idempotent by construction — the /// entry is removed under the lock, and both disposals are guarded — and this is the test that says so /// rather than leaving it to be inferred from three separate classes. /// [Fact] public async Task ClosingATabOverASessionThatAlreadyEnded_IsStillSafe() { var connections = new FakeConnectionFactory(bytesPerShell: 0); await using var workspace = CreateWorkspace(connections); var sessionId = await workspace.OpenSessionAsync( Request(), TerminalSize.Default, progress: null, TestContext.Current.CancellationToken); await WaitUntilAsync(() => !workspace.IsSessionLive(sessionId)); await Should.NotThrowAsync(() => workspace.CloseSessionAsync(sessionId)); workspace.CreditsFor(sessionId).ShouldBeNull(); } /// /// How connections are made. The renderer-gate tests never reach it — they stop at the gate — but they /// take a fake anyway, because reaching a real host from here would make this a network test. /// /// The gate's bound, or null for the shipped default. private static TerminalWorkspace CreateWorkspace( ISshConnectionFactory connections, TimeSpan? rendererTimeout = null) => new( StubAssets(), connections, TimeProvider.System, rendererTimeout is { } timeout ? new TerminalWorkspaceOptions { RendererTimeout = timeout } : null); private static InMemoryTerminalAssetProvider StubAssets() => new(new Dictionary(StringComparer.Ordinal) { // The placeholders, not a token and URL already filled in — the reattach tests below have to // connect a real renderer, and doing that by reading them back out of the served page is what // proves the workspace serves a page a real renderer could actually attach with, rather than // one that merely looks servable. [TerminalDataPlane.PagePath] = new( "text/html; charset=utf-8", Encoding.UTF8.GetBytes( $"")), }); private static SshConnectionRequest Request() => new("host.invalid", 22, "dodo", new SshPasswordCredential("irrelevant")); /// /// /// Attaches the way the real page does: by fetching the served page, reading the token and socket URL /// back out of it, and presenting them on the upgrade — rather than reaching into the workspace for a /// token it does not expose. A shortcut here would prove only that a socket can be opened, not that the /// workspace serves a page a renderer could actually attach with. /// /// /// And it waits for the attach rather than only for the handshake, for the reason /// TerminalDataPlaneTests.ConnectAsync sets out at length: the 101 is written before the socket /// is attachable, and a frame sent in between is dropped. A caller that opens a session on the socket /// this returns and then reads its opening frame is exactly the shape that loses that race. /// /// /// WaitForRendererAsync answers only for the first renderer ever to attach, so a second call /// returns immediately without proving anything about the second socket — and even for the first it /// answers too early for what the callers here need. It completes when the socket is attached, which /// is before has run: that is fired and /// forgotten from the accept thread, and a thread-pool that deschedules it for a timeslice lets the /// test open a session in the meantime. The replay then finds that session live and sends it a second, /// replay-flagged SessionOpened — harmless to the page, which treats it as idempotent, but it /// lands in the middle of the frames the test is counting on. /// /// /// ◆ SO WAIT FOR THE REPLAY TO BE OVER, NOT ONLY FOR THE ATTACH. RendererReattached 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. /// /// private static async Task ConnectRendererAsync(TerminalWorkspace workspace) { using var http = new HttpClient(); var page = await http.GetStringAsync(workspace.PageUrl, TestContext.Current.CancellationToken); var token = ExtractAttribute(page, "data-token"); var socketUrl = ExtractAttribute(page, "data-socket"); var client = new ClientWebSocket(); client.Options.AddSubProtocol(TerminalDataPlane.SubProtocol); client.Options.AddSubProtocol($"token.{token}"); client.Options.SetRequestHeader( "Origin", 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 { await client.ConnectAsync(new Uri(socketUrl), TestContext.Current.CancellationToken); await workspace.WaitForRendererAsync(TestContext.Current.CancellationToken); await replayed.Task.WaitAsync(ReplayTimeout, TestContext.Current.CancellationToken); } catch { client.Dispose(); throw; } finally { workspace.RendererReattached -= OnReattached; } return client; } private static string ExtractAttribute(string html, string name) { var marker = $"{name}=\""; var start = html.IndexOf(marker, StringComparison.Ordinal) + marker.Length; var end = html.IndexOf('"', start); return html[start..end]; } private static async Task<(byte Opcode, uint SessionId, byte[] Payload)> ReceiveFrameAsync( ClientWebSocket socket) { var buffer = new byte[64 * 1024]; var result = await socket.ReceiveAsync(buffer.AsMemory(), TestContext.Current.CancellationToken); TerminalFrame.TryRead(buffer.AsSpan(0, result.Count), out var opcode, out var sessionId, out var payload) .ShouldBeTrue(); return (opcode, sessionId, payload.ToArray()); } /// /// Polled rather than awaited on a task, because the point is what an observer of the property /// sees: the pump ends on a thread of its own, and the count has to catch up without anyone /// telling it to. /// private static async Task WaitUntilAsync(Func condition) { var deadline = TimeProvider.System.GetUtcNow() + PollTimeout; while (TimeProvider.System.GetUtcNow() < deadline) { if (condition()) { return; } await Task.Delay(20, TestContext.Current.CancellationToken); } throw new TimeoutException($"The condition was still false after {PollTimeout}."); } }