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>
55 lines
2.5 KiB
Python
55 lines
2.5 KiB
Python
"""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
|