Skip to content

Commit 2a28e42

Browse files
fix: drop wall-clock total from MCP relay stream timeouts (#2034)
* fix: drop wall-clock total from MCP relay stream timeouts An MCP response stream is long-lived by design (the upcoming spec's subscriptions/listen holds one open indefinitely); the relay's total=300 wall-clock bound cut healthy streams every 5 minutes and forced clients to re-subscribe. sock_read idle detection still bounds a dead stream. Applied to the ha_mcp_tools component webhook relay and the webhook proxy dev flavor (2.0.5.dev2); the stable proxy picks it up on the next promote. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * fix: address Codex relay-timeout findings Add a finite connect bound (30 s) to both relay ClientTimeouts: it covers connection-POOL acquisition, so a pool occupied by long-lived streams fails a new request instead of hanging it forever now that total is gone. Replace the hard-coded stable relay_timeout_total test expectation with a source-derived predicate that flips automatically when the promote workflow copies the dev tree — a hard-coded 300 would have failed every generated promotion PR. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk * chore: re-bump webhook-proxy-dev to 2.0.5.dev3 after #2033 merged dev2 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017iTWsCSMPqSHm7Po8yGzfk --------- Co-authored-by: kingpanther13 <kingpanther13@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent ca61cd9 commit 2a28e42

8 files changed

Lines changed: 162 additions & 7 deletions

File tree

custom_components/ha_mcp_tools/mcp_webhook.py

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -100,8 +100,15 @@
100100
# instead of a mislabeled JSON blob. ``text/html`` and friends stay coerced.
101101
_ALLOWED_CONTENT_TYPES = ("application/json", "text/event-stream", "text/plain")
102102

103-
# Long timeout for streamed MCP responses (matches mcp_proxy).
104-
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300)
103+
# Timeout for streamed MCP responses (matches mcp_proxy). Deliberately NO
104+
# wall-clock ``total``: an MCP response stream is long-lived by design (the
105+
# upcoming spec's ``subscriptions/listen`` holds one open indefinitely), so a
106+
# ``total`` bound would cut a *healthy* stream and force the client to
107+
# re-subscribe. ``sock_read`` bounds a *dead* one instead — idle detection, not
108+
# elapsed time. ``connect`` stays finite: it covers connection-POOL acquisition
109+
# (not just the TCP connect ``sock_connect`` bounds), so a pool exhausted by
110+
# long-lived streams fails a new request in 30 s instead of hanging it forever.
111+
_CLIENT_TIMEOUT = aiohttp.ClientTimeout(connect=30, sock_connect=10, sock_read=300)
105112

106113
# TOP-LEVEL hass.data flag recording that the ha_auth discovery views are bound
107114
# for this HA session. Deliberately NOT under DOMAIN so it survives

homeassistant-addon-webhook-proxy-dev/CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,18 @@ history from before the fork.
99
-->
1010

1111

12+
## v2.0.5.dev3 (2026-07-26)
13+
14+
### Bug Fixes
15+
16+
- Drop the 5-minute wall-clock `total` timeout from the relay's HTTP client so a
17+
long-lived MCP response stream (the upcoming spec's `subscriptions/listen`) is
18+
no longer cut every 300 s, forcing the client to re-subscribe. The `sock_read`
19+
idle timeout still bounds a dead stream, and a new finite `connect` bound
20+
keeps connection-pool acquisition from hanging new requests when long-lived
21+
streams occupy the pool.
22+
23+
1224
## v2.0.5.dev2 (2026-07-25)
1325

1426
### Features

homeassistant-addon-webhook-proxy-dev/config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
name: "Nabu Casa - Webhook Proxy for HA MCP (Dev)"
22
description: "DEV CHANNEL (unstable) — remote access proxy via Nabu Casa or any reverse proxy. Cannot run alongside the stable Webhook Proxy add-on."
3-
version: "2.0.5.dev2"
3+
version: "2.0.5.dev3"
44
slug: "ha_mcp_webhook_proxy_dev"
55
url: "https://github.com/homeassistant-ai/ha-mcp"
66
stage: experimental

homeassistant-addon-webhook-proxy-dev/mcp_proxy_dev/__init__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -725,8 +725,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
725725
"this webhook will be logged here."
726726
)
727727

728+
# Timeout for streamed MCP responses (matches the ha_mcp_tools custom
729+
# component). Deliberately NO wall-clock ``total``: an MCP response stream
730+
# is long-lived by design (the upcoming spec's ``subscriptions/listen``
731+
# holds one open indefinitely), so a ``total`` bound would cut a *healthy*
732+
# stream and force the client to re-subscribe. ``sock_read`` bounds a
733+
# *dead* one instead — idle detection, not elapsed time. ``connect`` stays
734+
# finite: it covers connection-POOL acquisition (not just the TCP connect
735+
# ``sock_connect`` bounds), so a pool exhausted by long-lived streams fails
736+
# a new request in 30 s instead of hanging it forever.
728737
session = aiohttp.ClientSession(
729-
timeout=aiohttp.ClientTimeout(total=300, sock_connect=10, sock_read=300),
738+
timeout=aiohttp.ClientTimeout(connect=30, sock_connect=10, sock_read=300),
730739
)
731740

732741
try:

homeassistant-addon-webhook-proxy-dev/mcp_proxy_dev/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,5 @@
77
"dependencies": ["webhook"],
88
"documentation": "https://github.com/homeassistant-ai/ha-mcp",
99
"iot_class": "local_push",
10-
"version": "2.0.5.dev2"
10+
"version": "2.0.5.dev3"
1111
}

tests/addon/test_webhook_proxy.py

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
import tempfile
1414
import time
1515
import types
16+
from dataclasses import dataclass
1617
from pathlib import Path
1718
from unittest.mock import AsyncMock, MagicMock, patch
1819

@@ -48,6 +49,22 @@
4849
},
4950
}
5051

52+
53+
def _relay_total_unbounded() -> bool:
54+
"""Feature-detect whether the CURRENT flavor's relay session drops the
55+
wall-clock ``total`` bound (long-lived MCP response streams). The marker is
56+
the ``Deliberately NO wall-clock`` comment the dev change introduced next
57+
to the relay ``ClientTimeout``; deriving the expectation from the staged
58+
source (instead of a hard-coded variant value) means the stable flavor's
59+
expectation flips automatically when the promote workflow copies the dev
60+
tree across — a hard-coded ``300`` would fail every generated promotion PR."""
61+
path = os.path.join(PROXY_ADDON_DIR, CURRENT["component"], "__init__.py")
62+
if not os.path.exists(path):
63+
return False
64+
with open(path, encoding="utf-8") as fh:
65+
return "Deliberately NO wall-clock" in fh.read()
66+
67+
5168
# Rebound per-variant by the autouse `_webhook_proxy_variant` fixture below.
5269
PROXY_ADDON_DIR = WEBHOOK_PROXY_VARIANTS["stable"]["addon_dir"]
5370
CURRENT = WEBHOOK_PROXY_VARIANTS["stable"]
@@ -93,6 +110,22 @@ class _FakeConfigEntryError(Exception):
93110
pass
94111

95112

113+
@dataclass(frozen=True)
114+
class _FakeClientTimeout:
115+
"""Field-faithful stand-in for ``aiohttp.ClientTimeout``.
116+
117+
Mirrors aiohttp's own defaults — every bound is ``None`` unless passed — so
118+
tests can assert on the timeout object the relay session is built with,
119+
including the bounds it deliberately leaves unset.
120+
"""
121+
122+
total: float | None = None
123+
connect: float | None = None
124+
sock_read: float | None = None
125+
sock_connect: float | None = None
126+
ceil_threshold: float = 5
127+
128+
96129
def _install_runtime_stubs():
97130
"""Inject homeassistant.* and aiohttp stubs into sys.modules.
98131
@@ -119,7 +152,7 @@ def _install_runtime_stubs():
119152

120153
aiohttp_mod = types.ModuleType("aiohttp")
121154
aiohttp_mod.ClientSession = MagicMock(name="ClientSession")
122-
aiohttp_mod.ClientTimeout = MagicMock(name="ClientTimeout")
155+
aiohttp_mod.ClientTimeout = _FakeClientTimeout
123156
aiohttp_mod.ClientError = type("ClientError", (Exception,), {})
124157
aiohttp_web = types.ModuleType("aiohttp.web")
125158
aiohttp_web.Request = MagicMock
@@ -1414,6 +1447,62 @@ async def test_no_config_file_returns_true(self, mod, hass):
14141447
assert mod.DOMAIN not in hass.data
14151448

14161449

1450+
class TestRelaySessionTimeout:
1451+
"""An MCP response stream may stay open indefinitely (the upcoming spec's
1452+
``subscriptions/listen``), so the relay session is bounded by read idleness
1453+
rather than elapsed wall-clock time — a ``total`` bound would cut a healthy
1454+
stream and force the client to re-subscribe."""
1455+
1456+
@pytest.fixture
1457+
def mod(self):
1458+
return _import_mcp_proxy()
1459+
1460+
@pytest.fixture
1461+
def hass(self):
1462+
h = MagicMock()
1463+
h.data = {}
1464+
1465+
async def run_executor(func, *args):
1466+
return func(*args)
1467+
1468+
h.async_add_executor_job = AsyncMock(side_effect=run_executor)
1469+
return h
1470+
1471+
async def _setup_timeout(self, mod, hass):
1472+
proxy_config = {
1473+
"target_url": "http://127.0.0.1:9583/private_zctpwlX7ZkIAr7oqdfLPxw",
1474+
"webhook_id": "mcp_test_webhook_id_12345",
1475+
}
1476+
with (
1477+
patch.object(mod, "_read_config", return_value=proxy_config),
1478+
patch.object(mod, "async_register"),
1479+
patch.object(
1480+
mod.aiohttp, "ClientSession", return_value=MagicMock()
1481+
) as mock_session,
1482+
):
1483+
await mod.async_setup_entry(hass, MagicMock())
1484+
return mock_session.call_args.kwargs["timeout"]
1485+
1486+
async def test_wall_clock_total_bound(self, mod, hass):
1487+
timeout = await self._setup_timeout(mod, hass)
1488+
if _relay_total_unbounded():
1489+
assert timeout.total is None
1490+
else:
1491+
# Pre-promote stable still carries the wall-clock cap; the
1492+
# source-derived predicate flips this branch off on promotion.
1493+
assert timeout.total == 300
1494+
1495+
async def test_idle_and_connect_bounds_still_set(self, mod, hass):
1496+
timeout = await self._setup_timeout(mod, hass)
1497+
assert timeout.sock_read == 300
1498+
assert timeout.sock_connect == 10
1499+
if _relay_total_unbounded():
1500+
# Pool-acquisition bound: with ``total`` gone, ``connect`` is what
1501+
# keeps a pool exhausted by long-lived streams from hanging new
1502+
# requests forever.
1503+
assert timeout.connect == 30
1504+
1505+
14171506
class TestUnloadEntry:
14181507
@pytest.fixture
14191508
def mod(self):

tests/src/unit/_embedded_stubs.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -399,10 +399,26 @@ def _make_fake_web() -> SimpleNamespace:
399399
)
400400

401401

402+
@dataclass(frozen=True)
403+
class ClientTimeout:
404+
"""Field-faithful stand-in for ``aiohttp.ClientTimeout``.
405+
406+
Mirrors aiohttp's own defaults — every bound is ``None`` unless passed — so
407+
tests can assert on the timeout object the relay session is built with,
408+
including the bounds it deliberately leaves unset.
409+
"""
410+
411+
total: float | None = None
412+
connect: float | None = None
413+
sock_read: float | None = None
414+
sock_connect: float | None = None
415+
ceil_threshold: float = 5
416+
417+
402418
def _make_fake_aiohttp() -> ModuleType:
403419
mod = ModuleType("aiohttp")
404420
mod.ClientError = ClientError # type: ignore[attr-defined]
405-
mod.ClientTimeout = MagicMock(name="ClientTimeout") # type: ignore[attr-defined]
421+
mod.ClientTimeout = ClientTimeout # type: ignore[attr-defined]
406422
mod.ClientSession = MagicMock(name="ClientSession") # type: ignore[attr-defined]
407423
mod.web = _make_fake_web() # type: ignore[attr-defined]
408424
return mod

tests/src/unit/test_mcp_webhook.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,28 @@ async def test_none_mode_forwards_200_and_ignores_bearer(self):
306306
request.read.assert_awaited()
307307

308308

309+
# ---------------------------------------------------------------------------
310+
# Relay client timeout
311+
# ---------------------------------------------------------------------------
312+
313+
314+
class TestRelayClientTimeout:
315+
"""An MCP response stream may stay open indefinitely (the upcoming spec's
316+
``subscriptions/listen``), so the relay session must be bounded by read
317+
idleness, never by elapsed wall-clock time."""
318+
319+
def test_no_wall_clock_total_bound(self):
320+
assert mw._CLIENT_TIMEOUT.total is None
321+
322+
def test_idle_and_connect_bounds_still_set(self):
323+
assert mw._CLIENT_TIMEOUT.sock_read == 300
324+
assert mw._CLIENT_TIMEOUT.sock_connect == 10
325+
# Pool-acquisition bound: with ``total`` gone, ``connect`` is what
326+
# keeps a pool exhausted by long-lived streams from hanging new
327+
# requests forever.
328+
assert mw._CLIENT_TIMEOUT.connect == 30
329+
330+
309331
# ---------------------------------------------------------------------------
310332
# Auth gate — ha_auth posture
311333
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)