fix: clean failed UIAutomator2 startups before retrying
This commit is contained in:
1 parent
4d6560dbd4
commit
3fbcdfefea
4 files changed
+304
-5
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
Reference in new issue
Block a user