Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d062b5b706 | ||
|
|
056ac3f770 | ||
|
|
3379ea2d44 | ||
|
|
bdc16fc448 | ||
|
|
e2161fd191 |
No files matched your search
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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__":
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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")) == []
|
||||
@@ -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
|
||||
@@ -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