Skip to content

Commit ec3a427

Browse files
committed
Fix Windows terminal regressions across Py version
This restructures _flush() so flush always runs regardless of whether os.get_blocking succeeds. The old code coupled the get_blocking probe with the flush inside single try/except OSError when the probe failed (AttributeError on Python <3.12, OSError on 3.11 console handles), the flush was skipped and nothing reached the screen. Each operation (fileno, get_blocking, set_blocking, flush, restore) is now isolated in its own try/except, with a finally block to guarantee blocking-state restoration. It also sets DISABLE_NEWLINE_AUTO_RETURN (0x0008) alongside VT100 in _init_windows() to prevent auto-CR on LF causing cursor drift, with graceful fallback for pre-1607 builds. Close #48
1 parent 3755c7c commit ec3a427

3 files changed

Lines changed: 147 additions & 13 deletions

File tree

.ci/validate-rawterm.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,30 @@
1818
_IS_WINDOWS = os.name == "nt"
1919

2020

21+
def check_version():
22+
"""Minimal Python version checks and platform capability report."""
23+
ver = sys.version_info
24+
assert ver >= (3, 6), "Python >= 3.6 required, got {}.{}".format(ver[0], ver[1])
25+
print("Python {}.{}.{} on {}".format(ver[0], ver[1], ver[2], sys.platform))
26+
27+
if _IS_WINDOWS:
28+
has_get_blocking = hasattr(os, "get_blocking")
29+
print(
30+
" os.get_blocking: {}".format(
31+
"available" if has_get_blocking else "unavailable (Python <3.12)"
32+
)
33+
)
34+
if has_get_blocking:
35+
# Probe whether it actually works on console handles
36+
try:
37+
os.get_blocking(sys.stdout.fileno())
38+
print(" os.get_blocking(stdout): works")
39+
except OSError:
40+
print(" os.get_blocking(stdout): OSError (console handle)")
41+
42+
print("version checks passed")
43+
44+
2145
def check_rawterm_units():
2246
"""rawterm Color, Style, Key, Box -- no terminal required."""
2347
from rawterm import Style, Color, Key, Box, NAMED_COLORS
@@ -165,6 +189,21 @@ def check_windows_console():
165189
stdout_h, out_mode.value
166190
), "SetConsoleMode(stdout, restore) failed"
167191

192+
# Test VT100 + DISABLE_NEWLINE_AUTO_RETURN combination
193+
DISABLE_NEWLINE_AUTO_RETURN = 0x0008
194+
new_out_both = (
195+
out_mode.value
196+
| ENABLE_VIRTUAL_TERMINAL_PROCESSING
197+
| DISABLE_NEWLINE_AUTO_RETURN
198+
)
199+
dnar_ok = kernel32.SetConsoleMode(stdout_h, new_out_both)
200+
kernel32.SetConsoleMode(stdout_h, out_mode.value) # always restore
201+
print(
202+
" DISABLE_NEWLINE_AUTO_RETURN: {}".format(
203+
"supported" if dnar_ok else "not supported (pre-1607)"
204+
)
205+
)
206+
168207
# Test VT100 input mode (may fail on older Windows -- not fatal)
169208
ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
170209
new_in = (in_mode.value | ENABLE_VIRTUAL_TERMINAL_INPUT) & ~0x0007
@@ -265,8 +304,60 @@ def check_menuconfig_headless():
265304
print("menuconfig headless + style validation passed")
266305

267306

307+
def check_flush_robustness():
308+
"""Verify _flush() survives missing or broken os.get_blocking.
309+
310+
Exercises the (AttributeError, OSError) handling added to fix
311+
issue #48: on Python <3.11, os.get_blocking does not exist; on
312+
Windows 3.11 console handles, it raises OSError. In both cases
313+
the flush must still run.
314+
"""
315+
import io
316+
from unittest import mock
317+
from rawterm import Terminal
318+
319+
# Construct a Terminal without entering raw mode -- we only need
320+
# _flush() and _write_raw(), which operate on sys.stdout.buffer.
321+
term = object.__new__(Terminal)
322+
323+
# Redirect stdout.buffer to a BytesIO so we can verify output
324+
buf = io.BytesIO()
325+
fake_stdout = mock.MagicMock()
326+
fake_stdout.buffer = buf
327+
fake_stdout.fileno.return_value = 1
328+
329+
with mock.patch("sys.stdout", fake_stdout):
330+
# Case 1: os.get_blocking raises AttributeError (Python <3.11)
331+
with mock.patch("os.get_blocking", side_effect=AttributeError):
332+
term._write_raw("hello")
333+
term._flush()
334+
assert buf.getvalue() == b"hello", "flush after AttributeError"
335+
336+
buf.seek(0)
337+
buf.truncate()
338+
339+
# Case 2: os.get_blocking raises OSError (Windows console handle)
340+
with mock.patch("os.get_blocking", side_effect=OSError):
341+
term._write_raw(" world")
342+
term._flush()
343+
assert buf.getvalue() == b" world", "flush after OSError"
344+
345+
buf.seek(0)
346+
buf.truncate()
347+
348+
# Case 3: os.get_blocking works normally (returns True)
349+
with mock.patch("os.get_blocking", return_value=True):
350+
term._write_raw("ok")
351+
term._flush()
352+
assert buf.getvalue() == b"ok", "flush with normal get_blocking"
353+
354+
print("_flush() robustness checks passed")
355+
356+
268357
if __name__ == "__main__":
358+
check_version()
269359
check_rawterm_units()
360+
check_flush_robustness()
270361
check_windows_console()
271362
check_terminal_init()
272363
check_menuconfig_headless()

menuconfig.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -515,7 +515,9 @@ def menuconfig(kconf, headless=False):
515515

516516
# Enter terminal mode via rawterm. _menuconfig() returns a string to print
517517
# on exit.
518-
print(rawterm.run(_menuconfig))
518+
result = rawterm.run(_menuconfig)
519+
if result is not None:
520+
print(result)
519521

520522

521523
def _load_config():

rawterm.py

Lines changed: 53 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -636,10 +636,24 @@ def _init_windows(self):
636636
self._old_in_mode = wintypes.DWORD()
637637
kernel32.GetConsoleMode(self._stdin_handle, ctypes.byref(self._old_in_mode))
638638

639-
# Enable VT100 output
639+
# Enable VT100 output. DISABLE_NEWLINE_AUTO_RETURN (0x0008)
640+
# prevents the console from inserting a CR before every LF,
641+
# which causes cursor-positioning drift in TUI apps. Microsoft
642+
# recommends setting both flags together for VT100 applications.
643+
#
644+
# Graceful fallback: if the combination is rejected (pre-1607
645+
# builds), retry with VT100 alone.
640646
ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004
641-
new_out = self._old_out_mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING
642-
kernel32.SetConsoleMode(self._stdout_handle, new_out)
647+
DISABLE_NEWLINE_AUTO_RETURN = 0x0008
648+
new_out = (
649+
self._old_out_mode.value
650+
| ENABLE_VIRTUAL_TERMINAL_PROCESSING
651+
| DISABLE_NEWLINE_AUTO_RETURN
652+
)
653+
if not kernel32.SetConsoleMode(self._stdout_handle, new_out):
654+
# Pre-1607: DISABLE_NEWLINE_AUTO_RETURN unsupported, VT100 only
655+
new_out = self._old_out_mode.value | ENABLE_VIRTUAL_TERMINAL_PROCESSING
656+
kernel32.SetConsoleMode(self._stdout_handle, new_out)
643657

644658
# Try VT100 input
645659
ENABLE_VIRTUAL_TERMINAL_INPUT = 0x0200
@@ -777,20 +791,47 @@ def _write_raw(self, s):
777791
pass
778792

779793
def _flush(self):
780-
"""Flush stdout."""
794+
"""Flush stdout.
795+
796+
Terminal fds are always blocking, so the non-blocking dance is
797+
purely defensive. Each step (get_blocking, set_blocking, flush,
798+
restore) is isolated so that a failure in the probe never
799+
prevents the flush from running.
800+
801+
os.get_blocking raises AttributeError when unavailable (Windows
802+
Python <3.12) and OSError on Windows console handles even when
803+
present, so we catch both and default to was_blocking=True
804+
(skip the set_blocking detour).
805+
"""
806+
was_blocking = True
807+
fd = None
781808
try:
782-
# Ensure blocking I/O for flush
783809
fd = sys.stdout.fileno()
784-
was_blocking = os.get_blocking(fd)
785-
if not was_blocking:
786-
os.set_blocking(fd, True)
810+
except OSError:
811+
pass
812+
813+
if fd is not None:
787814
try:
788-
sys.stdout.buffer.flush()
789-
finally:
790-
if not was_blocking:
791-
os.set_blocking(fd, False)
815+
was_blocking = os.get_blocking(fd)
816+
except (AttributeError, OSError):
817+
was_blocking = True
818+
819+
if not was_blocking:
820+
try:
821+
os.set_blocking(fd, True)
822+
except OSError:
823+
pass
824+
825+
try:
826+
sys.stdout.buffer.flush()
792827
except OSError:
793828
pass
829+
finally:
830+
if fd is not None and not was_blocking:
831+
try:
832+
os.set_blocking(fd, False)
833+
except OSError:
834+
pass
794835

795836
def update(self):
796837
"""Composite all regions and flush to terminal.

0 commit comments

Comments
 (0)