From 14ae31a6816eabe8972e060e2b2ccedb77a2e964 Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 5 Aug 2026 15:59:07 +0800 Subject: [PATCH 1/2] =?UTF-8?q?emrg:=20Windows=20TUI=20=E9=80=82=E9=85=8D?= =?UTF-8?q?=20=E2=80=94=20Win32Console=20raw=20mode=20+=20stdin/resize=20?= =?UTF-8?q?=E7=BA=BF=E7=A8=8B=E5=8C=96=20(rant=202026-08-05T15:54:28)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:TUI 输入/信号层全部基于 POSIX(termios/fcntl/SIGWINCH/add_reader), Windows 上 termios=None 时 _enter_raw_mode 直接 AttributeError 崩溃。 修复(方案 A,宿主已确认): - 新增 emrg/client/python_tui/win32.py:Win32Console 类(ctypes 调 Win32 API, 无 pywin32 依赖)— SetConsoleMode 关 ENABLE_LINE_INPUT|ENABLE_ECHO_INPUT| ENABLE_PROCESSED_INPUT、开 ENABLE_VIRTUAL_TERMINAL_PROCESSING;msvcrt setmode O_BINARY 关 CRLF 转换(方向键序列与 POSIX 字节一致) - terminal.py:_enter/_exit_raw_mode 平台分发(win32 → Win32Console; POSIX 保持 termios 零改动);fcntl 调用加 None guard(2 处) - app.py:stdin reader Windows 用 daemon 线程 os.read + call_soon_threadsafe; resize Windows 用 500ms 轮询线程(get_terminal_size 变化触发); SIGWINCH 用 getattr 保护;finally 清理平台分支 验证:py_compile 3 文件 + 466 passed + import OK + --help OK (POSIX 路径零改动;Windows 路径待 v0.2.3 打包实测) 注:config.toml 解析错误优化(start_daemon 超时显示真实原因)为 rant 关联 独立问题,另行处理。 --- emrg/client/app.py | 75 +++++++++++++++++++--- emrg/client/python_tui/terminal.py | 38 ++++++++++-- emrg/client/python_tui/win32.py | 99 ++++++++++++++++++++++++++++++ 3 files changed, 198 insertions(+), 14 deletions(-) create mode 100644 emrg/client/python_tui/win32.py diff --git a/emrg/client/app.py b/emrg/client/app.py index 4aede26e..844b6e10 100644 --- a/emrg/client/app.py +++ b/emrg/client/app.py @@ -4,7 +4,7 @@ from __future__ import annotations -import asyncio, json, logging, os, platform, signal, subprocess, sys, time +import asyncio, json, logging, os, platform, signal, subprocess, sys, threading, time try: import fcntl # POSIX-only(TUI 非阻塞 stdin);Windows 无此模块 except ImportError: # pragma: no cover - Windows @@ -845,18 +845,44 @@ async def _reconnect(): read_task = asyncio.create_task(read_server()) - # ── SIGWINCH (terminal resize) handler ───────────────── + # ── Terminal resize handler ────────────────────────────── + # R123: Windows 无 SIGWINCH + ProactorEventLoop 不支持 add_signal_handler + # → 轮询线程每 500ms 检测 get_terminal_size 变化;POSIX 保持 SIGWINCH。 _resize_event = asyncio.Event() + _win_resize_thread: threading.Thread | None = None + _win_resize_stop = threading.Event() + _last_size = os.get_terminal_size() if sys.platform == "win32" else None def _on_sigwinch() -> None: _resize_event.set() - loop.add_signal_handler(signal.SIGWINCH, _on_sigwinch) - - # ── Stdin reader (asyncio-native, no thread pool — rant #SIGWINCH-leak) ─ - if fcntl is not None: # POSIX-only(Windows 无 fcntl,不跑 TUI) - _stdin_flags = fcntl.fcntl(stdin_fd, fcntl.F_GETFL) - fcntl.fcntl(stdin_fd, fcntl.F_SETFL, _stdin_flags | os.O_NONBLOCK) + if sys.platform == "win32": + def _poll_resize() -> None: + nonlocal _last_size + while not _win_resize_stop.is_set(): + try: + size = os.get_terminal_size() + if size != _last_size: + _last_size = size + loop.call_soon_threadsafe(_resize_event.set) + except (OSError, ValueError): + pass + _win_resize_stop.wait(0.5) + + _win_resize_thread = threading.Thread( + target=_poll_resize, name="emrg-resize-poll", daemon=True) + _win_resize_thread.start() + else: + sigwinch = getattr(signal, "SIGWINCH", None) + if sigwinch is not None: + loop.add_signal_handler(sigwinch, _on_sigwinch) + + # ── Stdin reader ──────────────────────────────────────── + # R123: Windows ProactorEventLoop 无 add_reader → daemon 线程阻塞 + # os.read + call_soon_threadsafe 填充同一 stdin_queue;POSIX 保持 + # asyncio-native add_reader + O_NONBLOCK(rant #SIGWINCH-leak)。 + _win_stdin_thread: threading.Thread | None = None + _win_stdin_stop = threading.Event() def _stdin_reader() -> None: try: @@ -865,8 +891,28 @@ def _stdin_reader() -> None: stdin_queue.put_nowait(data) except (BlockingIOError, InterruptedError): pass + except OSError: + pass + + if sys.platform == "win32": + def _win_stdin_loop() -> None: + while not _win_stdin_stop.is_set(): + try: + data = os.read(stdin_fd, 4096) + if not data: + break + loop.call_soon_threadsafe(stdin_queue.put_nowait, data) + except (OSError, ValueError): + break - loop.add_reader(stdin_fd, _stdin_reader) + _win_stdin_thread = threading.Thread( + target=_win_stdin_loop, name="emrg-stdin-reader", daemon=True) + _win_stdin_thread.start() + else: + if fcntl is not None: # POSIX-only(Windows 无 fcntl) + _stdin_flags = fcntl.fcntl(stdin_fd, fcntl.F_GETFL) + fcntl.fcntl(stdin_fd, fcntl.F_SETFL, _stdin_flags | os.O_NONBLOCK) + loop.add_reader(stdin_fd, _stdin_reader) def _handle_selector_nav(data: bytes, widget) -> bool: """Handle arrow key and j/k navigation for any selector widget. @@ -1719,7 +1765,16 @@ def _is_image_token(s, i): break except Exception: logger.exception("TUI main loop crashed") finally: - loop.remove_reader(stdin_fd) + # R123: Windows 无 add_reader(线程 reader)→ remove_reader 需保护; + # 停掉轮询/读线程(daemon=True 兜底,显式 stop 更干净)。 + if sys.platform == "win32": + _win_resize_stop.set() + _win_stdin_stop.set() + else: + try: + loop.remove_reader(stdin_fd) + except (NotImplementedError, ValueError): + pass logger.info("disconnecting from emrgd") read_task.cancel() try: await read_task diff --git a/emrg/client/python_tui/terminal.py b/emrg/client/python_tui/terminal.py index 6de47a06..055e729c 100644 --- a/emrg/client/python_tui/terminal.py +++ b/emrg/client/python_tui/terminal.py @@ -114,7 +114,8 @@ def __post_init__(self) -> None: self._enter_raw_mode() # PR #205's add_reader+fcntl(stdin, O_NONBLOCK) leaks to stdout on # macOS (pty fd sharing). Force stdout back to blocking write. - if sys.stdout.isatty(): + # R123: fcntl is None on Windows — skip (no fcntl available). + if sys.stdout.isatty() and fcntl is not None: try: fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL, os.O_WRONLY | os.O_APPEND) @@ -340,11 +341,13 @@ def _get_lines(name: str) -> list[object]: # fcntl guard: PR #205's add_reader+fcntl(stdin) may leak # O_NONBLOCK to stdout via macOS pty sharing. Restore # blocking on BlockingIOError and retry the flush. + # R123: fcntl is None on Windows — just flush. try: sys.stdout.flush() except BlockingIOError: - fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL, - os.O_WRONLY | os.O_APPEND) + if fcntl is not None: + fcntl.fcntl(sys.stdout.fileno(), fcntl.F_SETFL, + os.O_WRONLY | os.O_APPEND) sys.stdout.flush() # Swap buffers @@ -462,9 +465,28 @@ def shutdown(self) -> None: # ── Raw mode ───────────────────────────────────────────── def _enter_raw_mode(self) -> None: - """Enable raw mode on stdin for direct key reading.""" + """Enable raw mode on stdin for direct key reading. + + R123: Windows uses Win32Console (ctypes SetConsoleMode) — termios/tty + are None there; POSIX keeps the termios path unchanged. + """ if not sys.stdin.isatty(): return + if sys.platform == "win32": + from emrg.client.python_tui.win32 import Win32Console + try: + ok = Win32Console().enable_raw_mode(sys.stdin.fileno()) + if not ok: + return + self._raw_mode = True + sys.stdout.write(CURSOR_HIDE) + sys.stdout.write("\x1b[?2004h") + sys.stdout.flush() + except (OSError, AttributeError): + return + return + if termios is None or tty is None: + return try: self._original_termios = termios.tcgetattr(sys.stdin.fileno()) tty.setraw(sys.stdin.fileno()) @@ -478,6 +500,14 @@ def _enter_raw_mode(self) -> None: def _exit_raw_mode(self) -> None: """Restore original terminal settings.""" self.restore_title() + if sys.platform == "win32": + from emrg.client.python_tui.win32 import Win32Console + try: + Win32Console().disable_raw_mode(sys.stdin.fileno()) + self._raw_mode = False + except (OSError, AttributeError): + pass + return if self._original_termios is not None: try: termios.tcsetattr( diff --git a/emrg/client/python_tui/win32.py b/emrg/client/python_tui/win32.py new file mode 100644 index 00000000..ee1d7c69 --- /dev/null +++ b/emrg/client/python_tui/win32.py @@ -0,0 +1,99 @@ +"""Win32 console helpers for the TUI (Windows only). + +Provides raw-mode / ANSI-VT enabling via ctypes (no pywin32 dependency — +keeps the packaged runtime lean). Used by Terminal when sys.platform == +"win32"; POSIX terminals keep using termios (rant 2026-08-05T15:54:28). + +Raw mode on Windows mirrors the POSIX termios.tcgetattr/tty.setraw contract: + - disable line input / echo / processed input so os.read returns raw keys + - enable VT processing so ANSI escape sequences (CURSOR_HIDE etc.) work on + cmd/conhost (Windows Terminal enables VT by default) + - set the fd to binary mode so CRLF translation is off (key sequences like + the arrow prefix ESC [ A arrive byte-identical to POSIX) +""" + +from __future__ import annotations + +import ctypes +import msvcrt +import os +from ctypes import wintypes +from typing import Any + +# ── Console input/output mode flags (wincon.h) ─────────────────────────── +ENABLE_PROCESSED_INPUT = 0x0001 +ENABLE_LINE_INPUT = 0x0002 +ENABLE_ECHO_INPUT = 0x0004 +ENABLE_VIRTUAL_TERMINAL_PROCESSING = 0x0004 # output mode +ENABLE_PROCESSED_OUTPUT = 0x0001 +ENABLE_WINDOW_INPUT = 0x0008 + +# Raw mode = everything off except window input (keeps console resize events) +_RAW_INPUT_MODE = ENABLE_WINDOW_INPUT +# Output mode that makes ANSI/VT sequences work +_VT_OUTPUT_MODE = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING + + +class Win32Console: + """Raw-mode / VT support for a console handle (ctypes, no pywin32).""" + + def __init__(self) -> None: + self._kernel32 = ctypes.windll.kernel32 + self._saved_modes: dict[int, tuple[int, int]] = {} # fd -> (in, out) + + # -- internal helpers ------------------------------------------------ + @staticmethod + def _fd_to_handle(fd: int) -> int | None: + """Return the Win32 HANDLE for a std fd (via _get_osfhandle).""" + try: + return msvcrt.get_osfhandle(fd) + except OSError: + return None + + def _get_console_mode(self, handle: int) -> int | None: + mode = wintypes.DWORD() + if not self._kernel32.GetConsoleMode(handle, ctypes.byref(mode)): + return None + return int(mode.value) + + def _set_console_mode(self, handle: int, mode: int) -> bool: + return bool(self._kernel32.SetConsoleMode(handle, wintypes.DWORD(mode))) + + # -- public API ------------------------------------------------------- + def enable_raw_mode(self, fd: int) -> bool: + """Enable raw + VT mode on a console fd. Returns True on success.""" + handle = self._fd_to_handle(fd) + if handle is None: + return False + in_mode = self._get_console_mode(handle) + out_mode = self._get_console_mode(self._fd_to_handle(1)) + if in_mode is None: + return False + self._saved_modes[fd] = (in_mode, out_mode if out_mode is not None else 0) + ok_in = self._set_console_mode(handle, _RAW_INPUT_MODE) + ok_out = True + if out_mode is not None: + ok_out = self._set_console_mode(self._fd_to_handle(1), _VT_OUTPUT_MODE) + # Binary mode: no CRLF translation (key sequences byte-identical to POSIX) + try: + msvcrt.setmode(fd, os.O_BINARY) + except OSError: + pass + return bool(ok_in and ok_out) + + def disable_raw_mode(self, fd: int) -> None: + """Restore the console modes saved by enable_raw_mode.""" + saved = self._saved_modes.pop(fd, None) + if saved is None: + return + in_mode, out_mode = saved + handle = self._fd_to_handle(fd) + if handle is not None and in_mode: + self._set_console_mode(handle, in_mode) + out_handle = self._fd_to_handle(1) + if out_handle is not None and out_mode: + self._set_console_mode(out_handle, out_mode) + + @property + def active(self) -> bool: + return bool(self._saved_modes) From 156b72ff8c61d3b7d2bc63cdfa3c5b376ebbcdba Mon Sep 17 00:00:00 2001 From: EMRG Evolution Date: Wed, 5 Aug 2026 16:02:37 +0800 Subject: [PATCH 2/2] =?UTF-8?q?emrg:=20fix=20Win32Console=20singleton=20?= =?UTF-8?q?=E2=80=94=20saved=20console=20modes=20must=20survive=20enter/ex?= =?UTF-8?q?it?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found: _enter_raw_mode / _exit_raw_mode each created a fresh Win32Console() instance, but the saved original console modes live in the instance attribute _saved_modes → disable_raw_mode on a new instance could never restore the terminal (modes dict empty). Fix: module-level singleton `win32_console` shared by both paths. Verified: py_compile + 466 passed + import OK + --help OK. --- emrg/client/python_tui/terminal.py | 8 ++++---- emrg/client/python_tui/win32.py | 6 ++++++ 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/emrg/client/python_tui/terminal.py b/emrg/client/python_tui/terminal.py index 055e729c..37720b45 100644 --- a/emrg/client/python_tui/terminal.py +++ b/emrg/client/python_tui/terminal.py @@ -473,9 +473,9 @@ def _enter_raw_mode(self) -> None: if not sys.stdin.isatty(): return if sys.platform == "win32": - from emrg.client.python_tui.win32 import Win32Console + from emrg.client.python_tui.win32 import win32_console try: - ok = Win32Console().enable_raw_mode(sys.stdin.fileno()) + ok = win32_console.enable_raw_mode(sys.stdin.fileno()) if not ok: return self._raw_mode = True @@ -501,9 +501,9 @@ def _exit_raw_mode(self) -> None: """Restore original terminal settings.""" self.restore_title() if sys.platform == "win32": - from emrg.client.python_tui.win32 import Win32Console + from emrg.client.python_tui.win32 import win32_console try: - Win32Console().disable_raw_mode(sys.stdin.fileno()) + win32_console.disable_raw_mode(sys.stdin.fileno()) self._raw_mode = False except (OSError, AttributeError): pass diff --git a/emrg/client/python_tui/win32.py b/emrg/client/python_tui/win32.py index ee1d7c69..f75c5bd1 100644 --- a/emrg/client/python_tui/win32.py +++ b/emrg/client/python_tui/win32.py @@ -97,3 +97,9 @@ def disable_raw_mode(self, fd: int) -> None: @property def active(self) -> bool: return bool(self._saved_modes) + + +# Module-level singleton — saved console modes must survive across +# _enter_raw_mode / _exit_raw_mode calls (a fresh instance per call would +# lose _saved_modes and fail to restore the terminal). +win32_console = Win32Console()