Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions cli-config.yaml.example
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,16 @@ database:
# wal_autocheckpoint: 1000 # pages between automatic checkpoints
# journal_size_limit: 67108864 # cap the WAL/journal file size in bytes

# =============================================================================
# Runtime Limits
# =============================================================================
# Long-running Hermes server processes raise their RLIMIT_NOFILE soft limit to
# this value when the operating system permits it. The value is clamped to the
# hard limit and never lowers an already higher soft limit. Set to 0, false, or
# null to disable the adjustment. Default: 4096.
runtime:
nofile_soft_limit: 4096

# =============================================================================
# Model Configuration
# =============================================================================
Expand Down
4 changes: 4 additions & 0 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -26045,6 +26045,10 @@ async def start_gateway(config: Optional[GatewayConfig] = None, replace: bool =
Useful for systemd services to avoid restart-loop deadlocks
when the previous process hasn't fully exited yet.
"""
from hermes_cli.resource_limits import apply_nofile_soft_limit

apply_nofile_soft_limit()

# Snapshot the checkout revision now, while sys.modules still matches disk,
# so a later `git pull` under this long-lived process can be detected (and
# risky work like model switching refused) instead of crashing on a stale
Expand Down
5 changes: 5 additions & 0 deletions hermes_cli/config_defaults.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@
"wal_autocheckpoint": None,
"journal_size_limit": None,
},
# Soft file-descriptor limit for long-running Hermes server processes.
# Clamped to the OS hard limit; 0/false/null disables the adjustment.
"runtime": {
"nofile_soft_limit": 4096,
},
# Global active chat session cap across CLI, TUI/dashboard, and messaging.
# None/0 = unbounded.
"max_concurrent_sessions": None,
Expand Down
9 changes: 9 additions & 0 deletions hermes_cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -10319,6 +10319,15 @@ def cmd_dashboard(args):
else:
os.execvpe(sys.executable, reexec_argv, env)

# Apply the final process/profile policy after dashboard routing, but before
# importing the web server or opening dashboard state. Applying it before a
# named-profile re-exec could leak that profile's higher limit into the
# machine/default dashboard, whose lower policy intentionally cannot undo it.
# This also covers Desktop SSH's isolated `serve` child, which does not route.
from hermes_cli.resource_limits import apply_nofile_soft_limit

apply_nofile_soft_limit()

if _token_file:
_ssh_session_token = _read_ssh_session_token_file(_token_file)

Expand Down
119 changes: 119 additions & 0 deletions hermes_cli/resource_limits.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""Best-effort process resource-limit adjustments for long-running services.

The public helper in this module is shared by the gateway and the dashboard/
serve entrypoints. It deliberately has no user-facing environment-variable
control: the target comes from the profile's canonical ``config.yaml`` loader.
"""

from __future__ import annotations

import logging
from collections.abc import Mapping
from typing import Any

from hermes_cli.config_defaults import DEFAULT_CONFIG

try: # ``resource`` is POSIX-only (and unavailable on Windows).
import resource as _resource
except (ImportError, ModuleNotFoundError): # pragma: no cover - Windows only
_resource = None # type: ignore[assignment]

logger = logging.getLogger(__name__)

DEFAULT_NOFILE_SOFT_LIMIT = int(DEFAULT_CONFIG["runtime"]["nofile_soft_limit"])
_MISSING = object()


def _configured_nofile_soft_limit(
config: Mapping[str, Any] | None,
) -> int | None:
"""Resolve ``runtime.nofile_soft_limit`` from a loaded config.

A missing key uses the default. Explicit ``0``, ``false``, and ``null``
disable the adjustment. Other non-integer or negative values are invalid
and are ignored (the caller fails open without changing the process limit).
"""
if config is None:
try:
# Use Hermes's real, profile-aware loader rather than reading YAML
# here. This also applies managed-scope overlays and defaults.
from hermes_cli.config import load_config_readonly

config = load_config_readonly()
except Exception:
logger.debug("Could not load config for RLIMIT_NOFILE", exc_info=True)
return None

if not isinstance(config, Mapping):
return None

runtime = config.get("runtime", _MISSING)
if runtime is _MISSING:
return DEFAULT_NOFILE_SOFT_LIMIT
if not isinstance(runtime, Mapping):
return None

raw_value = runtime.get("nofile_soft_limit", _MISSING)
if raw_value is _MISSING:
return DEFAULT_NOFILE_SOFT_LIMIT
if raw_value is None or raw_value is False:
return None
if raw_value is True or not isinstance(raw_value, int):
return None
if raw_value <= 0:
return None
return raw_value


def apply_nofile_soft_limit(
config: Mapping[str, Any] | None = None,
) -> bool:
"""Raise this process's ``RLIMIT_NOFILE`` soft limit when possible.

The target defaults to :data:`DEFAULT_NOFILE_SOFT_LIMIT` and can be set with
``runtime.nofile_soft_limit``. The target is clamped to a finite hard limit,
never lowers an existing higher soft limit, and returns ``False`` for an
explicit opt-out or when the platform/sandbox refuses the operation.

This is intentionally best-effort. Unsupported platforms, malformed
settings, and denied ``setrlimit`` calls must never prevent a server from
starting.
"""
if _resource is None:
return False

target = _configured_nofile_soft_limit(config)
if target is None:
return False

try:
nofile = _resource.RLIMIT_NOFILE
current_soft, current_hard = _resource.getrlimit(nofile)
# On platforms where RLIM_INFINITY is represented as -1, ordinary
# integer ordering would make an unlimited soft limit look lower than
# every positive target. Never replace infinity with a finite limit.
if current_soft == getattr(_resource, "RLIM_INFINITY", object()):
return False
if current_soft >= target:
return False

if current_hard == getattr(_resource, "RLIM_INFINITY", object()):
new_soft = target
else:
new_soft = min(target, current_hard)
if new_soft <= current_soft:
return False

_resource.setrlimit(nofile, (new_soft, current_hard))
return True
except Exception:
# This helper runs before server startup and must fail open for
# unsupported/sandboxed environments and denied resource changes.
logger.debug("Could not raise RLIMIT_NOFILE soft limit", exc_info=True)
return False


__all__ = [
"DEFAULT_NOFILE_SOFT_LIMIT",
"apply_nofile_soft_limit",
]
Loading