diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e11c0d..c64c366 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,31 @@ allowed to change behaviour, not just add to it. ## [Unreleased] +## [0.2.4] — 2026-09-07 + +### Fixed + +- **MeshTerm crashed on startup on Windows.** Building the screen raised + `ctypes.ArgumentError: expected LP__CSBI instance instead of pointer to + CONSOLE_SCREEN_BUFFER_INFO`, and on a double-clicked build the traceback and the window + it was printed in were destroyed together. + + `ctypes.windll.kernel32` is a cache: every caller in a process gets the same object, and + each function on it is a shared attribute. The emoji-width probe declared + `GetConsoleScreenBufferInfo` as taking a pointer to *its* copy of the screen-buffer + struct — which rewrote the signature for prompt_toolkit, which then called that same + function with a pointer to *its* copy. Same layout, different class; ctypes refused. + + Nothing in MeshTerm reaches for `ctypes.windll` any more. Five call sites now take + private handles from `core/win32dll.py`, so a signature declared for one purpose is + invisible to everything else in the process. + +### Changed + +- The log-detail preference offers the plain level names — Error, Warning, Info, Debug — + rather than descriptions of them. Someone being talked through a problem is told "set it + to debug", not "set it to everything". + ## [0.2.3] — 2026-09-07 ### Changed @@ -154,7 +179,8 @@ deliberately not reconstructed here. - Two `TYPE_CHECKING` imports the test suite referenced but never imported, on paths that happened never to run. -[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.3...HEAD +[Unreleased]: https://github.com/jpmartineau/MeshTerm/compare/v0.2.4...HEAD +[0.2.4]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.4 [0.2.3]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.3 [0.2.2]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.2 [0.2.1]: https://github.com/jpmartineau/MeshTerm/releases/tag/v0.2.1 diff --git a/README.md b/README.md index fd48068..8b6234e 100644 --- a/README.md +++ b/README.md @@ -68,14 +68,14 @@ if anything goes wrong at startup the window closes before you can read why. **Windows** — open Windows Terminal or PowerShell, `cd` to your downloads, then: ```powershell -.\meshterm-0.2.3-windows-x64.exe +.\meshterm-0.2.4-windows-x64.exe ``` **macOS and Linux** — open Terminal, `cd` to your downloads, then: ```bash -chmod +x meshterm-0.2.3-* -./meshterm-0.2.3-* +chmod +x meshterm-0.2.4-* +./meshterm-0.2.4-* ``` Rename it to something you don't mind typing and put it somewhere on your `PATH`, and it @@ -86,7 +86,7 @@ becomes just `meshterm` from anywhere. > project. > > - **macOS**: *"cannot be opened because the developer cannot be verified"*. Clear it with -> `xattr -d com.apple.quarantine meshterm-0.2.3-*` and run it again. +> `xattr -d com.apple.quarantine meshterm-0.2.4-*` and run it again. > - **Windows**: *"Windows protected your PC"*. Click **More info → Run anyway**. > > Every release ships a `SHA256SUMS` file if you'd rather check the download first. @@ -96,10 +96,10 @@ becomes just `meshterm` from anywhere. > MeshTerm uses that same folder. Set `MESHTERM_HOME` to try one in isolation: > > ```bash -> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.3-* # macOS, Linux +> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.4-* # macOS, Linux > ``` > ```powershell -> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.3-windows-x64.exe +> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.4-windows-x64.exe > ``` > > Same variable if you run two radios and want them kept apart. diff --git a/meshterm/__init__.py b/meshterm/__init__.py index ddcf4e4..8147d35 100644 --- a/meshterm/__init__.py +++ b/meshterm/__init__.py @@ -5,7 +5,7 @@ Supports USB, Bluetooth and TCP companion connection. For Windows, macOS and Lin from datetime import datetime -__version__ = "0.2.3" +__version__ = "0.2.4" __author__ = "Jean-Pierre Martineau" #: The year MeshTerm was first published; the copyright span starts here. diff --git a/meshterm/cli.py b/meshterm/cli.py index 0acd141..90915c2 100644 --- a/meshterm/cli.py +++ b/meshterm/cli.py @@ -17,6 +17,7 @@ import typer from rich.console import Console from .context import AppContext +from .core import win32dll from .core.admin_store import AdminStore from .core.config import Settings from .core.connection import DeviceCommandError, is_connection_lost @@ -375,7 +376,7 @@ def _owns_its_console() -> bool: import ctypes buffer = (ctypes.c_uint * 4)() - count = ctypes.windll.kernel32.GetConsoleProcessList(buffer, 4) # type: ignore[attr-defined] + count = win32dll.kernel32().GetConsoleProcessList(buffer, 4) except Exception: # pragma: no cover - no console at all, or a stubbed kernel32 return False return count == 1 diff --git a/meshterm/core/preferences.py b/meshterm/core/preferences.py index 163251d..01b9277 100644 --- a/meshterm/core/preferences.py +++ b/meshterm/core/preferences.py @@ -125,13 +125,14 @@ _WIDTH_CHOICES: dict[str, str] = {"auto": "auto", "yes": "yes", "no": "no"} #: Every preference MeshTerm has, in page order. Adding one here gives it a row on the #: Preferences page, a key in the YAML file, a ``preferences get``/``set`` CLI face, and a #: default — nothing else has to follow. -#: What the log file keeps. Named rather than numeric, because those names are what every -#: other tool calls these and the reader is often being talked through it by someone else. +#: What the log file keeps. The plain level names, which are what every other tool calls +#: these — someone being talked through a problem is being told "set it to debug", not +#: "set it to everything". _LOG_LEVEL_CHOICES: dict[str, str] = { - "ERROR": "Errors only", - "WARNING": "Warnings and errors", - "INFO": "Also what the app is doing", - "DEBUG": "Everything, for chasing a bug", + "ERROR": "Error", + "WARNING": "Warning", + "INFO": "Info", + "DEBUG": "Debug", } diff --git a/meshterm/core/win32dll.py b/meshterm/core/win32dll.py new file mode 100644 index 0000000..651c5ef --- /dev/null +++ b/meshterm/core/win32dll.py @@ -0,0 +1,64 @@ +"""Private handles on the Windows DLLs, because ``ctypes.windll`` is shared with everyone. + +``ctypes.windll.kernel32`` is a cache. Every caller in the process gets the *same* +``WinDLL`` object, and each function on it is a cached attribute — so +``ctypes.windll.kernel32.GetConsoleScreenBufferInfo`` is one object, shared by this +application and every library inside it. Setting ``.argtypes`` on it is not configuration, +it is a process-wide mutation, and the last writer wins. + +That is not theoretical. It crashed MeshTerm on Windows: + +* ``ui/tui/emoji_width.py`` measured an emoji by asking the console for its cursor + position, and declared ``GetConsoleScreenBufferInfo`` as taking a pointer to *its* copy + of the screen-buffer struct. +* prompt_toolkit later called the very same function object with a pointer to *its* copy + of the same struct — identical layout, different class. +* ctypes compared the two classes, found them unequal, and refused: + ``expected LP__CSBI instance instead of pointer to CONSOLE_SCREEN_BUFFER_INFO``. + +The application died while building its screen, which on a double-clicked build meant the +traceback and the window it was drawn in disappeared together. + +Constructing ``WinDLL`` directly builds a *new* object with its own function cache, so +declarations made on it are visible only to the caller that made them. Each call here +returns a fresh one: two parts of MeshTerm declaring the same function differently is the +same bug in miniature, and the cost is one small object at a call site that runs rarely. + +The rule this exists to enforce: **nothing in MeshTerm touches ``ctypes.windll``.** +""" + +from __future__ import annotations + +import ctypes +import sys + +__all__ = ["kernel32", "user32"] + + +def _library(name: str) -> ctypes.WinDLL: + """Load ``name`` into a WinDLL of our own. + + Args: + name: The DLL to load, without its extension. + + Returns: + A private handle whose function signatures nobody else can see or change. + + Raises: + RuntimeError: If called anywhere but Windows, where these do not exist. + """ + if sys.platform != "win32": # pragma: no cover - the callers all guard on platform + raise RuntimeError(f"{name} is a Windows library") + # `use_last_error` keeps GetLastError intact for the caller, which is the only way to + # tell "the call failed" from "the call succeeded and returned zero". + return ctypes.WinDLL(name, use_last_error=True) + + +def kernel32() -> ctypes.WinDLL: + """A private handle on ``kernel32`` — console, handles, memory.""" + return _library("kernel32") + + +def user32() -> ctypes.WinDLL: + """A private handle on ``user32`` — clipboard, keyboard state, windows.""" + return _library("user32") diff --git a/meshterm/ui/termfont.py b/meshterm/ui/termfont.py index afd9782..1511092 100644 --- a/meshterm/ui/termfont.py +++ b/meshterm/ui/termfont.py @@ -57,6 +57,8 @@ from dataclasses import dataclass from functools import lru_cache from pathlib import Path +from ..core import win32dll + #: Coverage levels a verdict (or a recommended font) can carry, strongest first. FULL = "full" CORE = "core" @@ -460,7 +462,7 @@ def _conhost_face() -> str | None: import ctypes import ctypes.wintypes as wintypes - kernel32 = ctypes.windll.kernel32 + kernel32 = win32dll.kernel32() if not kernel32.GetConsoleWindow(): return None diff --git a/meshterm/ui/tui/emoji_width.py b/meshterm/ui/tui/emoji_width.py index 1d26e3f..26591f6 100644 --- a/meshterm/ui/tui/emoji_width.py +++ b/meshterm/ui/tui/emoji_width.py @@ -89,6 +89,8 @@ from collections.abc import Callable from prompt_toolkit.layout.controls import FormattedTextControl +from ...core import win32dll + #: The zero-width joiner: the tell that the codepoints it sits between are **one glyph** #: (``🤷‍♂️``, ``👨‍👩‍👧``), which a terminal draws in the width of the sequence's base. _ZWJ = "‍" @@ -309,7 +311,10 @@ def _win_probe_width(probe: str) -> int | None: ("dwMaximumWindowSize", _COORD), ] - kernel32 = ctypes.windll.kernel32 + # A private handle: declaring these signatures on `ctypes.windll.kernel32` would + # rewrite them for prompt_toolkit too, which calls the same function object with a + # struct class of its own. See meshterm.core.win32dll. + kernel32 = win32dll.kernel32() # Declare signatures explicitly. Without this ctypes assumes a 32-bit int # return, which truncates the 64-bit console HANDLE and makes every # subsequent call fail — silently reducing the probe to "no measurement". diff --git a/meshterm/ui/tui/session.py b/meshterm/ui/tui/session.py index a430781..10441ec 100644 --- a/meshterm/ui/tui/session.py +++ b/meshterm/ui/tui/session.py @@ -29,6 +29,7 @@ from prompt_toolkit.utils import get_cwidth from rich.console import RenderableType from rich.text import Text +from ...core import win32dll from ...platforms import get_platform from ...services import modifier_watch from . import fastrender, fkeys, frame @@ -168,9 +169,7 @@ def _right_ctrl_down() -> bool: if sys.platform != "win32": return False try: - import ctypes - - return bool(ctypes.windll.user32.GetAsyncKeyState(0xA3) & 0x8000) # VK_RCONTROL + return bool(win32dll.user32().GetAsyncKeyState(0xA3) & 0x8000) # VK_RCONTROL except Exception: # noqa: BLE001 - a failed probe just leaves the plain action return False @@ -191,8 +190,10 @@ def _read_clipboard() -> str: import ctypes CF_UNICODETEXT = 13 - user32 = ctypes.windll.user32 - kernel32 = ctypes.windll.kernel32 + # Private handles; the argtypes below would otherwise be declared on objects + # every library in the process shares. See meshterm.core.win32dll. + user32 = win32dll.user32() + kernel32 = win32dll.kernel32() user32.GetClipboardData.restype = ctypes.c_void_p user32.GetClipboardData.argtypes = [ctypes.c_uint] kernel32.GlobalLock.restype = ctypes.c_void_p diff --git a/tests/test_win32dll.py b/tests/test_win32dll.py new file mode 100644 index 0000000..0917d37 --- /dev/null +++ b/tests/test_win32dll.py @@ -0,0 +1,81 @@ +"""Tests for the private Windows DLL handles. + +These pin a crash that only ever appeared in a frozen build on Windows, which is the +worst place for a bug to live: no traceback survived, because the window it was printed +in was destroyed by the same exit that printed it. + +``ctypes.windll.kernel32`` is a cache shared by the whole process. MeshTerm's emoji-width +probe declared ``GetConsoleScreenBufferInfo`` as taking a pointer to its own copy of the +screen-buffer struct; prompt_toolkit then called that same function object with a pointer +to *its* copy — same layout, different class — and ctypes refused the call while the app +was building its screen. +""" + +from __future__ import annotations + +import ctypes +import sys + +import pytest + +from meshterm.core import win32dll + +pytestmark = pytest.mark.skipif(sys.platform != "win32", reason="Windows libraries") + + +def test_each_handle_is_its_own() -> None: + """Two callers get two objects, so neither can see the other's declarations.""" + assert win32dll.kernel32() is not win32dll.kernel32() + assert win32dll.user32() is not win32dll.user32() + + +def test_the_shared_windll_is_the_thing_being_avoided() -> None: + """The premise: ``ctypes.windll`` hands the same function object to every caller. + + If this ever stops being true the module is unnecessary — but until then it is the + reason it exists, so it is worth asserting rather than assuming. + """ + first = ctypes.windll.kernel32.GetConsoleScreenBufferInfo + second = ctypes.windll.kernel32.GetConsoleScreenBufferInfo + assert first is second + + +def test_declaring_a_signature_does_not_reach_the_shared_one() -> None: + """The regression itself: our declarations must not be visible to anyone else. + + Before this module existed, the assignment below rewrote the signature that + prompt_toolkit's console output later called through, and the application died on + startup with ``expected LP__CSBI instance instead of pointer to + CONSOLE_SCREEN_BUFFER_INFO``. + """ + + class _MineAlone(ctypes.Structure): + _fields_ = [("whatever", ctypes.c_int)] + + shared = ctypes.windll.kernel32.GetConsoleScreenBufferInfo + before = shared.argtypes + + private = win32dll.kernel32() + private.GetConsoleScreenBufferInfo.argtypes = [ + ctypes.c_void_p, + ctypes.POINTER(_MineAlone), + ] + + assert ctypes.windll.kernel32.GetConsoleScreenBufferInfo.argtypes == before + assert private.GetConsoleScreenBufferInfo is not shared + + +def test_the_probe_leaves_the_shared_signature_alone() -> None: + """End to end: running the emoji probe must not disturb the process-wide function. + + The narrow assertion above can pass while the real call site still reaches for + ``ctypes.windll``, so this exercises the code that actually crashed. + """ + from meshterm.ui.tui import emoji_width + + shared = ctypes.windll.kernel32.GetConsoleScreenBufferInfo + before = shared.argtypes + # No console under pytest, so this returns None rather than measuring. That is fine: + # the declarations happen before the first call either way, which is what matters. + emoji_width._win_probe_width("x") # noqa: SLF001 - the point is this private path + assert ctypes.windll.kernel32.GetConsoleScreenBufferInfo.argtypes == before