Files
2026-07-13 12:49:17 +08:00

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"