From f6d1dba32ae54cc8a0cbf6786a50870b4b30615c Mon Sep 17 00:00:00 2001 From: chengyongru Date: Wed, 1 Jul 2026 13:59:53 +0800 Subject: [PATCH] fix(cron): tolerate unsupported directory fsync --- nanobot/cron/service.py | 9 +++++++-- tests/cron/test_cron_persistence.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/nanobot/cron/service.py b/nanobot/cron/service.py index b42373cc..d28882d7 100644 --- a/nanobot/cron/service.py +++ b/nanobot/cron/service.py @@ -1,6 +1,7 @@ """Cron service for scheduling agent tasks.""" import asyncio +import errno import json import os import time @@ -456,11 +457,15 @@ class CronService: os.replace(tmp_path, path) # fsync the parent directory so the rename itself is durable. # Skip on Windows where opening a directory raises PermissionError; - # NTFS journals metadata synchronously so this is a no-op there. + # some shared filesystems reject directory fsync with EINVAL. with suppress(PermissionError): fd = os.open(str(path.parent), os.O_RDONLY) try: - os.fsync(fd) + try: + os.fsync(fd) + except OSError as exc: + if exc.errno != errno.EINVAL: + raise finally: os.close(fd) except BaseException: diff --git a/tests/cron/test_cron_persistence.py b/tests/cron/test_cron_persistence.py index 863ff0d9..e72ed86f 100644 --- a/tests/cron/test_cron_persistence.py +++ b/tests/cron/test_cron_persistence.py @@ -8,6 +8,7 @@ jobs.json + don't silently overwrite corrupt store``. from __future__ import annotations +import errno import json from pathlib import Path from typing import Callable @@ -107,6 +108,34 @@ def test_save_store_failure_does_not_corrupt_existing_file( assert store_path.read_bytes() == original +def test_atomic_write_ignores_unsupported_directory_fsync( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """vboxsf-like filesystems can open directories but reject directory fsync.""" + store_path = tmp_path / "cron" / "jobs.json" + dir_fd = 987654 + + def fake_open(path: str, flags: int) -> int: + assert Path(path) == store_path.parent + return dir_fd + + def fake_fsync(fd: int) -> None: + if fd == dir_fd: + raise OSError(errno.EINVAL, "Invalid argument") + + def fake_close(fd: int) -> None: + assert fd == dir_fd + + monkeypatch.setattr("os.open", fake_open) + monkeypatch.setattr("os.fsync", fake_fsync) + monkeypatch.setattr("os.close", fake_close) + + CronService._atomic_write(store_path, '{"version": 1, "jobs": []}') + + assert store_path.read_text(encoding="utf-8") == '{"version": 1, "jobs": []}' + assert list(store_path.parent.glob("*.tmp")) == [] + + def test_load_jobs_preserves_corrupt_store_and_returns_none( tmp_path: Path, ) -> None: