Silence ~50 spurious test warnings that were being blamed on unrelated tests - #199
Merged
Merged
Conversation
…behind Five test modules build ProxyDeployment stand-ins with object.__new__ and drop them. The class carries an `async def __del__` because Ray Serve's only per-replica shutdown hook is the destructor and Serve awaits it; CPython's collector does not, so each dropped stand-in produced a "coroutine 'ProxyDeployment.__del__' was never awaited" RuntimeWarning charged to whichever unrelated test the collector happened to run in — 54 of the suite's 70 warnings. Route those modules through a shared ProxyDouble: the production class minus the Serve-only destructor. Source assertions and behaviour tests keep using the real class. Adds a guard that the production destructor stays a coroutine function, since a synchronous one would return before the deregistration and disconnect it contains ever ran. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Routing the five modules through ProxyDouble removed the warnings but nothing stopped them coming back: a module regressing to the raw class still passed, because the warning is charged to whichever unrelated test the collector tripped in rather than to the test that built the object. A session-wide pytest_warning_recorded hook collects those records and pytest_sessionfinish fails the run, naming ProxyDouble and the nodeids that were charged. Promoting the warning with -W error::pytest.PytestUnraisableExceptionWarning would also fail the run, but it fails on the misattributed tests, which is the misdirection this is meant to remove. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Setting exitstatus unconditionally downgraded INTERRUPTED to TESTS_FAILED: a pytest.exit bail-out — a real Ctrl-C, or this suite's own aiortc gate — came out as 1 instead of 2 whenever the session had also recorded proxy warnings. Only overwrite a passing status; the message still prints either way. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Running the test suite printed about fifty "coroutine was never awaited" warnings, and they were blamed on tests that had nothing to do with the cause — a CLI test, a mock-library internal, an asyncio scheduling callback. One of them landed inside a command-line test's captured output and broke its assertion, which is how a flaky failure with no apparent connection to proxy deployments turned out to be a proxy-deployment cleanup problem. This makes the noise go away, and adds a check that fails the suite if it ever comes back, without changing any shipped behaviour.
Problem
ProxyDeployment.__del__isasync def, and that is not an accident. A Ray Serve deployment class has exactly one per-replica shutdown hook — the destructor — and Serve awaits it. Fromray/serve/_private/replica.py(Ray 2.55.1):That is the path every real replica teardown takes, and it is where the Hypha deregistration, the
Falseregistration report and theserver.disconnect()that frees theclient_idactually run.CPython's collector does not await destructors. It calls
__del__, gets a coroutine object back and discards it, which raisesRuntimeWarning: coroutine 'ProxyDeployment.__del__' was never awaited— attributed to wherever the collector happened to trip, not to the code that built the object.Five test modules build
ProxyDeploymentstand-ins withobject.__new__(or the real constructor) and drop them:test_proxy_entry_saturation_tolerance,test_proxy_hypha_decoupled_health,test_service_id_registration_gate,test_usage_ledger,test_peer_connection_sweep. Together they accounted for 50–52 of the suite’s 66–68 warnings — the range is real, not measurement slop, because how many objects the collector finalises inside the session depends on where it trips.Note that explicitly awaiting the teardown in a test does not help. Calling
obj.__del__()by hand is an ordinary method call; it does not mark the object finalised, so the collector still calls the destructor afterwards and still discards the coroutine. Measured:Solution
A shared
tests/apps/_proxy_double.pyexportingPROXY_CLS(the production class, for source assertions and behaviour tests) andProxyDouble— that same class minus the Serve-only destructor. The five modules construct their stand-ins fromProxyDouble. Nothing else about the class changes, so the registration-gate tests still exercise the real__init__, the real claim path and the real_deregister_services. One cosmetic consequence:proxy_deployment.pylogsself.__class__.__name__during construction, so those tests now log "ProxyDouble". Nothing asserts on it.The stand-ins hold no resource the destructor would have released:
BIOENGINE_APP_DIRis unset under test so_usage_ledgerisNone,serverisNone, and no maintenance task is ever started — the suite reports no lingering pending asyncio tasks before or after. The un-awaited coroutine was the entire leak.A session-wide hook in
tests/conftest.pykeeps it that way.pytest_warning_recordedcollects any un-awaited-destructor record, andpytest_sessionfinishfails the run, namingProxyDoubleand the nodeids the records were charged to. It has to be session-wide precisely because the warning does not land on the test that caused it.-W error::pytest.PytestUnraisableExceptionWarningwould also turn these into failures — fourteen of them. It is the wrong tool rather than an ineffective one: the failures land on whichever unrelated test the collector tripped in (test_proxy_counts_a_raising_call_as_failedtook the blame in one run), which reintroduces exactly the misattribution this PR removes. A plain-W error::RuntimeWarningon its own fails nothing, because the warning is raised inside a coroutine's finaliser.Production code is deliberately unchanged
Moving the teardown out of the destructor into an explicit async shutdown hook is the textbook fix, and Ray Serve does not offer one.
call_destructorreaches user code in exactly two places:__del__, and__serve_multiplex_wrapper.shutdown(), which is Serve's own multiplexing internals rather than a user hook. Renaming the body to_shutdown()and delegating from__del__would change nothing observable and would not remove the destructor.The two alternatives both regress behaviour the service-registration record depends on:
_flush_usage,_deregister_services,_reset_server_connectionall perform I/O with timeouts).call_destructorand then tears the replica down. A scheduled task has nobody waiting on it, so the deregistration and the disconnect would race replica death — precisely theclient_idrelease that avoids "Client already exists and is active" on the successor, and the taggedFalsereport the replica-tag guard arbitrates.So the async destructor stays. What changes is that the constraint is now enforced rather than implied:
test_the_production_destructor_stays_awaitablefails if anyone makes it synchronous, and the module docstring records why the shape is what it is.Test plan
Full suite in the worker image:
525 passed, 25 skipped, 67–68 warningsbefore,528 passed, 25 skipped, 16 warningsafter. Un-awaitedProxyDeployment.__del__records: 50–52 before (52, 52, 51, 52, 52 across five runs here; an independent check saw 51, 52, 50 across three, so 50 is the observed floor), 0 after. Those are exact per-record tallies from thepytest_warning_recordedhook, not counts read off pytest's warnings summary — the summary compresses records into location-grouped blocks and cannot be totalled reliably. The 16 warnings that remain are pydantic and Ray deprecation notices from imports.All 27 tests in
test_service_id_registration_gate.pypass, including the four replica-tag invariants: a live replica deregistering itself, a departed replica's late deregistration being dropped, an untagged successor claiming from a tagged predecessor, and the crash-restart case.Mutation-proven:
ProxyDoubleno longer overrides the destructortest_dropping_the_test_double_is_silentfailstest_the_production_destructor_stays_awaitableandtest_dropping_the_production_class_warnsfailtest_dropping_the_production_class_warnsfailsThe hook is silent and the suite exits 0 on the unmutated tree.
Files
tests/apps/_proxy_double.pyPROXY_CLSandProxyDouble.tests/apps/test_proxy_teardown_hook.pytests/conftest.pytests/apps/test_proxy_entry_saturation_tolerance.py_bare_proxybuilds aProxyDouble.tests/apps/test_proxy_hypha_decoupled_health.py_bare_proxybuilds aProxyDouble.tests/apps/test_service_id_registration_gate.py_bare_proxyand_construct_proxybuild aProxyDouble.tests/apps/test_usage_ledger.pyProxyDouble.tests/apps/test_peer_connection_sweep.py_make_instancebuilds aProxyDouble.🤖 Generated with Claude Code