From 147dd3fc16a72a3634f0e3d7baf72674a9f23ade Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Thu, 29 Jan 2026 19:24:26 +0000 Subject: [PATCH] Close Docker sockets on errors --- src/webterm/docker_exec_session.py | 77 ++++++++++++++++-------------- src/webterm/docker_stats.py | 5 +- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/src/webterm/docker_exec_session.py b/src/webterm/docker_exec_session.py index a8866e4..99f29e7 100644 --- a/src/webterm/docker_exec_session.py +++ b/src/webterm/docker_exec_session.py @@ -127,21 +127,23 @@ class DockerExecSession(Session): def _request_json(self, method: str, path: str, payload: dict | None = None) -> dict: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(self._socket_path) - body = json.dumps(payload or {}).encode("utf-8") if payload is not None else b"" - headers = [ - f"{method} {path} HTTP/1.1", - "Host: localhost", - ] - if payload is not None: - headers.append("Content-Type: application/json") - headers.append(f"Content-Length: {len(body)}") - headers.append("") - headers.append("") - request = "\r\n".join(headers).encode("utf-8") + body - sock.sendall(request) - status, _headers, body_bytes = self._read_http_response(sock) - sock.close() + try: + sock.connect(self._socket_path) + body = json.dumps(payload or {}).encode("utf-8") if payload is not None else b"" + headers = [ + f"{method} {path} HTTP/1.1", + "Host: localhost", + ] + if payload is not None: + headers.append("Content-Type: application/json") + headers.append(f"Content-Length: {len(body)}") + headers.append("") + headers.append("") + request = "\r\n".join(headers).encode("utf-8") + body + sock.sendall(request) + status, _headers, body_bytes = self._read_http_response(sock) + finally: + sock.close() if status < 200 or status >= 300: detail = body_bytes.decode("utf-8", errors="replace") raise RuntimeError(f"Docker API request failed ({status}): {detail}") @@ -170,28 +172,31 @@ class DockerExecSession(Session): def _start_exec_socket(self, exec_id: str) -> socket.socket: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) - sock.connect(self._socket_path) - payload = json.dumps({"Detach": False, "Tty": True}).encode("utf-8") - headers = [ - f"POST /exec/{exec_id}/start HTTP/1.1", - "Host: localhost", - "Content-Type: application/json", - f"Content-Length: {len(payload)}", - "Connection: Upgrade", - "Upgrade: tcp", - "", - "", - ] - sock.sendall("\r\n".join(headers).encode("utf-8") + payload) - status, _headers, body = self._read_http_response(sock) - if status not in (101,) and (status < 200 or status >= 300): + try: + sock.connect(self._socket_path) + payload = json.dumps({"Detach": False, "Tty": True}).encode("utf-8") + headers = [ + f"POST /exec/{exec_id}/start HTTP/1.1", + "Host: localhost", + "Content-Type: application/json", + f"Content-Length: {len(payload)}", + "Connection: Upgrade", + "Upgrade: tcp", + "", + "", + ] + sock.sendall("\r\n".join(headers).encode("utf-8") + payload) + status, _headers, body = self._read_http_response(sock) + if status not in (101,) and (status < 200 or status >= 300): + detail = body.decode("utf-8", errors="replace") + raise RuntimeError(f"Docker API exec start failed ({status}): {detail}") + # Don't save body from HTTP upgrade - it contains protocol handshake data, + # not real terminal output (e.g., device attribute responses like "\x1b[?1;10;0c") + sock.settimeout(None) + return sock + except Exception: sock.close() - detail = body.decode("utf-8", errors="replace") - raise RuntimeError(f"Docker API exec start failed ({status}): {detail}") - # Don't save body from HTTP upgrade - it contains protocol handshake data, - # not real terminal output (e.g., device attribute responses like "\x1b[?1;10;0c") - sock.settimeout(None) - return sock + raise def _resize_exec(self, width: int, height: int) -> None: assert self._exec_id is not None diff --git a/src/webterm/docker_stats.py b/src/webterm/docker_stats.py index be381ce..fc3ce7f 100644 --- a/src/webterm/docker_stats.py +++ b/src/webterm/docker_stats.py @@ -81,6 +81,7 @@ class DockerStatsCollector: loop = asyncio.get_event_loop() def _sync_request() -> bytes | None: + sock: socket.socket | None = None try: sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) sock.settimeout(10.0) # Increased timeout @@ -97,11 +98,13 @@ class DockerStatsCollector: if not chunk: break chunks.append(chunk) - sock.close() return b"".join(chunks) except (OSError, TimeoutError) as e: log.debug("Socket error for %s: %s", path, e) return None + finally: + if sock is not None: + sock.close() response = await loop.run_in_executor(None, _sync_request) if response is None: