Skip to content

Commit 3b03a98

Browse files
author
Maksym Shtembuliak
committed
fix: own collector TCP admission and align capture readiness
1 parent 13dc45b commit 3b03a98

20 files changed

Lines changed: 822 additions & 57 deletions

‎CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,16 @@ the GitHub release body should be rendered from the matching version section her
2828

2929
### Fixed
3030

31+
- Collector listener shutdown now fences and drains TCP admission before
32+
clearing its sessions. Reconnecting during an entry reload no longer enters
33+
the `asyncio.Server` accept/close race or leaves a late socket in a retired
34+
listener. Collector identity, protocol selection and polling are unchanged.
35+
36+
- Proxy capture now renders its instructions, available actions and default
37+
selection from the same current collector state, including after a failed
38+
start. A stale poll snapshot can no longer contradict the reconnect/start
39+
choice. Live connection and endpoint checks before redirect are unchanged (#43).
40+
3141
- Older entries without a confirmed collector identity can now use the same
3242
explicit read-only framed/AT identity check as manual setup. Successful repair
3343
updates the existing entry and its selected connection settings in place;

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,12 @@ It is a small ESP8266/ESP32-based bridge that connects directly to the inverter
135135
3. Restart Home Assistant.
136136
4. Add **EyeBond Local** from **Settings → Devices & Services**.
137137

138+
Keep backup copies **outside** `config/custom_components/`. Renaming an old
139+
copy to `eybond_local_backup` inside that directory does not disable it:
140+
Home Assistant can discover its unchanged manifest and load the old code.
141+
Leave only the intended `eybond_local/` copy there, then fully restart Home
142+
Assistant; reloading the integration is not enough after replacing Python files.
143+
138144
### Testing the unreleased `main` branch
139145

140146
Use this only when a maintainer asks you to test a fix that is not in a release

‎README.uk.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,12 @@ PI30 та SRNE. Рівень підтримки залежить від моде
139139
3. Перезапустіть Home Assistant.
140140
4. Додайте **EyeBond Local** через **Налаштування → Пристрої та служби**.
141141

142+
Зберігайте резервні копії **поза** `config/custom_components/`. Перейменування
143+
старої папки на `eybond_local_backup` усередині цього каталогу не вимикає її:
144+
Home Assistant може знайти незмінений manifest і завантажити старий код.
145+
Залиште там лише потрібну папку `eybond_local/` і повністю перезапустіть Home
146+
Assistant; після заміни Python-файлів перезавантаження лише інтеграції недостатньо.
147+
142148
### Тестування неопублікованої гілки `main`
143149

144150
Використовуйте цей спосіб лише тоді, коли розробник просить перевірити виправлення,

‎custom_components/eybond_local/collector/transport/listener.py‎

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
_spawn_tracked_task,
3737
)
3838
from .connections import _CollectorAtConnection, _CollectorConnection
39+
from .tcp_acceptor import CollectorTcpAcceptor
3940

4041
logger = logging.getLogger(__name__)
4142

@@ -154,7 +155,7 @@ class _SharedEybondListener:
154155
def __init__(self, *, host: str, port: int) -> None:
155156
self._host = host
156157
self._port = int(port)
157-
self._server: asyncio.Server | None = None
158+
self._server: CollectorTcpAcceptor | None = None
158159
self._ref_count = 0
159160
self._connections: dict[str, _CollectorConnection] = {}
160161
self._at_connections: dict[str, _CollectorAtConnection] = {}
@@ -215,7 +216,7 @@ async def acquire(self) -> None:
215216
self._ref_count += 1
216217
if self._server is None:
217218
try:
218-
self._server = await asyncio.start_server(
219+
self._server = await CollectorTcpAcceptor.start(
219220
self._handle_connection,
220221
self._host,
221222
self._port,
@@ -235,6 +236,13 @@ async def release(self) -> bool:
235236
if self._ref_count != 0:
236237
return False
237238

239+
# Stop and join admission FIRST. A socket accepted during session
240+
# cleanup must not repopulate an inventory we have already cleared.
241+
if self._server is not None:
242+
self._server.close()
243+
await self._server.wait_closed()
244+
self._server = None
245+
238246
for pending in tuple(self._pending_sockets.values()):
239247
await self._close_pending_socket(pending)
240248
self._pending_sockets.clear()
@@ -260,10 +268,6 @@ async def release(self) -> bool:
260268
self._exclusive_routes.clear()
261269
self._session_inventory.clear()
262270

263-
if self._server is not None:
264-
self._server.close()
265-
await self._server.wait_closed()
266-
self._server = None
267271
return True
268272

269273
def register_payload_owner(self, collector_ip: str) -> None:
Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
"""Owned TCP admission without asyncio.Server's accept/close race (CPython #109564).
2+
3+
Only socket admission lives here. Identity, framing and established stream
4+
ownership belong to the shared collector listener. No event-loop monkeypatches.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import asyncio
10+
import errno
11+
import logging
12+
import socket
13+
from collections.abc import Awaitable, Callable
14+
15+
from .common import _close_writer_bounded
16+
17+
logger = logging.getLogger(__name__)
18+
19+
_BACKLOG = 100
20+
_MAX_ADMISSIONS = 100
21+
_RESOURCE_ERRORS = {errno.EMFILE, errno.ENFILE, errno.ENOBUFS, errno.ENOMEM}
22+
23+
24+
class CollectorTcpAcceptor:
25+
"""Accept, prepare and hand off streams; stop joins every uncommitted handoff."""
26+
27+
def __init__(
28+
self,
29+
sockets: list[socket.socket],
30+
handler: Callable[[asyncio.StreamReader, asyncio.StreamWriter], Awaitable[None]],
31+
) -> None:
32+
self._sockets = sockets
33+
self._handler = handler
34+
self._loop = asyncio.get_running_loop()
35+
self._closing = False
36+
self._admissions: dict[asyncio.Task[None], socket.socket] = {}
37+
self._retry_handles: dict[socket.socket, asyncio.TimerHandle] = {}
38+
self._close_task: asyncio.Task[None] | None = None
39+
40+
@classmethod
41+
async def start(cls, handler, host: str, port: int) -> CollectorTcpAcceptor:
42+
"""Bind all resolved addresses, rolling back the whole bind on failure."""
43+
loop = asyncio.get_running_loop()
44+
addresses = await loop.getaddrinfo(
45+
host or None, port, type=socket.SOCK_STREAM, flags=socket.AI_PASSIVE,
46+
)
47+
sockets: list[socket.socket] = []
48+
try:
49+
for family, sock_type, proto, _, address in dict.fromkeys(addresses):
50+
sock = socket.socket(family, sock_type, proto)
51+
sockets.append(sock)
52+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
53+
if family == socket.AF_INET6:
54+
sock.setsockopt(socket.IPPROTO_IPV6, socket.IPV6_V6ONLY, 1)
55+
sock.setblocking(False)
56+
sock.bind(address)
57+
sock.listen(_BACKLOG)
58+
if not sockets:
59+
raise OSError("collector_listener_no_bind_addresses")
60+
acceptor = cls(sockets, handler)
61+
try:
62+
for sock in sockets:
63+
acceptor._resume_accept(sock)
64+
except BaseException:
65+
acceptor.close()
66+
await acceptor.wait_closed()
67+
raise
68+
return acceptor
69+
except BaseException:
70+
for sock in sockets:
71+
sock.close()
72+
raise
73+
74+
@property
75+
def sockets(self) -> tuple[socket.socket, ...]:
76+
return tuple(self._sockets) if not self._closing else ()
77+
78+
def _resume_accept(self, sock: socket.socket) -> None:
79+
self._retry_handles.pop(sock, None)
80+
if not self._closing:
81+
self._loop.add_reader(sock.fileno(), self._accept_ready, sock)
82+
83+
def _accept_ready(self, sock: socket.socket) -> None:
84+
if self._closing:
85+
return
86+
# Acceptance and raw-socket ownership are synchronous. Cancelling an
87+
# await of sock_accept after its future completed could lose the result.
88+
for _ in range(_BACKLOG + 1):
89+
try:
90+
client, _ = sock.accept()
91+
except ConnectionAbortedError:
92+
continue
93+
except (BlockingIOError, InterruptedError):
94+
return
95+
except OSError as exc:
96+
# Back off resource exhaustion without a busy selector loop.
97+
# The retry remains owned/cancellable by this acceptor.
98+
self._loop.remove_reader(sock.fileno())
99+
logger.warning("Collector listener cannot accept a socket: %s", exc)
100+
if exc.errno in _RESOURCE_ERRORS:
101+
self._retry_handles[sock] = self._loop.call_later(1, self._resume_accept, sock)
102+
else:
103+
self.close()
104+
return
105+
if self._closing or len(self._admissions) >= _MAX_ADMISSIONS:
106+
client.close()
107+
else:
108+
# The record exists outside the coroutine too: cancellation
109+
# before its first instruction must not leak the raw socket.
110+
admission = self._admit(client)
111+
try:
112+
client.setblocking(False)
113+
task = asyncio.create_task(admission, name="collector_tcp_admit")
114+
except BaseException:
115+
admission.close()
116+
client.close()
117+
raise
118+
self._admissions[task] = client
119+
task.add_done_callback(self._admission_done)
120+
121+
async def _admit(self, client: socket.socket) -> None:
122+
writer: asyncio.StreamWriter | None = None
123+
handed_off = False
124+
try:
125+
# Public socket-to-stream API. Unlike start_server, this transport
126+
# has no asyncio.Server to attach to after it has been closed.
127+
reader, writer = await asyncio.open_connection(sock=client)
128+
if not self._closing:
129+
await self._handler(reader, writer)
130+
handed_off = True
131+
finally:
132+
if not handed_off:
133+
if writer is None:
134+
client.close()
135+
else:
136+
await _close_writer_bounded(writer)
137+
138+
def _admission_done(self, task: asyncio.Task[None]) -> None:
139+
client = self._admissions.pop(task, None)
140+
if client is None:
141+
return
142+
if task.cancelled():
143+
if client is not None:
144+
client.close()
145+
return
146+
error = task.exception()
147+
if error is not None:
148+
if client is not None:
149+
client.close()
150+
logger.error("Collector TCP admission failed", exc_info=error)
151+
152+
def close(self) -> None:
153+
"""Fence new handoffs before cancelling accept and closing bind sockets."""
154+
if self._closing:
155+
return
156+
self._closing = True
157+
for retry in self._retry_handles.values():
158+
retry.cancel()
159+
self._retry_handles.clear()
160+
for sock in self._sockets:
161+
self._loop.remove_reader(sock.fileno())
162+
sock.close()
163+
for task in tuple(self._admissions):
164+
task.cancel()
165+
self._close_task = asyncio.create_task(self._drain(), name="collector_tcp_close")
166+
167+
async def _drain(self) -> None:
168+
# Any already-queued readiness callback sees _closing before accept.
169+
tasks = tuple(self._admissions)
170+
await asyncio.gather(*tasks, return_exceptions=True)
171+
for task in tasks:
172+
self._admission_done(task)
173+
self._sockets.clear()
174+
175+
async def wait_closed(self) -> None:
176+
if self._close_task is None:
177+
raise RuntimeError("collector_listener_close_not_started")
178+
cancelled = False
179+
while not self._close_task.done():
180+
try:
181+
await asyncio.shield(self._close_task)
182+
except asyncio.CancelledError:
183+
cancelled = True
184+
self._close_task.result()
185+
if cancelled:
186+
raise asyncio.CancelledError

‎custom_components/eybond_local/flows/options/diagnostics.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
local_register_schema_override_details,
3232
resolve_local_metadata_rollback_paths,
3333
)
34+
from ...support.proxy_capture import ProxyCaptureOverview, proxy_capture_overview_values
3435
from .shared import (
3536
_BOOLEAN_SELECTOR,
3637
_MULTILINE_LOG_TEXT_SELECTOR,
@@ -915,9 +916,15 @@ def _localized_support_workflow(self, values: dict[str, Any]) -> dict[str, str]:
915916
),
916917
}
917918

918-
def _diagnostics_placeholders(self) -> dict[str, str]:
919+
def _diagnostics_placeholders(
920+
self, *, proxy_capture_overview: ProxyCaptureOverview | None = None,
921+
) -> dict[str, str]:
919922
coordinator = self._coordinator()
920923
values = coordinator.data.values if coordinator is not None else {}
924+
if proxy_capture_overview is not None:
925+
# Capture's instructions and actions must describe the same current
926+
# view. Keep trace/timer evidence, without mutating the poll snapshot.
927+
values = {**values, **proxy_capture_overview_values(proxy_capture_overview)}
921928
proxy_capture_download_url = self._fresh_proxy_capture_download_url(values)
922929
effective_owner_name = (
923930
coordinator.effective_owner_name if coordinator is not None else ""

‎custom_components/eybond_local/flows/options/proxy.py‎

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@
3333
_coerce_proxy_capture_duration_minutes,
3434
)
3535
from ...support.download import sign_proxy_capture_download_url
36+
from ...support.proxy_capture import ProxyCaptureOverview
3637

3738
logger = logging.getLogger(__name__)
3839

@@ -237,8 +238,11 @@ def _proxy_capture_action_error_message(self, exc: Exception) -> str:
237238
)
238239
return raw_error
239240

240-
def _proxy_capture_action_options(self, coordinator) -> list[SelectOptionDict]:
241-
overview = coordinator.proxy_capture_overview
241+
def _proxy_capture_action_options(
242+
self, coordinator, *, overview: ProxyCaptureOverview | None = None,
243+
) -> list[SelectOptionDict]:
244+
if overview is None:
245+
overview = coordinator.proxy_capture_overview
242246
options: list[SelectOptionDict] = []
243247
if overview.can_stop:
244248
options.append(
@@ -284,12 +288,14 @@ def _proxy_capture_action_options(self, coordinator) -> list[SelectOptionDict]:
284288
return options
285289

286290
def _default_proxy_capture_action(
287-
self, coordinator, options: list[SelectOptionDict]
291+
self, coordinator, options: list[SelectOptionDict], *,
292+
overview: ProxyCaptureOverview | None = None,
288293
) -> str:
289294
"""Return the default proxy-capture action for the current form state."""
290295

291296
option_values = {str(option["value"]) for option in options}
292-
overview = coordinator.proxy_capture_overview
297+
if overview is None:
298+
overview = coordinator.proxy_capture_overview
293299
if overview.can_start and "start" in option_values:
294300
return "start"
295301
if overview.can_stop and "refresh" in option_values:
@@ -304,9 +310,12 @@ def _show_proxy_capture_form(
304310
*,
305311
errors: dict[str, str] | None = None,
306312
) -> ConfigFlowResult:
307-
options = self._proxy_capture_action_options(coordinator)
308-
default_action = self._default_proxy_capture_action(coordinator, options)
309-
placeholders = self._diagnostics_placeholders()
313+
overview = coordinator.proxy_capture_overview
314+
options = self._proxy_capture_action_options(coordinator, overview=overview)
315+
default_action = self._default_proxy_capture_action(
316+
coordinator, options, overview=overview,
317+
)
318+
placeholders = self._diagnostics_placeholders(proxy_capture_overview=overview)
310319
return self.async_show_form(
311320
step_id="proxy_capture",
312321
data_schema=vol.Schema(

‎custom_components/eybond_local/runtime/coordinator/cloud_tools.py‎

Lines changed: 3 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
from ...support.memory_guard import read_available_memory_mib, shadow_learning_memory_blocker
4141
from ...support.proxy_capture import (
4242
build_proxy_capture_overview,
43+
proxy_capture_overview_values,
4344
resolve_proxy_wire_mode,
4445
)
4546
from ...support.proxy_capture.session import (
@@ -2550,23 +2551,8 @@ def _proxy_capture_overview_runtime_values(
25502551
latest_trace_path=self.latest_proxy_trace_path,
25512552
latest_manifest_path=self.latest_proxy_trace_manifest_path,
25522553
)
2553-
values: dict[str, Any] = {
2554-
"proxy_capture_status": overview.status,
2555-
"proxy_capture_status_label": overview.status_label,
2556-
"proxy_capture_summary": overview.summary,
2557-
"proxy_capture_blocking_reason": overview.blocking_reason,
2558-
"proxy_capture_can_start": overview.can_start,
2559-
"proxy_capture_can_reconnect_for_start": overview.can_reconnect_for_start,
2560-
"proxy_capture_can_stop": overview.can_stop,
2561-
"proxy_capture_critical_phase": overview.critical_phase,
2562-
"proxy_capture_redirect_required": overview.redirect_required,
2563-
"proxy_capture_collector_cloud_family": self.collector_cloud_family,
2564-
"proxy_capture_current_endpoint": overview.current_endpoint,
2565-
"proxy_capture_target_endpoint": overview.target_endpoint,
2566-
"proxy_capture_masked_endpoint": overview.masked_endpoint,
2567-
"proxy_trace_path": overview.latest_trace_path,
2568-
"proxy_trace_manifest_path": overview.latest_manifest_path,
2569-
}
2554+
values = proxy_capture_overview_values(overview)
2555+
values["proxy_capture_collector_cloud_family"] = self.collector_cloud_family
25702556
values.update(self._proxy_capture_timer_runtime_values(active_state))
25712557
if active_state is not None:
25722558
values.update(

0 commit comments

Comments
 (0)