Skip to content

Commit e52fe06

Browse files
committed
Route the handoff return through reconfigure()
Returning from a handoff installed margins by hand, which skipped two things reconfigure() already does. It never rebound the adapter. In the shrink-then-handoff-then-grow sequence the shrink had cleared it, so the terminal came back reserved while callers held the plain backend: display.output reported 24 rows with row 24 reserved, and erases through it were unbounded ED over the reserved row -- the exact destruction the bounded erase exists to prevent. It also never re-checked backend capability. A refused acquisition still holds a lease, so the handoff flag can be set for a backend that was never eligible; a DummyOutput that had just been refused received ESC[1;39r and reported itself reserved on return. That bypassed the compatibility protection for legacy Win32 and unknown backends too. Coming back is an ordinary reconfiguration, so it now goes through that one path: capability re-checked, geometry measured afresh, margins installed only if the result is eligible, adapter rebound. Reverting it fails three of the new tests.
1 parent df6940b commit e52fe06

2 files changed

Lines changed: 64 additions & 11 deletions

File tree

‎cmd2/terminal_display.py‎

Lines changed: 10 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -371,8 +371,10 @@ def _teardown(self) -> None:
371371
def reconfigure(self) -> bool:
372372
"""Resample the terminal and re-establish the reservation for the new geometry.
373373
374-
Used after a resize. A terminal that has shrunk below the floor releases rather than
375-
narrowing, and one that has grown back above it reacquires.
374+
Used after a resize, and on the way back from a handoff. A terminal that has shrunk
375+
below the floor releases rather than narrowing, and one that has grown back above it
376+
reacquires. An unqualified backend never installs anything: a refused acquisition
377+
still holds a lease, so capability is re-checked here rather than assumed settled.
376378
377379
:return: whether a reservation is installed after this call
378380
"""
@@ -428,15 +430,12 @@ def reacquire_region_after_handoff(self) -> None:
428430
if not self._handoff_active or self._depth == 0:
429431
return
430432
self._handoff_active = False
431-
geometry = self._measure()
432-
if not geometry.is_eligible:
433-
# The terminal shrank while the guest had it. Nothing is installed, so callers
434-
# go back to the plain backend -- which they do by way of the geometry check in
435-
# `output`, rather than by unbinding the adapter here, so that a terminal which
436-
# grows again reuses the same object.
437-
return
438-
self._terminal.install_region(geometry)
439-
self._geometry = geometry
433+
# Coming back is an ordinary reconfiguration, so it goes through the one path that
434+
# does the whole job: re-check backend capability, measure, install only if the
435+
# result is eligible, and rebind the adapter. Reinstalling margins here directly
436+
# would leave a reserved terminal whose callers still hold the plain backend, and
437+
# therefore unbounded erases running over the reserved row.
438+
self.reconfigure()
440439

441440
def revalidate_viewport(self) -> bool:
442441
"""Re-check the viewport origin and rebuild the region if it moved.

‎tests/test_terminal_display.py‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -700,3 +700,57 @@ def test_a_handoff_without_a_lease_is_not_recorded(self) -> None:
700700
display.release_region_for_handoff()
701701
assert display.acquire()
702702
assert display.is_reserved
703+
704+
705+
class TestHandoffReturnRestoresEverything:
706+
"""Coming back is an ordinary reconfiguration. Installing margins without rebinding the
707+
adapter leaves a reserved terminal whose callers hold the plain backend -- and therefore
708+
unbounded erases running over the reserved row."""
709+
710+
def test_the_adapter_is_rebound_when_the_region_comes_back(self) -> None:
711+
output, _stream, screen = make_resizable_output(rows=24, columns=80)
712+
display = TerminalDisplay(output)
713+
display.acquire()
714+
adapter = display.output
715+
screen.rows = 2
716+
display.reconfigure()
717+
adapter.enter_alternate_screen()
718+
screen.rows = 24
719+
adapter.quit_alternate_screen()
720+
721+
assert display.is_reserved
722+
assert isinstance(display.output, ReservedOutput), "callers were left on the backend"
723+
assert display.output.get_size() == Size(rows=23, columns=80)
724+
725+
def test_erases_through_the_returned_output_are_bounded(self) -> None:
726+
"""The failure this guards against destroys the reserved row outright."""
727+
output, stream, screen = make_resizable_output(rows=24, columns=80)
728+
display = TerminalDisplay(output)
729+
display.acquire()
730+
adapter = display.output
731+
screen.rows = 2
732+
display.reconfigure()
733+
adapter.enter_alternate_screen()
734+
screen.rows = 24
735+
adapter.quit_alternate_screen()
736+
737+
display.output.flush()
738+
stream.truncate(0), stream.seek(0)
739+
display.output.erase_down()
740+
display.output.flush()
741+
assert stream.getvalue() == "\x1b[23M"
742+
assert "\x1b[J" not in stream.getvalue()
743+
744+
def test_an_unqualified_backend_is_still_unqualified_after_a_handoff(self) -> None:
745+
"""A refused acquisition still holds a lease, so the handoff flag can be set for a
746+
backend that was never eligible. Capability has to be re-checked, not assumed."""
747+
display = TerminalDisplay(DummyOutput())
748+
assert not display.acquire()
749+
assert display.lease_depth == 1
750+
751+
display.release_region_for_handoff()
752+
display.reacquire_region_after_handoff()
753+
754+
assert not display.is_reserved
755+
assert display.geometry is None
756+
assert display.output is display.terminal.output

0 commit comments

Comments
 (0)