Skip to content
Merged
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
32 changes: 32 additions & 0 deletions tests/apps/_proxy_double.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""A ``ProxyDeployment`` stand-in for tests that no collector has to finalise.

``ProxyDeployment.__del__`` is ``async def`` because a deployment class's only
per-replica shutdown hook in Ray Serve *is* the destructor, and Serve awaits it
(``call_destructor`` in ``ray/serve/_private/replica.py``). CPython's collector
does not await it: it calls ``__del__``, gets a coroutine object back and drops
it. Every hand-built proxy a test leaves behind therefore turns into
``RuntimeWarning: coroutine 'ProxyDeployment.__del__' was never awaited``,
reported against whichever unrelated test the collector happened to run in.

Tests build their stand-ins from :class:`ProxyDouble`, which is the production
class minus that Serve-only hook. Nothing else differs, so source assertions and
behaviour tests should keep using ``PROXY_CLS``.
"""

from __future__ import annotations

from bioengine.apps import proxy_deployment as pd_module

PROXY_CLS = pd_module.ProxyDeployment.func_or_class


class ProxyDouble(PROXY_CLS):
"""``ProxyDeployment`` without the async destructor Ray Serve drives."""

def __del__(self) -> None:
"""Drop the inherited ``async def __del__``.

No Serve replica owns a test instance, so there is nobody to await the
production destructor — leaving it in place only hands the collector a
coroutine it will discard with a warning.
"""
7 changes: 3 additions & 4 deletions tests/apps/test_peer_connection_sweep.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,9 +22,8 @@

import pytest

from bioengine.apps import proxy_deployment as pd_module

_ProxyDeployment = pd_module.ProxyDeployment.func_or_class
from tests.apps._proxy_double import PROXY_CLS as _ProxyDeployment
from tests.apps._proxy_double import ProxyDouble


class _StubPeerConnection:
Expand All @@ -38,7 +37,7 @@ async def close(self) -> None:
def _make_instance() -> _ProxyDeployment:
"""Skip ``__init__`` (it wants ~15 constructor args and a Ray Serve
context) and stamp on just the attributes the sweep touches."""
obj = _ProxyDeployment.__new__(_ProxyDeployment)
obj = ProxyDouble.__new__(ProxyDouble)
obj.application_id = "test-app"
obj._active_peer_connections = {}
return obj
Expand Down
6 changes: 3 additions & 3 deletions tests/apps/test_proxy_entry_saturation_tolerance.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,8 @@
import pytest

from bioengine.apps import proxy_deployment as pd_module

_ProxyCls = pd_module.ProxyDeployment.func_or_class
from tests.apps._proxy_double import PROXY_CLS as _ProxyCls
from tests.apps._proxy_double import ProxyDouble


class _WsService:
Expand Down Expand Up @@ -63,7 +63,7 @@ async def __call__(self, *args, **kwargs):


def _bare_proxy(**attrs):
inst = object.__new__(_ProxyCls)
inst = object.__new__(ProxyDouble)
inst.application_id = "app"
inst._own_deployment_name = "ProxyDeployment"
inst.entry_deployment_ready = False
Expand Down
6 changes: 3 additions & 3 deletions tests/apps/test_proxy_hypha_decoupled_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,12 @@
import pytest

from bioengine.apps import proxy_deployment as pd_module

_ProxyCls = pd_module.ProxyDeployment.func_or_class
from tests.apps._proxy_double import PROXY_CLS as _ProxyCls
from tests.apps._proxy_double import ProxyDouble


def _bare_proxy(**attrs):
inst = object.__new__(_ProxyCls)
inst = object.__new__(ProxyDouble)
inst.application_id = "app"
inst.entry_deployment_ready = True
inst.server = None
Expand Down
51 changes: 51 additions & 0 deletions tests/apps/test_proxy_teardown_hook.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
"""Pin how ``ProxyDeployment``'s teardown hook behaves in and out of Ray Serve.

Serve gives a deployment class exactly one per-replica shutdown hook — the
destructor — and awaits it (``call_destructor`` in
``ray/serve/_private/replica.py``, whose comment says "Make sure to accept
``async def __del__(self)`` as well"). That is why the production destructor is
a coroutine function, and why making it synchronous would silently drop the
Hypha deregistration and client_id release the registration record depends on.

Nothing outside Serve awaits it, so every proxy a test hand-builds and drops
used to hand CPython's collector a coroutine it discarded with a
``RuntimeWarning``, charged to whichever unrelated test was running at the time.
``ProxyDouble`` exists to keep that off the suite.
"""

from __future__ import annotations

import gc
import inspect
import warnings

from tests.apps._proxy_double import PROXY_CLS, ProxyDouble


def _unawaited_destructor_warnings(cls) -> list[str]:
"""Build an instance of ``cls``, drop it, and collect the GC's complaints."""
with warnings.catch_warnings(record=True) as caught:
warnings.simplefilter("always")
instance = object.__new__(cls)
del instance
gc.collect()
gc.collect()
return [str(w.message) for w in caught if "never awaited" in str(w.message)]


def test_the_production_destructor_stays_awaitable() -> None:
"""Serve awaits ``__del__``; a synchronous one would return before the
deregistration and disconnect it contains ever ran."""
assert inspect.iscoroutinefunction(PROXY_CLS.__del__)


def test_dropping_the_production_class_warns() -> None:
"""Positive control for the test below — without it, a double that stopped
suppressing anything would still look clean."""
assert _unawaited_destructor_warnings(PROXY_CLS) == [
"coroutine 'ProxyDeployment.__del__' was never awaited"
]


def test_dropping_the_test_double_is_silent() -> None:
assert _unawaited_destructor_warnings(ProxyDouble) == []
15 changes: 10 additions & 5 deletions tests/apps/test_service_id_registration_gate.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@
from bioengine.apps import proxy_deployment as pd_module
from bioengine.apps.manager import AppsManager
from bioengine.cluster.proxy_actor import BioEngineProxyActor
from tests.apps._proxy_double import PROXY_CLS as _ProxyCls
from tests.apps._proxy_double import ProxyDouble

_ProxyCls = pd_module.ProxyDeployment.func_or_class
_ActorCls = BioEngineProxyActor.__ray_actor_class__

APP_ID = "nuclei-seg"
Expand Down Expand Up @@ -263,7 +264,7 @@ def _record_claim(self, application_id: str, replica_id=None) -> None:


def _bare_proxy(**attrs):
inst = object.__new__(_ProxyCls)
inst = object.__new__(ProxyDouble)
inst.application_id = APP_ID
inst._replica_id = "replica-0"
inst.entry_deployment_ready = True
Expand Down Expand Up @@ -324,7 +325,11 @@ async def test_deregistering_reports_the_service_as_gone() -> None:


def _construct_proxy(monkeypatch, handle: _Handle, replica_tag: str = "replica-0"):
"""Build a real ProxyDeployment, with only Ray's two lookups stubbed."""
"""Build a real ProxyDeployment, with only Ray's two lookups stubbed.

``ProxyDouble`` is that class minus Ray Serve's async destructor, which no
test has a replica to await; nothing on the claim path differs.
"""
monkeypatch.setattr(pd_module.ray, "get_actor", lambda name, namespace: handle)
monkeypatch.setattr(
pd_module,
Expand All @@ -333,7 +338,7 @@ def _construct_proxy(monkeypatch, handle: _Handle, replica_tag: str = "replica-0
deployment="ProxyDeployment", replica_tag=replica_tag, app_name=APP_ID
),
)
return _ProxyCls(
return ProxyDouble(
application_id=APP_ID,
application_name="Nuclei Segmentation",
application_description="Segment nuclei.",
Expand Down Expand Up @@ -381,7 +386,7 @@ def test_a_missing_actor_handle_never_breaks_the_replica() -> None:
def test_reporting_survives_a_part_built_replica() -> None:
# __del__ -> _deregister_services -> here, reachable before __init__ has
# assigned the handle at all.
inst = object.__new__(_ProxyCls)
inst = object.__new__(ProxyDouble)
inst._report_service_registration(False) # must not raise AttributeError


Expand Down
7 changes: 3 additions & 4 deletions tests/apps/test_usage_ledger.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,7 @@
ledger_dir_for_app,
read_usage,
)

_ProxyCls = pd_module.ProxyDeployment.func_or_class
from tests.apps._proxy_double import ProxyDouble


# ===== helpers =====
Expand All @@ -71,7 +70,7 @@ def __getattr__(self, name):


def _bare_proxy(tmp_path: Path, behaviour, *, authorized_users=None, slots=4):
inst = object.__new__(_ProxyCls)
inst = object.__new__(ProxyDouble)
inst.application_id = "counted-app"
inst.workspace = "host-ws"
inst.authorized_users = authorized_users or {"*": ["*"]}
Expand Down Expand Up @@ -925,7 +924,7 @@ async def _register_rtc(server, service_id, config):

def test_an_app_without_durable_storage_still_deploys(tmp_path: Path, monkeypatch) -> None:
monkeypatch.delenv("BIOENGINE_APP_DIR", raising=False)
inst = object.__new__(_ProxyCls)
inst = object.__new__(ProxyDouble)
inst.application_id = "counted-app"
inst.workspace = "host-ws"
inst.app_data = {}
Expand Down
30 changes: 30 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,5 +259,35 @@ async def artifact_manager(hypha_client: RemoteService) -> ObjectProxy:
return artifact_manager_service


_UNAWAITED_DESTRUCTOR = "coroutine 'ProxyDeployment.__del__' was never awaited"
_unawaited_destructor_nodeids: list = []


def pytest_warning_recorded(warning_message, nodeid, **_):
"""Record proxies left for the collector to finalise.

Ray Serve awaits ``ProxyDeployment.__del__``; CPython's collector calls it,
gets a coroutine and discards it. The warning is charged to whichever test
was running when the collector tripped, not to the one that built the
object, so it has to be caught session-wide rather than per test.
"""
if _UNAWAITED_DESTRUCTOR in str(warning_message.message):
_unawaited_destructor_nodeids.append(nodeid)


def pytest_sessionfinish(session) -> None:
if not _unawaited_destructor_nodeids:
return
# Only claim a passing run: INTERRUPTED and the error statuses say more.
if session.exitstatus == 0:
session.exitstatus = 1
print(
f"\n{len(_unawaited_destructor_nodeids)} un-awaited ProxyDeployment "
f"destructor(s). Build test proxies from tests.apps._proxy_double."
f"ProxyDouble. Charged to: "
f"{', '.join(sorted(set(_unawaited_destructor_nodeids)))}"
)


# Configure asyncio for pytest
pytest_plugins = ("pytest_asyncio",)
Loading