From 58b02d99536fa8105085268729551d8482bbc4d2 Mon Sep 17 00:00:00 2001 From: Nikita Petrov Date: Mon, 3 Aug 2026 08:14:24 +0100 Subject: [PATCH] Fix 100% CPU spin on closed port forward socket dataplicity 0.4.40 `portforward.Connection.run` handles a zero byte recv() with a `break` that only leaves the inner `for` loop over the poll results, not the outer `while`. EOF keeps the socket permanently readable, so poll() returns immediately and the thread calls recv() forever, burning a core. The loop's only per connection exit is `channel.is_closed`, which flips only when the m2m server answers `Channel.close()` with notify_close. While the tunnel is unhealthy - exactly when sockets get aborted - that answer never arrives. `close_event` cannot be used instead, it is shared by the whole port forwarding service. Reloading the config entry does not stop the orphaned thread either. recv() now raises a BaseException that escapes the `except Exception` around it, still runs the `finally` cleanup of run(), and is swallowed by a wrapper. Verified against dataplicity 0.4.40 with a peer that sends FIN and keeps the socket in CLOSE_WAIT: before: thread alive after 6s, 5.58s CPU burned after: thread exits at once, 0.00s CPU, channel.close() called --- custom_components/dataplicity/utils.py | 73 ++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/custom_components/dataplicity/utils.py b/custom_components/dataplicity/utils.py index 0ed019d..8ff46ee 100644 --- a/custom_components/dataplicity/utils.py +++ b/custom_components/dataplicity/utils.py @@ -142,6 +142,76 @@ def install_package( return True +class _ConnectionEOF(BaseException): + """Signals that a forwarded socket has reached EOF. + + Deliberately not an Exception: `portforward.Connection.run` wraps recv() + in `except Exception`, which would swallow the signal. + """ + + +class _EOFAwareSocket: + """Socket proxy that raises _ConnectionEOF on a zero byte recv().""" + + def __init__(self, sock): + self._sock = sock + + def recv(self, *args, **kwargs): + data = self._sock.recv(*args, **kwargs) + if not data: + raise _ConnectionEOF + return data + + def __getattr__(self, name): + return getattr(self._sock, name) + + +def fix_portforward_eof(): + """Stop a peer closed socket from spinning a CPU core at 100%. + + In dataplicity 0.4.40 `portforward.Connection.run` reacts to a zero byte + recv() with a `break` that leaves only the inner `for` loop over the poll + results, not the outer `while`. EOF keeps the socket permanently readable, + so poll() returns immediately and the thread calls recv() forever. + + The loop's only per connection exit is `self.channel.is_closed`, but + `Channel.close()` merely *requests* a close - `is_closed` flips when the + m2m server answers with notify_close. While the tunnel is unhealthy, which + is exactly when sockets get aborted, that answer never arrives and the + thread keeps burning a core. `close_event` is no alternative: it is shared + by the whole port forwarding service, so a single connection must not set + it. Reloading the config entry does not help either, the orphaned thread + just keeps running. + + The connection therefore leaves the loop on its own: recv() raises a + BaseException that escapes the `except Exception` around it, still runs the + `finally` cleanup of run(), and is swallowed by a wrapper. + """ + from dataplicity import portforward + + if getattr(portforward, "_eof_fix_applied", False): + return + + connect = portforward.Connection._connect + run = portforward.Connection.run + + def _connect(self) -> bool: + connected = connect(self) + if connected and self.socket is not None: + self.socket = _EOFAwareSocket(self.socket) + return connected + + def _run(self): + try: + run(self) + except _ConnectionEOF: + _LOGGER.debug("Port forward connection closed by peer") + + portforward.Connection._connect = _connect + portforward.Connection.run = _run + portforward._eof_fix_applied = True + + def import_client(): # fix: type object 'array.array' has no attribute 'tostring' from dataplicity import iptool @@ -153,6 +223,9 @@ def import_client(): device_meta.get_os_version = lambda: "Linux" + # fix: 100% CPU spin when a forwarded socket is closed by the peer + fix_portforward_eof() + from dataplicity.client import Client return Client