From 3fbcdfefea62c91ef9967f4ccaf29648a38ebfad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=99=88=E9=93=AD=E8=BD=A9?= Date: Mon, 12 Oct 2026 10:58:47 +0800 Subject: [PATCH] fix: clean failed UIAutomator2 startups before retrying --- artemis/clients/ui_automator_client.py | 48 +++- docs/ai-automation.md | 2 + .../unit/clients/test_ui_automator_startup.py | 250 ++++++++++++++++++ tests/unit/test_ui_automator_client_awake.py | 9 +- 4 files changed, 304 insertions(+), 5 deletions(-) create mode 100644 tests/unit/clients/test_ui_automator_startup.py diff --git a/artemis/clients/ui_automator_client.py b/artemis/clients/ui_automator_client.py index 12a73fb..209c0f1 100644 --- a/artemis/clients/ui_automator_client.py +++ b/artemis/clients/ui_automator_client.py @@ -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 diff --git a/docs/ai-automation.md b/docs/ai-automation.md index 48705cd..45565f1 100644 --- a/docs/ai-automation.md +++ b/docs/ai-automation.md @@ -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 同名字段可覆盖。原有结果与截图保留,未完成保存的录屏尽力恢复。 diff --git a/tests/unit/clients/test_ui_automator_startup.py b/tests/unit/clients/test_ui_automator_startup.py new file mode 100644 index 0000000..1f59659 --- /dev/null +++ b/tests/unit/clients/test_ui_automator_startup.py @@ -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 == [] diff --git a/tests/unit/test_ui_automator_client_awake.py b/tests/unit/test_ui_automator_client_awake.py index 12e3a82..117e550 100644 --- a/tests/unit/test_ui_automator_client_awake.py +++ b/tests/unit/test_ui_automator_client_awake.py @@ -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",