Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d062b5b706 |
No files matched your search
+27
-1
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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".
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in new issue
Block a user