1 Commits
Author SHA1 Message Date
Jean-Pierre MartineauandClaude Opus 5 d062b5b706 The Windows console call that broke the app is ours alone
Installers / windows-x64 (push) Canceled after 0s
Installers / macos-x64 (push) Canceled after 0s
Installers / macos-arm64 (push) Canceled after 0s
Installers / linux-x64 (push) Canceled after 0s
Installers / linux-arm64 (push) Canceled after 0s
GitHub release / build (push) Canceled after 0s
GitHub release / create the release (push) Canceled after 0s
MeshTerm died building its screen on Windows with a ctypes type error, and on a
double-clicked build the traceback and the window it printed to were destroyed
together — which is why this needed a log file before it could be looked at.

`ctypes.windll.kernel32` is a cache. Every caller in the process receives the same
object and each function on it is a shared attribute, so declaring `.argtypes` is
not configuration, it is a process-wide mutation where the last writer wins. The
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 own copy — same layout, different class — and ctypes
refused it.

Nothing here reaches for `ctypes.windll` any more. Five call sites take private
handles from `core/win32dll.py`, built by constructing `WinDLL` directly so each
one gets its own function cache and a declaration made for one purpose cannot be
seen by anything else. Each call returns a fresh handle, because two parts of
MeshTerm disagreeing about a signature is the same bug in miniature.

The regression test was checked against the defect rather than assumed to cover
it: with the old line restored it fails, and with the fix it passes. A test that
passes either way would only have recorded that somebody thought about the
problem.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 15:16:26 -04:00
10 changed files with 203 additions and 22 deletions

No files matched your search

+27 -1
View File
@@ -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
+6 -6
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
+7 -6
View File
@@ -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",
}
+64
View File
@@ -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")
+3 -1
View File
@@ -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
+6 -1
View File
@@ -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".
+6 -5
View File
@@ -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
+81
View File
@@ -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