mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-08-12 16:38:39 -04:00
fix(core): stop atomic writes from colliding on a constant PID suffix (#5721)
atomic_write_json/atomic_write_text build their temp filename as
"{path}.tmp.{os.getpid()}". os.getpid() is constant for the life of a
process, so it only ever distinguishes concurrent writers that live in
different OS processes. Odysseus runs as a single long-lived process
per container, so two concurrent writers to the same path (e.g. two
request handlers racing a settings save) always compute the identical
temp path. Whichever finishes os.replace() first removes the shared
tmp file out from under the other, which then raises FileNotFoundError
on its own os.replace() instead of landing its write.
Fix: derive the temp suffix from uuid4() instead of the PID, so every
call gets a distinct temp path regardless of process/thread identity.
routes/prefs_routes.py's _save() had an independent, hand-rolled copy
of the exact same PID-suffix logic (not the shared core.atomic_io
helper other routes already use, e.g. routes/auth_routes.py) with the
same bug. Replaced it with a call to atomic_write_json.
Fixes #5596
Co-authored-by: Alexandre Teixeira <111787685+alteixeira20@users.noreply.github.com>
This commit is contained in:
+8
-4
@@ -15,17 +15,21 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
|
import uuid
|
||||||
from typing import Any, Optional
|
from typing import Any, Optional
|
||||||
|
|
||||||
|
|
||||||
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
def atomic_write_json(path: str, data: Any, *, indent: Optional[int] = None) -> None:
|
||||||
"""Atomically persist `data` as JSON at `path`.
|
"""Atomically persist `data` as JSON at `path`.
|
||||||
|
|
||||||
The temp file uses the live PID as a suffix so two processes saving the
|
The temp file uses a random suffix so two concurrent writers saving the
|
||||||
same file (e.g. unit tests) don't collide on the rename target.
|
same file don't collide on the rename target. A PID suffix does not do
|
||||||
|
this: the PID is constant for the life of a process, so two writers on
|
||||||
|
the same path within one process (or one single-process container, where
|
||||||
|
the PID never changes at all) still race for the same temp file.
|
||||||
"""
|
"""
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{os.getpid()}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
json.dump(data, f, indent=indent)
|
json.dump(data, f, indent=indent)
|
||||||
f.flush()
|
f.flush()
|
||||||
@@ -37,7 +41,7 @@ def atomic_write_text(path: str, text: str) -> None:
|
|||||||
if not isinstance(text, str):
|
if not isinstance(text, str):
|
||||||
raise TypeError("atomic_write_text expects a string")
|
raise TypeError("atomic_write_text expects a string")
|
||||||
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
|
||||||
tmp = f"{path}.tmp.{os.getpid()}"
|
tmp = f"{path}.tmp.{uuid.uuid4().hex}"
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
with open(tmp, "w", encoding="utf-8") as f:
|
||||||
f.write(text)
|
f.write(text)
|
||||||
f.flush()
|
f.flush()
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""User preferences API — per-user key/value store backed by a JSON file."""
|
"""User preferences API — per-user key/value store backed by a JSON file."""
|
||||||
import json
|
import json
|
||||||
import os
|
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, Request
|
||||||
|
from core.atomic_io import atomic_write_json
|
||||||
from src.auth_helpers import get_current_user
|
from src.auth_helpers import get_current_user
|
||||||
from src.constants import USER_PREFS_FILE
|
from src.constants import USER_PREFS_FILE
|
||||||
|
|
||||||
@@ -20,13 +20,7 @@ def _load():
|
|||||||
|
|
||||||
|
|
||||||
def _save(prefs):
|
def _save(prefs):
|
||||||
os.makedirs(os.path.dirname(PREFS_FILE) or ".", exist_ok=True)
|
atomic_write_json(PREFS_FILE, prefs, indent=2)
|
||||||
tmp = f"{PREFS_FILE}.tmp.{os.getpid()}"
|
|
||||||
with open(tmp, "w", encoding="utf-8") as f:
|
|
||||||
json.dump(prefs, f, indent=2)
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
os.replace(tmp, PREFS_FILE)
|
|
||||||
|
|
||||||
|
|
||||||
def _load_for_user(user: Optional[str] = None) -> dict:
|
def _load_for_user(user: Optional[str] = None) -> dict:
|
||||||
|
|||||||
+44
-6
@@ -1,17 +1,19 @@
|
|||||||
"""Tests for ``core.atomic_io`` durability and crash-safety behavior.
|
"""Tests for ``core.atomic_io`` durability and crash-safety behavior.
|
||||||
|
|
||||||
``core.atomic_io`` provides ``atomic_write_json`` and ``atomic_write_text``.
|
``core.atomic_io`` provides ``atomic_write_json`` and ``atomic_write_text``.
|
||||||
Both write to a sibling ``.tmp.<pid>`` file, ``fsync`` it, then ``os.replace``
|
Both write to a sibling ``.tmp.<random>`` file, ``fsync`` it, then
|
||||||
into place so a crash mid-write leaves the previous good copy untouched rather
|
``os.replace`` into place so a crash mid-write leaves the previous good copy
|
||||||
than a truncated/empty file.
|
untouched rather than a truncated/empty file.
|
||||||
|
|
||||||
These tests cover the happy path (round-trip, indent, parent-dir creation,
|
These tests cover the happy path (round-trip, indent, parent-dir creation,
|
||||||
full overwrite, no leftover tmp) and the two failure paths the implementation
|
full overwrite, no leftover tmp), the two failure paths the implementation
|
||||||
guarantees: the target file is preserved when serialization fails before the
|
guarantees (the target file is preserved when serialization fails before the
|
||||||
replace, and when ``os.replace`` itself fails.
|
replace, and when ``os.replace`` itself fails), and that two concurrent
|
||||||
|
writers to the same path don't collide on the same temp file.
|
||||||
"""
|
"""
|
||||||
import importlib.util
|
import importlib.util
|
||||||
import json
|
import json
|
||||||
|
import threading
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -84,6 +86,42 @@ def test_atomic_write_json_leaves_no_tmp_file(tmp_path):
|
|||||||
assert _tmp_siblings(tmp_path, "data.json") == []
|
assert _tmp_siblings(tmp_path, "data.json") == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_atomic_write_json_concurrent_writers_do_not_collide(tmp_path):
|
||||||
|
# Both writers run in this same process, so a PID-based tmp suffix is
|
||||||
|
# identical for both: whichever writer finishes first unlinks the tmp
|
||||||
|
# file (via os.replace) out from under the other, which then raises
|
||||||
|
# FileNotFoundError on its own os.replace instead of landing its write.
|
||||||
|
target = tmp_path / "settings.json"
|
||||||
|
orig_dump = json.dump
|
||||||
|
barrier = threading.Barrier(2)
|
||||||
|
errors = []
|
||||||
|
|
||||||
|
def slow_dump(obj, fp, **kwargs):
|
||||||
|
orig_dump(obj, fp, **kwargs)
|
||||||
|
fp.flush()
|
||||||
|
barrier.wait()
|
||||||
|
|
||||||
|
def write(payload):
|
||||||
|
try:
|
||||||
|
atomic_write_json(str(target), payload)
|
||||||
|
except Exception as exc: # noqa: BLE001 - captured for the assertion below
|
||||||
|
errors.append(exc)
|
||||||
|
|
||||||
|
json.dump = slow_dump
|
||||||
|
try:
|
||||||
|
t1 = threading.Thread(target=write, args=({"writer": "A"},))
|
||||||
|
t2 = threading.Thread(target=write, args=({"writer": "B"},))
|
||||||
|
t1.start()
|
||||||
|
t2.start()
|
||||||
|
t1.join()
|
||||||
|
t2.join()
|
||||||
|
finally:
|
||||||
|
json.dump = orig_dump
|
||||||
|
|
||||||
|
assert errors == []
|
||||||
|
assert json.loads(target.read_text(encoding="utf-8"))["writer"] in ("A", "B")
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# atomic_write_json — failure path: target preserved on serialization error.
|
# atomic_write_json — failure path: target preserved on serialization error.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
import routes.prefs_routes as prefs_routes
|
import routes.prefs_routes as prefs_routes
|
||||||
|
from core import atomic_io
|
||||||
|
|
||||||
|
|
||||||
def test_save_replaces_prefs_file_atomically(monkeypatch, tmp_path):
|
def test_save_replaces_prefs_file_atomically(monkeypatch, tmp_path):
|
||||||
calls = []
|
calls = []
|
||||||
real_replace = prefs_routes.os.replace
|
real_replace = atomic_io.os.replace
|
||||||
|
|
||||||
def fake_replace(src, dst):
|
def fake_replace(src, dst):
|
||||||
calls.append((src, dst))
|
calls.append((src, dst))
|
||||||
@@ -13,7 +14,7 @@ def test_save_replaces_prefs_file_atomically(monkeypatch, tmp_path):
|
|||||||
|
|
||||||
prefs_file = tmp_path / "data" / "user_prefs.json"
|
prefs_file = tmp_path / "data" / "user_prefs.json"
|
||||||
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
monkeypatch.setattr(prefs_routes, "PREFS_FILE", str(prefs_file))
|
||||||
monkeypatch.setattr(prefs_routes.os, "replace", fake_replace)
|
monkeypatch.setattr(atomic_io.os, "replace", fake_replace)
|
||||||
|
|
||||||
prefs_routes._save({"theme": "dark"})
|
prefs_routes._save({"theme": "dark"})
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user