fix: clean failed UIAutomator2 startups before retrying

This commit is contained in:
chenmingxuan committed 2026-10-12 10:58:47 +08:00
1 parent 4d6560dbd4
commit 3fbcdfefea
4 files changed
+304 -5

No files matched your search

+46 -2
View File
@@ -247,6 +247,48 @@ def _ensure_maestro_not_installed(device_id: str) -> None:
_uninstall_package(device_id, MAESTRO_PACKAGE)
class UIAutomatorConnectionError(RuntimeError):
"""Terminal connection failure with device context and the original cause."""
def __init__(self, device_id: str, attempts: int, cause: Exception) -> None:
# The library appends its whole output buffer on every readiness poll.
# Keep the original exception as __cause__, but do not repeat those
# identical startup lines dozens of times in the user-facing failure.
lines = [line for arg in cause.args for line in str(arg).splitlines()]
detail = "\n".join(dict.fromkeys(lines)) or str(cause)
super().__init__(
f"UIAutomator2 连接失败(设备:{device_id};已尝试 {attempts} 次)。"
f"\n底层原因:{type(cause).__name__}: {detail}"
)
class _ManagedUIAutomatorDevice(u2.Device):
"""Release a failed launch before the constructor can lose its handle.
UIAutomator2 registers exit cleanup only after startup succeeds. Its
partially constructed Device is not returned when readiness fails, so
the caller cannot stop that launch. Intercept the startup boundary here
instead of modifying the installed library or killing device processes.
"""
def start_uiautomator(self) -> None:
try:
super().start_uiautomator()
except BaseException:
# _process is set only for a server launched by this instance;
# attaching to a server already on the device leaves it None.
if self._process is not None:
try:
super().stop_uiautomator(wait=False)
except Exception as cleanup_error:
# Cleanup must never hide the startup error or interrupt.
logger.warning(
f"Failed to stop this UIAutomator2 launch on {self._dev.serial}: "
f"{cleanup_error}"
)
raise
class UIAutomatorClient:
"""UIAutomator2 client for Android screen data retrieval.
@@ -297,7 +339,7 @@ class UIAutomatorClient:
if attempt:
time.sleep(1.0 * attempt)
try:
self._device = u2.connect(self._device_id)
self._device = _ManagedUIAutomatorDevice(self._device_id)
break
except Exception as exc:
last_error = exc
@@ -307,7 +349,9 @@ class UIAutomatorClient:
)
else:
self._awake_strategy = None
raise last_error
raise UIAutomatorConnectionError(
self._device_id, attempt + 1, last_error
) from last_error
logger.info("UIAutomator2 connected successfully")
return self._device
+2
View File
@@ -52,6 +52,8 @@ MCP 同步仅检查 `$CODEX_HOME/config.toml`;未设置或为空时使用当
执行保护默认总时限 1800 秒,单任务、批次和 MCP 都适用,排队及用户暂停不计时。设备观测默认 60 秒、最多 3 次,ADB 命令默认 10 秒;观测失败不会继续向模型发送占位图或旧元素索引。Flash 连续 3 次动作失败先重新观察再切换兜底,兜底仍失败则结束任务并保存原因。不能以提高步数或反复重提任务代替设备诊断。
UIAutomator2 启动失败时,先释放本次连接启动的服务,再按现有策略重试;最终错误保留设备编号、尝试次数和底层原因。初始化失败且没有操作步骤时,用例目标属于未执行、未验证,应先检查设备诊断和启动日志,不能据此认定被测功能失败。
`mobile_run_task(runtime_overrides={"timeout_seconds":1800,"observation_timeout_seconds":60,"observation_max_attempts":3,"device_command_timeout_seconds":10,"action_failure_attempts":3})` 可为单任务覆盖执行保护;其中 `model` 是 API 模型名,外层 `model` 仍是 Flash/Pro 模式。相同字段也支持批次的 `task_config`。全局默认通过网页功能配置或明确授权的 `mobile_config(section="capabilities", changes={"task_timeout_seconds":1800}, persist_defaults=true)` 保存,对后续任务生效,不接受密钥。
任务已经产生最终结果,但 SDK 线程或资源清理仍卡住时,默认最多再等待 30 秒后回收工作进程,避免继续占用队列。全局可用 `ARTEMIS_WORKER_CLEANUP_TIMEOUT_SECONDS` 或功能配置的 `worker_cleanup_timeout_seconds` 调整,任务 JSON 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。
@@ -0,0 +1,250 @@
"""UIAutomator startup recovery, using the real library with isolated device IO."""
from dataclasses import dataclass, field
import json
import threading
from types import SimpleNamespace
from unittest.mock import MagicMock
import pytest
from uiautomator2 import base, core
from artemis.clients import ui_automator_client
from artemis.clients.accessibility_client import HelperUnavailable
from artemis.clients.screen_client_factory import FallbackScreenClient
@dataclass
class Clock:
now: float = 0.0
def time(self):
return self.now
def sleep(self, seconds):
self.now += seconds
class DeviceSocket:
def __init__(self, mode):
self.mode = mode
self.closed = threading.Event()
self.started = threading.Event()
def recv(self, _size):
if not self.started.is_set():
self.started.set()
output = b"[server] INFO: [UiAutomator2Server] Starting Server\n"
if self.mode == "conflict":
output += (
b"java.lang.IllegalStateException: UiAutomationService already registered!"
)
return output
self.closed.wait()
return b""
class DeviceConnection:
def __init__(self, mode):
self.conn = DeviceSocket(mode)
self.close_calls = 0
self.close_error = None
def close(self):
self.close_calls += 1
if self.close_error:
raise self.close_error
self.conn.closed.set()
@dataclass
class DeviceServer:
modes: list[str] = field(default_factory=lambda: ["not_ready"])
connections: list[DeviceConnection] = field(default_factory=list)
previous_closed_at_launch: list[bool] = field(default_factory=list)
clock: Clock = field(default_factory=Clock)
cleanup_callbacks: MagicMock = field(default_factory=MagicMock)
close_error: Exception | None = None
probe_error: BaseException | None = None
def shell(self, command, stream=False):
assert stream is True
assert "com.wetest.uia2.Main" in command
self.previous_closed_at_launch.append(all(c.conn.closed.is_set() for c in self.connections))
index = min(len(self.connections), len(self.modes) - 1)
connection = DeviceConnection(self.modes[index])
connection.close_error = self.close_error
self.connections.append(connection)
return connection
def http_request(self, _device, _port, method, path, *args, **kwargs):
if self.connections:
latest = self.connections[-1]
assert latest.conn.started.wait(timeout=2)
if self.probe_error is not None:
raise self.probe_error
if latest.conn.mode == "ready" and not latest.conn.closed.is_set():
if method == "POST":
return core.HTTPResponse(
json.dumps({"result": {"displayWidth": 1080}}).encode()
)
return core.HTTPResponse(b"pong")
raise core.HTTPError("simulated device server is not ready")
@pytest.fixture
def server(monkeypatch):
simulated = DeviceServer()
device = SimpleNamespace(serial="isolated-startup-device", shell=simulated.shell)
monkeypatch.setattr(base._BaseClient, "_wait_for_device", lambda _self: device)
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", lambda _self: None)
monkeypatch.setattr(core, "_http_request", simulated.http_request)
monkeypatch.setattr(core, "time", simulated.clock)
monkeypatch.setattr(core, "atexit", SimpleNamespace(register=simulated.cleanup_callbacks))
monkeypatch.setattr(ui_automator_client, "time", simulated.clock)
monkeypatch.setattr(ui_automator_client, "_ensure_maestro_not_installed", lambda _s: None)
monkeypatch.setattr(ui_automator_client, "ensure_device_awake", lambda _s: "simulated")
yield simulated
# Close fake sockets even when a regression leaves a real library reader alive.
for connection in simulated.connections:
connection.conn.closed.set()
def test_each_failed_launch_is_closed_before_retry_and_final_failure(server):
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
assert server.clock.now == 93.0
assert server.cleanup_callbacks.call_count == 0
assert client._device is None
assert client._awake_strategy is None
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert "isolated-startup-device" in str(caught.value)
assert "3" in str(caught.value)
def test_retry_can_connect_after_failed_launch_was_stopped(server):
server.modes = ["not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
client.connect()
client.connect() # Cached device info also uses the real library RPC path.
assert len(server.connections) == 2
assert server.previous_closed_at_launch == [True, True]
assert server.connections[0].close_calls == 1
assert server.connections[1].close_calls == 0
assert server.cleanup_callbacks.call_count == 1
client.disconnect(stop_server=True)
assert server.connections[1].close_calls == 1
def test_later_connection_can_succeed_after_all_previous_attempts_failed(server):
server.modes = ["not_ready", "not_ready", "not_ready", "ready"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.connect()
assert len(server.connections) == 4
assert server.previous_closed_at_launch == [True, True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1, 0]
client.disconnect(stop_server=True)
assert server.connections[-1].close_calls == 1
def test_auto_fallback_cleans_failed_launches_before_next_attempt(server):
helper = MagicMock()
helper.connect.side_effect = HelperUnavailable("simulated helper unavailable")
client = FallbackScreenClient(
"isolated-startup-device",
helper=helper,
uiautomator_factory=lambda: ui_automator_client.UIAutomatorClient(
"isolated-startup-device"
),
state_probe=lambda _serial: "device",
)
with pytest.raises(RuntimeError, match="server not ready"):
client.connect()
client.disconnect()
assert len(server.connections) == 3
assert server.previous_closed_at_launch == [True, True, True]
assert [c.close_calls for c in server.connections] == [1, 1, 1]
helper.disconnect.assert_called_once()
def test_service_conflict_closes_own_streams_and_preserves_cause(server):
server.modes = ["conflict"]
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="already registered") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.AccessibilityServiceAlreadyRegisteredError)
assert len(server.connections) == 3
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_cleanup_error_does_not_replace_original_startup_failure(server):
server.close_error = OSError("simulated cleanup connection error")
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="server not ready") as caught:
client.connect()
assert isinstance(caught.value.__cause__, core.LaunchUiAutomationError)
assert [c.close_calls for c in server.connections] == [1, 1, 1]
def test_interrupt_during_startup_also_closes_own_stream(server):
server.probe_error = KeyboardInterrupt()
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(KeyboardInterrupt):
client.connect()
assert len(server.connections) == 1
assert server.connections[0].close_calls == 1
def test_failure_before_launch_does_not_stop_another_device_service(server, monkeypatch):
failure = OSError("simulated jar transfer failure")
def fail_before_launch(_self):
raise failure
monkeypatch.setattr(core.BasicUiautomatorServer, "_setup_jar", fail_before_launch)
stop = MagicMock()
monkeypatch.setattr(core.BasicUiautomatorServer, "stop_uiautomator", stop)
client = ui_automator_client.UIAutomatorClient("isolated-startup-device")
with pytest.raises(RuntimeError, match="jar transfer failure") as caught:
client.connect()
assert caught.value.__cause__ is failure
assert server.connections == []
stop.assert_not_called()
def test_default_helper_success_does_not_start_uiautomator(server):
factory = MagicMock()
client = FallbackScreenClient(
"isolated-startup-device",
helper=MagicMock(),
uiautomator_factory=factory,
state_probe=lambda _serial: "device",
)
client.connect()
client.disconnect()
factory.assert_not_called()
assert server.connections == []
+6 -3
View File
@@ -6,7 +6,7 @@ from artemis.clients.ui_automator_client import UIAutomatorClient
from artemis.drivers.android.adb_driver import AndroidAdbDriver
@patch("artemis.clients.ui_automator_client.u2.connect")
@patch("artemis.clients.ui_automator_client._ManagedUIAutomatorDevice")
@patch(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
@@ -25,7 +25,10 @@ def test_new_ui_connection_enrolls_device_in_shared_awake_strategy(
mock_connect.assert_called_once_with("device-123")
@patch("artemis.clients.ui_automator_client.u2.connect", side_effect=RuntimeError("offline"))
@patch(
"artemis.clients.ui_automator_client._ManagedUIAutomatorDevice",
side_effect=RuntimeError("offline"),
)
@patch(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",
@@ -43,7 +46,7 @@ def test_failed_ui_connection_does_not_stop_process_awake_service(
assert client._awake_strategy is None
@patch("artemis.clients.ui_automator_client.u2.connect")
@patch("artemis.clients.ui_automator_client._ManagedUIAutomatorDevice")
@patch(
"artemis.clients.ui_automator_client.ensure_device_awake",
return_value="host_heartbeat",