fix(logging): fall back from concurrent-log-handler when portalocker is dead

A Windows bundle whose venv never processed pywin32.pth (see the launcher
fix) fails 'import pywintypes' with ModuleNotFoundError; portalocker 3.x
has no msvcrt fallback, so CLH retries lock() 20x and raises 'Cannot
acquire lock after 20 attempts' — which handleError suppressed entirely.
Result: zero file logging, silently, on every affected install.

* probe portalocker once at import (scratch lock/unlock) and fall back to
  stdlib RotatingFileHandler when it fails; the fallback disables rollover
  (multi-process appends make Windows renames fail with WinError 32, the
  #44873 trap CLH exists to avoid) and setup_logging() warns once
* the suppressed CLH lock timeout now warns once through the logging
  system instead of vanishing
This commit is contained in:
ethernet
2026-09-11 16:45:38 -04:00
parent f67a3b59db
commit 26c39cbb27
2 changed files with 186 additions and 7 deletions

View File

@@ -28,9 +28,21 @@ from typing import Optional, Sequence
# relies on stdlib's exact ``_open()``/``doRollover()`` lifecycle for the
# 0660 chmod and eager file creation; CLH opens lazily and rotates differently.
if sys.platform == "win32":
from concurrent_log_handler import ( # noqa: E402
ConcurrentRotatingFileHandler as RotatingFileHandler,
)
if _portalocker_probe():
from concurrent_log_handler import ( # noqa: E402
ConcurrentRotatingFileHandler as RotatingFileHandler,
)
else:
# portalocker cannot take a lock on this box (typical cause: a sealed
# bundle whose venv never processed pywin32.pth, so `import pywintypes`
# fails and portalocker's Win32Locker has no msvcrt fallback). CLH
# would silently drop every record through the suppressed lock-timeout
# below; fall back to stdlib rotation instead. Rollover is disabled in
# the fallback: multi-process appends make Windows renames fail with
# WinError 32, the exact #44873 trap CLH exists to avoid.
from logging.handlers import RotatingFileHandler # noqa: E402
_WINDOWS_CLH_FALLBACK = True
else:
from logging.handlers import RotatingFileHandler # noqa: E402
@@ -40,6 +52,50 @@ from hermes_constants import get_config_path, get_hermes_home, mkdir_under_herme
# setup_logging() is idempotent: a second call is a no-op unless ``force=True``.
_logging_initialized = False
# True only when CLH was rejected at import because portalocker cannot take a
# lock on this Windows box; file handlers then use stdlib rotation (rollover
# disabled — see the module-header comment) and setup_logging() warns once.
_WINDOWS_CLH_FALLBACK = False
_WINDOWS_CLH_FALLBACK_REASON = ""
_fallback_warned = False
def _portalocker_probe() -> bool:
"""Return True when portalocker can actually take a lock on this box.
concurrent-log-handler locks every write through portalocker, which on
Windows instantiates Win32Locker and imports pywintypes. Bundled payloads
have shipped with that import broken (the venv's .pth files were never
processed), and portalocker 3.x gives no msvcrt fallback — every emit then
dies with the ImportError, CLH retries 20x, and the suppressed "Cannot
acquire lock" RuntimeError below hides it completely. Probe a scratch file
once at import so we can fall back to stdlib rotation instead of silently
dropping every record. No-op (True) off Windows, where stdlib is in use.
"""
global _WINDOWS_CLH_FALLBACK_REASON
if sys.platform != "win32":
return True
try:
import portalocker
import tempfile
except Exception as exc:
_WINDOWS_CLH_FALLBACK_REASON = repr(exc)
return False
fd, path = tempfile.mkstemp(prefix="hermes-portalocker-")
try:
with os.fdopen(fd, "r+") as stream:
portalocker.lock(stream, portalocker.LOCK_EX)
portalocker.unlock(stream)
except Exception as exc:
_WINDOWS_CLH_FALLBACK_REASON = repr(exc)
return False
finally:
try:
os.unlink(path)
except OSError:
pass
return True
# Thread-local per-conversation session context.
_session_context = threading.local()
@@ -81,6 +137,33 @@ def _is_windows_concurrent_log_lock_timeout(exc: BaseException | None) -> bool:
)
_windows_lock_timeout_warned = False
_windows_lock_timeout_warn_lock = threading.Lock()
def _warn_windows_lock_timeout_once() -> None:
"""Report a suppressed CLH lock timeout exactly once per process.
Every emit after the first failure raises the same RuntimeError, so the
warning must be one-shot or it would spam errors.log as badly as the
stderr noise it replaces. CLH does not chain the underlying cause (the
RuntimeError is raised outside the except, from the retry loop's else
clause), so the message cannot include it; a constantly repeating timeout
means file logging is degraded — the startup portalocker probe in this
module should have caught a dead portalocker and fallen back already.
"""
global _windows_lock_timeout_warned
with _windows_lock_timeout_warn_lock:
if _windows_lock_timeout_warned:
return
_windows_lock_timeout_warned = True
logging.getLogger("hermes_logging").warning(
"concurrent-log-handler timed out acquiring the cross-process log "
"lock; this and later records were dropped (the Desktop slash-worker "
"surface stays clean, but file logging is degraded)."
)
# Third-party loggers that are noisy at DEBUG/INFO level.
_NOISY_LOGGERS = (
"openai", "openai._base_client", "httpx", "httpcore", "asyncio", "hpack", "hpack.hpack",
@@ -177,6 +260,7 @@ def setup_logging(
``gateway.log`` and ``mode="gui"`` adds ``gui.log``.
"""
global _logging_initialized
global _fallback_warned
home = hermes_home or get_hermes_home()
log_dir = mkdir_under_hermes_home(home / "logs")
cfg_level, cfg_max_size, cfg_backup = _read_logging_config()
@@ -206,6 +290,16 @@ def setup_logging(
log_filter=_ComponentFilter(COMPONENT_PREFIXES[component]) if component else None,
)
if _WINDOWS_CLH_FALLBACK and not _fallback_warned:
# One-shot, and the file handlers above are already live, so this lands
# in errors.log/agent.log — the fallback must never be invisible again.
_fallback_warned = True
logging.getLogger("hermes_logging").warning(
"concurrent-log-handler unavailable on this Windows install (%s); "
"file logging fell back to stdlib rotation without rollover.",
_WINDOWS_CLH_FALLBACK_REASON or "portalocker probe failed",
)
if _logging_initialized and not force:
return log_dir
@@ -322,10 +416,13 @@ class _ManagedRotatingFileHandler(RotatingFileHandler):
CLH's ``emit()`` routes that RuntimeError here, so this is the single point to
silence it before stdlib prints to stderr (which the Desktop slash-worker
captures into chat output).
captures into chat output). Silencing is not silent: warn once through the
logging system so a wedged lock is visible in the logs instead of a black hole.
"""
if not _is_windows_concurrent_log_lock_timeout(sys.exc_info()[1]):
super().handleError(record)
if _is_windows_concurrent_log_lock_timeout(sys.exc_info()[1]):
_warn_windows_lock_timeout_once()
return
super().handleError(record)
def _open(self):
stream = super()._open()
@@ -345,6 +442,10 @@ def _new_file_handler(
) -> "_ManagedRotatingFileHandler":
"""Create the ``logs/`` directory and a configured ``_ManagedRotatingFileHandler``."""
mkdir_under_hermes_home(path.parent)
if _WINDOWS_CLH_FALLBACK:
# stdlib fallback: no rollover, or the file pins at the size threshold
# and every emit re-triggers the WinError 32 rename failure (#44873).
max_bytes, backup_count = 0, 0
handler = _ManagedRotatingFileHandler(
str(path), maxBytes=max_bytes, backupCount=backup_count, encoding="utf-8"
)

View File

@@ -441,7 +441,8 @@ class TestWindowsConcurrentLogLockTimeout:
RuntimeError raised in ``_do_lock()`` is caught *inside* CLH and routed
to ``handleError`` with the exception live in ``sys.exc_info()``. We
invoke ``handleError`` the same way CLH would and assert no traceback
reaches stderr (the slash-worker surface).
reaches stderr (the slash-worker surface) — but the suppression must
still surface once through the logging system, not stay a black hole.
Windows-only: the suppression is keyed on the real host, and only on
Windows is the base handler CLH at all — the fake platform gave us the
@@ -450,19 +451,96 @@ class TestWindowsConcurrentLogLockTimeout:
record = logger.makeRecord(
logger.name, logging.INFO, __file__, 0, "force rollover", (), None,
)
captured_warnings: list[logging.LogRecord] = []
class _Capture(logging.Handler):
def emit(self, record: logging.LogRecord) -> None:
captured_warnings.append(record)
listener = _Capture()
logging.getLogger("hermes_logging").addHandler(listener)
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(hermes_logging, "_windows_lock_timeout_warned", False)
try:
try:
raise RuntimeError("Cannot acquire lock after 20 attempts")
except RuntimeError:
handler.handleError(record)
try:
raise RuntimeError("Cannot acquire lock after 20 attempts")
except RuntimeError:
handler.handleError(record)
captured = capsys.readouterr()
assert "Cannot acquire lock after 20 attempts" not in captured.err
assert "--- Logging error ---" not in captured.err
# One-shot warning: the second suppressed emit must not re-warn.
assert len(captured_warnings) == 1
assert "concurrent-log-handler" in captured_warnings[0].getMessage()
finally:
monkeypatch.undo()
logging.getLogger("hermes_logging").removeHandler(listener)
logger.removeHandler(handler)
handler.close()
def test_lock_timeout_warning_is_one_shot(self, caplog):
"""The suppressed-timeout warning is exactly-once per process.
Every emit after the first CLH lock failure raises the same
RuntimeError, so warn-once is what keeps errors.log from being spammed
as badly as the stderr noise the suppression replaces."""
monkeypatch = pytest.MonkeyPatch()
monkeypatch.setattr(hermes_logging, "_windows_lock_timeout_warned", False)
try:
with caplog.at_level(logging.WARNING, logger="hermes_logging"):
hermes_logging._warn_windows_lock_timeout_once()
hermes_logging._warn_windows_lock_timeout_once()
finally:
monkeypatch.undo()
warnings = [r for r in caplog.records if r.levelno >= logging.WARNING]
assert len(warnings) == 1
assert "concurrent-log-handler" in warnings[0].getMessage()
def test_portalocker_probe_false_when_lock_raises(self, monkeypatch):
"""The import-time probe catches a dead portalocker (the sealed-bundle
pywintypes failure) instead of letting CLH drop records silently."""
class FakePortalocker:
LOCK_EX = 2
@staticmethod
def lock(f, flags):
raise ImportError("pywintypes is required for Win32Locker but not found")
@staticmethod
def unlock(f):
return None
monkeypatch.setitem(sys.modules, "portalocker", FakePortalocker)
monkeypatch.setattr(sys, "platform", "win32")
assert hermes_logging._portalocker_probe() is False
assert "pywintypes" in hermes_logging._WINDOWS_CLH_FALLBACK_REASON
def test_portalocker_probe_true_off_windows(self):
# Off Windows the probe is a no-op: stdlib rotation is already in use.
assert hermes_logging._portalocker_probe() is True
def test_fallback_handler_disables_rollover(self, tmp_path, monkeypatch):
"""The stdlib fallback must not roll over: multi-process append
handles make Windows renames fail with WinError 32, pinning the file
and spamming stderr on every emit (#44873)."""
monkeypatch.setattr(hermes_logging, "_WINDOWS_CLH_FALLBACK", True)
handler = hermes_logging._new_file_handler(
tmp_path / "agent.log", level=logging.INFO,
max_bytes=5 * 1024 * 1024, backup_count=3,
formatter=logging.Formatter("%(message)s"),
)
try:
assert handler.maxBytes == 0
assert handler.backupCount == 0
finally:
handler.close()
class TestReadLoggingConfig: