111 lines
4.0 KiB
Python
111 lines
4.0 KiB
Python
"""Connection resilience when a domain reload leaves the Unity socket half-open."""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from core.config import config
|
|
import transport.legacy.unity_connection as uc
|
|
from transport.legacy.unity_connection import UnityConnection
|
|
|
|
|
|
def _start_silent_bridge():
|
|
"""Accept and handshake, then never answer (a wedged half-open bridge)."""
|
|
srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
|
srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
|
srv.bind(("127.0.0.1", 0))
|
|
srv.listen(8)
|
|
port = srv.getsockname()[1]
|
|
accepted: list[socket.socket] = []
|
|
stop = threading.Event()
|
|
|
|
def serve():
|
|
srv.settimeout(0.5)
|
|
while not stop.is_set():
|
|
try:
|
|
client, _ = srv.accept()
|
|
except socket.timeout:
|
|
continue
|
|
except OSError:
|
|
break
|
|
accepted.append(client)
|
|
try:
|
|
client.sendall(b"WELCOME UNITY-MCP 1 FRAMING=1\n")
|
|
except OSError:
|
|
pass
|
|
|
|
threading.Thread(target=serve, daemon=True).start()
|
|
return srv, port, stop, accepted
|
|
|
|
|
|
def _write_reloading_status(tmp_path):
|
|
status_dir = Path(tmp_path) / ".unity-mcp"
|
|
status_dir.mkdir(parents=True, exist_ok=True)
|
|
(status_dir / "unity-mcp-status-deadbeef.json").write_text(
|
|
json.dumps({"reloading": True, "reason": "reloading", "project_path": "x"})
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def silent_bridge(monkeypatch, tmp_path):
|
|
srv, port, stop, accepted = _start_silent_bridge()
|
|
monkeypatch.setattr(uc.Path, "home", lambda: Path(tmp_path))
|
|
monkeypatch.setattr(uc.stdio_port_registry, "get_port", lambda instance_id=None: port)
|
|
monkeypatch.setattr(uc.stdio_port_registry, "get_instance", lambda instance_id: None)
|
|
monkeypatch.setattr(config, "connection_timeout", 1.0)
|
|
monkeypatch.setattr(config, "command_total_timeout", 2.0, raising=False)
|
|
try:
|
|
yield port
|
|
finally:
|
|
stop.set()
|
|
try:
|
|
srv.close()
|
|
except OSError:
|
|
pass
|
|
for client in accepted:
|
|
try:
|
|
client.close()
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def test_send_command_against_wedged_socket_is_bounded(silent_bridge, monkeypatch):
|
|
"""A connection_timeout longer than the total budget must not let a single blocking
|
|
recv overrun the ceiling: the deadline caps each recv, so the call still stops near
|
|
command_total_timeout rather than connection_timeout."""
|
|
monkeypatch.setattr(config, "connection_timeout", 5.0)
|
|
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
|
|
start = time.monotonic()
|
|
with pytest.raises(TimeoutError, match="exceeded total deadline"):
|
|
conn.send_command("get_editor_state", {})
|
|
assert time.monotonic() - start < 4.0
|
|
|
|
|
|
def test_send_command_with_retry_is_bounded_by_deadline(silent_bridge, tmp_path, monkeypatch):
|
|
"""The reload-wait loop honours command_total_timeout, not just max_wait_s."""
|
|
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
|
|
monkeypatch.setattr(uc, "get_unity_connection", lambda instance_id=None: conn)
|
|
_write_reloading_status(tmp_path)
|
|
|
|
start = time.monotonic()
|
|
resp = uc.send_command_with_retry("get_editor_state", {}, instance_id="Repro@deadbeef")
|
|
assert time.monotonic() - start < 6.0
|
|
assert uc._extract_response_reason(resp) == "reloading"
|
|
|
|
|
|
def test_reload_signal_drops_socket_for_fresh_reconnect(silent_bridge, tmp_path):
|
|
"""A 'reloading' status drops the socket so the next command reconnects."""
|
|
conn = UnityConnection(port=silent_bridge, instance_id="Repro@deadbeef")
|
|
assert conn.connect() is True
|
|
assert conn.sock is not None
|
|
_write_reloading_status(tmp_path)
|
|
|
|
response = conn.send_command("get_editor_state", {})
|
|
assert conn.sock is None
|
|
assert uc._extract_response_reason(response) == "reloading"
|