From 307651bb410c8788347a39e006f6dc7fe214fe76 Mon Sep 17 00:00:00 2001 From: Deepak Ganesh Date: Sat, 25 Jul 2026 16:10:50 +0530 Subject: [PATCH 1/2] Avoid redundant renderer resets Track whether the renderer has produced terminal state so application resets only clean up when needed. Add a lifecycle regression test covering startup and shutdown reset counts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/prompt_toolkit/application/application.py | 6 ++-- src/prompt_toolkit/renderer.py | 8 +++++ tests/test_application.py | 32 +++++++++++++++++++ 3 files changed, 44 insertions(+), 2 deletions(-) create mode 100644 tests/test_application.py diff --git a/src/prompt_toolkit/application/application.py b/src/prompt_toolkit/application/application.py index 5e9c6a997..26e8ed85a 100644 --- a/src/prompt_toolkit/application/application.py +++ b/src/prompt_toolkit/application/application.py @@ -416,7 +416,8 @@ def reset(self) -> None: self._background_tasks: set[Task[None]] = set() - self.renderer.reset() + if self.renderer.has_rendered: + self.renderer.reset() self.key_processor.reset() self.layout.reset() self.vi_state.reset() @@ -750,7 +751,8 @@ def flush_input() -> None: # _redraw has a good chance to fail if it calls widgets # with bad code. Make sure to reset the renderer # anyway. - self.renderer.reset() + if self.renderer.has_rendered: + self.renderer.reset() # Unset `is_running`, this ensures that possibly # scheduled draws won't paint during the following diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index fcfde223e..f85af6b57 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -363,6 +363,7 @@ def __init__( self._mouse_support_enabled = False self._bracketed_paste_enabled = False self._cursor_key_mode_reset = False + self._has_rendered = False # Future set when we are waiting for a CPR flag. self._waiting_for_cpr_futures: deque[Future[None]] = deque() @@ -428,6 +429,7 @@ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> N # Flush output. `disable_mouse_support` needs to write to stdout. self.output.flush() + self._has_rendered = False @property def last_rendered_screen(self) -> Screen | None: @@ -437,6 +439,11 @@ def last_rendered_screen(self) -> Screen | None: """ return self._last_screen + @property + def has_rendered(self) -> bool: + """Whether the renderer currently has terminal state to reset.""" + return self._has_rendered + @property def height_is_known(self) -> bool: """ @@ -598,6 +605,7 @@ def render( won't print any changes to this part. """ output = self.output + self._has_rendered = True # Enter alternate screen. if self.full_screen and not self._in_alternate_screen: diff --git a/tests/test_application.py b/tests/test_application.py new file mode 100644 index 000000000..6d7dd7b6f --- /dev/null +++ b/tests/test_application.py @@ -0,0 +1,32 @@ +from __future__ import annotations + +from prompt_toolkit.application import Application +from prompt_toolkit.input import create_pipe_input +from prompt_toolkit.output import DummyOutput +from prompt_toolkit.renderer import Renderer + + +def test_renderer_is_not_reset_repeatedly_during_application_lifecycle( + monkeypatch, +) -> None: + reset_calls = 0 + reset_calls_before_render: list[int] = [] + original_reset = Renderer.reset + + def reset(self: Renderer, *args: object, **kwargs: object) -> None: + nonlocal reset_calls + reset_calls += 1 + original_reset(self, *args, **kwargs) + + monkeypatch.setattr(Renderer, "reset", reset) + + with create_pipe_input() as input: + app: Application[None] = Application( + input=input, + output=DummyOutput(), + before_render=lambda _: reset_calls_before_render.append(reset_calls), + ) + app.run(pre_run=lambda: app.exit()) + + assert reset_calls_before_render == [1, 1] + assert reset_calls == 2 From e2c21a7fd5e56c116006f12e81b74de5dd555164 Mon Sep 17 00:00:00 2001 From: Deepak Ganesh Date: Sat, 25 Jul 2026 16:45:04 +0530 Subject: [PATCH 2/2] Preserve alternate-screen cleanup after resize Keep renderer cleanup pending when a resize reset intentionally retains the alternate screen. Cover a failed full-screen redraw after resize to ensure final cleanup quits the alternate screen. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/prompt_toolkit/renderer.py | 2 +- tests/test_application.py | 49 ++++++++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/prompt_toolkit/renderer.py b/src/prompt_toolkit/renderer.py index f85af6b57..f9a78e984 100644 --- a/src/prompt_toolkit/renderer.py +++ b/src/prompt_toolkit/renderer.py @@ -429,7 +429,7 @@ def reset(self, _scroll: bool = False, leave_alternate_screen: bool = True) -> N # Flush output. `disable_mouse_support` needs to write to stdout. self.output.flush() - self._has_rendered = False + self._has_rendered = self._in_alternate_screen @property def last_rendered_screen(self) -> Screen | None: diff --git a/tests/test_application.py b/tests/test_application.py index 6d7dd7b6f..3bbae6f05 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -1,5 +1,9 @@ from __future__ import annotations +import asyncio + +import pytest + from prompt_toolkit.application import Application from prompt_toolkit.input import create_pipe_input from prompt_toolkit.output import DummyOutput @@ -30,3 +34,48 @@ def reset(self: Renderer, *args: object, **kwargs: object) -> None: assert reset_calls_before_render == [1, 1] assert reset_calls == 2 + + +def test_resize_redraw_failure_quits_full_screen() -> None: + class FullScreenOutput(DummyOutput): + def __init__(self) -> None: + self.alternate_screen_entered = 0 + self.alternate_screen_quit = 0 + + def enter_alternate_screen(self) -> None: + self.alternate_screen_entered += 1 + + def quit_alternate_screen(self) -> None: + self.alternate_screen_quit += 1 + + output = FullScreenOutput() + redraw_error = RuntimeError("redraw failed") + render_count = 0 + + def before_render(app: Application[None]) -> None: + nonlocal render_count + render_count += 1 + + if render_count == 2: + app.exit(exception=redraw_error) + + if render_count >= 2: + raise redraw_error + + with create_pipe_input() as input: + app: Application[None] = Application( + full_screen=True, + input=input, + output=output, + before_render=before_render, + ) + + def resize() -> None: + with pytest.raises(RuntimeError, match="redraw failed"): + app._on_resize() + + with pytest.raises(RuntimeError, match="redraw failed"): + app.run(pre_run=lambda: asyncio.get_running_loop().call_soon(resize)) + + assert output.alternate_screen_entered == 1 + assert output.alternate_screen_quit == 1