From 585cee1a42ed1f198a56734b8ffad03f3d006886 Mon Sep 17 00:00:00 2001 From: Leon Phull Date: Wed, 5 Aug 2026 13:48:14 +0200 Subject: [PATCH] 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. --- hermes_cli/gateway.py | 24 +++++++++++++++++++- hermes_cli/resource_limits.py | 13 +++++++++++ tests/hermes_cli/test_gateway_service.py | 29 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index 06144650cb..da35d02cfd 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -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""" + SoftResourceLimits + + NumberOfFiles + {nofile_target} + +""" + return f""" @@ -4175,7 +4197,7 @@ def generate_launchd_plist() -> str: ExitTimeOut 25 - +{nofile_block} StandardOutPath {log_dir}/gateway.log diff --git a/hermes_cli/resource_limits.py b/hermes_cli/resource_limits.py index 6fa0f789a9..a9daf54be5 100644 --- a/hermes_cli/resource_limits.py +++ b/hermes_cli/resource_limits.py @@ -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", ] diff --git a/tests/hermes_cli/test_gateway_service.py b/tests/hermes_cli/test_gateway_service.py index 9b5aa9942a..a7b54809ad 100644 --- a/tests/hermes_cli/test_gateway_service.py +++ b/tests/hermes_cli/test_gateway_service.py @@ -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 "SoftResourceLimits" in plist + assert "NumberOfFiles" in plist + assert "65536" 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: