Files
MeshTerm/meshterm/core/instancelock.py
T
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

95 lines
3.6 KiB
Python

"""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()