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..37720b45 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 win32_console + try: + ok = win32_console.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 win32_console + try: + win32_console.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..f75c5bd1 --- /dev/null +++ b/emrg/client/python_tui/win32.py @@ -0,0 +1,105 @@ +"""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) + + +# 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()