From 68bb0e042beca9caba2e1e43eba1017f29242607 Mon Sep 17 00:00:00 2001 From: inix-x Date: Fri, 3 Jul 2026 23:09:48 +0800 Subject: [PATCH 1/2] fix(proxy): fsync savings dir after atomic rename _save_locked fsynced the temp file's bytes but never the directory entry the rename created, so the most recent proxy_savings.json write could be lost on power-loss. fsync the parent directory after the replace. Best-effort, POSIX-only, no-op on Windows. --- CHANGELOG.md | 6 +++ headroom/proxy/savings_tracker.py | 16 ++++++++ tests/test_proxy_savings_history.py | 62 +++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd36c2dfd..2a8bd9931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Unreleased ### Fixed +- **proxy:** the savings store now fsyncs its parent directory after the + atomic rename, so the most recent `proxy_savings.json` write survives a + power-loss or crash. `_save_locked` fsynced the temp file's contents but + never the directory entry the rename created, leaving the rename itself + non-durable on POSIX. Best-effort — a no-op on Windows and virtual + filesystems where directory fsync is unsupported. - `headroom learn` now honors `CLAUDE_CONFIG_DIR`. It resolved the Claude config directory as `~/.claude` and wrote global memory to `~/.claude/CLAUDE.md`, so users who relocate their Claude config via that diff --git a/headroom/proxy/savings_tracker.py b/headroom/proxy/savings_tracker.py index a3549db9e..ac0a16fcf 100644 --- a/headroom/proxy/savings_tracker.py +++ b/headroom/proxy/savings_tracker.py @@ -1018,6 +1018,22 @@ def _save_locked(self) -> None: except OSError: pass raise + + # Persist the rename itself — the fsync above flushed the file's + # bytes, but the directory entry the rename created isn't durable + # until the parent directory is fsynced too (POSIX). Best-effort — + # directory fsync is unsupported on Windows and some virtual + # filesystems; the file and atomic rename are already durable, so a + # failure here only forgoes the last-save crash guarantee, never + # correctness. (FP4b) + try: + dir_fd = os.open(self._path.parent, os.O_RDONLY) + try: + os.fsync(dir_fd) + finally: + os.close(dir_fd) + except OSError: + pass except OSError as e: logger.warning("Failed to save savings history to %s: %s", self._path, e) diff --git a/tests/test_proxy_savings_history.py b/tests/test_proxy_savings_history.py index f6b258171..6c06e6ad4 100644 --- a/tests/test_proxy_savings_history.py +++ b/tests/test_proxy_savings_history.py @@ -4,6 +4,8 @@ import asyncio import json +import os +import stat from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace @@ -290,6 +292,66 @@ def flock(self, _fh, operation: int) -> None: assert persisted["lifetime"]["tokens_saved"] == 15 +def test_savings_tracker_save_fsyncs_parent_directory(tmp_path, monkeypatch): + # The file fsync persists contents, but the rename isn't durable until the + # parent directory is fsynced too — without it a crash can drop the last + # save. Assert a directory fd is fsynced on save. (FP4b) + path = tmp_path / "proxy_savings.json" + tracker = SavingsTracker(path=str(path)) + + real_fsync = os.fsync + dir_fds_synced: list[int] = [] + + def _spy_fsync(fd: int) -> None: + try: + if stat.S_ISDIR(os.fstat(fd).st_mode): + dir_fds_synced.append(fd) + except OSError: + pass + real_fsync(fd) + + monkeypatch.setattr(savings_tracker_module.os, "fsync", _spy_fsync) + + tracker.record_request( + model="gpt-4o", + input_tokens=120, + tokens_saved=10, + timestamp="2026-03-27T09:00:00Z", + ) + + # Parent directory fsynced (rename durable) and the save still landed intact. + assert dir_fds_synced, "parent directory was never fsynced after os.replace" + persisted = json.loads(path.read_text(encoding="utf-8")) + assert persisted["lifetime"]["tokens_saved"] == 10 + + +def test_savings_tracker_save_survives_directory_fsync_failure(tmp_path, monkeypatch): + # On Windows and some virtual filesystems the directory fsync fails — the + # save must still complete because the file and atomic rename are already + # durable on their own. (FP4b) + path = tmp_path / "proxy_savings.json" + tracker = SavingsTracker(path=str(path)) + + real_open = os.open + + def _failing_open(target, *args, **kwargs): + if str(target) == str(path.parent): + raise OSError("directory fsync unsupported") + return real_open(target, *args, **kwargs) + + monkeypatch.setattr(savings_tracker_module.os, "open", _failing_open) + + tracker.record_request( + model="gpt-4o", + input_tokens=120, + tokens_saved=10, + timestamp="2026-03-27T09:00:00Z", + ) + + persisted = json.loads(path.read_text(encoding="utf-8")) + assert persisted["lifetime"]["tokens_saved"] == 10 + + def test_litellm_resolution_and_savings_estimation_fallbacks(monkeypatch): def fake_cost_per_token(*, model, prompt_tokens, completion_tokens): if model in {"gpt-4o", "anthropic/claude-sonnet-4-6"}: From af49462c68036d77fdf30e5d007e053fb8b56415 Mon Sep 17 00:00:00 2001 From: inix-x Date: Sat, 4 Jul 2026 18:14:36 +0800 Subject: [PATCH 2/2] style(tests): sort savings-history imports Merge into fix/savings-tracker-dir-fsync placed import math after stat, tripping ruff I001 (ruff==0.15.17) in the CI lint job. --- tests/test_proxy_savings_history.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_proxy_savings_history.py b/tests/test_proxy_savings_history.py index 5701de3fc..e9460966f 100644 --- a/tests/test_proxy_savings_history.py +++ b/tests/test_proxy_savings_history.py @@ -4,9 +4,9 @@ import asyncio import json +import math import os import stat -import math from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace