Repository: microsoft/terminal (language: cpp)

Issue:
The virtual viewport bottom is not maintained correctly
### Windows Terminal version (or Windows build number)

10.0.18363.1379, 7f5a19b627037353fc988755ed25e6ecb5bf4675

### Other Software

_No response_

### Steps to reproduce

1. Start a bash shell in conhost.
2. Open the Properties page, and enable the _Disable Scroll-Forward_ option on the _Terminal_ tab.
3. Generate some output to get a few pages of content in the backscroll buffer.
4. Execute `printf "\e[H"` to move the cursor to the top of the current page.
5. Scroll back to the top of the buffer so the cursor is now out of view.
6. Type a printable character

### Expected Behavior

All of the buffer contents should be preserved.

### Actual Behavior

The line that the cursor was on becomes the bottom of the buffer, and everything below that point is truncated. Technically the data is still there - if you press enter a few times, it'll start scrolling into view. The problem is that the virtual viewport bottom is incorrectly set, so with _Scroll-Forward_ disabled, everything below that point is hidden.

There are a number of ways this bug can be triggered - even just resizing the window can do it - but the above described method is probably the easiest to reproduce consistently. Anything that calls `SCREEN_INFORMATION::UpdateBottom` can potentially trigger it, and there are a quite a few code paths leading to that.


Discussion / hints:
In general I think the bottom of the virtual viewport should always be increasing, except for two cases: 1. If the buffer has been shrunk for some reason, then we need to make sure the virtual bottom hasn't ended up out of range. 2. If the buffer is completely cleared with an `_EraseScrollback` call, then the virtual bottom needs to be reset too.

So I was thinking we could easily make the `UpdateBottom` method check if the bottom is increasing, or is potentially out of range, and only update in those two situations. Then all that's left is the `_EraseScrollback` call, which can be handled as a special case - it just needs to reset the bottom to 0, and it should then update automatically to the correct size.

Does that seem a reasonable solution? Or are there other cases I haven't thought of where it should be allowed to update to a smaller size?

Edit: I meant `_EraseScrollback` rather than `VtEraseAll`.
This seems reasonable to me. @zadjii-msft?
That _seems_ reasonable to me. I'll take a look at the PR later to double check. 
**REVERSION NOTES**

Bug #9872 reported that this made behavior slightly worse in many regards.

> ### Windows Terminal version (or Windows build number)
> [74909c0](https://github.com/microsoft/terminal/commit/74909c0c6511787907a6a369c8046c11ee373ae8)
> 
> ### Other Software
> _No response_
> 
> ### Steps to reproduce
> 1. Make sure you're using a build with PR #9770 merged.
> 2. Open a conhost cmd shell.
> 3. Output some content to fill a few pages of the buffer.
> 4. Execute `cls`.
> 
> ### Expected Behavior
> The buffer should be cleared and the viewport should be moved back to the top of the buffer.
> 
> ### Actual Behavior
> The buffer is cleared, but the viewport remains where it was before the `cls`.
> 
> I'm afraid I broke this with PR #9770. Previously `cls` would have reset the virtual bottom when it reset the viewport, but it doesn't do that anymore. And I suspect there are probably more cases like this.
> 
> I'm really not sure what the right solution is though - maybe every viewport change made through the console API should reset the virtual bottom? But whatever we do, there are going to be some cases that behave weirdly - I think that's inevitable when you're mixing the legacy APIs with VT.
> 
> For now, though, I think it might be a good idea to revert #9770, because this bug seems a lot worse than #9754.



Fix patch:
diff --git a/src/host/getset.cpp b/src/host/getset.cpp
index 55732eeb71a..17ff3f3978d 100644
--- a/src/host/getset.cpp
+++ b/src/host/getset.cpp
@@ -688,8 +688,6 @@ void ApiRoutines::GetLargestConsoleWindowSizeImpl(const SCREEN_INFORMATION& cont
         LockConsole();
         auto Unlock = wil::scope_exit([&] { UnlockConsole(); });
 
-        auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
-
         auto& buffer = context.GetActiveBuffer();
 
         const auto coordScreenBufferSize = buffer.GetBufferSize().Dimensions();
@@ -707,12 +705,9 @@ void ApiRoutines::GetLargestConsoleWindowSizeImpl(const SCREEN_INFORMATION& cont
         // Attempt to "snap" the viewport to the cursor position. If the cursor
         // is not in the current viewport, we'll try and move the viewport so
         // that the cursor is visible.
-        // microsoft/terminal#1222 - Use the "virtual" viewport here, so that
-        // when the console is in terminal-scrolling mode, the viewport snaps
-        // back to the virtual viewport's location.
-        const auto currentViewport = gci.IsTerminalScrolling() ?
-                                         buffer.GetVirtualViewport().ToInclusive() :
-                                         buffer.GetViewport().ToInclusive();
+        // GH#1222 and GH#9754 - Use the "virtual" viewport here, so that the
+        // viewport snaps back to the virtual viewport's location.
+        const auto currentViewport = buffer.GetVirtualViewport().ToInclusive();
         COORD delta{ 0 };
         {
             // When evaluating the X offset, we must convert the buffer position to
@@ -746,6 +741,12 @@ void ApiRoutines::GetLargestConsoleWindowSizeImpl(const SCREEN_INFORMATION& cont
         // buffer for us.
         RETURN_IF_NTSTATUS_FAILED(buffer.SetViewportOrigin(true, newWindowOrigin, true));
 
+        // SetViewportOrigin will only move the virtual bottom down, but in
+        // this particular case we also need to allow the virtual bottom to
+        // be moved up, so we have to call UpdateBottom explicitly. This is
+        // how the cmd shell's CLS command resets the buffer.
+        buffer.UpdateBottom();
+
         return S_OK;
     }
     CATCH_RETURN();
diff --git a/src/host/outputStream.cpp b/src/host/outputStream.cpp
index 9a7e9185e5b..03ad7642159 100644
--- a/src/host/outputStream.cpp
+++ b/src/host/outputStream.cpp
@@ -304,8 +304,8 @@ bool ConhostInternalGetSet::ResizeWindow(const size_t width, const size_t height
     csbiex.cbSize = sizeof(CONSOLE_SCREEN_BUFFER_INFOEX);
     api->GetConsoleScreenBufferInfoExImpl(screenInfo, csbiex);
 
-    const auto oldViewport = Viewport::FromInclusive(csbiex.srWindow);
-    const auto newViewport = Viewport::FromDimensions(oldViewport.Origin(), sColumns, sRows);
+    const auto oldViewport = screenInfo.GetVirtualViewport();
+    auto newViewport = Viewport::FromDimensions(oldViewport.Origin(), sColumns, sRows);
     // Always resize the width of the console
     csbiex.dwSize.X = sColumns;
     // Only set the screen buffer's height if it's currently less than
@@ -315,6 +315,16 @@ bool ConhostInternalGetSet::ResizeWindow(const size_t width, const size_t height
         csbiex.dwSize.Y = sRows;
     }
 
+    // If the cursor row is now past the bottom of the viewport, we'll have to
+    // move the viewport down to bring it back into view. However, we don't want
+    // to do this in pty mode, because the conpty resize operation is dependent
+    // on the viewport *not* being adjusted.
+    const short cursorOverflow = csbiex.dwCursorPosition.Y - newViewport.BottomInclusive();
+    if (cursorOverflow > 0 && !IsConsolePty())
+    {
+        newViewport = Viewport::Offset(newViewport, { 0, cursorOverflow });
+    }
+
     // SetWindowInfo expect inclusive rects
     const auto sri = newViewport.ToInclusive();
 
diff --git a/src/host/screenInfo.cpp b/src/host/screenInfo.cpp
index e490d3cd287..2c5f2f4c9b3 100644
--- a/src/host/screenInfo.cpp
+++ b/src/host/screenInfo.cpp
@@ -818,7 +818,10 @@ void SCREEN_INFORMATION::SetViewportSize(const COORD* const pcoordSize)
     // Update our internal virtual bottom tracker if requested. This helps keep
     //      the viewport's logical position consistent from the perspective of a
     //      VT client application, even if the user scrolls the viewport with the mouse.
-    if (updateBottom)
+    //      We typically only want to this to move the virtual bottom down, though,
+    //      otherwise it can end up "truncating" the buffer if the user is viewing
+    //      the scrollback at the time the viewport origin is updated.
+    if (updateBottom && _virtualBottom < _viewport.BottomInclusive())
     {
         UpdateBottom();
     }
@@ -1239,23 +1242,21 @@ void SCREEN_INFORMATION::_InternalSetViewportSize(const COORD* const pcoordSize,
         srNewViewport.Top = std::max<SHORT>(0, srNewViewport.Top - offBottomDelta);
     }
 
-    // See MSFT:19917443
-    // If we're in terminal scrolling mode, and we've changed the height of the
-    //      viewport, the new viewport's bottom to the _virtualBottom.
-    // GH#1206 - Only do this if the viewport is _growing_ in height. This can
-    // cause unexpected behavior if we try to anchor the _virtualBottom to a
-    // position that will be greater than the height of the buffer.
-    const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
-    auto newViewport = Viewport::FromInclusive(srNewViewport);
-    if (gci.IsTerminalScrolling() && newViewport.Height() >= _viewport.Height())
+    // In general we want to avoid moving the virtual bottom unless it's aligned with
+    // the visible viewport, so we check whether the changes we're making would cause
+    // the bottom of the visible viewport to intersect the virtual bottom at any point.
+    // If so, we update the virtual bottom to match. We also update the virtual bottom
+    // if it's less than the new viewport height minus 1, because that would otherwise
+    // leave the virtual viewport extended past the top of the buffer.
+    const auto newViewport = Viewport::FromInclusive(srNewViewport);
+    if ((_virtualBottom >= _viewport.BottomInclusive() && _virtualBottom < newViewport.BottomInclusive()) ||
+        (_virtualBottom <= _viewport.BottomInclusive() && _virtualBottom > newViewport.BottomInclusive()) ||
+        _virtualBottom < newViewport.Height() - 1)
     {
-        const auto newTop = static_cast<short>(std::max(0, _virtualBottom - (newViewport.Height() - 1)));
-
-        newViewport = Viewport::FromDimensions(COORD({ newViewport.Left(), newTop }), newViewport.Dimensions());
+        _virtualBottom = srNewViewport.Bottom;
     }
 
     _viewport = newViewport;
-    UpdateBottom();
     Tracing::s_TraceWindowViewport(_viewport);
 
     // In Conpty mode, call TriggerScroll here without params. By not providing
@@ -1267,6 +1268,7 @@ void SCREEN_INFORMATION::_InternalSetViewportSize(const COORD* const pcoordSize,
     // till the start of the next frame. If any other text gets output before
     // that frame starts, there's a very real chance that it'll cause errors as
     // the engine tries to invalidate those regions.
+    const auto& gci = ServiceLocator::LocateGlobals().getConsoleInformation();
     if (gci.IsInVtIoMode() && ServiceLocator::LocateGlobals().pRender)
     {
         ServiceLocator::LocateGlobals().pRender->TriggerScroll();
@@ -1462,12 +1464,17 @@ bool SCREEN_INFORMATION::IsMaximizedY() const
 
     if (SUCCEEDED(hr))
     {
-        auto& newCursor = newTextBuffer->GetCursor();
+        // Make sure the new virtual bottom is far enough down to include both
+        // the cursor row and the last non-space row.
+        const auto cursorRow = newTextBuffer->GetCursor().GetPosition().Y;
+        const auto lastNonSpaceRow = newTextBuffer->GetLastNonSpaceCharacter().Y;
+        _virtualBottom = std::max({ cursorRow, lastNonSpaceRow });
+
         // Adjust the viewport so the cursor doesn't wildly fly off up or down.
-        const auto sCursorHeightInViewportAfter = newCursor.GetPosition().Y - _viewport.Top();
+        const auto sCursorHeightInViewportAfter = cursorRow - _viewport.Top();
         COORD coordCursorHeightDiff = { 0 };
         coordCursorHeightDiff.Y = gsl::narrow_cast<short>(sCursorHeightInViewportAfter - sCursorHeightInViewportBefore);
-        LOG_IF_FAILED(SetViewportOrigin(false, coordCursorHeightDiff, true));
+        LOG_IF_FAILED(SetViewportOrigin(false, coordCursorHeightDiff, false));
 
         newTextBuffer->SetCurrentAttributes(oldPrimaryAttributes);
 
@@ -1749,7 +1756,7 @@ void SCREEN_INFORMATION::SetCursorDBMode(const bool DoubleCursor)
     return STATUS_SUCCESS;
 }
 
-void SCREEN_INFORMATION::MakeCursorVisible(const COORD CursorPosition, const bool updateBottom)
+void SCREEN_INFORMATION::MakeCursorVisible(const COORD CursorPosition)
 {
     COORD WindowOrigin;
 
@@ -1781,7 +1788,7 @@ void SCREEN_INFORMATION::MakeCursorVisible(const COORD CursorPosition, const boo
 
     if (WindowOrigin.X != 0 || WindowOrigin.Y != 0)
     {
-        LOG_IF_FAILED(SetViewportOrigin(false, WindowOrigin, updateBottom));
+        LOG_IF_FAILED(SetViewportOrigin(false, WindowOrigin, false));
     }
 }
 
@@ -2308,6 +2315,10 @@ void SCREEN_INFORMATION::SetViewport(const Viewport& newViewport,
     const COORD coordNewOrigin = { 0, sNewTop };
     RETURN_IF_FAILED(SetViewportOrigin(true, coordNewOrigin, true));
 
+    // SetViewportOrigin will only move the virtual bottom down, but in this
+    // case we need to reset it to the top, so we have to update it explicitly.
+    UpdateBottom();
+
     // Place the cursor at the same x coord, on the row that's now the top
     RETURN_IF_FAILED(SetCursorPosition(COORD{ oldCursorPos.X, sNewTop }, false));
 
diff --git a/src/host/screenInfo.hpp b/src/host/screenInfo.hpp
index cb1b7f7dad3..0042c55b530 100644
--- a/src/host/screenInfo.hpp
+++ b/src/host/screenInfo.hpp
@@ -191,7 +191,7 @@ class SCREEN_INFORMATION : public ConsoleObjectHeader, public Microsoft::Console
     void SetCursorDBMode(const bool DoubleCursor);
     [[nodiscard]] NTSTATUS SetCursorPosition(const COORD Position, const bool TurnOn);
 
-    void MakeCursorVisible(const COORD CursorPosition, const bool updateBottom = true);
+    void MakeCursorVisible(const COORD CursorPosition);
 
     Microsoft::Console::Types::Viewport GetRelativeScrollMargins() const;
     Microsoft::Console::Types::Viewport GetAbsoluteScrollMargins() const;
diff --git a/src/host/selection.cpp b/src/host/selection.cpp
index 4706c20c3a0..70ec0336f46 100644
--- a/src/host/selection.cpp
+++ b/src/host/selection.cpp
@@ -213,7 +213,7 @@ void Selection::ExtendSelection(_In_ COORD coordBufferPos)
         }
 
         // scroll if necessary to make cursor visible.
-        screenInfo.MakeCursorVisible(coordBufferPos, false);
+        screenInfo.MakeCursorVisible(coordBufferPos);
 
         _dwSelectionFlags |= CONSOLE_SELECTION_NOT_EMPTY;
         _srSelectionRect.Left = _srSelectionRect.Right = _coordSelectionAnchor.X;
@@ -224,7 +224,7 @@ void Selection::ExtendSelection(_In_ COORD coordBufferPos)
     else
     {
         // scroll if necessary to make cursor visible.
-        screenInfo.MakeCursorVisible(coordBufferPos, false);
+        screenInfo.MakeCursorVisible(coordBufferPos);
     }
 
     // remember previous selection rect
@@ -609,5 +609,5 @@ void Selection::SelectAll()
     SelectNewRegion(coordNewSelStart, coordNewSelEnd);
 
     // restore the old window position
-    LOG_IF_FAILED(screenInfo.SetViewportOrigin(true, coordWindowOrigin, true));
+    LOG_IF_FAILED(screenInfo.SetViewportOrigin(true, coordWindowOrigin, false));
 }
diff --git a/src/host/selectionInput.cpp b/src/host/selectionInput.cpp
index d178b33b0bd..490718d7255 100644
--- a/src/host/selectionInput.cpp
+++ b/src/host/selectionInput.cpp
@@ -927,7 +927,7 @@ bool Selection::_HandleMarkModeSelectionNav(const INPUT_KEY_INFO* const pInputKe
 
             cursor.SetHasMoved(true);
             _coordSelectionAnchor = textBuffer.GetCursor().GetPosition();
-            ScreenInfo.MakeCursorVisible(_coordSelectionAnchor, false);
+            ScreenInfo.MakeCursorVisible(_coordSelectionAnchor);
             _srSelectionRect.Left = _srSelectionRect.Right = _coordSelectionAnchor.X;
             _srSelectionRect.Top = _srSelectionRect.Bottom = _coordSelectionAnchor.Y;
         }
diff --git a/src/host/ut_host/ScreenBufferTests.cpp b/src/host/ut_host/ScreenBufferTests.cpp
index 311470e6c62..030e8f46d8a 100644
--- a/src/host/ut_host/ScreenBufferTests.cpp
+++ b/src/host/ut_host/ScreenBufferTests.cpp
@@ -80,6 +80,8 @@ class ScreenBufferTests
         auto defaultSize = COORD{ CommonState::s_csWindowWidth, CommonState::s_csWindowHeight };
         currentBuffer.SetViewport(Viewport::FromDimensions(defaultSize), true);
         VERIFY_ARE_EQUAL(COORD({ 0, 0 }), currentBuffer.GetTextBuffer().GetCursor().GetPosition());
+        // Make sure the virtual bottom is correctly positioned.
+        currentBuffer.UpdateBottom();
 
         return true;
     }
@@ -225,6 +227,12 @@ class ScreenBufferTests
     TEST_METHOD(TestAddHyperlinkCustomIdDifferentUri);
 
     TEST_METHOD(UpdateVirtualBottomWhenCursorMovesBelowIt);
+    TEST_METHOD(UpdateVirtualBottomWithSetConsoleCursorPosition);
+    TEST_METHOD(UpdateVirtualBottomAfterInternalSetViewportSize);
+    TEST_METHOD(UpdateVirtualBottomAfterResizeWithReflow);
+    TEST_METHOD(DontChangeVirtualBottomWithOffscreenLinefeed);
+    TEST_METHOD(DontChangeVirtualBottomAfterResizeWindow);
+    TEST_METHOD(DontChangeVirtualBottomWithMakeCursorVisible);
     TEST_METHOD(RetainHorizontalOffsetWhenMovingToBottom);
 
     TEST_METHOD(TestWriteConsoleVTQuirkMode);
@@ -6133,6 +6141,272 @@ void ScreenBufferTests::UpdateVirtualBottomWhenCursorMovesBelowIt()
     VERIFY_ARE_EQUAL(newVirtualBottom, si.GetViewport().BottomInclusive());
 }
 
+void ScreenBufferTests::UpdateVirtualBottomWithSetConsoleCursorPosition()
+{
+    auto& g = ServiceLocator::LocateGlobals();
+    auto& gci = g.getConsoleInformation();
+    auto& si = gci.GetActiveOutputBuffer();
+    auto& cursor = si.GetTextBuffer().GetCursor();
+
+    Log::Comment(L"Pan down so the initial viewport is a couple of pages down");
+    const auto initialOrigin = COORD{ 0, si.GetViewport().Height() * 2 };
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, initialOrigin, true));
+    VERIFY_ARE_EQUAL(initialOrigin, si.GetViewport().Origin());
+
+    Log::Comment(L"Make sure the initial virtual bottom is at the bottom of the viewport");
+    const auto initialVirtualBottom = si.GetViewport().BottomInclusive();
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Pan to the top of the buffer without changing the virtual bottom");
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, 0 }, false));
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Set the cursor position to the initial origin");
+    VERIFY_SUCCEEDED(g.api->SetConsoleCursorPositionImpl(si, initialOrigin));
+    VERIFY_ARE_EQUAL(initialOrigin, cursor.GetPosition());
+
+    Log::Comment(L"Confirm that the viewport has moved down");
+    VERIFY_ARE_EQUAL(initialOrigin, si.GetViewport().Origin());
+
+    Log::Comment(L"Confirm that the virtual bottom has not changed");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Pan further down so the viewport is below the cursor");
+    const auto belowCursor = COORD{ 0, cursor.GetPosition().Y + 10 };
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, belowCursor, false));
+    VERIFY_ARE_EQUAL(belowCursor, si.GetViewport().Origin());
+
+    Log::Comment(L"Set the cursor position one line down, still inside the virtual viewport");
+    const auto oneLineDown = COORD{ 0, cursor.GetPosition().Y + 1 };
+    VERIFY_SUCCEEDED(g.api->SetConsoleCursorPositionImpl(si, oneLineDown));
+    VERIFY_ARE_EQUAL(oneLineDown, cursor.GetPosition());
+
+    Log::Comment(L"Confirm that the viewport has moved back to the initial origin");
+    VERIFY_ARE_EQUAL(initialOrigin, si.GetViewport().Origin());
+
+    Log::Comment(L"Confirm that the virtual bottom has not changed");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Set the cursor position to the top of the buffer");
+    const auto topOfBuffer = COORD{ 0, 0 };
+    VERIFY_SUCCEEDED(g.api->SetConsoleCursorPositionImpl(si, topOfBuffer));
+    VERIFY_ARE_EQUAL(topOfBuffer, cursor.GetPosition());
+
+    Log::Comment(L"Confirm that the viewport has moved to the top of the buffer");
+    VERIFY_ARE_EQUAL(topOfBuffer, si.GetViewport().Origin());
+
+    Log::Comment(L"Confirm that the virtual bottom has also moved up");
+    VERIFY_ARE_EQUAL(si.GetViewport().BottomInclusive(), si._virtualBottom);
+}
+
+void ScreenBufferTests::UpdateVirtualBottomAfterInternalSetViewportSize()
+{
+    auto& g = ServiceLocator::LocateGlobals();
+    auto& gci = g.getConsoleInformation();
+    auto& si = gci.GetActiveOutputBuffer();
+    auto& cursor = si.GetTextBuffer().GetCursor();
+    auto& stateMachine = si.GetStateMachine();
+
+    Log::Comment(L"Pan down so the initial viewport is a couple of pages down");
+    const auto initialOrigin = COORD{ 0, si.GetViewport().Height() * 2 };
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, initialOrigin, true));
+    VERIFY_ARE_EQUAL(initialOrigin, si.GetViewport().Origin());
+
+    Log::Comment(L"Make sure the initial virtual bottom is at the bottom of the viewport");
+    const auto initialVirtualBottom = si.GetViewport().BottomInclusive();
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Set the cursor to the bottom of the current page");
+    stateMachine.ProcessString(L"\033[9999H");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, cursor.GetPosition().Y);
+
+    Log::Comment(L"Pan to the top of the buffer without changing the virtual bottom");
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, 0 }, false));
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Shrink the viewport height by two lines");
+    auto viewportSize = si.GetViewport().Dimensions();
+    viewportSize.Y -= 2;
+    si._InternalSetViewportSize(&viewportSize, false, false);
+    VERIFY_ARE_EQUAL(viewportSize, si.GetViewport().Dimensions());
+
+    Log::Comment(L"Confirm that the virtual bottom has not changed");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Position the viewport just above the virtual bottom");
+    short viewportTop = si._virtualBottom - viewportSize.Y;
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, viewportTop }, false));
+    VERIFY_ARE_EQUAL(si._virtualBottom - 1, si.GetViewport().BottomInclusive());
+
+    Log::Comment(L"Expand the viewport height so it 'passes through' the virtual bottom");
+    viewportSize.Y += 2;
+    si._InternalSetViewportSize(&viewportSize, false, false);
+    VERIFY_ARE_EQUAL(viewportSize, si.GetViewport().Dimensions());
+
+    Log::Comment(L"Confirm that the virtual bottom has aligned with the viewport bottom");
+    VERIFY_ARE_EQUAL(si._virtualBottom, si.GetViewport().BottomInclusive());
+
+    Log::Comment(L"Position the viewport bottom just below the virtual bottom");
+    viewportTop = si._virtualBottom - viewportSize.Y + 2;
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, viewportTop }, false));
+    VERIFY_ARE_EQUAL(si._virtualBottom + 1, si.GetViewport().BottomInclusive());
+
+    Log::Comment(L"Shrink the viewport height so it 'passes through' the virtual bottom");
+    viewportSize.Y -= 2;
+    si._InternalSetViewportSize(&viewportSize, false, false);
+    VERIFY_ARE_EQUAL(viewportSize, si.GetViewport().Dimensions());
+
+    Log::Comment(L"Confirm that the virtual bottom has aligned with the viewport bottom");
+    VERIFY_ARE_EQUAL(si._virtualBottom, si.GetViewport().BottomInclusive());
+}
+
+void ScreenBufferTests::UpdateVirtualBottomAfterResizeWithReflow()
+{
+    auto& g = ServiceLocator::LocateGlobals();
+    auto& gci = g.getConsoleInformation();
+    auto& si = gci.GetActiveOutputBuffer();
+    auto& cursor = si.GetTextBuffer().GetCursor();
+    auto& stateMachine = si.GetStateMachine();
+
+    Log::Comment(L"Output a couple of pages of content");
+    auto bufferSize = si.GetTextBuffer().GetSize().Dimensions();
+    const auto viewportSize = si.GetViewport().Dimensions();
+    const auto line = std::wstring(bufferSize.X - 1, L'X') + L'\n';
+    for (auto i = 0; i < viewportSize.Y * 2; i++)
+    {
+        stateMachine.ProcessString(line);
+    }
+
+    Log::Comment(L"Set the cursor to the top of the current page");
+    stateMachine.ProcessString(L"\033[H");
+    VERIFY_ARE_EQUAL(si.GetViewport().Origin(), cursor.GetPosition());
+
+    Log::Comment(L"Pan to the top of the buffer without changing the virtual bottom");
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, 0 }, false));
+
+    Log::Comment(L"Shrink the viewport width by a half");
+    bufferSize.X /= 2;
+    VERIFY_NT_SUCCESS(si.ResizeWithReflow(bufferSize));
+
+    Log::Comment(L"Confirm that the virtual viewport includes the last non-space row");
+    const auto lastNonSpaceRow = si.GetTextBuffer().GetLastNonSpaceCharacter().Y;
+    VERIFY_IS_GREATER_THAN_OR_EQUAL(si._virtualBottom, lastNonSpaceRow);
+}
+
+void ScreenBufferTests::DontChangeVirtualBottomWithOffscreenLinefeed()
+{
+    auto& g = ServiceLocator::LocateGlobals();
+    auto& gci = g.getConsoleInformation();
+    auto& si = gci.GetActiveOutputBuffer();
+    auto& cursor = si.GetTextBuffer().GetCursor();
+    auto& stateMachine = si.GetStateMachine();
+
+    Log::Comment(L"Pan down so the initial viewport is a couple of pages down");
+    const auto initialOrigin = COORD{ 0, si.GetViewport().Height() * 2 };
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, initialOrigin, true));
+    VERIFY_ARE_EQUAL(initialOrigin, si.GetViewport().Origin());
+
+    Log::Comment(L"Make sure the initial virtual bottom is at the bottom of the viewport");
+    const auto initialVirtualBottom = si.GetViewport().BottomInclusive();
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Set the cursor to the top of the current page");
+    stateMachine.ProcessString(L"\033[H");
+    VERIFY_ARE_EQUAL(initialOrigin, cursor.GetPosition());
+
+    Log::Comment(L"Pan to the top of the buffer without changing the virtual bottom");
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, 0 }, false));
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Output a line feed");
+    stateMachine.ProcessString(L"\n");
+
+    Log::Comment(L"Confirm that the virtual bottom has not changed");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+}
+
+void ScreenBufferTests::DontChangeVirtualBottomAfterResizeWindow()
+{
+    auto& g = ServiceLocator::LocateGlobals();
+    auto& gci = g.getConsoleInformation();
+    auto& si = gci.GetActiveOutputBuffer();
+    auto& cursor = si.GetTextBuffer().GetCursor();
+    auto& stateMachine = si.GetStateMachine();
+
+    Log::Comment(L"Pan down so the initial viewport is a couple of pages down");
+    const auto initialOrigin = COORD{ 0, si.GetViewport().Height() * 2 };
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, initialOrigin, true));
+    VERIFY_ARE_EQUAL(initialOrigin, si.GetViewport().Origin());
+
+    Log::Comment(L"Make sure the initial virtual bottom is at the bottom of the viewport");
+    const auto initialVirtualBottom = si.GetViewport().BottomInclusive();
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Set the cursor to the bottom of the current page");
+    stateMachine.ProcessString(L"\033[9999H");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, cursor.GetPosition().Y);
+
+    Log::Comment(L"Pan to the top of the buffer without changing the virtual bottom");
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, 0 }, false));
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Shrink the viewport height");
+    std::wstringstream ss;
+    auto viewportWidth = si.GetViewport().Width();
+    auto viewportHeight = si.GetViewport().Height() - 2;
+    ss << L"\x1b[8;" << viewportHeight << L";" << viewportWidth << L"t";
+    stateMachine.ProcessString(ss.str());
+    VERIFY_ARE_EQUAL(viewportHeight, si.GetViewport().Height());
+
+    Log::Comment(L"Confirm that the virtual bottom has not changed");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+}
+
+void ScreenBufferTests::DontChangeVirtualBottomWithMakeCursorVisible()
+{
+    auto& g = ServiceLocator::LocateGlobals();
+    auto& gci = g.getConsoleInformation();
+    auto& si = gci.GetActiveOutputBuffer();
+    auto& cursor = si.GetTextBuffer().GetCursor();
+    auto& stateMachine = si.GetStateMachine();
+
+    Log::Comment(L"Pan down so the initial viewport is a couple of pages down");
+    const auto initialOrigin = COORD{ 0, si.GetViewport().Height() * 2 };
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, initialOrigin, true));
+    VERIFY_ARE_EQUAL(initialOrigin, si.GetViewport().Origin());
+
+    Log::Comment(L"Make sure the initial virtual bottom is at the bottom of the viewport");
+    const auto initialVirtualBottom = si.GetViewport().BottomInclusive();
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Set the cursor to the top of the current page");
+    stateMachine.ProcessString(L"\033[H");
+    VERIFY_ARE_EQUAL(initialOrigin, cursor.GetPosition());
+
+    Log::Comment(L"Pan to the top of the buffer without changing the virtual bottom");
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, { 0, 0 }, false));
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Make the cursor visible");
+    si.MakeCurrentCursorVisible();
+    VERIFY_ARE_EQUAL(si.GetViewport().BottomInclusive(), cursor.GetPosition().Y);
+
+    Log::Comment(L"Confirm that the virtual bottom has not changed");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+
+    Log::Comment(L"Pan further down so the viewport is below the cursor");
+    const auto belowCursor = COORD{ 0, cursor.GetPosition().Y + 10 };
+    VERIFY_SUCCEEDED(si.SetViewportOrigin(true, belowCursor, false));
+    VERIFY_ARE_EQUAL(belowCursor, si.GetViewport().Origin());
+
+    Log::Comment(L"Make the cursor visible");
+    si.MakeCurrentCursorVisible();
+    VERIFY_ARE_EQUAL(si.GetViewport().Top(), cursor.GetPosition().Y);
+
+    Log::Comment(L"Confirm that the virtual bottom has not changed");
+    VERIFY_ARE_EQUAL(initialVirtualBottom, si._virtualBottom);
+}
+
 void ScreenBufferTests::RetainHorizontalOffsetWhenMovingToBottom()
 {
     auto& g = ServiceLocator::LocateGlobals();


Test patch: