5 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
Jean-Pierre MartineauandClaude Opus 5 056ac3f770 MeshTerm 0.2.3
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
The build to diagnose a double-click with: a window that waits after a failure, a
log that keeps what happened, and a level you can turn up before reproducing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:57:24 -04:00
Jean-Pierre MartineauandClaude Opus 5 3379ea2d44 A crash you can read, on a window that waits
Double-clicking the executable on Windows gives the process its own console, and
Windows destroys that console the moment the process ends. So a traceback was
drawn and erased in the same instant: the report was "it displays a trace but the
window closes almost instantly", which is not a rendering problem, it is the
window being owned by the thing that died.

Two changes, because one of them is not enough.

The window now waits. `GetConsoleProcessList` says how many processes share this
console; when the answer is one, nobody else is holding the window open and it
will close with us. On that path only, and only after a failure, MeshTerm asks for
a keypress before it goes. A run started from a shell leaves its output on the
screen anyway, and a run that worked should never make anyone press a key to be
allowed to leave.

And the interactive session logs what killed it. It was catching the missing
Windows console and re-raising everything else untouched, which is precisely
backwards for the case that needs a record most: the session whose console is
about to vanish is the one whose traceback nobody will get to read. It goes into
the file first, and the terminal is told where the file is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:49:54 -04:00
Jean-Pierre MartineauandClaude Opus 5 bdc16fc448 The log is a text file that records what went wrong
Three things were true of the log at once: it was JSON Lines, it never rotated,
and it almost never recorded a failure. Of the last twenty thousand records in a
real one, nine were warnings and three were errors. It faithfully kept the boring
parts and missed the interesting ones, which is exactly backwards for the only
job it has.

It is plain text now, `~/.meshterm/meshterm.log`, one line per record with the
traceback indented under the line that raised it. JSON Lines was there in case
something replayed it; nothing ever did, and the actual reader is a person who has
hit a problem and is about to paste the last twenty lines into an issue. That
reader has a text editor.

It rotates, at 2MB across three files. The old handler grew without limit and a
real one reached 11MB over ten weeks — a lot of disk on a handheld, and an
unreasonable thing to ask someone to attach.

How much it keeps is a preference, `log_level` under a new Diagnostics group,
defaulting to WARNING. That is what makes the file useful rather than large: it
holds the problems, and turning it up to DEBUG for one run is how you chase
something. Loaded before logging is configured, since it decides how logging is
configured, and handed to the context afterwards so the file is not read twice.

And failures are written down at last. Every unhandled error reaches the log with
its traceback and the terminal says where to find it; expected errors — an
ambiguous device, a bad preference — go in as warnings, because a log that only
holds crashes cannot answer "what happened just before". The bug form now asks for
the file, which it had no reason to before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:43:13 -04:00
Jean-Pierre MartineauandClaude Opus 5 e2161fd191 Two MeshTerms can share a machine, but not a directory
Every copy on a machine reads and writes the same ~/.meshterm — a checkout, a
downloaded build, a second one for a second radio — and nothing about how it was
installed changes that. Two at once will not corrupt anything, but each holds the
JSON stores in memory and rewrites them whole, so the second to save discards the
first's changes. A lost contact is much harder to notice than a crash.

The interactive session now takes an exclusive lock on the directory it is using,
and a second one is refused with the fix in the message: set MESHTERM_HOME. The
lock belongs to the operating system rather than to a process id written in a
file, because a pid file has to answer "is that still alive?", the answer differs
per platform, and it is wrong once a reboot recycles the number — so a crash
leaves a lock nobody can explain and everyone learns to delete it. One-shot
subcommands do not take it; blocking `meshterm contacts` because a menu is open in
another window would be an obstacle rather than a guard.

The scratch-file race is fixed at the same time, and it was ten places rather than
the three I first counted: every store under core/ carried the same three lines,
writing to a fixed `.tmp` name beside its file. Two processes saving together took
turns inside that one scratch file and then each renamed whatever was in it over
the real one — the rename is atomic, the thing being renamed was not. They now go
through one `write_atomically`, whose temporary carries the writing process's id,
and which cleans up after a failed write instead of leaving a partial file behind.

And the Windows console error is answered rather than raised. prompt_toolkit needs
a Win32 console; Git Bash, MSYS and Cygwin are not one, and its complaint arrives
as a traceback that reads like a broken application when the answer is "open a
different terminal". It now says that, and points out that every screen has a
subcommand which pipes perfectly well.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 14:31:39 -04:00
25 changed files with 877 additions and 104 deletions

No files matched your search

+11
View File
@@ -163,6 +163,17 @@ body:
a photo of a terminal is hard to read. Paste the whole traceback if it crashed.
render: text
- type: textarea
id: log
attributes:
label: The log file
description: >
MeshTerm keeps one at `~/.meshterm/meshterm.log` (or wherever `MESHTERM_HOME`
points). Attach it, or paste the last twenty lines. If the problem is hard to
catch, set **Log detail** to `DEBUG` on the Preferences page, do it once more,
and send that instead — it records a great deal more.
render: text
- type: textarea
id: extra
attributes:
+72 -1
View File
@@ -17,6 +17,75 @@ 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
- The log is a plain text file, `~/.meshterm/meshterm.log`, one readable line per record
with the traceback under the line that raised it. It was JSON Lines on the theory that
something would replay it; nothing ever did, and the job it actually has is being opened
by a person who has hit a problem.
- It rotates, at 2 MB across three files. It used to grow without limit — a real one
reached 11 MB in ten weeks, which is a lot of disk on a handheld and an unreasonable
thing to attach to an issue.
- **How much it keeps is a preference** — `log_level`, under a new Diagnostics group,
defaulting to `WARNING`. The file now holds the problems rather than a narration of a
working session: of the last twenty thousand records in a real log, twelve were a
warning or an error.
### Added
- One interactive MeshTerm per data directory. A second one is turned away with the way
out in the message, rather than quietly overwriting the first one's contacts and
settings. The lock is held by the operating system, so a crash releases it — a process
id in a file would leave a lock nobody could explain.
- Failures are written down. Every unhandled error reaches the log with its traceback and
the terminal says where to find it; expected errors go in as warnings, because a log
that only holds crashes cannot answer "what happened just before". The bug report form
asks for the file.
- The window stays open after a crash when MeshTerm owns it. Double-clicking the
executable on Windows gives the process its own console, which is destroyed the instant
the process ends — so an error was drawn and erased together and all anyone saw was a
flash. It now waits for a keypress, but only on that path: a run from a shell leaves its
output on screen already, and a successful run should not make anyone press a key.
- A readable answer when there is no Windows console. Git Bash, MSYS and Cygwin are not
Windows consoles, and prompt_toolkit's own error says so in the language of its
internals, arriving as a traceback that reads like a broken app rather than the
instruction it is.
### Fixed
- All ten stores write through one helper, and the file each writes to first is named
for the process writing it. They shared a fixed `.tmp` name, so two copies of MeshTerm
saving at the same moment took turns inside one scratch file and then each renamed
whatever was in it over the real one. The rename was always atomic; what was being
renamed was not.
## [0.2.2] — 2026-09-07
### Fixed
@@ -110,7 +179,9 @@ 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.2...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
[0.2.0]: https://github.com/jpmartineau/MeshTerm/tree/v0.2.0
+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.2-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.2-*
./meshterm-0.2.2-*
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.2-*` 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.2-* # macOS, Linux
> MESHTERM_HOME=~/meshterm-test ./meshterm-0.2.4-* # macOS, Linux
> ```
> ```powershell
> $env:MESHTERM_HOME = "$HOME\meshterm-test"; .\meshterm-0.2.2-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.2"
__version__ = "0.2.4"
__author__ = "Jean-Pierre Martineau"
#: The year MeshTerm was first published; the copyright span starts here.
+142 -15
View File
@@ -10,19 +10,23 @@ from __future__ import annotations
import asyncio
import logging
import sys
from pathlib import Path
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
from .core.device_config import DeviceConfigError
from .core.device_store import DeviceStore
from .core.preferences import PreferenceError
from .core.instancelock import InstanceBusy, hold_instance_lock
from .core.preferences import PreferenceError, Preferences
from .core.selection import DeviceSelectionError
from .persistence.logging import configure_logging
from .persistence.logging import configure_logging, get_logger, level_from_name, log_path
from .persistence.repository import Repository
from .platforms import Resolution, resolve, set_platform
from .tools import all_tools
@@ -141,9 +145,19 @@ def main_callback(
settings.db_path = db_path
console = make_console()
configure_logging(console, settings.config_dir, level=logging.INFO, quiet=quiet or json_output)
# Loaded before logging is configured, because how much goes in the file is one of
# them — and handed to the context afterwards so the file is not read twice.
prefs = Preferences.load(settings.config_dir / "preferences.yaml")
configure_logging(
console,
settings.config_dir,
level=logging.INFO,
file_level=level_from_name(prefs.log_level),
quiet=quiet or json_output,
)
app_ctx = AppContext(
preferences=prefs,
console=console,
settings=settings,
repo=Repository(settings.db_path),
@@ -166,7 +180,60 @@ def main_callback(
if ctx.invoked_subcommand is None:
from .ui.menu import run_menu
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
# The interactive session is the one that holds the stores in memory and rewrites
# them whole, so it is the one that claims the directory. One-shot subcommands are
# brief and mostly read; blocking `meshterm contacts` because a menu is open in
# another window would be an obstacle rather than a guard.
try:
with hold_instance_lock(settings.config_dir):
asyncio.run(_drive(run_menu(app_ctx), app_ctx))
except InstanceBusy as exc:
console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(code=1) from None
except Exception as exc:
if type(exc).__name__ == "NoConsoleScreenBufferError":
# prompt_toolkit needs a real Win32 console and reports its absence in the
# language of its own internals. A traceback reads as "this app is broken"
# when the answer is "open a different terminal", so answer that instead.
get_logger().warning("no Windows console available: %s", exc)
_report_no_windows_console(console)
raise typer.Exit(code=1) from None
# Logged before it is re-raised, and this is the case the log matters most for:
# a session launched by double-clicking the executable owns its console window,
# so when it dies the window closes with it and takes the traceback along. The
# file is the only thing left to read afterwards.
get_logger().exception("the interactive session raised an unhandled error")
_report_log_location(console, settings.config_dir)
raise
def _report_log_location(console: Console, config_dir: Path) -> None:
"""Point at the log after a fault, since it is the copy that outlives the terminal."""
console.print()
console.print(f"[muted]The details are in {log_path(config_dir)}[/muted]")
console.print("[muted]Attach it to a bug report. For more detail next time, raise[/muted]")
console.print("[muted]'Log detail' on the Preferences page.[/muted]")
def _report_no_windows_console(console: Console) -> None:
"""Explain a console MeshTerm cannot draw in, and how to get one it can.
Reached when prompt_toolkit finds no Win32 console screen buffer: a Git Bash, MSYS or
Cygwin shell — which announce themselves as ``xterm`` and are not Windows consoles at
all — or a process whose output has been redirected away from one.
"""
console.print("[err]✗[/err] MeshTerm needs a Windows console, and could not find one.")
console.print()
console.print("That usually means one of two things:")
console.print()
console.print(" • You are in Git Bash, MSYS or Cygwin. Those are not Windows consoles.")
console.print(" Open [accent]Windows Terminal[/accent] or [accent]PowerShell[/accent]")
console.print(" and run it from there.")
console.print(" • The output is piped or redirected somewhere. The full-screen menu")
console.print(" needs the terminal itself.")
console.print()
console.print("Every screen also has a subcommand, and those pipe fine —")
console.print("try [accent]meshterm --help[/accent].")
@app.command(name="platform")
@@ -218,20 +285,30 @@ def run_tool_command(tool: Tool, params: dict) -> None:
asyncio.run(_drive(_execute_and_render(tool, params, _state), _state))
except (DeviceSelectionError, DeviceConfigError, DeviceCommandError, PreferenceError) as exc:
# Expected user-facing error (ambiguous/absent device, a bad config or preference
# value, or a transient command failure): show the message, not a traceback.
# value, or a transient command failure): show the message, not a traceback. Logged
# at warning because it is a thing that went wrong, even though it is an ordinary
# one — a log that only holds crashes cannot answer "what happened before it".
get_logger().warning("%s failed: %s", tool.name, exc)
_state.console.print(f"[err]✗[/err] {exc}")
raise typer.Exit(1) from exc
except Exception as exc:
# A dropped serial link (device unplugged/powered off mid-command) can't be recovered
# from in a one-shot scripted run the way the interactive menu offers — but it should
# still read as a clean message, not a traceback. Anything else propagates as before.
if not is_connection_lost(exc):
raise
_state.console.print(
"[err]✗[/err] the connection to your device was lost "
"(it may have been unplugged or powered off)."
)
raise typer.Exit(1) from exc
# still read as a clean message, not a traceback.
if is_connection_lost(exc):
get_logger().warning("%s lost the device connection: %s", tool.name, exc)
_state.console.print(
"[err]✗[/err] the connection to your device was lost "
"(it may have been unplugged or powered off)."
)
raise typer.Exit(1) from exc
# Anything else is a real fault. It still reaches the terminal as a traceback,
# because a person looking at one wants to see it — but it also lands in the log,
# which is the copy that survives the terminal being closed and is the one thing
# worth attaching to a bug report.
get_logger().exception("%s raised an unhandled error", tool.name)
_report_log_location(_state.console, _state.settings.config_dir)
raise
async def _drive(coro, ctx: AppContext) -> None:
@@ -280,9 +357,59 @@ def _register_all() -> None:
_register_all()
def _owns_its_console() -> bool:
"""Whether this process is the only one on its console — i.e. it was double-clicked.
Windows gives a console to a process launched from Explorer and destroys it the moment
that process ends, so an error message is displayed and erased in the same instant.
Launched from a shell instead, the console belongs to the shell and survives.
``GetConsoleProcessList`` tells the two apart: it reports how many processes are
attached to this console. One is us, alone, holding a window that dies with us.
Returns:
``True`` only on Windows, and only when nothing else shares the console.
"""
if sys.platform != "win32":
return False
try:
import ctypes
buffer = (ctypes.c_uint * 4)()
count = win32dll.kernel32().GetConsoleProcessList(buffer, 4)
except Exception: # pragma: no cover - no console at all, or a stubbed kernel32
return False
return count == 1
def main() -> None:
"""Console-script entry point."""
app()
"""Console-script entry point.
Holds the window open after a failure when MeshTerm owns it, because otherwise the
error is drawn and destroyed together and the person who needs to read it sees a
flash. Only on that path: a successful run should not make anyone press a key, and a
run from a shell leaves its output on the screen anyway.
"""
try:
app()
except SystemExit as exit_request:
if exit_request.code and _owns_its_console():
_wait_before_the_window_closes()
raise
except BaseException:
if _owns_its_console():
_wait_before_the_window_closes()
raise
def _wait_before_the_window_closes() -> None:
"""Ask for a keypress so a message stays readable, and never fail doing it."""
try:
print()
print("-- Press Enter to close this window --")
input()
except Exception: # pragma: no cover - stdin closed, redirected, or gone
pass
if __name__ == "__main__":
+2 -10
View File
@@ -14,11 +14,10 @@ a multi-user secret store; treat the file accordingly.
from __future__ import annotations
import json
import os
import stat
from dataclasses import dataclass
from pathlib import Path
from .atomicwrite import write_atomically
from .models import Contact, LoginResult, utcnow
@@ -139,11 +138,4 @@ class AdminStore:
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically with owner-only permissions where supported."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
try: # best-effort: not all platforms/filesystems honor chmod
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
except OSError: # pragma: no cover - platform-dependent
pass
tmp.replace(self._path)
write_atomically(self._path, json.dumps(records, indent=2), owner_only=True)
+2 -4
View File
@@ -20,6 +20,7 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .atomicwrite import write_atomically
from .models import utcnow
#: The direct (zero-hop) cadence choices offered in the editor, in hours.
@@ -190,10 +191,7 @@ class AdvertStore:
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(records, indent=2))
def _as_hours(value: object, default: int) -> int:
+54
View File
@@ -0,0 +1,54 @@
"""THE way MeshTerm replaces a file it owns: all of the new contents, or none of them.
Every store under ``core/`` keeps its state in one file that it rewrites whole. Writing
in place means a crash — or a laptop lid closing — halfway through leaves a truncated
file where a contact list used to be, so each write goes to a neighbouring temporary file
first and lands with a single rename, which the filesystem either does or doesn't.
The neighbouring file's name carries the writing process's id. That is not fussiness:
MeshTerm's data directory is shared by every copy on the machine — a checkout, a
downloaded build, a second instance for a second radio — and a fixed ``.tmp`` name means
two of them writing at the same moment take turns clobbering one scratch file, then each
rename whatever is in it over the real one. The rename is atomic; the thing being renamed
was not. Per-process names make the two writes independent again, and the last rename
simply wins, which is a lost update rather than a corrupted file.
``MESHTERM_HOME`` is the way to avoid sharing the directory in the first place — see
:func:`meshterm.core.config.default_config_dir`.
"""
from __future__ import annotations
import os
import stat
from pathlib import Path
__all__ = ["write_atomically"]
def write_atomically(path: Path, text: str, *, owner_only: bool = False) -> None:
"""Replace ``path`` with ``text`` in one step, creating parent directories as needed.
Args:
path: The file to replace. Its directory is created if it does not exist.
text: The complete new contents, written as UTF-8.
owner_only: Restrict the file to the owner (``0600``) before it is put in place,
for anything holding a credential. Applied to the temporary file rather than
the destination, so there is no moment where the real file exists with wider
permissions. Best-effort: not every platform or filesystem honours it.
"""
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(f"{path.suffix}.{os.getpid()}.tmp")
try:
tmp.write_text(text, encoding="utf-8")
if owner_only:
try:
os.chmod(tmp, stat.S_IRUSR | stat.S_IWUSR)
except OSError: # pragma: no cover - platform-dependent
pass
tmp.replace(path)
except BaseException:
# A failed write leaves no litter behind. `missing_ok` because the failure may
# well have been the write that would have created it.
tmp.unlink(missing_ok=True)
raise
+2 -4
View File
@@ -30,6 +30,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING
from .atomicwrite import write_atomically
from .channels import (
CHANNEL_SECRET_BYTES,
CHANNEL_SLOT_EMPTY_RUN,
@@ -156,10 +157,7 @@ class ChannelStore:
if channels
}
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
def _channel_from_json(entry: object) -> RememberedChannel | None:
+2 -4
View File
@@ -49,6 +49,7 @@ import json
from dataclasses import dataclass
from pathlib import Path
from .atomicwrite import write_atomically
from .models import Contact, advert_time
@@ -332,10 +333,7 @@ class ContactStore:
if contacts
}
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
def _contact_to_json(contact: RememberedContact) -> dict:
+2 -4
View File
@@ -19,6 +19,7 @@ from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from .atomicwrite import write_atomically
from .models import utcnow
#: Message states: waiting in the outbox, landed, or abandoned after the retry budget.
@@ -253,10 +254,7 @@ class CourierStore:
for m in self._load()
],
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
def _as_int(value: object, default: int) -> int:
+2 -4
View File
@@ -19,6 +19,7 @@ import json
from dataclasses import dataclass
from pathlib import Path
from .atomicwrite import write_atomically
from .discovery import (
TRANSPORT_BLE,
TRANSPORT_SERIAL,
@@ -259,7 +260,4 @@ class DeviceStore:
},
"last": last,
}
# Atomic-ish replace so a crash mid-write can't truncate the existing registry.
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(payload, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(payload, indent=2))
+94
View File
@@ -0,0 +1,94 @@
"""One interactive MeshTerm per data directory, and a clear answer when you want two.
Every copy of MeshTerm on a machine reads and writes the same ``~/.meshterm`` — a
checkout, a downloaded build, a second instance for a second radio. Nothing about how it
was installed changes that. Two running at once will not corrupt anything (the database
is in WAL mode and every file lands by rename) but each holds the JSON stores in memory
and rewrites them whole, so the second one to save quietly discards the first one's
changes. A lost contact is much harder to notice than a crash.
So the interactive session takes an exclusive lock on the directory it is using, and a
second one is turned away with the fix in the message: set ``MESHTERM_HOME``.
The lock is held by the operating system rather than written down as a process id in a
file. A pid file has to answer "is that process still alive?", the answer is different on
every platform, and it is wrong after a reboot recycles the number — so a crash leaves a
lock nobody can explain and everybody works around. An OS lock is released when the
process ends, however it ends.
One-shot CLI subcommands do not take it. They are brief, mostly read, and blocking
``meshterm contacts`` because a menu is open elsewhere would be an obstacle rather than a
guard.
"""
from __future__ import annotations
import sys
from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
__all__ = ["InstanceBusy", "LOCK_NAME", "hold_instance_lock"]
#: The lock file, kept beside the data it guards. Empty; only the lock on it matters.
LOCK_NAME = ".lock"
class InstanceBusy(RuntimeError):
"""Raised when another interactive MeshTerm already holds this data directory."""
def __init__(self, config_dir: Path) -> None:
"""Build the message, which is mostly the way out of the situation."""
super().__init__(
f"Another MeshTerm is already using {config_dir}.\n\n"
"Two of them sharing one directory will quietly overwrite each other's\n"
"contacts and settings, so this one stopped instead.\n\n"
"To run a second one — a downloaded build beside your own, or a second\n"
"radio — give it a directory of its own:\n\n"
" MESHTERM_HOME=~/meshterm-other meshterm\n"
)
self.config_dir = config_dir
def _try_lock(handle) -> bool:
"""Take an exclusive, non-blocking lock on an open file; ``False`` if someone has it."""
if sys.platform == "win32":
import msvcrt
try:
msvcrt.locking(handle.fileno(), msvcrt.LK_NBLCK, 1)
except OSError:
return False
return True
import fcntl
try:
fcntl.flock(handle.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
return False
return True
@contextmanager
def hold_instance_lock(config_dir: Path) -> Iterator[None]:
"""Hold ``config_dir`` for this process, releasing it however the process ends.
Args:
config_dir: The data directory to claim.
Raises:
InstanceBusy: Another interactive MeshTerm holds it.
"""
config_dir.mkdir(parents=True, exist_ok=True)
path = config_dir / LOCK_NAME
handle = open(path, "a+b") # noqa: SIM115 - closed in the finally below
try:
if not _try_lock(handle):
raise InstanceBusy(config_dir)
yield
finally:
# Closing releases the lock on every platform. The file itself stays: deleting it
# would race a second process that has already opened it and is about to lock,
# which is how one directory ends up handed to two of them.
handle.close()
+3 -4
View File
@@ -23,6 +23,8 @@ from __future__ import annotations
import json
from pathlib import Path
from .atomicwrite import write_atomically
class MuteStore:
"""Reads and writes the set of channels whose notifications are muted, memory-first.
@@ -92,7 +94,4 @@ class MuteStore:
def _save(self) -> None:
"""Persist the whole muted set atomically (a crash mid-write keeps the old file)."""
data = {"muted": sorted(self._state)}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
+28 -4
View File
@@ -42,6 +42,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .atomicwrite import write_atomically
from .watch_store import DEFAULT_SILENCE_HOURS, OFF, SILENCE_CHOICES_H
#: Display groups, in the order the page and the file present them. The order is the
@@ -56,6 +57,7 @@ GROUPS: tuple[str, ...] = (
"Map",
"History",
"Display",
"Diagnostics",
)
@@ -123,6 +125,17 @@ _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. 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": "Error",
"WARNING": "Warning",
"INFO": "Info",
"DEBUG": "Debug",
}
PREFERENCES: tuple[PrefSpec, ...] = (
# --- Sending -----------------------------------------------------------------
PrefSpec(
@@ -282,6 +295,20 @@ PREFERENCES: tuple[PrefSpec, ...] = (
default="auto",
choices=_WIDTH_CHOICES,
),
# --- Diagnostics ---------------------------------------------------------------
PrefSpec(
key="log_level",
label="Log detail",
help="How much MeshTerm writes to its log file",
group="Diagnostics",
value_type="enum",
# Problems, not a narration of a working session. The file used to take
# everything, which buried the dozen interesting lines under twenty thousand
# dull ones and made it awkward to attach to a bug report.
default="WARNING",
choices=_LOG_LEVEL_CHOICES,
relaunch=True,
),
)
_BY_KEY: dict[str, PrefSpec] = {spec.key: spec for spec in PREFERENCES}
@@ -602,10 +629,7 @@ class Preferences:
"""
if self._path is None:
raise RuntimeError("these preferences have no file to save to")
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(self.as_yaml(), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, self.as_yaml())
#: The set installed for this process. Starts as a defaults-only, file-less instance so
+2 -4
View File
@@ -17,6 +17,7 @@ from datetime import datetime
from pathlib import Path
from .admin_store import admin_key
from .atomicwrite import write_atomically
from .models import Contact, utcnow
#: How many command-line entries are kept per node (newest last).
@@ -109,7 +110,4 @@ class RemoteStore:
def _write(self, records: dict[str, dict]) -> None:
"""Persist ``records`` atomically (crash mid-write keeps the previous file)."""
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(records, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(records, indent=2))
+2 -4
View File
@@ -32,6 +32,7 @@ from dataclasses import dataclass
from pathlib import Path
from typing import TYPE_CHECKING, Any
from .atomicwrite import write_atomically
from .device_config import DeviceConfigError, format_value, get_spec, parse_value
if TYPE_CHECKING:
@@ -147,10 +148,7 @@ class SettingsStore:
if values
}
}
self._path.parent.mkdir(parents=True, exist_ok=True)
tmp = self._path.with_suffix(self._path.suffix + ".tmp")
tmp.write_text(json.dumps(data, indent=2), encoding="utf-8")
tmp.replace(self._path)
write_atomically(self._path, json.dumps(data, indent=2))
@dataclass(frozen=True)
+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")
+71 -28
View File
@@ -1,13 +1,28 @@
"""Structured logging setup.
"""Logging setup: a plain text file you can open, and a console that stays out of the way.
Two sinks are configured: a Rich-formatted console handler for human-readable output,
and a JSON-lines file handler that captures every record for later replay/analysis.
Two sinks. The console handler is Rich-formatted and shows what a person watching the
terminal should see. The file handler writes ordinary log lines to
``<config_dir>/meshterm.log`` — one record per line, timestamp, level, logger, message,
with a traceback indented under the line that raised it.
Plain text on purpose. The file used to be JSON Lines, on the theory that something might
replay it; nothing ever did, and the one job it actually has is being opened by a person
who has hit a problem — often the person reporting it, who then pastes the last twenty
lines into an issue. That reader has a text editor, not a JSON parser.
The file rotates. It used to be a single :class:`~logging.FileHandler`, which grows
without limit: a real one reached 11MB over ten weeks, which is both a lot of disk on a
handheld and an unreasonable thing to attach to a bug report.
How much is written is a preference — ``log_level``, defaulting to ``WARNING``, so the
file holds the problems rather than a narration of a working session. Turn it down to
``INFO`` or ``DEBUG`` when chasing something.
"""
from __future__ import annotations
import json
import logging
from logging.handlers import RotatingFileHandler
from pathlib import Path
from rich.console import Console
@@ -15,28 +30,49 @@ from rich.logging import RichHandler
_LOGGER_NAME = "meshterm"
#: The log file, in the config directory beside the data it describes.
LOG_FILENAME = "meshterm.log"
class JsonlFormatter(logging.Formatter):
"""Format log records as one JSON object per line."""
#: Keep this much before rolling over, and this many rolled files. Roughly a fortnight of
#: a chatty session at DEBUG, and small enough that the whole set can be attached to an
#: issue without apology.
_MAX_BYTES = 2 * 1024 * 1024
_BACKUP_COUNT = 3
def format(self, record: logging.LogRecord) -> str:
"""Serialize a log record to a compact JSON string.
#: ``2026-09-07 14:23:11 WARNING meshterm.core.connection: link lost, retrying``
#: Level is padded so the messages line up; the logger name says which part spoke.
_LINE_FORMAT = "%(asctime)s %(levelname)-8s %(name)s: %(message)s"
_TIME_FORMAT = "%Y-%m-%d %H:%M:%S"
Args:
record: The record to format.
Returns:
A single-line JSON document.
"""
payload = {
"ts": self.formatTime(record, "%Y-%m-%dT%H:%M:%S"),
"level": record.levelname,
"logger": record.name,
"msg": record.getMessage(),
}
if record.exc_info:
payload["exc"] = self.formatException(record.exc_info)
return json.dumps(payload)
#: The levels the ``log_level`` preference offers, by their own names. Spelled out rather
#: than taken from ``logging.getLevelName``, whose string-to-number direction is deprecated,
#: or ``getLevelNamesMapping``, which arrived in 3.11 and this package supports 3.10.
_LEVELS: dict[str, int] = {
"ERROR": logging.ERROR,
"WARNING": logging.WARNING,
"INFO": logging.INFO,
"DEBUG": logging.DEBUG,
}
def level_from_name(name: str, *, fallback: int = logging.WARNING) -> int:
"""Turn a ``log_level`` preference value into a level number.
Args:
name: A level name, in any case.
fallback: Used when the name is unrecognised — a hand-edited ``preferences.yaml``
with a typo in it should lose some log detail, not stop the app from starting.
Returns:
The matching level number.
"""
return _LEVELS.get(name.strip().upper(), fallback)
def log_path(log_dir: Path) -> Path:
"""Return the log file's path, for an error message that wants to name it."""
return log_dir / LOG_FILENAME
def configure_logging(
@@ -44,14 +80,16 @@ def configure_logging(
log_dir: Path,
*,
level: int = logging.INFO,
file_level: int = logging.WARNING,
quiet: bool = False,
) -> logging.Logger:
"""Configure and return the application logger.
Args:
console: The Rich console the handler should render to.
log_dir: Directory for the ``meshterm.log.jsonl`` event file.
log_dir: Directory for the ``meshterm.log`` file.
level: Console log level.
file_level: File log level, from the ``log_level`` preference.
quiet: When ``True``, suppress console output (file logging continues).
Returns:
@@ -70,9 +108,14 @@ def configure_logging(
logger.addHandler(rich_handler)
log_dir.mkdir(parents=True, exist_ok=True)
file_handler = logging.FileHandler(log_dir / "meshterm.log.jsonl", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(JsonlFormatter())
file_handler = RotatingFileHandler(
log_path(log_dir),
maxBytes=_MAX_BYTES,
backupCount=_BACKUP_COUNT,
encoding="utf-8",
)
file_handler.setLevel(file_level)
file_handler.setFormatter(logging.Formatter(_LINE_FORMAT, datefmt=_TIME_FORMAT))
logger.addHandler(file_handler)
_quiet_library_console(file_handler)
@@ -100,8 +143,8 @@ def _quiet_library_console(file_handler: logging.Handler) -> None:
1. Give the root logger a :class:`~logging.NullHandler` so ``basicConfig`` — which only
acts when the root has no handlers — becomes a no-op and never adds its stream handler.
2. Take each known library logger off propagation and attach only ``file_handler``, so
its records are still captured to the JSON-lines log but never reach the console, even
if some other code path installs a root stream handler anyway.
its records are still captured to the log but never reach the console, even if some
other code path installs a root stream handler anyway.
Args:
file_handler: The application's file handler to also capture library records to.
+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
+110
View File
@@ -0,0 +1,110 @@
"""Tests for the one-interactive-session-per-directory guard, and the atomic writer.
Both exist for the same reason: every copy of MeshTerm on a machine shares one data
directory unless told otherwise, so two of them running at once is a thing that happens
by accident rather than by choice.
"""
from __future__ import annotations
import json
import subprocess
import sys
from pathlib import Path
import pytest
from meshterm.core.atomicwrite import write_atomically
from meshterm.core.instancelock import LOCK_NAME, InstanceBusy, hold_instance_lock
REPO_ROOT = Path(__file__).resolve().parent.parent
def test_the_lock_is_released_when_the_holder_leaves(tmp_path: Path) -> None:
"""Two sessions in sequence are fine; the guard is against overlap, not against reuse."""
with hold_instance_lock(tmp_path):
pass
with hold_instance_lock(tmp_path):
pass
def test_the_lock_file_lives_beside_the_data(tmp_path: Path) -> None:
"""The lock sits in the directory it guards, and the directory is made if missing."""
target = tmp_path / "not-yet"
with hold_instance_lock(target):
assert (target / LOCK_NAME).exists()
def test_a_second_process_is_turned_away(tmp_path: Path) -> None:
"""The real test of an OS lock: another *process* cannot take it.
Nothing in-process proves anything here — a lock that only excluded the thread that
already holds it would pass a single-process test and fail in the one situation it
exists for.
"""
program = (
"import pathlib, sys\n"
f"sys.path.insert(0, {str(REPO_ROOT)!r})\n"
"from meshterm.core.instancelock import hold_instance_lock, InstanceBusy\n"
"try:\n"
f" with hold_instance_lock(pathlib.Path({str(tmp_path)!r})):\n"
" print('TAKEN')\n"
"except InstanceBusy:\n"
" print('REFUSED')\n"
)
with hold_instance_lock(tmp_path):
done = subprocess.run(
[sys.executable, "-c", program], capture_output=True, text=True, timeout=60
)
assert done.stdout.strip() == "REFUSED", done.stderr[-500:]
def test_the_refusal_says_how_to_run_two(tmp_path: Path) -> None:
"""The message is mostly the way out, because that is what the reader needs next.
Someone who sees this is doing something reasonable — trying a downloaded build, or
running a second radio — so being told *no* without being told *how* would be the
unhelpful half of the answer.
"""
message = str(InstanceBusy(tmp_path))
assert "MESHTERM_HOME" in message
assert str(tmp_path) in message
def test_a_write_lands_whole_or_not_at_all(tmp_path: Path) -> None:
"""The written file holds exactly what was asked for, and no scratch file survives."""
target = tmp_path / "sub" / "contacts.json"
write_atomically(target, json.dumps({"a": 1}))
assert json.loads(target.read_text(encoding="utf-8")) == {"a": 1}
assert list(tmp_path.rglob("*.tmp")) == []
def test_the_scratch_file_is_named_for_its_writer(tmp_path: Path, monkeypatch) -> None:
"""Two processes writing the same file must not share one temporary name.
The rename is atomic; the thing being renamed is not. A fixed ``.tmp`` name lets two
writers interleave inside one scratch file and then each rename whatever ended up
there over the real one.
"""
seen: list[Path] = []
real = Path.write_text
def spy(self: Path, *args: object, **kwargs: object) -> int:
seen.append(self)
return real(self, *args, **kwargs) # type: ignore[arg-type]
monkeypatch.setattr(Path, "write_text", spy)
write_atomically(tmp_path / "x.json", "{}")
assert seen and str(__import__("os").getpid()) in seen[0].name
def test_a_failed_write_leaves_no_litter(tmp_path: Path, monkeypatch) -> None:
"""A write that raises cleans up after itself rather than leaving a partial file."""
def boom(self: Path, *args: object, **kwargs: object) -> int:
raise OSError("disk full")
monkeypatch.setattr(Path, "write_text", boom)
with pytest.raises(OSError):
write_atomically(tmp_path / "y.json", "{}")
assert list(tmp_path.rglob("*.tmp")) == []
+109
View File
@@ -0,0 +1,109 @@
"""Tests for the log file: plain text, bounded, and holding the things that went wrong.
The log's one real job is being opened by a person who has hit a problem — often the
person reporting it. Every assertion here is about that reader.
"""
from __future__ import annotations
import logging
from pathlib import Path
from rich.console import Console
from meshterm.persistence.logging import (
LOG_FILENAME,
configure_logging,
get_logger,
level_from_name,
log_path,
)
def _read(log_dir: Path) -> str:
for handler in get_logger().handlers:
handler.flush()
return log_path(log_dir).read_text(encoding="utf-8")
def test_the_log_is_plain_text(tmp_path: Path) -> None:
"""One readable line per record: when, how bad, who said it, and what."""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
get_logger("core.connection").warning("link lost, retrying")
line = _read(tmp_path).splitlines()[0]
assert "WARNING" in line
assert "meshterm.core.connection" in line
assert "link lost, retrying" in line
# A date as well as a time. "it happened at 14:40" is useless a week later.
assert line.startswith("20")
def test_the_file_is_named_plainly(tmp_path: Path) -> None:
"""``meshterm.log`` — an extension every text editor already opens."""
assert log_path(tmp_path).name == LOG_FILENAME == "meshterm.log"
def test_the_level_keeps_the_quiet_records_out(tmp_path: Path) -> None:
"""At the default, the file holds problems rather than a narration of a working run.
This is the whole point of the default being WARNING: a log where the dozen useful
lines sit under twenty thousand dull ones is one nobody reads.
"""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
log = get_logger()
log.debug("opened a screen")
log.info("connected to a radio")
log.warning("something went wrong")
written = _read(tmp_path)
assert "something went wrong" in written
assert "opened a screen" not in written
assert "connected to a radio" not in written
def test_turning_it_up_lets_everything_through(tmp_path: Path) -> None:
"""DEBUG is there for chasing something, and keeps what WARNING drops."""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.DEBUG, quiet=True)
get_logger().debug("opened a screen")
assert "opened a screen" in _read(tmp_path)
def test_a_traceback_goes_in_with_the_record(tmp_path: Path) -> None:
"""An exception's traceback is written under the record that reports it.
This is most of why the file is worth attaching to a bug report: the terminal scrolls
away and gets closed, and this copy does not.
"""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
try:
raise ValueError("the device said something impossible")
except ValueError:
get_logger().exception("tool raised")
written = _read(tmp_path)
assert "tool raised" in written
assert "ValueError: the device said something impossible" in written
assert "Traceback (most recent call last)" in written
def test_the_file_is_bounded(tmp_path: Path) -> None:
"""It rotates. An unbounded log reached 11MB in ten weeks on a real install."""
configure_logging(Console(quiet=True), tmp_path, file_level=logging.WARNING, quiet=True)
handler = next(h for h in get_logger().handlers if hasattr(h, "maxBytes"))
assert handler.maxBytes > 0
assert handler.backupCount > 0
def test_a_level_name_becomes_a_level(tmp_path: Path) -> None:
"""The preference stores a name; the handler needs a number."""
assert level_from_name("DEBUG") == logging.DEBUG
assert level_from_name("warning") == logging.WARNING
assert level_from_name(" Error ") == logging.ERROR
def test_a_nonsense_level_still_starts_the_app() -> None:
"""A typo in a hand-edited preferences file loses log detail, not the app.
Someone editing YAML by hand and writing ``WARN`` should get a slightly quieter log,
not a program that refuses to launch.
"""
assert level_from_name("WARN") == logging.WARNING
assert level_from_name("") == logging.WARNING
+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