Skip to content

Commit

Permalink
[WIP] CI: Add tests on FreeBSD
Browse files Browse the repository at this point in the history
  • Loading branch information
francis-clairicia committed Oct 19, 2024
1 parent 67c2d7d commit 69c5ffb
Show file tree
Hide file tree
Showing 9 changed files with 121 additions and 40 deletions.
33 changes: 32 additions & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ jobs:
- python_version: '3.13'
tox_py: py313

name: Tests (${{ matrix.os }}, ${{ matrix.python_version }})
name: test (${{ matrix.os }}, ${{ matrix.python_version }})
steps:
- uses: actions/checkout@v4
with:
Expand Down Expand Up @@ -100,3 +100,34 @@ jobs:
test-functional,
OS-${{ runner.os }},
Py-${{ matrix.python_version }}
test-freebsd:
# TODO: Add this when the workflow is stable.
# if: |
# (github.event_name != 'push' || !startsWith(github.event.head_commit.message, 'Bump version:'))
# && (github.event_name != 'pull_request' || (github.event.pull_request.draft != true && !contains(github.event.pull_request.labels.*.name, 'pr-skip-test')))
runs-on: ubuntu-24.04

name: test (FreeBSD, 3.11)
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Launch tests
# Add 10 minutes timeout because some wheels are long to build
timeout-minutes: 30
uses: vmactions/freebsd-vm@v1
with:
release: '14.1'
# py311-sqlite3 is needed for coverage.py
# rust is needed to install cryptography from source
prepare: |
pkg install -y git python311 py311-pdm py311-sqlite3 rust
curl -sSL https://pdm-project.org/install-pdm.py | python3.11 - --version=2.19.3
pdm config check_update false
pdm config install.cache true
run: |
pdm install --frozen-lockfile --no-self --no-default --dev --group tox
pdm run tox run -f py311-standard -f coverage -vv
- name: Check files in workspace
run: ls -lA
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ async def client_connection_task(client_socket: _socket.socket, task_group: Task
# The remote host closed the connection before starting the task.
# See this test for details:
# test____serve_forever____accept_client____client_sent_RST_packet_right_after_accept
logger.warning("A client connection was interrupted just after listener.accept()")
pass
else:
self.__accepted_socket_factory.log_connection_error(logger, exc)

Expand Down Expand Up @@ -181,7 +181,7 @@ async def raw_accept(self) -> _socket.socket:
raise _utils.error_from_errno(_errno.EBADF) from None
finally:
self.__accept_scope = None
else:
elif exc.errno not in constants.IGNORABLE_ACCEPT_ERRNOS:
raise

def backend(self) -> AsyncBackend:
Expand Down
34 changes: 32 additions & 2 deletions src/easynetwork/lowlevel/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
"ACCEPT_CAPACITY_ERROR_SLEEP_TIME",
"DEFAULT_SERIALIZER_LIMIT",
"DEFAULT_STREAM_BUFSIZE",
"IGNORABLE_ACCEPT_ERRNOS",
"MAX_DATAGRAM_BUFSIZE",
"NOT_CONNECTED_SOCKET_ERRNOS",
"SC_IOV_MAX",
Expand Down Expand Up @@ -57,8 +58,7 @@
}
)

# Errors that accept(2) can return, and which indicate that the system is
# overloaded
# Errors that accept(2) can return, and which indicate that the system is overloaded
ACCEPT_CAPACITY_ERRNOS: Final[frozenset[int]] = frozenset(
{
_errno.EMFILE,
Expand All @@ -71,6 +71,36 @@
# How long to sleep when we get one of those errors
ACCEPT_CAPACITY_ERROR_SLEEP_TIME: Final[float] = 0.100

# Taken from Trio project
# Errors that accept(2) can return, and can be skipped
IGNORABLE_ACCEPT_ERRNOS: frozenset[int] = frozenset(
{
errno
for name in (
# Linux can do this when the a connection is denied by the firewall
"EPERM",
# BSDs with an early close/reset
"ECONNABORTED",
# All the other miscellany noted above -- may not happen in practice, but
# whatever.
"EPROTO",
"ENETDOWN",
"ENOPROTOOPT",
"EHOSTDOWN",
"ENONET",
"EHOSTUNREACH",
"EOPNOTSUPP",
"ENETUNREACH",
"ENOSR",
"ESOCKTNOSUPPORT",
"EPROTONOSUPPORT",
"ETIMEDOUT",
"ECONNRESET",
)
if (errno := getattr(_errno, name, None)) is not None
}
)

# Number of seconds to wait for SSL handshake to complete
# The default timeout matches that of Nginx.
SSL_HANDSHAKE_TIMEOUT: Final[float] = 60.0
Expand Down
10 changes: 1 addition & 9 deletions src/easynetwork/servers/async_tcp.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,7 +284,6 @@ async def __client_initializer(

client_address = lowlevel_client.extra(INETSocketAttribute.peername, None)
if client_address is None:
self.__client_closed_before_starting_task(self.logger)
yield None
return

Expand Down Expand Up @@ -358,17 +357,10 @@ def __client_tls_handshake_error_handler(cls, logger: logging.Logger, exc: Excep
or _utils.is_ssl_eof_error(exc)
or exc.errno in constants.NOT_CONNECTED_SOCKET_ERRNOS
):
cls.__client_closed_before_starting_task(logger)
pass
case _: # pragma: no cover
logger.warning("Error in client task (during TLS handshake)", exc_info=exc)

@staticmethod
def __client_closed_before_starting_task(logger: logging.Logger) -> None:
# The remote host closed the connection before starting the task.
# See this test for details:
# test____serve_forever____accept_client____client_sent_RST_packet_right_after_accept
logger.warning("A client connection was interrupted just after listener.accept()")

@_utils.inherit_doc(_base.BaseAsyncNetworkServerImpl)
def get_addresses(self) -> Sequence[SocketAddress]:
return self._with_lowlevel_servers(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -87,9 +87,7 @@ async def test____send_packet____default(self, client: AsyncUDPNetworkClient[str
async with asyncio.timeout(3):
assert await server.recvfrom() == (b"ABCDEF", client.get_local_address())

# Windows and MacOS do not raise error
@PlatformMarkers.skipif_platform_win32
@PlatformMarkers.skipif_platform_macOS
@PlatformMarkers.runs_only_on_platform("linux", "Windows, MacOS and BSD-like do not raise error")
async def test____send_packet____connection_refused(
self,
client: AsyncUDPNetworkClient[str, str],
Expand All @@ -99,9 +97,7 @@ async def test____send_packet____connection_refused(
with pytest.raises(ConnectionRefusedError):
await client.send_packet("ABCDEF")

# Windows and MacOS do not raise error
@PlatformMarkers.skipif_platform_win32
@PlatformMarkers.skipif_platform_macOS
@PlatformMarkers.runs_only_on_platform("linux", "Windows, MacOS and BSD-like do not raise error")
async def test____send_packet____connection_refused____after_previous_successful_try(
self,
client: AsyncUDPNetworkClient[str, str],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,6 @@ async def test____serve_forever____accept_client____client_sent_RST_packet_right
from socket import socket as SocketType

caplog.set_level(logging.WARNING, LOGGER.name)
logger_crash_maximum_nb_lines[LOGGER.name] = 1

socket = SocketType()

Expand All @@ -638,10 +637,9 @@ async def test____serve_forever____accept_client____client_sent_RST_packet_right
# and will fail at client initialization when calling socket.getpeername() (errno.ENOTCONN will be raised)
await asyncio.sleep(0.1)

# ENOTCONN error should not create a big Traceback error but only a warning (at least)
assert len(caplog.records) == 1
assert caplog.records[0].levelno == logging.WARNING
assert caplog.records[0].message == "A client connection was interrupted just after listener.accept()"
# On Linux: ENOTCONN error should not create a big Traceback error
# On BSD: ECONNABORTED error on accept() should not create a big Traceback error
assert len(caplog.records) == 0

async def test____serve_forever____client_extra_attributes(
self,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,17 +58,13 @@ def test____send_packet____default(self, client: UDPNetworkClient[str, str], ser
client.send_packet("ABCDEF")
assert server.recvfrom(1024) == (b"ABCDEF", client.get_local_address())

# Windows and MacOS do not raise error
@PlatformMarkers.skipif_platform_win32
@PlatformMarkers.skipif_platform_macOS
@PlatformMarkers.runs_only_on_platform("linux", "Windows, MacOS and BSD-like do not raise error")
def test____send_packet____connection_refused(self, client: UDPNetworkClient[str, str], server: Socket) -> None:
server.close()
with pytest.raises(ConnectionRefusedError):
client.send_packet("ABCDEF")

# Windows and MacOS do not raise error
@PlatformMarkers.skipif_platform_win32
@PlatformMarkers.skipif_platform_macOS
@PlatformMarkers.runs_only_on_platform("linux", "Windows, MacOS and BSD-like do not raise error")
def test____send_packet____connection_refused____after_previous_successful_try(
self,
client: UDPNetworkClient[str, str],
Expand Down
18 changes: 17 additions & 1 deletion tests/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
_T_Args = TypeVarTuple("_T_Args")


def _make_skipif_platform(platform: str, reason: str, *, skip_only_on_ci: bool) -> pytest.MarkDecorator:
def _make_skipif_platform(platform: str | tuple[str, ...], reason: str, *, skip_only_on_ci: bool) -> pytest.MarkDecorator:
condition: bool = sys.platform.startswith(platform)
if skip_only_on_ci:
# skip if 'CI' is set to a non-empty value
Expand All @@ -32,8 +32,13 @@ def _make_skipif_platform(platform: str, reason: str, *, skip_only_on_ci: bool)
return pytest.mark.skipif(condition, reason=reason)


def _make_skipif_not_on_platform(platform: str | tuple[str, ...], reason: str) -> pytest.MarkDecorator:
return pytest.mark.skipif(not sys.platform.startswith(platform), reason=reason)


@final
class PlatformMarkers:
###### SKIP SOME PLATFORMS ######
@staticmethod
def skipif_platform_win32_because(reason: str, *, skip_only_on_ci: bool = False) -> pytest.MarkDecorator:
return _make_skipif_platform("win32", reason, skip_only_on_ci=skip_only_on_ci)
Expand All @@ -46,9 +51,20 @@ def skipif_platform_macOS_because(reason: str, *, skip_only_on_ci: bool = False)
def skipif_platform_linux_because(reason: str, *, skip_only_on_ci: bool = False) -> pytest.MarkDecorator:
return _make_skipif_platform("linux", reason, skip_only_on_ci=skip_only_on_ci)

@staticmethod
def skipif_platform_bsd_because(reason: str, *, skip_only_on_ci: bool = False) -> pytest.MarkDecorator:
return _make_skipif_platform(("freebsd", "openbsd", "netbsd"), reason, skip_only_on_ci=skip_only_on_ci)

skipif_platform_win32 = skipif_platform_win32_because("cannot run on Windows")
skipif_platform_macOS = skipif_platform_macOS_because("cannot run on MacOS")
skipif_platform_linux = skipif_platform_linux_because("cannot run on Linux")
skipif_platform_bsd = skipif_platform_bsd_because("Cannot run on BSD-related platforms (e.g. FreeBSD)")

###### RESTRICT TESTS FOR PLATFORMS ######

@staticmethod
def runs_only_on_platform(platform: str | tuple[str, ...], reason: str) -> pytest.MarkDecorator:
return _make_skipif_not_on_platform(platform, reason)


def send_return(gen: Generator[Any, _T_contra, _V_co], value: _T_contra, /) -> _V_co:
Expand Down
38 changes: 30 additions & 8 deletions tests/unit_test/test_async/test_asyncio_backend/test_stream.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
StreamReaderBufferedProtocol,
)
from easynetwork.lowlevel.api_async.backend._asyncio.tasks import CancelScope, TaskGroup as AsyncIOTaskGroup
from easynetwork.lowlevel.constants import ACCEPT_CAPACITY_ERRNOS, NOT_CONNECTED_SOCKET_ERRNOS
from easynetwork.lowlevel.constants import ACCEPT_CAPACITY_ERRNOS, IGNORABLE_ACCEPT_ERRNOS, NOT_CONNECTED_SOCKET_ERRNOS
from easynetwork.lowlevel.socket import SocketAttribute

import pytest
Expand Down Expand Up @@ -345,11 +345,9 @@ async def test____serve____connect____error_raised(
assert len(caplog.records) == 0
accepted_socket_factory.log_connection_error.assert_not_called()
case OSError(errno=errno) if errno in NOT_CONNECTED_SOCKET_ERRNOS:
# ENOTCONN error should not create a big Traceback error but only a warning (at least)
assert len(caplog.records) == 1
assert caplog.records[0].levelno == logging.WARNING
assert caplog.records[0].message == "A client connection was interrupted just after listener.accept()"
assert caplog.records[0].exc_info is None
# ENOTCONN error should not create a big Traceback error
assert len(caplog.records) == 0
accepted_socket_factory.log_connection_error.assert_not_called()
case _:
assert len(caplog.records) == 0
accepted_socket_factory.log_connection_error.assert_called_once_with(
Expand All @@ -368,7 +366,7 @@ async def test____accept____accept_capacity_error(
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
caplog.set_level(logging.ERROR)
caplog.set_level(logging.WARNING)
mock_tcp_listener_socket.accept.side_effect = OSError(errno_value, os.strerror(errno_value))

# Act
Expand All @@ -380,21 +378,45 @@ async def test____accept____accept_capacity_error(
# Assert
assert len(caplog.records) in {9, 10}
for record in caplog.records:
assert record.levelno == logging.ERROR
assert "retrying" in record.message
assert (
record.exc_info is not None
and isinstance(record.exc_info[1], OSError)
and record.exc_info[1].errno == errno_value
)

@pytest.mark.parametrize("errno_value", sorted(IGNORABLE_ACCEPT_ERRNOS), ids=errno_errorcode.__getitem__)
async def test____accept____ignorable_error(
self,
errno_value: int,
listener: ListenerSocketAdapter[Any],
mock_tcp_listener_socket: MagicMock,
mock_tcp_socket: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
caplog.set_level(logging.WARNING)
mock_tcp_listener_socket.accept.side_effect = [
OSError(errno_value, os.strerror(errno_value)),
(mock_tcp_socket, ("127.0.0.1", 12345)),
]

# Act
socket = await listener.raw_accept()

# Assert
assert socket is mock_tcp_socket
assert len(caplog.records) == 0

async def test____accept____reraise_other_OSErrors(
self,
listener: ListenerSocketAdapter[Any],
mock_tcp_listener_socket: MagicMock,
caplog: pytest.LogCaptureFixture,
) -> None:
# Arrange
caplog.set_level(logging.ERROR)
caplog.set_level(logging.WARNING)
exc = OSError()
mock_tcp_listener_socket.accept.side_effect = exc

Expand Down

0 comments on commit 69c5ffb

Please sign in to comment.