fix(gateway): persist RLIMIT_NOFILE floor into the generated launchd plist

launchd starts children with soft nofile=256; hermes gateway start rewrites the plist and previously stripped any manually-added SoftResourceLimits, silently reintroducing EMFILE crashes under load. The plist generator now embeds the configured runtime.nofile_soft_limit so the persisted service definition and the in-process floor share one knob.
This commit is contained in:
Leon Phull
2026-08-05 13:48:14 +02:00
committed by Teknium
parent b06f79a100
commit 585cee1a42
3 changed files with 65 additions and 1 deletions
+23 -1
View File
@@ -4129,6 +4129,28 @@ def generate_launchd_plist() -> str:
)
prog_args_xml = "\n ".join(prog_args)
# Persist the configured RLIMIT_NOFILE floor into the service definition
# itself. launchd starts children with a soft limit of 256 by default;
# without this block every plist rewrite (e.g. `hermes gateway start`)
# would silently strip a manually-added limit and reintroduce EMFILE
# crashes under load. The in-process floor (resource_limits.py) still
# applies as a second layer for non-launchd launches.
nofile_block = ""
try:
from hermes_cli.resource_limits import configured_nofile_soft_limit
nofile_target = configured_nofile_soft_limit()
except Exception:
nofile_target = None
if nofile_target:
nofile_block = f"""
<key>SoftResourceLimits</key>
<dict>
<key>NumberOfFiles</key>
<integer>{nofile_target}</integer>
</dict>
"""
return f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
@@ -4175,7 +4197,7 @@ def generate_launchd_plist() -> str:
<key>ExitTimeOut</key>
<integer>25</integer>
{nofile_block}
<key>StandardOutPath</key>
<string>{log_dir}/gateway.log</string>
+13
View File
@@ -65,6 +65,18 @@ def _configured_nofile_soft_limit(
return raw_value
def configured_nofile_soft_limit(
config: Mapping[str, Any] | None = None,
) -> int | None:
"""Public accessor for the resolved ``runtime.nofile_soft_limit`` target.
Used by service-definition generators (e.g. the launchd plist) so the
persisted service limits and the in-process floor share one config knob.
Returns ``None`` when the adjustment is disabled or unresolvable.
"""
return _configured_nofile_soft_limit(config)
def apply_nofile_soft_limit(
config: Mapping[str, Any] | None = None,
) -> bool:
@@ -116,4 +128,5 @@ def apply_nofile_soft_limit(
__all__ = [
"DEFAULT_NOFILE_SOFT_LIMIT",
"apply_nofile_soft_limit",
"configured_nofile_soft_limit",
]
+29
View File
@@ -232,6 +232,35 @@ class TestGeneratedSystemdUnits:
assert str(local_bin) in plist
assert str(profile_node_bin) not in plist
def test_launchd_plist_persists_configured_nofile_soft_limit(self, monkeypatch):
"""The generated plist must carry SoftResourceLimits/NumberOfFiles so a
plist rewrite by `hermes gateway start` cannot strip the FD floor and
reintroduce EMFILE crashes (launchd default soft limit is 256)."""
import hermes_cli.resource_limits as resource_limits
monkeypatch.setattr(
resource_limits, "configured_nofile_soft_limit", lambda config=None: 65536
)
plist = gateway_cli.generate_launchd_plist()
assert "<key>SoftResourceLimits</key>" in plist
assert "<key>NumberOfFiles</key>" in plist
assert "<integer>65536</integer>" in plist
def test_launchd_plist_omits_nofile_block_when_disabled(self, monkeypatch):
"""runtime.nofile_soft_limit: 0/false/null disables the adjustment; the
plist must then not contain a SoftResourceLimits block at all."""
import hermes_cli.resource_limits as resource_limits
monkeypatch.setattr(
resource_limits, "configured_nofile_soft_limit", lambda config=None: None
)
plist = gateway_cli.generate_launchd_plist()
assert "SoftResourceLimits" not in plist
class TestGatewayStopCleanup: