OverviewFilesCommitsIssuesDocsReleasesPeers

blender-bridge / blender_addon / connection.py

138 lines · python ref:
"""UDS client connection — Python mirror of src/uds_server.h client side.

Length-prefixed messages: [4-byte LE length] [payload].
Non-blocking receive for use with Blender's timer callbacks.
"""

import select
import socket
import struct


class BridgeConnection:
    """Unix Domain Socket client for BlenderBridge signaling."""

    def __init__(self, socket_path: str = "/tmp/blender_bridge.sock"):
        self._sock = None
        self._path = socket_path
        self._recv_buf = bytearray()
        self._disconnected_unexpectedly = False

    # ── Lifecycle ────────────────────────────────────────────────────────

    def connect(self) -> bool:
        """Connect to the Godot-side UDS server."""
        try:
            self._sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
            self._sock.connect(self._path)
            self._sock.setblocking(False)
            self._disconnected_unexpectedly = False
            # Send handshake
            self.send_message(b"hello")
            return True
        except OSError:
            self._sock = None
            return False

    def close(self):
        """Intentional close — no auto-reconnect."""
        if self._sock is not None:
            try:
                self._sock.close()
            except OSError:
                pass
            self._sock = None
        self._recv_buf.clear()
        self._disconnected_unexpectedly = False

    def _close_unexpected(self):
        """Unexpected disconnect (EOF/error) — triggers auto-reconnect."""
        if self._sock is not None:
            try:
                self._sock.close()
            except OSError:
                pass
            self._sock = None
        self._recv_buf.clear()
        self._disconnected_unexpectedly = True

    @property
    def connected(self) -> bool:
        return self._sock is not None

    @property
    def should_reconnect(self) -> bool:
        """True if disconnected unexpectedly and should attempt reconnect."""
        return self._disconnected_unexpectedly and self._sock is None

    def clear_reconnect(self):
        """Clear the reconnect flag (e.g. after successful reconnect)."""
        self._disconnected_unexpectedly = False

    # ── Send ─────────────────────────────────────────────────────────────

    def send_message(self, data: bytes):
        """Send a length-prefixed message.

        Handles BlockingIOError (send buffer full) by waiting for writability
        instead of treating it as a fatal disconnect. This is critical when
        streaming many tile_ready messages — the non-blocking socket's send
        buffer can fill up, and sendall() would raise BlockingIOError.
        """
        if self._sock is None:
            return
        frame = struct.pack("<I", len(data)) + data
        sent = 0
        while sent < len(frame):
            try:
                n = self._sock.send(frame[sent:])
                if n == 0:
                    self._close_unexpected()
                    return
                sent += n
            except BlockingIOError:
                # Send buffer full — wait up to 5s for writability
                _, writable, _ = select.select([], [self._sock], [], 5.0)
                if not writable:
                    self._close_unexpected()
                    return
            except OSError:
                self._close_unexpected()
                return

    # ── Receive (non-blocking) ───────────────────────────────────────────

    def poll_messages(self) -> list[bytes]:
        """Read all available messages (non-blocking). Returns list of payloads."""
        if self._sock is None:
            return []

        # Read available data
        try:
            while True:
                chunk = self._sock.recv(4096)
                if not chunk:
                    # Server closed
                    self._close_unexpected()
                    return []
                self._recv_buf.extend(chunk)
        except BlockingIOError:
            pass  # No more data available
        except OSError:
            self._close_unexpected()
            return []

        # Parse complete messages
        messages = []
        while len(self._recv_buf) >= 4:
            msg_len = struct.unpack_from("<I", self._recv_buf, 0)[0]
            if msg_len > 16 * 1024 * 1024:  # 16 MB sanity limit
                self._close_unexpected()
                return messages
            if len(self._recv_buf) < 4 + msg_len:
                break  # Incomplete
            messages.append(bytes(self._recv_buf[4 : 4 + msg_len]))
            del self._recv_buf[: 4 + msg_len]

        return messages