chore: 初始化仓库(排除 adb 二进制文件以解决推送超时)
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
"""设备生命周期 + 通用 Worker 基类 + 全局状态注册表 + 心跳看门狗。
|
||||
|
||||
STFDevice — 单设备生命周期:acquire(占用+adb连接)→ release(断开+释放)
|
||||
BaseWorker — 通用 worker 基类:封装 STF 生命周期 + u2 连接 + 状态上报 + stop
|
||||
+ 生命周期钩子(setup/teardown/on_error) + 心跳上报
|
||||
子类只需实现 run_task(d),可选实现钩子,不用管 STF/adb/状态/异常
|
||||
_WORKERS — 全局状态注册表,供 web_server 读取实时状态
|
||||
_watchdog — 心跳看门狗线程,检测 worker 卡死
|
||||
|
||||
新增任务只需写一个 BaseWorker 子类 + 一个 BaseTask 子类,
|
||||
完全不用碰这里的代码。
|
||||
"""
|
||||
import time
|
||||
import threading
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
|
||||
import uiautomator2 as u2
|
||||
|
||||
from core.logger import get_logger
|
||||
from core.stf_client import DeviceOfflineError, STFError
|
||||
from .adb_helper import adb_connect, adb_disconnect
|
||||
|
||||
_log = get_logger("core.worker")
|
||||
|
||||
# 心跳超时阈值(秒)。worker 超过这个时间没更新心跳,判定为卡死
|
||||
_HEARTBEAT_TIMEOUT = 120
|
||||
# 看门狗检查间隔
|
||||
_WATCHDOG_INTERVAL = 30
|
||||
# u2.connect / d.info 超时(秒)。超过则放弃,避免 worker 永久 hang
|
||||
_U2_CONNECT_TIMEOUT = 30
|
||||
_U2_INFO_TIMEOUT = 10
|
||||
|
||||
|
||||
class STFDevice:
|
||||
"""单设备生命周期:acquire(占用+连接)→ release(断开+释放)。
|
||||
|
||||
用 try/finally 保证设备一定被释放,即使养号中途报错。
|
||||
"""
|
||||
|
||||
def __init__(self, stf_client, serial=None):
|
||||
self.stf = stf_client
|
||||
self.serial = serial
|
||||
self.remote_adb_url = None
|
||||
|
||||
def acquire(self, serial=None):
|
||||
self.serial = serial or self.serial or self._pick_free()
|
||||
_log.info(f"[{self.serial}] 选定设备")
|
||||
|
||||
ok, msg = self.stf.occupy(self.serial)
|
||||
if not ok:
|
||||
raise STFError(f"占用设备失败: {msg}", "conflict")
|
||||
_log.info(f"[{self.serial}] STF 占用成功")
|
||||
|
||||
# 优先直连设备 serial(当 serial 本身就是 IP:5555 时,如 Tailscale 网络)
|
||||
# 这比 STF remoteConnect 桥接更稳定,避免桥接地址认证失败
|
||||
if ":" in self.serial:
|
||||
self.remote_adb_url = self.serial
|
||||
_log.info(f"[{self.serial}] 直连设备: {self.remote_adb_url}")
|
||||
else:
|
||||
# serial 不是 IP:port 格式,走 STF remoteConnect 桥接
|
||||
self.remote_adb_url = self.stf.remote_connect(self.serial)
|
||||
_log.info(f"[{self.serial}] STF 桥接: {self.remote_adb_url}")
|
||||
|
||||
if not adb_connect(self.remote_adb_url):
|
||||
raise STFError(f"adb connect 失败: {self.remote_adb_url}", "offline")
|
||||
time.sleep(2)
|
||||
|
||||
def _pick_free(self):
|
||||
free = self.stf.list_free_devices()
|
||||
if not free:
|
||||
raise STFError("没有可用的在线空闲设备", "offline")
|
||||
return free[0]["serial"]
|
||||
|
||||
def release(self):
|
||||
if not self.serial:
|
||||
return
|
||||
# 直连模式(remote_adb_url == serial):不 disconnect
|
||||
# STF agent 可能共享同一个 adb 连接,disconnect 会导致 STF 设备离线后重新连接
|
||||
if self.remote_adb_url and self.remote_adb_url != self.serial:
|
||||
adb_disconnect(self.remote_adb_url)
|
||||
try:
|
||||
self.stf.remote_disconnect(self.serial)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
self.stf.release(self.serial)
|
||||
_log.info(f"[{self.serial}] 已释放")
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] 释放失败: {e}")
|
||||
|
||||
|
||||
# ================== 全局 worker 状态注册表(供 web_server 读取) ==================
|
||||
_WORKERS_LOCK = threading.Lock()
|
||||
_WORKERS = {} # serial -> status dict
|
||||
|
||||
|
||||
def get_all_worker_status():
|
||||
with _WORKERS_LOCK:
|
||||
return list(_WORKERS.values())
|
||||
|
||||
|
||||
def _update_status(serial, **fields):
|
||||
with _WORKERS_LOCK:
|
||||
s = _WORKERS.setdefault(serial, {"serial": serial})
|
||||
s.update(fields)
|
||||
s["last_heartbeat"] = time.time()
|
||||
|
||||
|
||||
def _remove_worker(serial):
|
||||
with _WORKERS_LOCK:
|
||||
_WORKERS.pop(serial, None)
|
||||
|
||||
|
||||
def get_worker_heartbeat(serial):
|
||||
"""返回 worker 最近一次心跳时间,没有返回 0。"""
|
||||
with _WORKERS_LOCK:
|
||||
return _WORKERS.get(serial, {}).get("last_heartbeat", 0)
|
||||
|
||||
|
||||
# ================== 心跳看门狗 ==================
|
||||
class _Watchdog(threading.Thread):
|
||||
"""后台线程:检测 worker 心跳超时,自动标记异常并设置 stop 信号。
|
||||
|
||||
worker 卡死(如 u2 操作 hang 住)时,看门狗能发现并标记状态,
|
||||
避免设备一直被占用却不干活。
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(daemon=True, name="worker-watchdog")
|
||||
self._stop = threading.Event()
|
||||
|
||||
def run(self):
|
||||
while not self._stop.wait(_WATCHDOG_INTERVAL):
|
||||
try:
|
||||
self._check()
|
||||
except Exception as e:
|
||||
_log.error(f"看门狗检查异常: {e}")
|
||||
|
||||
def _check(self):
|
||||
now = time.time()
|
||||
with _WORKERS_LOCK:
|
||||
stale = []
|
||||
for serial, s in _WORKERS.items():
|
||||
# 只检查运行中的 worker
|
||||
if s.get("status") not in ("running", "connecting"):
|
||||
continue
|
||||
hb = s.get("last_heartbeat", 0)
|
||||
if hb and now - hb > _HEARTBEAT_TIMEOUT:
|
||||
stale.append(serial)
|
||||
for serial in stale:
|
||||
_log.error(f"[{serial}] 心跳超时 {now - get_worker_heartbeat(serial):.0f}s,标记卡死")
|
||||
_update_status(serial, status="error",
|
||||
last_error=f"心跳超时 {_HEARTBEAT_TIMEOUT}s,worker 可能卡死")
|
||||
|
||||
def stop(self):
|
||||
self._stop.set()
|
||||
|
||||
|
||||
_watchdog = None
|
||||
|
||||
|
||||
def start_watchdog():
|
||||
"""启动看门狗(web_server 启动时调一次)。"""
|
||||
global _watchdog
|
||||
if _watchdog is None or not _watchdog.is_alive():
|
||||
_watchdog = _Watchdog()
|
||||
_watchdog.start()
|
||||
_log.info("心跳看门狗已启动")
|
||||
|
||||
|
||||
def stop_watchdog():
|
||||
"""停止看门狗。"""
|
||||
global _watchdog
|
||||
if _watchdog:
|
||||
_watchdog.stop()
|
||||
_watchdog = None
|
||||
|
||||
|
||||
# ================== 通用 Worker 基类 ==================
|
||||
class BaseWorker(threading.Thread):
|
||||
"""通用 worker 基类。
|
||||
|
||||
自动处理:STF 占用/释放、u2 连接、状态上报、异常捕获、stop 信号、心跳。
|
||||
子类只需实现 run_task(d) 方法,专注业务逻辑。
|
||||
|
||||
生命周期(基类 run() 已封装,不要重写):
|
||||
1. acquire 设备(STF 占用 + adb 连接)
|
||||
2. u2.connect 拿到 d
|
||||
3. 调用 setup(d) ← 子类可选钩子,做初始化
|
||||
4. 调用子类 run_task(d) ← 业务逻辑
|
||||
5. 调用 teardown(d) ← 子类可选钩子,做清理
|
||||
6. finally: release 设备
|
||||
|
||||
子类可选钩子:
|
||||
setup(d) — run_task 前的初始化(如启动 app、检查登录)。默认空。
|
||||
teardown(d) — run_task 后的清理(无论成功失败)。默认空。
|
||||
on_error(d, err) — run_task 抛异常时回调。默认记录日志。
|
||||
|
||||
子类可用工具方法:
|
||||
self.stopped() — 判断是否被要求停止(循环里检查)
|
||||
self.set_action(s) — 上报当前动作(同时刷新心跳)
|
||||
self.set_progress() — 上报进度字段(同时刷新心跳)
|
||||
self.heartbeat() — 手动刷新心跳(长操作中间调)
|
||||
|
||||
进度上报规范(通用,适配任意 app):
|
||||
self.set_progress(done=5, total=80, unit="视频")
|
||||
- done: 已完成数量(int)
|
||||
- total: 总数量(int)
|
||||
- unit: 计数单位(str,如 "视频"/"轮次"/"条")
|
||||
- action_counts: 操作计数 dict(可选,如 {"like": 3, "comment": 1})
|
||||
- 也可传任意业务字段,前端会原样展示在"进度"列
|
||||
示例:
|
||||
self.set_progress(done=5, total=80, unit="视频",
|
||||
action_counts={"like": 3, "comment": 1})
|
||||
前端展示:进度条 + "5/80 视频" + 操作计数徽章
|
||||
|
||||
运行时长终止(通用,适配任意 app):
|
||||
self.max_duration — 最大运行时长(秒),0=不限时
|
||||
self.is_time_up() — 判断是否已达 max_duration
|
||||
子类在循环里检查 self.stopped() or self.is_time_up()
|
||||
到时自动停(自然结束,不是 error),状态=done
|
||||
|
||||
异常分类(影响上层重试策略):
|
||||
DeviceOfflineError — 设备掉线/不可用,不重试(换设备也没用)
|
||||
其他异常 — 可重试
|
||||
"""
|
||||
|
||||
def __init__(self, stf_client, serial, params=None, daemon=True):
|
||||
super().__init__(daemon=daemon)
|
||||
self.stf = stf_client
|
||||
self.serial = serial
|
||||
self.params = params or {}
|
||||
self._stop_flag = threading.Event()
|
||||
self.d = None # u2.Device,run_task 里用
|
||||
# 通用进度字段(子类通过 set_progress 上报)
|
||||
self._progress = {"done": 0, "total": 0, "unit": "",
|
||||
"action_counts": {}}
|
||||
# 通用运行时长终止(子类设 max_duration>0 启用)
|
||||
self.max_duration = 0 # 秒,0=不限时
|
||||
self._start_time = 0.0 # run_task 开始时间戳
|
||||
|
||||
def _start_timer(self):
|
||||
"""子类在 run_task 开头调用,启动运行时长计时。"""
|
||||
self._start_time = time.time()
|
||||
|
||||
def is_time_up(self):
|
||||
"""是否已达最大运行时长。max_duration=0 时永远返回 False。"""
|
||||
if self.max_duration <= 0 or self._start_time <= 0:
|
||||
return False
|
||||
return (time.time() - self._start_time) >= self.max_duration
|
||||
|
||||
def elapsed(self):
|
||||
"""已运行时长(秒)。run_task 未启动时返回 0。"""
|
||||
if self._start_time <= 0:
|
||||
return 0
|
||||
return int(time.time() - self._start_time)
|
||||
|
||||
def stop(self):
|
||||
"""外部调用,请求停止(子类应在循环里检查 self.stopped())。"""
|
||||
self._stop_flag.set()
|
||||
|
||||
def stopped(self):
|
||||
"""子类在循环里调用,判断是否被要求停止。"""
|
||||
return self._stop_flag.is_set()
|
||||
|
||||
def set_action(self, action):
|
||||
"""上报当前动作(子类用)。同时刷新心跳。"""
|
||||
_update_status(self.serial, current_action=action)
|
||||
|
||||
def set_progress(self, **fields):
|
||||
"""上报进度字段(子类用)。同时刷新心跳。
|
||||
|
||||
通用字段(前端会解析展示):
|
||||
done (int) — 已完成数量
|
||||
total (int) — 总数量
|
||||
unit (str) — 计数单位("视频"/"轮次"/"条")
|
||||
action_counts (dict) — 操作计数 {"like": 3, "comment": 1}
|
||||
|
||||
也可传任意业务字段,会合并到 progress 里,前端原样展示。
|
||||
|
||||
示例:
|
||||
self.set_progress(done=5, total=80, unit="视频",
|
||||
action_counts={"like": 3, "comment": 1})
|
||||
"""
|
||||
self._progress.update(fields)
|
||||
# 同步到全局状态字典,前端通过 get_status 读取
|
||||
_update_status(self.serial, progress=dict(self._progress))
|
||||
|
||||
def heartbeat(self):
|
||||
"""手动刷新心跳。长操作中间调,避免看门狗误判卡死。"""
|
||||
_update_status(self.serial, last_heartbeat=time.time())
|
||||
|
||||
# ================== 子类实现/可选钩子 ==================
|
||||
def run_task(self, d):
|
||||
"""子类实现:拿到 u2.Device d,做具体业务。
|
||||
|
||||
不需要管 STF 占用/释放、异常、状态上报,基类已处理。
|
||||
循环里记得检查 self.stopped() 以便响应停止请求。
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def setup(self, d):
|
||||
"""run_task 前的初始化钩子。默认空,子类按需重写。
|
||||
|
||||
典型用途:启动 app、检查登录状态、进入主页。
|
||||
抛异常会中断任务(走 on_error)。
|
||||
"""
|
||||
|
||||
def teardown(self, d):
|
||||
"""run_task 后的清理钩子(无论成功失败都调)。默认空,子类按需重写。
|
||||
|
||||
典型用途:退出 app、清理临时文件。
|
||||
抛异常只记日志,不影响主流程。
|
||||
"""
|
||||
|
||||
def on_error(self, d, error):
|
||||
"""run_task 抛异常时的回调。默认空,子类按需重写。
|
||||
|
||||
典型用途:截图保存现场、上报错误详情。
|
||||
"""
|
||||
|
||||
# ================== 基类主循环(不要重写) ==================
|
||||
def run(self):
|
||||
"""基类主循环:不要重写。子类实现 run_task + 可选钩子。"""
|
||||
device = STFDevice(self.stf, serial=self.serial)
|
||||
_update_status(self.serial, status="connecting", stf_occupied=False,
|
||||
last_error="", remote_adb_url="", model="",
|
||||
current_action="")
|
||||
try:
|
||||
device.acquire()
|
||||
remote = device.remote_adb_url
|
||||
# u2.connect 可能 hang 住(atx-agent 无响应),用线程池+超时保护
|
||||
self.d = self._u2_connect_with_timeout(remote)
|
||||
if self.d is None:
|
||||
raise STFError(f"u2.connect 超时 {_U2_CONNECT_TIMEOUT}s,atx-agent 无响应", "offline")
|
||||
_log.info(f"[{self.serial}] u2 连接成功")
|
||||
model = ""
|
||||
try:
|
||||
info = self._u2_info_with_timeout()
|
||||
if info:
|
||||
model = info.get("productName") or ""
|
||||
except Exception:
|
||||
pass
|
||||
_update_status(self.serial, status="running", stf_occupied=True,
|
||||
remote_adb_url=remote, model=model)
|
||||
|
||||
# setup 钩子
|
||||
try:
|
||||
self.setup(self.d)
|
||||
except Exception as e:
|
||||
_log.error(f"[{self.serial}] setup 异常: {e}")
|
||||
raise
|
||||
|
||||
# 业务逻辑
|
||||
_log.info(f"[{self.serial}] 进入 run_task,开始执行业务逻辑")
|
||||
self.run_task(self.d)
|
||||
_log.info(f"[{self.serial}] run_task 执行完毕")
|
||||
|
||||
# teardown 钩子(成功路径)
|
||||
try:
|
||||
self.teardown(self.d)
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] teardown 异常: {e}")
|
||||
|
||||
if not self.stopped():
|
||||
_update_status(self.serial, status="done")
|
||||
|
||||
except DeviceOfflineError as e:
|
||||
# 设备掉线,不重试
|
||||
_log.error(f"[{self.serial}] 设备离线: {e}")
|
||||
_update_status(self.serial, status="error", last_error=str(e))
|
||||
self._safe_on_error(e)
|
||||
except Exception as e:
|
||||
err_str = str(e)
|
||||
# 识别 Windows TCP 端口耗尽(WinError 10048)或 adb server 连接失败
|
||||
# 这类错误是临时性的,重试时需要更长退避等端口释放
|
||||
is_transient = ("10048" in err_str
|
||||
or "connect to adb server failed" in err_str
|
||||
or "Unable to connect to uiautomator2 server" in err_str)
|
||||
if is_transient:
|
||||
_log.error(f"[{self.serial}] ADB/u2 连接错误(可能端口耗尽): {e}")
|
||||
_update_status(self.serial, status="error",
|
||||
last_error=f"[transient] {e}")
|
||||
else:
|
||||
_log.error(f"[{self.serial}] 异常: {e}", exc_info=True)
|
||||
_update_status(self.serial, status="error", last_error=str(e))
|
||||
self._safe_on_error(e)
|
||||
finally:
|
||||
try:
|
||||
device.release()
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] release 异常: {e}")
|
||||
# 只在仍在运行/连接中时才标记 released;
|
||||
# done/error/failed 等终态保持不变,避免覆盖业务结果
|
||||
with _WORKERS_LOCK:
|
||||
cur = _WORKERS.get(self.serial, {}).get("status")
|
||||
if cur in ("running", "connecting"):
|
||||
_update_status(self.serial, stf_occupied=False, status="released")
|
||||
else:
|
||||
_update_status(self.serial, stf_occupied=False)
|
||||
|
||||
def _u2_connect_with_timeout(self, remote):
|
||||
"""u2.connect 带超时保护,避免 atx-agent 无响应时永久 hang。"""
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
fut = pool.submit(u2.connect, remote)
|
||||
try:
|
||||
return fut.result(timeout=_U2_CONNECT_TIMEOUT)
|
||||
except FuturesTimeout:
|
||||
_log.error(f"[{self.serial}] u2.connect 超时 {_U2_CONNECT_TIMEOUT}s")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log.error(f"[{self.serial}] u2.connect 异常: {e}")
|
||||
return None
|
||||
|
||||
def _u2_info_with_timeout(self):
|
||||
"""self.d.info 带超时保护,避免 atx-agent 无响应时永久 hang。"""
|
||||
with ThreadPoolExecutor(max_workers=1) as pool:
|
||||
fut = pool.submit(lambda: self.d.info)
|
||||
try:
|
||||
return fut.result(timeout=_U2_INFO_TIMEOUT)
|
||||
except FuturesTimeout:
|
||||
_log.warning(f"[{self.serial}] d.info 超时 {_U2_INFO_TIMEOUT}s,跳过型号获取")
|
||||
return None
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] d.info 异常: {e}")
|
||||
return None
|
||||
|
||||
def _safe_on_error(self, error):
|
||||
"""安全调用 on_error 钩子,不让钩子异常影响主流程。"""
|
||||
try:
|
||||
self.on_error(self.d, error)
|
||||
except Exception as e:
|
||||
_log.warning(f"[{self.serial}] on_error 钩子异常: {e}")
|
||||
Reference in New Issue
Block a user