From 9d54d8394bf270355020673cb68c75968c234535 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 29 Sep 2026 02:22:42 +0800 Subject: [PATCH] fix(logging): release spdlog file handles before drop --- backtrader/utils/log_message.py | 32 ++++++++++--- tests/unit/utils/test_logging_split_files.py | 47 ++++++++++++++++---- 2 files changed, 66 insertions(+), 13 deletions(-) diff --git a/backtrader/utils/log_message.py b/backtrader/utils/log_message.py index 7aad1fb4..abdc3ab5 100644 --- a/backtrader/utils/log_message.py +++ b/backtrader/utils/log_message.py @@ -797,6 +797,26 @@ def close(self): super().close() +def _release_spdlog_logger(spdlog_mod, logger, name): + """Flush, close and unregister one spdlog file logger. + + ``spdlog.drop`` unregisters the logger but does not release its file + handle on the Windows binding. Closing first keeps temporary probes, + rollover and retention cleanup portable across supported platforms. + """ + try: + flush = getattr(logger, "flush", None) + if callable(flush): + flush() + finally: + try: + close = getattr(logger, "close", None) + if callable(close): + close() + finally: + spdlog_mod.drop(name) + + def _detect_spdlog(): """Return the ``spdlog`` module if usable, else ``None``. @@ -812,6 +832,7 @@ def _detect_spdlog(): with tempfile.TemporaryDirectory() as tmp: probe_name = f"bt_backend_probe.{os.getpid()}.{time.monotonic_ns()}" + probe = None try: probe = spdlog.FileLogger(probe_name, os.path.join(tmp, "probe.log"), truncate=True) probe.set_pattern("%v") @@ -823,7 +844,10 @@ def _detect_spdlog(): if "probe" not in stream.read(): return None finally: - spdlog.drop(probe_name) + if probe is None: + spdlog.drop(probe_name) + else: + _release_spdlog_logger(spdlog, probe, probe_name) return spdlog except Exception: return None @@ -913,8 +937,7 @@ def emit(self, record): def _rotate(self, today): path = self._path_for(today) if self._owner_pid == os.getpid(): - self._logger.flush() - self._mod.drop(self._unique) + _release_spdlog_logger(self._mod, self._logger, self._unique) self._unique = f"bt.{self._level_name}.{os.getpid()}.{id(self):x}" self._logger = self._open_logger(path) self._path = path @@ -942,8 +965,7 @@ def close(self): try: if getattr(self, "_owner_pid", None) == os.getpid() and not self._bt_closed: try: - self._logger.flush() - self._mod.drop(self._unique) + _release_spdlog_logger(self._mod, self._logger, self._unique) except Exception: # nosec B110 # Best-effort cleanup only. pass diff --git a/tests/unit/utils/test_logging_split_files.py b/tests/unit/utils/test_logging_split_files.py index bafbef06..d34b2ec8 100644 --- a/tests/unit/utils/test_logging_split_files.py +++ b/tests/unit/utils/test_logging_split_files.py @@ -26,12 +26,10 @@ import backtrader as bt from backtrader.utils import log_message -try: - import spdlog # noqa: F401 - - HAS_SPDLOG = True -except ImportError: - HAS_SPDLOG = False +# An importable package can still lack a usable native extension or fail its +# file-write smoke probe. Keep the optional test matrix aligned with the same +# availability contract used by ``backend=\"auto\"`` at runtime. +HAS_SPDLOG = log_message._detect_spdlog() is not None BACKENDS = [ "stdlib", @@ -79,6 +77,39 @@ def _managed_handlers(logger): ] +def _symlink_or_skip(link, target): + """Create a directory symlink or skip when Windows policy forbids it.""" + + try: + link.symlink_to(target, target_is_directory=True) + except OSError as exc: + if os.name == "nt" and getattr(exc, "winerror", None) == 1314: + pytest.skip( + "Windows symlink creation requires Developer Mode or SeCreateSymbolicLinkPrivilege" + ) + raise + + +def test_release_spdlog_logger_closes_file_before_unregistering(): + """Windows file handles must be closed before ``spdlog.drop`` unregisters them.""" + events = [] + + class FakeLogger: + def flush(self): + events.append("flush") + + def close(self): + events.append("close") + + class FakeSpdlog: + @staticmethod + def drop(name): + events.append(("drop", name)) + + log_message._release_spdlog_logger(FakeSpdlog(), FakeLogger(), "probe") + assert events == ["flush", "close", ("drop", "probe")] + + # --------------------------------------------------------------------------- # layout + routing (parametrized over available backends) # --------------------------------------------------------------------------- @@ -503,7 +534,7 @@ def test_retention_does_not_follow_symlink_or_delete_date_file(tmp_path): outside = tmp_path / "outside" outside.mkdir() (outside / "keep.txt").write_text("keep") - (script / "2000_01_01").symlink_to(outside, target_is_directory=True) + _symlink_or_skip(script / "2000_01_01", outside) (script / "2000_01_02").write_text("date-named file") _configure(tmp_path) assert (script / "2000_01_01").is_symlink() @@ -516,7 +547,7 @@ def test_script_symlink_is_rejected_without_cleanup(tmp_path): script.parent.mkdir() outside = tmp_path / "outside" outside.mkdir() - script.symlink_to(outside, target_is_directory=True) + _symlink_or_skip(script, outside) with pytest.raises(ValueError, match="symbolic"): _configure(tmp_path)