Files
hermes-webui/tests/test_atomic_writer_fsync.py
T
d6e5dc302d Release Batch D: terminal resource-leak fix + fsync atomic writers (#5835, #5843) (#5848)
Phase 2 backend reliability/data-integrity batch. Each byte-verified vs PR head,
Codex SAFE, 20 own + 355 regression tests pass, disjoint files.

- #5835 (ai-ag2026): stop leaking fd + reader thread + shell on client/shell exit (#4633 family)
- #5843 (ai-ag2026): fsync atomic JSON writers (oauth/passkeys/discoverability) + thread-safe temp

Co-authored-by: t <a@b>
Co-authored-by: ai-ag2026 <ai-ag2026@users.noreply.github.com>
2026-07-09 10:54:06 -07:00

106 lines
3.8 KiB
Python

"""Regression tests — atomic JSON writers fsync and use collision-safe temps.
Three writers wrote to a temp file and os.replace'd it into place but never
fsynced, so a power loss between the write and the physical flush could leave a
zero-length/garbage file where durable JSON should be:
- session_discoverability._atomic_write_json (also used a pid-only temp name,
which collides between two request threads of the same ThreadingHTTPServer
process writing the same _index.json),
- passkeys._atomic_write_json (credentials),
- oauth._write_auth_json (access/refresh tokens).
Each now fsyncs before the rename; session_discoverability additionally puts the
thread id in the temp name and cleans it up on failure. These pin the fsync, the
per-thread temp name, and that content/permissions are unchanged.
"""
import json
import os
from pathlib import Path
import pytest
def _spy_fsync(monkeypatch, module):
calls = []
real = os.fsync
monkeypatch.setattr(module.os, "fsync", lambda fd: (calls.append(fd), real(fd))[1])
return calls
def test_session_discoverability_writer_fsyncs_and_roundtrips(tmp_path, monkeypatch):
import api.session_discoverability as sd
calls = _spy_fsync(monkeypatch, sd)
path = tmp_path / "_index.json"
payload = [{"session_id": "s1", "title": "Grüße 🌍"}]
sd._atomic_write_json(path, payload)
assert calls, "atomic write did not fsync before rename"
assert json.loads(path.read_text(encoding="utf-8")) == payload
assert [p.name for p in tmp_path.iterdir()] == ["_index.json"] # no temp debris
def test_session_discoverability_temp_name_is_thread_unique(monkeypatch):
"""Two threads (same pid) must not collide on one temp path."""
import api.session_discoverability as sd
names = set()
real_replace = os.replace
def capture(src, dst):
names.add(Path(src).name)
return real_replace(src, dst)
monkeypatch.setattr(sd.os, "replace", capture)
import tempfile as _tf
d = Path(_tf.mkdtemp())
idents = iter([1111, 2222])
monkeypatch.setattr(sd.threading, "get_ident", lambda: next(idents))
sd._atomic_write_json(d / "_index.json", [{"a": 1}])
sd._atomic_write_json(d / "_index.json", [{"a": 2}])
assert len(names) == 2, f"temp names collided across threads: {names}"
def test_session_discoverability_failed_write_leaves_no_debris(tmp_path, monkeypatch):
import api.session_discoverability as sd
original = tmp_path / "_index.json"
original.write_text('["keep"]', encoding="utf-8")
def boom(src, dst):
raise RuntimeError("simulated crash before rename")
monkeypatch.setattr(sd.os, "replace", boom)
with pytest.raises(RuntimeError):
sd._atomic_write_json(original, [{"new": True}])
assert original.read_text(encoding="utf-8") == '["keep"]' # original intact
assert [p.name for p in tmp_path.iterdir()] == ["_index.json"] # temp cleaned up
def test_passkeys_writer_fsyncs_and_keeps_0600(tmp_path, monkeypatch):
import api.passkeys as passkeys
calls = _spy_fsync(monkeypatch, passkeys)
path = tmp_path / "passkeys.json"
passkeys._atomic_write_json(path, [{"id": "cred-1"}])
assert calls, "passkeys write did not fsync"
assert json.loads(path.read_text(encoding="utf-8")) == [{"id": "cred-1"}]
assert (os.stat(path).st_mode & 0o777) == 0o600 # owner-only preserved
def test_oauth_writer_fsyncs_and_keeps_0600(tmp_path, monkeypatch):
import api.oauth as oauth
calls = _spy_fsync(monkeypatch, oauth)
path = tmp_path / "auth.json"
oauth._write_auth_json({"access_token": "x"}, auth_path=path)
assert calls, "oauth auth.json write did not fsync"
assert json.loads(path.read_text(encoding="utf-8")) == {"access_token": "x"}
assert (os.stat(path).st_mode & 0o777) == 0o600 # owner-only preserved