fix(engine-state): resolve atomic checkpoint review findings (#285)
This commit is contained in:
parent
fbff161a2e
commit
26237f2335
1 changed files with 28 additions and 28 deletions
|
|
@ -14,6 +14,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import stat
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import warnings
|
import warnings
|
||||||
|
|
@ -28,46 +29,45 @@ def _atomic_write_text(target: Path, content: str, *, encoding: str = "utf-8") -
|
||||||
"""ADR-0156 (W-022) — atomic checkpoint write.
|
"""ADR-0156 (W-022) — atomic checkpoint write.
|
||||||
|
|
||||||
Write ``content`` to a temp file in the same directory as ``target``,
|
Write ``content`` to a temp file in the same directory as ``target``,
|
||||||
fsync it, then ``os.replace`` it into place. Same-directory rename is
|
fsync it, then ``os.replace`` it into place. Same-directory rename is
|
||||||
atomic on POSIX (and on Windows since Python 3.3 via ``os.replace``).
|
atomic on POSIX (and on Windows since Python 3.3 via ``os.replace``).
|
||||||
A SIGINT/SIGKILL between ``write`` and ``replace`` leaves the prior
|
A SIGINT/SIGKILL between ``write`` and ``replace`` leaves the prior
|
||||||
target file fully intact; an interrupted write leaves an orphan
|
target file fully intact.
|
||||||
temp file that is cleaned up on the next ``save_*`` call.
|
|
||||||
|
|
||||||
ADR-0146 §"File Operations and Invariants" specified this behavior;
|
Existing file mode bits are preserved when replacing a prior checkpoint
|
||||||
pre-W-022 the writers used ``Path.write_text`` directly, which
|
file so engine-state readers do not silently lose access after a save.
|
||||||
truncated the target before writing and corrupted the checkpoint
|
|
||||||
on mid-write process termination.
|
|
||||||
"""
|
"""
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
# NamedTemporaryFile with delete=False so we can rename out of its handle.
|
|
||||||
# ``dir=target.parent`` keeps the rename on the same filesystem (atomic).
|
existing_mode: int | None = None
|
||||||
with tempfile.NamedTemporaryFile(
|
if target.exists():
|
||||||
mode="w",
|
existing_mode = stat.S_IMODE(target.stat().st_mode)
|
||||||
encoding=encoding,
|
|
||||||
dir=str(target.parent),
|
tmp_path: Path | None = None
|
||||||
prefix=f".{target.name}.",
|
try:
|
||||||
suffix=".tmp",
|
with tempfile.NamedTemporaryFile(
|
||||||
delete=False,
|
mode="w",
|
||||||
) as fh:
|
encoding=encoding,
|
||||||
tmp_path = Path(fh.name)
|
dir=str(target.parent),
|
||||||
try:
|
prefix=f".{target.name}.",
|
||||||
|
suffix=".tmp",
|
||||||
|
delete=False,
|
||||||
|
) as fh:
|
||||||
|
tmp_path = Path(fh.name)
|
||||||
fh.write(content)
|
fh.write(content)
|
||||||
fh.flush()
|
fh.flush()
|
||||||
os.fsync(fh.fileno())
|
os.fsync(fh.fileno())
|
||||||
except BaseException:
|
|
||||||
|
if existing_mode is not None and tmp_path is not None:
|
||||||
|
os.chmod(tmp_path, existing_mode)
|
||||||
|
|
||||||
|
os.replace(tmp_path, target)
|
||||||
|
except BaseException:
|
||||||
|
if tmp_path is not None:
|
||||||
try:
|
try:
|
||||||
tmp_path.unlink()
|
tmp_path.unlink()
|
||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
pass
|
pass
|
||||||
raise
|
|
||||||
try:
|
|
||||||
os.replace(tmp_path, target)
|
|
||||||
except BaseException:
|
|
||||||
try:
|
|
||||||
tmp_path.unlink()
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
_SCHEMA_VERSION = 1
|
_SCHEMA_VERSION = 1
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue