Files
MeshTerm/meshterm/core/instancelock.py
T
Jean-Pierre MartineauandClaude Fable 5.1 c9f5a71ce7 Every Python file names its licence with an SPDX identifier
Apache-2.0 does not require per-file headers, but a file lifted out of the tree carried no marking at all. One comment line, first (or right after a shebang), on every .py under meshterm/, tests/, packaging/ and scripts/, plus the extensionless SPI bridge; tests/test_spdx.py keeps a new file from landing without one.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-13 13:09:18 -04:00

96 lines
3.7 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""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()