Skip to content
Open
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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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 <knative-dev@googlegroups.com>"]
readme = "README.md"
Expand Down
37 changes: 37 additions & 0 deletions src/func_python/_ulimit.py
Original file line number Diff line number Diff line change
@@ -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)
2 changes: 2 additions & 0 deletions src/func_python/cloudevent.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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':
Expand Down
2 changes: 2 additions & 0 deletions src/func_python/http.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import hypercorn.asyncio

import func_python.sock
from func_python._ulimit import _raise_nofile_limit

DEFAULT_LOG_LEVEL = logging.INFO

Expand All @@ -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':
Expand Down
120 changes: 120 additions & 0 deletions tests/test_ulimit.py
Original file line number Diff line number Diff line change
@@ -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()