From 8b645135bc7b56215b9cc5b7ae7db286f93f8fa9 Mon Sep 17 00:00:00 2001 From: yorkhellen Date: Thu, 2 Jul 2026 21:40:49 +0800 Subject: [PATCH] fix(pairing): restore durable atomic writes --- nanobot/utils/helpers.py | 12 +++++++++- tests/utils/test_helpers.py | 46 ++++++++++++++++++++++++++++++++++++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/nanobot/utils/helpers.py b/nanobot/utils/helpers.py index 36270fa2..a8681b8f 100644 --- a/nanobot/utils/helpers.py +++ b/nanobot/utils/helpers.py @@ -2,6 +2,7 @@ import base64 import json +import os import re import shutil import time @@ -447,8 +448,17 @@ def _cleanup_tool_result_buckets(root: Path, current_bucket: Path) -> None: def _write_text_atomic(path: Path, content: str) -> None: tmp = path.with_name(f".{path.name}.{uuid.uuid4().hex}.tmp") try: - tmp.write_text(content, encoding="utf-8") + with open(tmp, "w", encoding="utf-8") as f: + f.write(content) + f.flush() + os.fsync(f.fileno()) tmp.replace(path) + with suppress(OSError, NotImplementedError): + dfd = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(dfd) + finally: + os.close(dfd) finally: if tmp.exists(): tmp.unlink(missing_ok=True) diff --git a/tests/utils/test_helpers.py b/tests/utils/test_helpers.py index bd6493df..483eb77e 100644 --- a/tests/utils/test_helpers.py +++ b/tests/utils/test_helpers.py @@ -1,6 +1,9 @@ +from pathlib import Path + import tiktoken -from nanobot.utils.helpers import split_message, truncate_text_to_tokens +from nanobot.utils import helpers +from nanobot.utils.helpers import _write_text_atomic, split_message, truncate_text_to_tokens def test_split_message_no_code_blocks_unchanged(): @@ -31,3 +34,44 @@ def test_truncate_text_to_tokens_non_positive_budget_returns_text(): text = "anything" assert truncate_text_to_tokens(text, 0) == text + + +def test_write_text_atomic_fsyncs_file_and_parent_directory( + tmp_path: Path, monkeypatch +) -> None: + target = tmp_path / "pairing.json" + fsync_calls: list[int] = [] + closed_fds: list[int] = [] + + def fake_fsync(fd: int) -> None: + fsync_calls.append(fd) + + monkeypatch.setattr(helpers.os, "fsync", fake_fsync) + monkeypatch.setattr(helpers.os, "open", lambda path, flags: 12345) + monkeypatch.setattr(helpers.os, "close", lambda fd: closed_fds.append(fd)) + + _write_text_atomic(target, '{"approved": {}}') + + assert target.read_text(encoding="utf-8") == '{"approved": {}}' + assert len(fsync_calls) == 2 + assert fsync_calls[0] != 12345 + assert fsync_calls[1] == 12345 + assert closed_fds == [12345] + + +def test_write_text_atomic_keeps_file_when_directory_fsync_is_unsupported( + tmp_path: Path, monkeypatch +) -> None: + target = tmp_path / "pairing.json" + fsync_calls: list[int] = [] + + def fake_open(path, flags): + raise OSError("directory fsync unsupported") + + monkeypatch.setattr(helpers.os, "fsync", lambda fd: fsync_calls.append(fd)) + monkeypatch.setattr(helpers.os, "open", fake_open) + + _write_text_atomic(target, '{"pending": {}}') + + assert target.read_text(encoding="utf-8") == '{"pending": {}}' + assert len(fsync_calls) == 1