diff --git a/CHANGELOG.md b/CHANGELOG.md index 3061f1b9..8fc4ef3c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,16 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Fixed ### Security +## 0.8.2 - 2026-04-16 + +### Added + +- Automatically raise the soft `RLIMIT_NOFILE` to the hard limit at + `serve()` startup, matching Go and Java runtime behaviour. Finite hard + limits are honoured in full; when the hard limit is `RLIM_INFINITY` the + soft limit is capped at 65536 to stay within kernel constraints. + Failures are logged as warnings and never fatal (knative/func#3513). + ## 0.8.1 - 2026-04-14 ### Fixed diff --git a/pyproject.toml b/pyproject.toml index 4be72430..d2f4585d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "func-python" -version = "0.8.1" +version = "0.8.2" description = "Knative Functions Python Middleware" authors = ["The Knative Authors "] readme = "README.md" diff --git a/src/func_python/_ulimit.py b/src/func_python/_ulimit.py new file mode 100644 index 00000000..c05d8401 --- /dev/null +++ b/src/func_python/_ulimit.py @@ -0,0 +1,37 @@ +import logging + +# Module-level import so tests can patch func_python._ulimit._resource directly. +# On non-Unix platforms (e.g. Windows) the resource module is unavailable; +# _resource is set to None and _raise_nofile_limit() becomes a no-op. +try: + import resource as _resource +except ImportError: + _resource = None + +_MAX_NOFILE = 65536 # safe cap when hard == RLIM_INFINITY + +_logger = logging.getLogger(__name__) + + +def _raise_nofile_limit(): + """Raise the process soft open-file limit to the hard limit. + + Matches the automatic behaviour of the Go and Java runtimes. + Silently skips on non-Unix platforms where resource is unavailable. + """ + if _resource is None: + return # non-Unix (e.g. Windows) — skip + + try: + soft, hard = _resource.getrlimit(_resource.RLIMIT_NOFILE) + if soft < hard: + # When hard is RLIM_INFINITY the kernel rejects setting the soft + # limit to RLIM_INFINITY without CAP_SYS_RESOURCE, so cap the + # soft limit at a known-safe value. For finite hard limits, raise + # the soft limit all the way to the hard limit as the Go and Java + # runtimes do. + target = _MAX_NOFILE if hard == _resource.RLIM_INFINITY else hard + _resource.setrlimit(_resource.RLIMIT_NOFILE, (target, hard)) + _logger.debug("Raised open-file limit from %d to %d", soft, target) + except (ValueError, OSError) as e: + _logger.warning("Could not raise open-file limit: %s", e) diff --git a/src/func_python/cloudevent.py b/src/func_python/cloudevent.py index 8465803a..101742f4 100644 --- a/src/func_python/cloudevent.py +++ b/src/func_python/cloudevent.py @@ -13,6 +13,7 @@ from cloudevents.core.exceptions import CloudEventValidationError import func_python.sock +from func_python._ulimit import _raise_nofile_limit DEFAULT_LOG_LEVEL = logging.INFO @@ -24,6 +25,7 @@ def serve(f): and starting. The function can be either a constructor for a functon instance (named "new") or a simple ASGI handler function (named "handle"). """ + _raise_nofile_limit() logging.debug("func runtime creating function instance") if f.__name__ == 'new': diff --git a/src/func_python/http.py b/src/func_python/http.py index 4133f51d..440a692d 100644 --- a/src/func_python/http.py +++ b/src/func_python/http.py @@ -8,6 +8,7 @@ import hypercorn.asyncio import func_python.sock +from func_python._ulimit import _raise_nofile_limit DEFAULT_LOG_LEVEL = logging.INFO @@ -19,6 +20,7 @@ def serve(f): and starting. The function can be either a constructor for a functon instance (named "new") or a simple ASGI handler function (named "handle"). """ + _raise_nofile_limit() logging.debug("func runtime creating function instance") if f.__name__ == 'new': diff --git a/tests/test_ulimit.py b/tests/test_ulimit.py new file mode 100644 index 00000000..f7a6b1bd --- /dev/null +++ b/tests/test_ulimit.py @@ -0,0 +1,120 @@ +import logging +import resource +from unittest.mock import MagicMock, patch + + +def _make_resource(soft, hard, rlim_infinity=None): + """Build a minimal mock of the resource module.""" + mock = MagicMock(spec=resource) + mock.RLIMIT_NOFILE = resource.RLIMIT_NOFILE + mock.RLIM_INFINITY = rlim_infinity if rlim_infinity is not None else resource.RLIM_INFINITY + mock.getrlimit.return_value = (soft, hard) + return mock + + +def _call_with_resource(mock_resource): + """Call _raise_nofile_limit() with the given resource mock patched in place.""" + with patch("func_python._ulimit._resource", mock_resource): + from func_python._ulimit import _raise_nofile_limit + _raise_nofile_limit() + return mock_resource + + +# --------------------------------------------------------------------------- +# Unit tests for _raise_nofile_limit() +# --------------------------------------------------------------------------- + +def test_raises_soft_limit_to_hard(): + """When soft < hard (finite), setrlimit is called with the full hard value.""" + mock_resource = _make_resource(soft=1024, hard=4096) + _call_with_resource(mock_resource) + mock_resource.setrlimit.assert_called_once_with( + resource.RLIMIT_NOFILE, (4096, 4096) + ) + + +def test_raises_soft_limit_to_hard_above_65536(): + """Hard limits above 65536 must be honoured in full, not capped.""" + mock_resource = _make_resource(soft=1024, hard=131072) + _call_with_resource(mock_resource) + mock_resource.setrlimit.assert_called_once_with( + resource.RLIMIT_NOFILE, (131072, 131072) + ) + + +def test_no_change_when_soft_equals_hard(): + """When soft == hard, setrlimit must not be called.""" + mock_resource = _make_resource(soft=4096, hard=4096) + _call_with_resource(mock_resource) + mock_resource.setrlimit.assert_not_called() + + +def test_rlim_infinity_capped_at_max(): + """When hard == RLIM_INFINITY the soft limit must be capped at _MAX_NOFILE.""" + from func_python._ulimit import _MAX_NOFILE + rlim_infinity = resource.RLIM_INFINITY + mock_resource = _make_resource(soft=1024, hard=rlim_infinity, + rlim_infinity=rlim_infinity) + _call_with_resource(mock_resource) + mock_resource.setrlimit.assert_called_once_with( + resource.RLIMIT_NOFILE, (_MAX_NOFILE, rlim_infinity) + ) + + +def test_import_error_is_silently_skipped(): + """When resource is unavailable (non-Unix), no exception is raised.""" + with patch("func_python._ulimit._resource", None): + from func_python._ulimit import _raise_nofile_limit + _raise_nofile_limit() # must not raise + + +def test_os_error_logs_warning(caplog): + """When setrlimit raises OSError, a warning is logged and no exception propagates.""" + mock_resource = _make_resource(soft=1024, hard=4096) + mock_resource.setrlimit.side_effect = OSError("operation not permitted") + with caplog.at_level(logging.WARNING, logger="func_python._ulimit"): + _call_with_resource(mock_resource) + assert any("Could not raise open-file limit" in r.message for r in caplog.records) + + +def test_value_error_logs_warning(caplog): + """When setrlimit raises ValueError, a warning is logged and no exception propagates.""" + mock_resource = _make_resource(soft=1024, hard=4096) + mock_resource.setrlimit.side_effect = ValueError("invalid argument") + with caplog.at_level(logging.WARNING, logger="func_python._ulimit"): + _call_with_resource(mock_resource) + assert any("Could not raise open-file limit" in r.message for r in caplog.records) + + +# --------------------------------------------------------------------------- +# Wire-up tests: verify serve() in http.py and cloudevent.py call the helper +# --------------------------------------------------------------------------- + +def test_http_serve_calls_raise_nofile_limit(): + """serve() in http.py must call _raise_nofile_limit() before doing anything else.""" + with patch("func_python.http._raise_nofile_limit") as mock_fn: + with patch("func_python.http.ASGIApplication") as mock_app: + mock_app.return_value.serve.return_value = None + from func_python.http import serve + + async def handle(scope, receive, send): + pass + + serve(handle) + + mock_fn.assert_called_once() + + +def test_cloudevent_serve_calls_raise_nofile_limit(): + """serve() in cloudevent.py must call _raise_nofile_limit() before doing anything else.""" + with patch("func_python.cloudevent._raise_nofile_limit") as mock_fn: + with patch("func_python.cloudevent.ASGIApplication") as mock_app: + mock_app.return_value.serve.return_value = None + from func_python.cloudevent import serve + + async def handle(scope, receive, send): + pass + + serve(handle) + + mock_fn.assert_called_once()