Skip to content

Commit 1b89770

Browse files
jquastbczsalba
andauthored
bugfix: crash on rapidly received SIGWINCH (#165)
**Problem**: pytermgui calls print() and eventually sys.stdout.flush() from within a signal handler, which is a blocking operating, and can cause crashes:: RuntimeError: reentrant call inside <_io.BufferedWriter name='<stdout>'> <details> Traceback (most recent call last): File "/home/jq/Code/pytermgui/examples/simple_app.py", line 213, in <module> main(sys.argv[1:]) ~~~~^^^^^^^^^^^^^^ File "/home/jq/Code/pytermgui/examples/simple_app.py", line 141, in main with ptg.WindowManager() as manager: ~~~~~~~~~~~~~~~~~^^ File "/home/jq/Code/pytermgui/pytermgui/window_manager/manager.py", line 117, in __exit__ self.run() ~~~~~~~~^^ File "/home/jq/Code/pytermgui/pytermgui/window_manager/manager.py", line 198, in run self._run_input_loop() ~~~~~~~~~~~~~~~~~~~~^^ File "/home/jq/Code/pytermgui/pytermgui/window_manager/manager.py", line 135, in _run_input_loop key = getch(interrupts=False) File "/home/jq/Code/pytermgui/pytermgui/input.py", line 438, in getch key = _getch() File "/home/jq/Code/pytermgui/pytermgui/input.py", line 152, in __call__ buff = "".join(self.get_chars()) File "/home/jq/Code/pytermgui/pytermgui/input.py", line 140, in get_chars yield self._read(1) ~~~~~~~~~~^^^ File "/home/jq/Code/pytermgui/pytermgui/input.py", line 119, in _read char = os.read(sys.stdin.fileno(), 1) File "/home/jq/Code/pytermgui/pytermgui/term.py", line 358, in _update_size self._call_listener(self.RESIZE, self.size) ~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^ File "/home/jq/Code/pytermgui/pytermgui/term.py", line 339, in _call_listener callback(data) ~~~~~~~~^^^^^^ File "/home/jq/Code/pytermgui/pytermgui/window_manager/manager.py", line 174, in on_resize self.compositor.redraw() ~~~~~~~~~~~~~~~~~~~~~~^^ File "/home/jq/Code/pytermgui/pytermgui/window_manager/compositor.py", line 270, in redraw self.draw(force=True) ~~~~~~~~~^^^^^^^^^^^^ File "/home/jq/Code/pytermgui/pytermgui/window_manager/compositor.py", line 259, in draw with self.terminal.frame() as frame: ~~~~~~~~~~~~~~~~~~~^^ File "/home/jq/.pyenv/versions/3.15.0a5/lib/python3.15/contextlib.py", line 148, in __exit__ next(self.gen) ~~~~^^^^^^^^^^ File "/home/jq/Code/pytermgui/pytermgui/term.py", line 465, in frame self.flush() ~~~~~~~~~~^^ File "/home/jq/Code/pytermgui/pytermgui/term.py", line 601, in flush self._stream.flush() ~~~~~~~~~~~~~~~~~~^^ File "/home/jq/Code/pytermgui/pytermgui/term.py", line 353, in _update_size if hasattr(self, "resolution"): ~~~~~~~^^^^^^^^^^^^^^^^^^^^ File "/home/jq/.pyenv/versions/3.15.0a5/lib/python3.15/functools.py", line 1146, in __get__ val = self.func(instance) File "/home/jq/Code/pytermgui/pytermgui/term.py", line 308, in resolution sys.stdout.flush() ~~~~~~~~~~~~~~~~^^ RuntimeError: reentrant call inside <_io.BufferedWriter name='<stdout>'> </details> I have written about "signal safety" with SIGNWICH here, and apply a similar solution for pytermgui, https://blessed.readthedocs.io/en/latest/measuring.html#using-sigwinch - Remove screen wipe (``\x1b[H\x1b[2J``) from resize handler - this was the cause of the crash - use threading.Event() as a semaphore, thread-safe, naturally - Reduce windows polling period from 0.001 to 0.01s - main loop periodically checks process_pending_resize() Co-authored-by: Balázs Cene <bczsalba@gmail.com>
1 parent 7aeb3df commit 1b89770

File tree

2 files changed

+30
-8
lines changed

2 files changed

+30
-8
lines changed

pytermgui/term.py

Lines changed: 28 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import os
99
import signal
1010
import sys
11+
import threading
1112
import time
1213
from contextlib import contextmanager
1314
from datetime import datetime
@@ -263,6 +264,9 @@ def __init__(
263264

264265
self._listeners: dict[int, list[Callable[..., Any]]] = {}
265266

267+
# Async-signal-safe resize mechanism
268+
self._resize_pending = threading.Event()
269+
266270
if hasattr(signal, "SIGWINCH"):
267271
signal.signal(signal.SIGWINCH, self._update_size)
268272
else:
@@ -278,16 +282,16 @@ def __init__(
278282
["" for _ in range(self.width)] for y in range(self.height)
279283
]
280284

281-
def _window_terminal_resize(self):
285+
def _window_terminal_resize(self) -> None:
282286
from time import sleep # pylint: disable=import-outside-toplevel
283287

284288
_previous = get_terminal_size()
285289
while True:
286290
_next = get_terminal_size()
287291
if _previous != _next:
288-
self._update_size()
292+
self._resize_pending.set()
289293
_previous = _next
290-
sleep(0.001)
294+
sleep(0.01)
291295

292296
def __fancy_repr__(self) -> Generator[FancyYield, None, None]:
293297
"""Returns a cool looking repr."""
@@ -348,17 +352,34 @@ def _get_size(self) -> tuple[int, int]:
348352
return (size[0], size[1])
349353

350354
def _update_size(self, *_: Any) -> None:
351-
"""Resize terminal when SIGWINCH occurs, and call listeners."""
355+
"""Signal handler for SIGWINCH - ONLY sets flag (async-signal-safe)."""
352356

353-
if hasattr(self, "resolution"):
354-
del self.resolution
357+
self._resize_pending.set()
355358

356-
self.size = self._get_size()
359+
def process_pending_resize(self) -> bool:
360+
"""Process pending resize event if one is queued.
361+
362+
Call this periodically from the main event loop.
363+
364+
:returns: True if a resize was processed.
365+
"""
366+
if not self._resize_pending.is_set():
367+
return False
368+
369+
self._resize_pending.clear()
357370

371+
# Check __dict__ directly to avoid triggering the cached_property getter,
372+
# which uses signals and can only run in the main thread
373+
if "resolution" in self.__dict__:
374+
del self.__dict__["resolution"]
375+
376+
self.size = self._get_size()
358377
self._call_listener(self.RESIZE, self.size)
359378

360379
# Wipe the screen in case anything got messed up
361380
self.write("\x1b[H\x1b[2J")
381+
382+
return True
362383

363384
@property
364385
def width(self) -> int:
@@ -459,7 +480,6 @@ def frame(self) -> Generator[StringIO, None, None]:
459480
yield buffer
460481

461482
finally:
462-
# Write buffer directly to stream - bypasses write()'s \x1b[2J detection
463483
self._stream.write(buffer.getvalue())
464484
self._stream.write("\x1b[?2026l")
465485
self._stream.flush()

pytermgui/window_manager/compositor.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,8 @@ def _draw_loop(self) -> None:
6565
time.sleep(self._frametime - elapsed)
6666
continue
6767

68+
self.terminal.process_pending_resize()
69+
6870
animator.step(elapsed)
6971

7072
last_frame = time.perf_counter()

0 commit comments

Comments
 (0)