Skip to content

Commit df96851

Browse files
swissmoclaude
andcommitted
fix: recognize BrokenResourceError, close silent-branch test gap
Addresses two well-researched review findings, both verified directly against the pinned mcp==1.28.1/anyio==4.10.0 in this repo's venv before acting on them: 1. _is_only_closed_resource_errors (renamed _is_only_disconnect_teardown_errors) only recognized anyio.ClosedResourceError, missing its sibling anyio.BrokenResourceError -- confirmed a plain Exception subclass, not a subclass of ClosedResourceError. Per the reviewer's anyio-internals walkthrough, which one surfaces for the exact same disconnect race depends on which end of the memory stream closes first (our end closing raises Closed; the peer's streamable_http terminate() closing first can wake a parked sender into raising Broken instead). This repo already treats the two as an equivalent pair for streamable-HTTP memory-stream teardown elsewhere (tests/src/haos_runtime.py's transient-error tuple) -- now the classifier does too. Not a regression either way: a BrokenResourceError-only leaf simply fails closed (stays at ERROR) without this, same as before the PR existed. Also independently confirmed the reviewer's mcp-version finding while verifying this: at the exact pinned mcp==1.28.1, Server._handle_request already catches (BrokenResourceError, ClosedResourceError) around respond() and drops it at DEBUG (mcp/server/lowlevel/server.py:805-815) -- absent at older mcp versions. The live-validated production capture this PR's tests describe as "the shape actually logged in production" is accurate for whichever mcp version that deployment has (older, given third-party deps aren't reinstalled per ha-mcp reload), not a version-independent guarantee -- worth keeping in mind if this classifier ever needs revisiting again as mcp gets upgraded. 2. No test constructed a genuine ModuleNotFoundError for exactly ha_mcp.log_filters to prove the "older ha-mcp" branch in _install_log_filters_if_available() actually stays silent -- the existing test only exercised the module-present-but-attribute-missing ImportError case, and asserted on survival, not on log output. Dropping the err.name check would have left the suite green while every older install gained a startup warning. Added TestInstallLogFiltersIfAvailable: calls the function directly (not through the full _serve()/_thread_main harness) with caplog, covering all three branches -- genuinely missing module (silent), attribute missing (warns), and a different missing dependency inside the import (warns, naming it) -- using the same __path__ = [] sys.modules technique _stub_ha_mcp_surface already established for this class of problem. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent 69f4d88 commit df96851

3 files changed

Lines changed: 181 additions & 24 deletions

File tree

src/ha_mcp/log_filters.py

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -85,25 +85,40 @@ def filter(self, record: logging.LogRecord) -> bool:
8585
return True
8686

8787

88-
def _is_only_closed_resource_errors(err: BaseException) -> bool:
89-
"""True if ``err`` is (or an ExceptionGroup wrapping only) ClosedResourceError.
88+
_DISCONNECT_TEARDOWN_ERRORS = (anyio.ClosedResourceError, anyio.BrokenResourceError)
89+
90+
91+
def _is_only_disconnect_teardown_errors(err: BaseException) -> bool:
92+
"""True if ``err`` is (or an ExceptionGroup wrapping only) a benign
93+
streamable-HTTP memory-stream teardown error.
9094
9195
``mcp.server.lowlevel.server.Server.run()`` dispatches each incoming
92-
message via ``anyio.create_task_group().start_soon(...)``, so a
93-
``ClosedResourceError`` raised while delivering one message's response
94-
is raised from a task-group child -- anyio always wraps that in an
95-
``ExceptionGroup``, even for a single failure. The exception a "session
96-
crashed" log record carries is therefore
97-
``ExceptionGroup(...[ClosedResourceError])``, not a bare
98-
``ClosedResourceError``. Recurse through (possibly nested) groups; any
99-
non-``ClosedResourceError`` leaf means this is not the known-benign
100-
disconnect race, so the caller should leave the record alone.
96+
message via ``anyio.create_task_group().start_soon(...)``, so an error
97+
raised while delivering one message's response is raised from a
98+
task-group child -- anyio always wraps that in an ``ExceptionGroup``,
99+
even for a single failure. The exception a "session crashed" log record
100+
carries is therefore ``ExceptionGroup(...[<teardown error>])``, not a
101+
bare one.
102+
103+
Both ``ClosedResourceError`` and ``BrokenResourceError`` -- sibling
104+
``Exception`` subclasses, neither a subclass of the other -- can surface
105+
here for the same disconnect race: which one depends on which end
106+
closed first (our end closing raises ``Closed``; the peer's
107+
``streamable_http`` ``terminate()`` closing first can wake a parked
108+
sender into raising ``Broken`` instead, even though the situation is
109+
identical from the caller's perspective). This repo already treats the
110+
two as an equivalent pair for streamable-HTTP memory-stream teardown
111+
elsewhere -- see ``tests/src/haos_runtime.py``'s transient-error tuple.
112+
113+
Recurse through (possibly nested) groups; any other leaf type means
114+
this is not the known-benign disconnect race, so the caller should
115+
leave the record alone.
101116
"""
102-
if isinstance(err, anyio.ClosedResourceError):
117+
if isinstance(err, _DISCONNECT_TEARDOWN_ERRORS):
103118
return True
104119
if isinstance(err, BaseExceptionGroup):
105120
return bool(err.exceptions) and all(
106-
_is_only_closed_resource_errors(sub) for sub in err.exceptions
121+
_is_only_disconnect_teardown_errors(sub) for sub in err.exceptions
107122
)
108123
return False
109124

@@ -118,8 +133,9 @@ class SessionDisconnectLogFilter(logging.Filter):
118133
timeout -- lets the SDK finish serving the response and tear the
119134
transport down while ``app.run()`` is still working; the eventual attempt
120135
to deliver the response then writes into an already-closed memory stream
121-
and raises ``anyio.ClosedResourceError`` (wrapped in an ``ExceptionGroup``
122-
-- see ``_is_only_closed_resource_errors``). The SDK already catches this
136+
and raises ``anyio.ClosedResourceError`` or ``anyio.BrokenResourceError``
137+
(wrapped in an ``ExceptionGroup`` -- see
138+
``_is_only_disconnect_teardown_errors``). The SDK already catches this
123139
(``except Exception: logger.exception(...)`` in both the stateless and
124140
stateful session runners of mcp/server/streamable_http_manager.py) -- it
125141
just logs it as an alarming ERROR-level traceback. That's an expected
@@ -135,7 +151,7 @@ def filter(self, record: logging.LogRecord) -> bool:
135151
return True
136152

137153
err = record.exc_info[1]
138-
if err is None or not _is_only_closed_resource_errors(err):
154+
if err is None or not _is_only_disconnect_teardown_errors(err):
139155
return True
140156

141157
record.msg = (

tests/src/unit/test_embedded_server.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2694,6 +2694,86 @@ def http_app(self, path, stateless_http):
26942694
assert isinstance(mgr._thread_exc, _StopServe)
26952695

26962696

2697+
class TestInstallLogFiltersIfAvailable:
2698+
"""Direct unit coverage of _install_log_filters_if_available()'s branch
2699+
logic. CodeRabbit review finding: the _serve()-level tests above only
2700+
assert that serving continues either way, never that the SILENT
2701+
"older ha-mcp" case and the WARNING cases are actually distinguishable
2702+
by their log output -- dropping the err.name check entirely would leave
2703+
the suite green while every older install started logging noise on
2704+
every startup. These tests call the function directly (no need for the
2705+
full _stub_ha_mcp_surface/_thread_main harness) and assert on caplog.
2706+
"""
2707+
2708+
def setup_method(self):
2709+
"""Snapshot ha_mcp.* sys.modules entries so each test's fakes don't
2710+
leak into the next -- ha_mcp is genuinely importable in this test
2711+
process (unlike the fully-stubbed harness the heavier _serve()-level
2712+
tests above build)."""
2713+
self._saved = {
2714+
name: sys.modules.get(name) for name in ("ha_mcp", "ha_mcp.log_filters")
2715+
}
2716+
2717+
def teardown_method(self):
2718+
"""Restore the snapshotted sys.modules entries exactly."""
2719+
for name, mod in self._saved.items():
2720+
if mod is None:
2721+
sys.modules.pop(name, None)
2722+
else:
2723+
sys.modules[name] = mod
2724+
2725+
def test_genuinely_missing_module_is_silent(self, caplog):
2726+
"""The true 'older ha-mcp, module doesn't exist yet' case: point the
2727+
faked ha_mcp package's own __path__ at nothing, so Python's real
2728+
import machinery genuinely cannot find log_filters.py and raises
2729+
ModuleNotFoundError(name='ha_mcp.log_filters') -- not a hand-built
2730+
stand-in. This is exactly the branch CodeRabbit found untested."""
2731+
fake_ha_mcp = ModuleType("ha_mcp")
2732+
fake_ha_mcp.__path__ = [] # nothing to search -- a genuine "not found"
2733+
sys.modules["ha_mcp"] = fake_ha_mcp
2734+
sys.modules.pop("ha_mcp.log_filters", None)
2735+
2736+
with caplog.at_level("WARNING", logger=es._LOGGER.name):
2737+
es._install_log_filters_if_available()
2738+
2739+
assert caplog.records == []
2740+
2741+
def test_module_present_but_attribute_missing_warns(self, caplog):
2742+
"""The existing ImportError case (module exists, helper attribute
2743+
doesn't) must actually log a WARNING -- the discriminating half of
2744+
the same gap: a prior test proved serving continues, not that the
2745+
output differs from the silent case above."""
2746+
sys.modules["ha_mcp.log_filters"] = ModuleType("ha_mcp.log_filters")
2747+
2748+
with caplog.at_level("WARNING", logger=es._LOGGER.name):
2749+
es._install_log_filters_if_available()
2750+
2751+
assert "Could not install MCP SDK log-noise filters" in caplog.text
2752+
2753+
def test_different_missing_dependency_warns_and_names_it(self, monkeypatch, caplog):
2754+
"""A ModuleNotFoundError for anything OTHER than ha_mcp.log_filters
2755+
itself (e.g. a stale fastmcp/pydantic left over from a previous
2756+
install -- _purge_ha_mcp_modules deliberately never reinstalls
2757+
third-party dependencies) is not the older-server case and must
2758+
warn, naming the actual missing dependency rather than staying
2759+
silent."""
2760+
sys.modules.pop("ha_mcp.log_filters", None)
2761+
real_import = __import__
2762+
2763+
def _fake_import(name, *args, **kwargs):
2764+
if name == "ha_mcp.log_filters":
2765+
raise ModuleNotFoundError("No module named 'fastmcp'", name="fastmcp")
2766+
return real_import(name, *args, **kwargs)
2767+
2768+
monkeypatch.setattr("builtins.__import__", _fake_import)
2769+
2770+
with caplog.at_level("WARNING", logger=es._LOGGER.name):
2771+
es._install_log_filters_if_available()
2772+
2773+
assert "fastmcp" in caplog.text
2774+
assert "Could not install MCP SDK log-noise filters" in caplog.text
2775+
2776+
26972777
# ---------------------------------------------------------------------------
26982778
# start / stop lifecycle + idempotency
26992779
# ---------------------------------------------------------------------------

tests/src/unit/test_session_disconnect_log_filter.py

Lines changed: 69 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@
77

88
from ha_mcp.log_filters import (
99
SessionDisconnectLogFilter,
10-
_is_only_closed_resource_errors,
10+
_is_only_disconnect_teardown_errors,
1111
)
1212

1313

1414
async def _raise_closed_resource_error() -> None:
15-
"""Raise the exact exception the disconnect race produces."""
15+
"""Raise one of the two exceptions the disconnect race can produce."""
1616
raise anyio.ClosedResourceError()
1717

1818

19+
async def _raise_broken_resource_error() -> None:
20+
"""Raise the other exception the same disconnect race can produce --
21+
which one depends on which end of the memory stream closed first."""
22+
raise anyio.BrokenResourceError()
23+
24+
1925
async def _raise_runtime_error() -> None:
2026
"""Raise an unrelated failure, standing in for a real server bug."""
2127
raise RuntimeError("real bug")
@@ -100,6 +106,20 @@ def test_demotes_stateful_session_crash_from_closed_resource_error(self):
100106
assert record.levelno == logging.WARNING
101107
assert record.exc_info is None
102108

109+
def test_demotes_stateless_session_crash_from_broken_resource_error(self):
110+
"""The disconnect race's other exception type: which one surfaces
111+
depends on which end of the memory stream closed first, but both
112+
are the same benign situation."""
113+
err = anyio.BrokenResourceError()
114+
record = self._make_record(
115+
"mcp.server.streamable_http_manager",
116+
"Stateless session crashed",
117+
err,
118+
)
119+
assert self.log_filter.filter(record) is True
120+
assert record.levelno == logging.WARNING
121+
assert record.exc_info is None
122+
103123
async def test_demotes_real_task_group_exception_group(self):
104124
"""The shape actually logged in production: mcp.server.lowlevel.server
105125
dispatches message handling via anyio.create_task_group().start_soon,
@@ -118,6 +138,21 @@ async def test_demotes_real_task_group_exception_group(self):
118138
assert record.exc_info is None
119139
assert "client disconnected before response delivery" in record.getMessage()
120140

141+
async def test_demotes_real_task_group_exception_group_broken_resource(self):
142+
"""Same production shape, but with BrokenResourceError as the leaf --
143+
the other half of the disconnect-race pair."""
144+
caught = await _run_in_task_group(_raise_broken_resource_error)
145+
assert isinstance(caught, BaseExceptionGroup)
146+
147+
record = self._make_record(
148+
"mcp.server.streamable_http_manager",
149+
"Stateless session crashed",
150+
caught,
151+
)
152+
assert self.log_filter.filter(record) is True
153+
assert record.levelno == logging.WARNING
154+
assert record.exc_info is None
155+
121156
async def test_leaves_mixed_exception_group_at_error(self):
122157
"""A task group with one ClosedResourceError AND one unrelated failure
123158
signals a real problem alongside the expected disconnect race -- the
@@ -174,32 +209,58 @@ def test_passes_record_without_exc_info(self):
174209
assert record.levelno == logging.ERROR
175210

176211

177-
class TestIsOnlyClosedResourceErrors:
212+
class TestIsOnlyDisconnectTeardownErrors:
178213
"""Direct coverage of the recursive classifier the filter relies on."""
179214

180215
def test_bare_closed_resource_error(self):
181216
"""A bare ClosedResourceError matches on its own."""
182-
assert _is_only_closed_resource_errors(anyio.ClosedResourceError()) is True
217+
assert _is_only_disconnect_teardown_errors(anyio.ClosedResourceError()) is True
218+
219+
def test_bare_broken_resource_error(self):
220+
"""A bare BrokenResourceError matches too -- the other half of the
221+
disconnect-race pair (sibling Exception subclasses, neither a
222+
subclass of the other, so this needs its own explicit check)."""
223+
assert _is_only_disconnect_teardown_errors(anyio.BrokenResourceError()) is True
183224

184225
def test_bare_other_exception(self):
185226
"""An unrelated bare exception never matches."""
186-
assert _is_only_closed_resource_errors(RuntimeError("x")) is False
227+
assert _is_only_disconnect_teardown_errors(RuntimeError("x")) is False
187228

188229
def test_group_of_one_closed_resource_error(self):
189230
"""A single-item group wrapping just the known-benign exception matches."""
190231
group = ExceptionGroup("eg", [anyio.ClosedResourceError()])
191-
assert _is_only_closed_resource_errors(group) is True
232+
assert _is_only_disconnect_teardown_errors(group) is True
233+
234+
def test_group_of_one_broken_resource_error(self):
235+
"""Same, for the BrokenResourceError half of the pair."""
236+
group = ExceptionGroup("eg", [anyio.BrokenResourceError()])
237+
assert _is_only_disconnect_teardown_errors(group) is True
238+
239+
def test_group_mixing_both_benign_types(self):
240+
"""A group with both ClosedResourceError and BrokenResourceError
241+
leaves is still all-benign -- e.g. concurrent responses racing the
242+
same teardown from opposite ends."""
243+
group = ExceptionGroup(
244+
"eg", [anyio.ClosedResourceError(), anyio.BrokenResourceError()]
245+
)
246+
assert _is_only_disconnect_teardown_errors(group) is True
192247

193248
def test_nested_group_of_closed_resource_errors(self):
194249
"""Nested groups are unwrapped recursively, matching all-benign leaves."""
195250
inner = ExceptionGroup("inner", [anyio.ClosedResourceError()])
196251
outer = ExceptionGroup("outer", [inner, anyio.ClosedResourceError()])
197-
assert _is_only_closed_resource_errors(outer) is True
252+
assert _is_only_disconnect_teardown_errors(outer) is True
253+
254+
def test_nested_group_with_broken_resource_error_leaf(self):
255+
"""Nested groups recurse correctly for the BrokenResourceError half too."""
256+
inner = ExceptionGroup("inner", [anyio.BrokenResourceError()])
257+
outer = ExceptionGroup("outer", [inner, anyio.ClosedResourceError()])
258+
assert _is_only_disconnect_teardown_errors(outer) is True
198259

199260
def test_mixed_group_is_rejected(self):
200261
"""A group with even one non-benign leaf must not match."""
201262
group = ExceptionGroup("eg", [anyio.ClosedResourceError(), RuntimeError("x")])
202-
assert _is_only_closed_resource_errors(group) is False
263+
assert _is_only_disconnect_teardown_errors(group) is False
203264

204265
def test_empty_group_is_rejected(self):
205266
"""Defensive: an ExceptionGroup always carries at least one exception

0 commit comments

Comments
 (0)