修复一点点小bug

This commit is contained in:
2025-10-24 14:36:00 +08:00
parent dcb3f8e5af
commit 34b1d1ec77
6 changed files with 289 additions and 189 deletions

2
.idea/workspace.xml generated
View File

@@ -6,7 +6,7 @@
<component name="ChangeListManager"> <component name="ChangeListManager">
<list default="true" id="eceeff5e-51c1-459c-a911-d21ec090a423" name="Changes" comment="20250904-初步功能已完成"> <list default="true" id="eceeff5e-51c1-459c-a911-d21ec090a423" name="Changes" comment="20250904-初步功能已完成">
<change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" /> <change beforePath="$PROJECT_DIR$/.idea/workspace.xml" beforeDir="false" afterPath="$PROJECT_DIR$/.idea/workspace.xml" afterDir="false" />
<change beforePath="$PROJECT_DIR$/Module/FlaskService.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/FlaskService.py" afterDir="false" /> <change beforePath="$PROJECT_DIR$/Module/DeviceInfo.py" beforeDir="false" afterPath="$PROJECT_DIR$/Module/DeviceInfo.py" afterDir="false" />
</list> </list>
<option name="SHOW_DIALOG" value="false" /> <option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" /> <option name="HIGHLIGHT_CONFLICTS" value="true" />

View File

@@ -1,3 +1,4 @@
# -*- coding: utf-8 -*-
import os import os
import signal import signal
import subprocess import subprocess
@@ -6,30 +7,33 @@ import time
from concurrent.futures import ThreadPoolExecutor, as_completed from concurrent.futures import ThreadPoolExecutor, as_completed
from pathlib import Path from pathlib import Path
from typing import Dict, Optional, List from typing import Dict, Optional, List
import random
import socket
import http.client
import psutil
import hashlib # 仍保留,如需后续扩展
import tidevice import tidevice
import wda import wda
from tidevice import Usbmux, ConnectionType from tidevice import Usbmux, ConnectionType
from tidevice._device import BaseDevice from tidevice._device import BaseDevice
from Entity.DeviceModel import DeviceModel from Entity.DeviceModel import DeviceModel
from Entity.Variables import WdaAppBundleId, wdaFunctionPort, wdaScreenPort from Entity.Variables import WdaAppBundleId, wdaFunctionPort, wdaScreenPort
from Module.FlaskSubprocessManager import FlaskSubprocessManager from Module.FlaskSubprocessManager import FlaskSubprocessManager
from Module.IOSActivator import IOSActivator from Module.IOSActivator import IOSActivator
from Utils.LogManager import LogManager from Utils.LogManager import LogManager
import socket
import http.client
from collections import defaultdict
import psutil
import hashlib
class DeviceInfo: class DeviceInfo:
REMOVE_GRACE_SEC = 5.0 # 设备离线宽限期(秒) # --- 时序参数(更稳) ---
ADD_STABLE_SEC = 1.5 # 设备上线稳定期(秒) REMOVE_GRACE_SEC = 8.0 # 设备离线宽限期(秒)
ORPHAN_COOLDOWN = 3.0 # 拓扑变更后暂停孤儿清理(秒) ADD_STABLE_SEC = 2.5 # 设备上线稳定期(秒)
ORPHAN_COOLDOWN = 8.0 # 拓扑变更后暂停孤儿清理(秒)
HEAL_INTERVAL = 5.0 # 健康巡检间隔(秒)
def __init__(self): def __init__(self):
# 自增端口游标仅作兜底扫描使用
self._port = 9110 self._port = 9110
self._models: Dict[str, DeviceModel] = {} self._models: Dict[str, DeviceModel] = {}
self._procs: Dict[str, subprocess.Popen] = {} self._procs: Dict[str, subprocess.Popen] = {}
@@ -38,20 +42,24 @@ class DeviceInfo:
self._pool = ThreadPoolExecutor(max_workers=6) self._pool = ThreadPoolExecutor(max_workers=6)
self._last_heal_check_ts = 0.0 self._last_heal_check_ts = 0.0
self._heal_backoff: Dict[str, float] = defaultdict(float) # udid -> next_allowed_ts self._heal_backoff: Dict[str, float] = {} # udid -> next_allowed_ts
# 并发保护 & 状态表 # 并发保护 & 状态表
self._lock = threading.RLock() self._lock = threading.RLock()
self._port_by_udid: Dict[str, int] = {} # UDID -> local_port self._port_by_udid: Dict[str, int] = {} # UDID -> 当前使用的本地端口
self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID self._pid_by_udid: Dict[str, int] = {} # UDID -> iproxy PID
# 抗抖:最近一次看到在线的时间 / 首次看到在线的时间 # 抗抖
self._last_seen: Dict[str, float] = {} # udid -> ts self._last_seen: Dict[str, float] = {} # udid -> ts
self._first_seen: Dict[str, float] = {} # udid -> ts(首次在线) self._first_seen: Dict[str, float] = {} # udid -> ts(首次在线)
self._last_topology_change_ts = 0.0 # 最近一次“新增或真正移除”的时间 self._last_topology_change_ts = 0.0
LogManager.info("DeviceInfo init 完成;日志已启用", udid="system")
# ---------------- 主循环 ---------------- # ---------------- 主循环 ----------------
def listen(self): def listen(self):
method = "listen"
LogManager.method_info("进入主循环", method, udid="system")
orphan_gc_tick = 0 orphan_gc_tick = 0
while True: while True:
now = time.time() now = time.time()
@@ -59,8 +67,7 @@ class DeviceInfo:
usb = Usbmux().device_list() usb = Usbmux().device_list()
online_now = {d.udid for d in usb if d.conn_type == ConnectionType.USB} online_now = {d.udid for d in usb if d.conn_type == ConnectionType.USB}
except Exception as e: except Exception as e:
# 如果拉设备列表失败,本轮不做增删(避免误杀) LogManager.warning(f"[device_list] 异常:{e}", udid="system")
LogManager.warning(f"device_list() 异常:{e}")
time.sleep(1) time.sleep(1)
continue continue
@@ -68,33 +75,37 @@ class DeviceInfo:
for u in online_now: for u in online_now:
if u not in self._first_seen: if u not in self._first_seen:
self._first_seen[u] = now self._first_seen[u] = now
LogManager.method_info("first seen", method, udid=u)
self._last_seen[u] = now self._last_seen[u] = now
# 处理真正移除(连续缺席超过宽限期)
with self._lock: with self._lock:
known = set(self._models.keys()) known = set(self._models.keys())
# 真正移除(连续缺席超过宽限期)
for udid in list(known): for udid in list(known):
last = self._last_seen.get(udid, 0.0) last = self._last_seen.get(udid, 0.0)
if udid not in online_now and (now - last) >= self.REMOVE_GRACE_SEC: if udid not in online_now and (now - last) >= self.REMOVE_GRACE_SEC:
self._remove_device(udid) # 真正下线 LogManager.info(f"设备判定离线(超过宽限期 {self.REMOVE_GRACE_SEC}slast_seen={last}", udid=udid)
self._remove_device(udid)
self._last_topology_change_ts = now self._last_topology_change_ts = now
# 处理真正新增(连续在线超过稳定期) # 真正新增(连续在线超过稳定期)
new_candidates = [u for u in online_now if u not in known] new_candidates = [u for u in online_now if u not in known]
to_add = [u for u in new_candidates if (now - self._first_seen.get(u, now)) >= self.ADD_STABLE_SEC] to_add = [u for u in new_candidates if (now - self._first_seen.get(u, now)) >= self.ADD_STABLE_SEC]
if to_add: if to_add:
LogManager.info(f"新增设备稳定上线:{to_add}", udid="system")
futures = {self._pool.submit(self._add_device, u): u for u in to_add} futures = {self._pool.submit(self._add_device, u): u for u in to_add}
for f in as_completed(futures, timeout=30): for f in as_completed(futures, timeout=45):
try: try:
f.result() f.result()
self._last_topology_change_ts = time.time() self._last_topology_change_ts = time.time()
except Exception as e: except Exception as e:
LogManager.error(f"异步连接失败:{e}") LogManager.error(f"异步连接失败:{e}", udid="system")
# 定期健康检查 + 自愈 # 定期健康检查 + 自愈
self._check_and_heal_tunnels(interval=2.0) self._check_and_heal_tunnels(interval=self.HEAL_INTERVAL)
# 每 10 次清理一次孤儿 iproxy但在拓扑变更后 N 秒暂停执行,避免插拔风暴期误杀 # 周期性孤儿清理(拓扑变更冷却之后
orphan_gc_tick += 1 orphan_gc_tick += 1
if orphan_gc_tick >= 10: if orphan_gc_tick >= 10:
orphan_gc_tick = 0 orphan_gc_tick = 0
@@ -103,53 +114,64 @@ class DeviceInfo:
time.sleep(1) time.sleep(1)
# ---------------- 新增设备 ---------------- # ---------------- 新增设备 ----------------
def _add_device(self, udid: str): def _add_device(self, udid: str):
method = "_add_device"
LogManager.method_info("开始新增设备", method, udid=udid)
if not self._trusted(udid): if not self._trusted(udid):
LogManager.method_warning("未信任设备,跳过", method, udid=udid)
return return
r = self.startWda(udid) r = self.startWda(udid)
if r is False: if r is False:
LogManager.info("启动wda失败") LogManager.method_error("启动 WDA 失败,放弃新增", method, udid=udid)
return return
# iOS 17+ 激活/信任阶段更抖,稍等更稳
time.sleep(5)
w, h, s = self._screen_info(udid) w, h, s = self._screen_info(udid)
if w == 0 or h == 0 or s == 0: if w == 0 or h == 0 or s == 0:
print("未获取到设备屏幕信息") LogManager.method_warning("未获取到屏幕信息,放弃新增", method, udid=udid)
return return
print("获取设备信息成功")
# 固定端口分配(加锁,避免竞态) # 不复用端口:直接起一个新端口
with self._lock: proc = self._start_iproxy(udid, port=None)
port = self._alloc_port(udid)
proc = self._start_iproxy(udid, port)
if not proc: if not proc:
print("启动iproxy失败") LogManager.method_error("启动 iproxy 失败,放弃新增", method, udid=udid)
return return
with self._lock: with self._lock:
model = DeviceModel(deviceId=udid, screenPort=port, port = self._port_by_udid[udid]
width=w, height=h, scale=s, type=1) model = DeviceModel(deviceId=udid, screenPort=port, width=w, height=h, scale=s, type=1)
model.ready = True model.ready = True
self._models[udid] = model self._models[udid] = model
self._procs[udid] = proc self._procs[udid] = proc
self._pid_by_udid[udid] = proc.pid
print("备添加设备") LogManager.method_info(f"备添加完成port={port}, {w}x{h}@{s}", method, udid=udid)
self._manager_send(model) self._manager_send(model)
# ---------------- 移除设备(仅在宽限期后调用) ---------------- # ---------------- 移除设备 ----------------
def _remove_device(self, udid: str): def _remove_device(self, udid: str):
method = "_remove_device"
LogManager.method_info("开始移除设备", method, udid=udid)
with self._lock: with self._lock:
model = self._models.pop(udid, None) model = self._models.pop(udid, None)
proc = self._procs.pop(udid, None) proc = self._procs.pop(udid, None)
self._pid_by_udid.pop(udid, None) pid = self._pid_by_udid.pop(udid, None)
# 不清 _port_by_udid,端口下次仍复用,前端更稳定 self._port_by_udid.pop(udid, None)
if not model: if not model:
LogManager.method_warning("未找到设备模型,可能重复移除", method, udid=udid)
return return
model.type = 2 model.type = 2
self._kill(proc) self._kill(proc)
if pid:
self._kill_pid_gracefully(pid)
self._manager_send(model) self._manager_send(model)
LogManager.method_info("设备移除完毕", method, udid=udid)
# ---------------- 工具函数 ---------------- # ---------------- 工具函数 ----------------
def _trusted(self, udid: str) -> bool: def _trusted(self, udid: str) -> bool:
@@ -160,129 +182,257 @@ class DeviceInfo:
return False return False
def startWda(self, udid): def startWda(self, udid):
print("进入启动wda方法") method = "startWda"
LogManager.method_info("进入启动流程", method, udid=udid)
try: try:
dev = tidevice.Device(udid) dev = tidevice.Device(udid)
systemVersion = int(dev.product_version.split(".")[0]) systemVersion = int(dev.product_version.split(".")[0])
# 判断运行wda的逻辑
if systemVersion > 17: if systemVersion > 17:
LogManager.method_info(f"iOS 主版本 {systemVersion},使用 IOSActivator", method, udid=udid)
ios = IOSActivator() ios = IOSActivator()
threading.Thread( threading.Thread(target=ios.activate, args=(udid,), daemon=True).start()
target=ios.activate,
args=(udid,)
).start()
else: else:
LogManager.method_info(f"app_start WDA: {WdaAppBundleId}", method, udid=udid)
dev.app_start(WdaAppBundleId) dev.app_start(WdaAppBundleId)
print("启动wda成功") LogManager.method_info("WDA 启动完成,等待稳定...", method, udid=udid)
time.sleep(3) time.sleep(3)
return True return True
except Exception as e: except Exception as e:
print("启动wda遇到错误:", e) LogManager.method_error(f"WDA 启动异常:{e}", method, udid=udid)
return False return False
def _screen_info(self, udid: str): def _screen_info(self, udid: str):
method = "_screen_info"
try: try:
c = wda.USBClient(udid, wdaFunctionPort) c = wda.USBClient(udid, wdaFunctionPort)
c.home() c.home()
size = c.window_size() size = c.window_size()
scale = c.scale scale = c.scale
LogManager.method_info(f"屏幕信息:{int(size.width)}x{int(size.height)}@{float(scale)}", method, udid=udid)
return int(size.width), int(size.height), float(scale) return int(size.width), int(size.height), float(scale)
except Exception as e: except Exception as e:
print("获取设备信息遇到错误:", e) LogManager.method_warning(f"获取屏幕信息异常:{e}", method, udid=udid)
return 0, 0, 0 return 0, 0, 0
# ---------------- 端口映射(保留你之前的“先杀后启”“隐藏黑窗”修复) ---------------- # ---------------- 端口/进程:不复用端口 ----------------
def _start_iproxy(self, udid: str, port: int) -> Optional[subprocess.Popen]: def _is_port_bindable(self, port: int, host: str = "127.0.0.1") -> bool:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind((host, port))
return True
except OSError:
return False
finally:
s.close()
def _pick_new_port(self, tries: int = 40) -> int:
method = "_pick_new_port"
# 先在 9111~9499 随机尝试
for _ in range(tries // 2):
p = random.randint(9111, 9499)
if self._is_port_bindable(p):
LogManager.method_info(f"端口候选可用(9k段){p}", method, udid="system")
return p
else:
LogManager.method_info(f"端口候选占用(9k段){p}", method, udid="system")
# 再在 20000~48000 随机尝试
for _ in range(tries):
p = random.randint(20000, 48000)
if self._is_port_bindable(p):
LogManager.method_info(f"端口候选可用(20k-48k){p}", method, udid="system")
return p
else:
LogManager.method_info(f"端口候选占用(20k-48k){p}", method, udid="system")
LogManager.method_warning("随机端口尝试耗尽,改顺序扫描", method, udid="system")
return self._pick_free_port(start=49152, limit=10000)
def _wait_until_listening(self, port: int, timeout: float = 2.0) -> bool:
method = "_wait_until_listening"
deadline = time.time() + timeout
while time.time() < deadline:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.2)
if s.connect_ex(("127.0.0.1", port)) == 0:
LogManager.method_info(f"端口已开始监听:{port}", method, udid="system")
return True
time.sleep(0.05)
LogManager.method_warning(f"监听验收超时:{port}", method, udid="system")
return False
def _start_iproxy(self, udid: str, port: Optional[int] = None) -> Optional[subprocess.Popen]:
method = "_start_iproxy"
try: try:
with self._lock: with self._lock:
old_pid = self._pid_by_udid.get(udid) old_pid = self._pid_by_udid.get(udid)
if old_pid: if old_pid:
LogManager.method_info(f"发现旧 iproxy准备结束pid={old_pid}", method, udid=udid)
self._kill_pid_gracefully(old_pid) self._kill_pid_gracefully(old_pid)
self._pid_by_udid.pop(udid, None) self._pid_by_udid.pop(udid, None)
time.sleep(0.2) time.sleep(0.2)
if not self._is_port_free(port): attempts = 0
port = self._pick_free_port(start=max(self._port, port)) while attempts < 3:
attempts += 1
local_port = port if (attempts == 1 and port is not None) else self._pick_new_port()
if not self._is_port_bindable(local_port):
LogManager.method_info(f"[attempt {attempts}] 端口竞争,换候选:{local_port}", method, udid=udid)
continue
LogManager.method_info(f"[attempt {attempts}] 启动 iproxyport={local_port}", method, udid=udid)
creationflags = 0 creationflags = 0
startupinfo = None startupinfo = None
if os.name == "nt": if os.name == "nt":
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | getattr(subprocess, creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | \
"CREATE_NEW_PROCESS_GROUP", getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0x00000200)
0x00000200)
si = subprocess.STARTUPINFO() si = subprocess.STARTUPINFO()
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW si.dwFlags |= subprocess.STARTF_USESHOWWINDOW
si.wShowWindow = 0 si.wShowWindow = 0
startupinfo = si startupinfo = si
cmd = [self._iproxy_path, "-u", udid, str(port), str(wdaScreenPort)] cmd = [self._iproxy_path, "-u", udid, str(local_port), str(wdaScreenPort)]
proc = subprocess.Popen( try:
cmd, proc = subprocess.Popen(
stdout=subprocess.DEVNULL, cmd,
stderr=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
creationflags=creationflags, stderr=subprocess.DEVNULL,
startupinfo=startupinfo creationflags=creationflags,
) startupinfo=startupinfo
self._procs[udid] = proc )
self._pid_by_udid[udid] = proc.pid except Exception as e:
self._port_by_udid[udid] = port LogManager.method_warning(f"创建进程失败:{e}", method, udid=udid)
continue
if not self._wait_until_listening(local_port, timeout=2.0):
LogManager.method_warning(f"[attempt {attempts}] iproxy 未监听,重试换端口", method, udid=udid)
self._kill(proc)
continue
with self._lock:
self._procs[udid] = proc
self._pid_by_udid[udid] = proc.pid
self._port_by_udid[udid] = local_port
LogManager.method_info(f"iproxy 启动成功并监听pid={proc.pid}, port={local_port}", method, udid=udid)
return proc return proc
LogManager.method_error("iproxy 启动多次失败", method, udid=udid)
return None
except Exception as e: except Exception as e:
print(e) LogManager.method_error(f"_start_iproxy 异常:{e}", method, udid=udid)
return None return None
def _kill(self, proc: Optional[subprocess.Popen]): def _kill(self, proc: Optional[subprocess.Popen]):
method = "_kill"
if not proc: if not proc:
return return
try: try:
proc.terminate() proc.terminate()
proc.wait(timeout=2) proc.wait(timeout=2)
LogManager.method_info("进程已正常终止", method, udid="system")
except Exception: except Exception:
try: try:
os.kill(proc.pid, signal.SIGKILL) os.kill(proc.pid, signal.SIGKILL)
except Exception: LogManager.method_warning("进程被强制杀死", method, udid="system")
pass except Exception as e:
LogManager.method_warning(f"强杀失败:{e}", method, udid="system")
# ---------------- 端口分配(加锁 + 稳定端口) ---------------- # ---------------- 自愈:直接换新端口重启 + 指数退避 ----------------
def _alloc_port(self, udid: str) -> int: def _restart_iproxy(self, udid: str):
""" method = "_restart_iproxy"
为 UDID 分配一个**稳定**的本地端口: now = time.time()
- 同一 UDID 优先复用上次端口(减少前端切换) next_allowed = self._heal_backoff.get(udid, 0.0)
- 初次分配使用 “20000-45000” 的稳定哈希起点向上探测空闲 if now < next_allowed:
""" delta = round(next_allowed - now, 2)
# 已有则直接复用 LogManager.method_info(f"自愈被退避抑制,剩余 {delta}s", method, udid=udid)
if udid in self._port_by_udid: return
p = self._port_by_udid[udid]
if self._is_port_free(p):
return p
# 基于 UDID 计算稳定起点 with self._lock:
h = int(hashlib.sha1(udid.encode("utf-8")).hexdigest(), 16) proc = self._procs.get(udid)
start = 20000 + (h % 25000) # 20000~44999 if proc:
# 避免和你类里默认的 9110 等端口冲突,向上找空闲 LogManager.method_info(f"为重启准备清理旧 iproxypid={proc.pid}", method, udid=udid)
p = self._pick_free_port(start=start, limit=4000) self._kill(proc)
self._port_by_udid[udid] = p time.sleep(0.2)
return p model = self._models.get(udid)
if not model:
LogManager.method_warning("模型不存在,取消自愈", method, udid=udid)
return
def _manager_send(self, model: DeviceModel): proc2 = self._start_iproxy(udid, port=None)
if not proc2:
backoff_old = max(1.5, next_allowed - now + 1.0) if next_allowed > now else 1.5
backoff = min(backoff_old * 1.7, 15.0)
self._heal_backoff[udid] = now + backoff
LogManager.method_warning(f"重启失败,扩展退避 {round(backoff,2)}s", method, udid=udid)
return
# 成功后短退避
self._heal_backoff[udid] = now + 1.2
# 通知前端新端口
with self._lock:
model = self._models.get(udid)
if model:
model.screenPort = self._port_by_udid.get(udid, model.screenPort)
self._models[udid] = model
self._manager_send(model)
LogManager.method_info(f"重启成功,使用新端口 {self._port_by_udid.get(udid)}", method, udid=udid)
# ---------------- 健康检查 ----------------
def _health_check_mjpeg(self, port: int, timeout: float = 0.8) -> bool:
method = "_health_check_mjpeg"
try: try:
self._manager.send(model.toDict()) conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout)
conn.request("HEAD", "/")
resp = conn.getresponse()
_ = resp.read(128)
conn.close()
return True
except Exception: except Exception:
pass return False
def _find_iproxy(self) -> str: def _health_check_wda(self, udid: str) -> bool:
base = Path(__file__).resolve().parent.parent method = "_health_check_wda"
name = "iproxy.exe" try:
path = base / "resources" / "iproxy" / name c = wda.USBClient(udid, wdaFunctionPort)
print(str(path)) st = c.status()
if path.is_file(): return bool(st)
return str(path) except Exception:
raise FileNotFoundError(f"iproxy 不存在: {path}") return False
# ------------ Windows 专用:列出所有 iproxy 命令行(更安全) ------------ def _check_and_heal_tunnels(self, interval: float = 5.0):
method = "_check_and_heal_tunnels"
now = time.time()
if now - self._last_heal_check_ts < interval:
return
self._last_heal_check_ts = now
if (now - self._last_topology_change_ts) < max(self.ORPHAN_COOLDOWN, 6.0):
LogManager.method_info("拓扑变更冷却中,本轮跳过自愈", method, udid="system")
return
with self._lock:
items = list(self._models.items())
for udid, model in items:
port = model.screenPort
if port <= 0:
continue
ok_local = self._health_check_mjpeg(port, timeout=0.8)
ok_wda = self._health_check_wda(udid)
LogManager.method_info(f"健康检查mjpeg={ok_local}, wda={ok_wda}, port={port}", method, udid=udid)
if not (ok_local and ok_wda):
LogManager.method_warning(f"检测到不健康触发重启port={port}", method, udid=udid)
self._restart_iproxy(udid)
# ---------------- Windows 专用:列出所有 iproxy 命令行 ----------------
def _get_all_iproxy_cmdlines(self) -> List[str]: def _get_all_iproxy_cmdlines(self) -> List[str]:
method = "_get_all_iproxy_cmdlines"
lines: List[str] = [] lines: List[str] = []
live_pids = set()
with self._lock: with self._lock:
live_pids = set(self._pid_by_udid.values()) live_pids = set(self._pid_by_udid.values())
for p in psutil.process_iter(attrs=["name", "cmdline", "pid"]): for p in psutil.process_iter(attrs=["name", "cmdline", "pid"]):
@@ -290,7 +440,6 @@ class DeviceInfo:
name = (p.info.get("name") or "").lower() name = (p.info.get("name") or "").lower()
if name != "iproxy.exe": if name != "iproxy.exe":
continue continue
# 跳过我们自己登记在册的 iproxy避免误杀
if p.info["pid"] in live_pids: if p.info["pid"] in live_pids:
continue continue
cmdline = p.info.get("cmdline") or [] cmdline = p.info.get("cmdline") or []
@@ -301,41 +450,48 @@ class DeviceInfo:
lines.append(f"{cmd} {p.info['pid']}") lines.append(f"{cmd} {p.info['pid']}")
except (psutil.NoSuchProcess, psutil.AccessDenied): except (psutil.NoSuchProcess, psutil.AccessDenied):
continue continue
LogManager.method_info(f"扫描到候选 iproxy 进程数={len(lines)}", method, udid="system")
return lines return lines
# ------------ 杀孤儿 ------------ # ---------------- 杀孤儿 ----------------
def _cleanup_orphan_iproxy(self): def _cleanup_orphan_iproxy(self):
live_udids = set() method = "_cleanup_orphan_iproxy"
live_pids = set()
with self._lock: with self._lock:
live_udids = set(self._models.keys()) live_udids = set(self._models.keys())
live_pids = set(self._pid_by_udid.values()) live_pids = set(self._pid_by_udid.values())
cleaned = 0
for ln in self._get_all_iproxy_cmdlines(): for ln in self._get_all_iproxy_cmdlines():
parts = ln.split() parts = ln.split()
try: try:
udid = parts[parts.index('-u') + 1] udid = parts[parts.index('-u') + 1]
pid = int(parts[-1]) pid = int(parts[-1])
# 既不在我们的 PID 表里,且 UDID 不在线,才算孤儿
if pid not in live_pids and udid not in live_udids: if pid not in live_pids and udid not in live_udids:
self._kill_pid_gracefully(pid) self._kill_pid_gracefully(pid)
LogManager.warning(f'扫到孤儿 iproxy已清理 {udid} PID={pid}') cleaned += 1
LogManager.method_warning(f"孤儿 iproxy 已清理udid={udid}, pid={pid}", method, udid="system")
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
# ------------ 按 PID 强杀 ------------ if cleaned:
LogManager.method_info(f"孤儿清理完成,数量={cleaned}", method, udid="system")
# ---------------- 按 PID 强杀 ----------------
def _kill_pid_gracefully(self, pid: int): def _kill_pid_gracefully(self, pid: int):
method = "_kill_pid_gracefully"
try: try:
p = psutil.Process(pid) p = psutil.Process(pid)
p.terminate() p.terminate()
try: try:
p.wait(timeout=1.0) p.wait(timeout=1.0)
LogManager.method_info(f"进程已终止pid={pid}", method, udid="system")
except psutil.TimeoutExpired: except psutil.TimeoutExpired:
p.kill() p.kill()
except Exception: LogManager.method_warning(f"进程被强制 killpid={pid}", method, udid="system")
pass except Exception as e:
LogManager.method_warning(f"kill 进程异常pid={pid}, err={e}", method, udid="system")
# ------------ 端口工具 ------------ # ---------------- 端口工具(兜底) ----------------
def _is_port_free(self, port: int) -> bool: def _is_port_free(self, port: int) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -347,91 +503,35 @@ class DeviceInfo:
return False return False
def _pick_free_port(self, start: int = None, limit: int = 2000) -> int: def _pick_free_port(self, start: int = None, limit: int = 2000) -> int:
"""从 start 起向上找一个空闲端口。(注意:调用方务必在 self._lock 下)""" method = "_pick_free_port"
p = self._port if start is None else start p = self._port if start is None else start
tried = 0 tried = 0
while tried < limit: while tried < limit:
p += 1 p += 1
tried += 1 tried += 1
if self._is_port_free(p): if self._is_port_free(p):
self._port = p # 更新游标 LogManager.method_info(f"顺序扫描找到端口:{p}", method, udid="system")
return p return p
LogManager.method_error("顺序扫描未找到可用端口(范围内)", method, udid="system")
raise RuntimeError("未找到可用端口(扫描范围内)") raise RuntimeError("未找到可用端口(扫描范围内)")
def _health_check_mjpeg(self, port: int, timeout: float = 1.0) -> bool: # ---------------- 其他 ----------------
""" def _manager_send(self, model: DeviceModel):
对 http://127.0.0.1:<port>/ 做非常轻量的探活。 method = "_manager_send"
WDA mjpegServer(默认9100)通常根路径就会有 multipart/x-mixed-replace。
"""
try: try:
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=timeout) self._manager.send(model.toDict())
conn.request("GET", "/") LogManager.method_info("已通知管理器(前端)", method, udid=model.deviceId)
resp = conn.getresponse() except Exception as e:
alive = 200 <= resp.status < 400 LogManager.method_warning(f"通知管理器异常:{e}", method, udid=model.deviceId)
try:
resp.read(256)
except Exception:
pass
conn.close()
return alive
except Exception:
return False
def _restart_iproxy(self, udid: str): def _find_iproxy(self) -> str:
"""重启某个 udid 的 iproxy带退避""" method = "_find_iproxy"
now = time.time() base = Path(__file__).resolve().parent.parent
next_allowed = self._heal_backoff[udid] name = "iproxy.exe"
if now < next_allowed: path = base / "resources" / "iproxy" / name
return # 处于退避窗口内,先不重启 LogManager.method_info(f"查找 iproxy 路径:{path}", method, udid="system")
if path.is_file():
with self._lock: return str(path)
proc = self._procs.get(udid) err = f"iproxy 不存在: {path}"
if proc: LogManager.method_error(err, method, udid="system")
self._kill(proc) raise FileNotFoundError(err)
time.sleep(0.3)
model = self._models.get(udid)
if not model:
return
# 如果端口被别的进程占用了,换一个新端口并通知管理器
if not self._is_port_free(model.screenPort):
new_port = self._pick_free_port(start=max(self._port, model.screenPort))
model.screenPort = new_port
self._models[udid] = model
self._port_by_udid[udid] = new_port
self._manager_send(model) # 通知前端/上位机端口变化
proc2 = self._start_iproxy(udid, model.screenPort)
if not proc2:
# 启动失败,设置退避(逐步增加上限)
self._heal_backoff[udid] = now + 2.0
return
self._procs[udid] = proc2
self._pid_by_udid[udid] = proc2.pid
# 成功后缩短退避
self._heal_backoff[udid] = now + 0.5
def _check_and_heal_tunnels(self, interval: float = 2.0):
"""
定期巡检所有在线设备的本地映射端口是否“活着”,不活就重启 iproxy。
"""
now = time.time()
if now - self._last_heal_check_ts < interval:
return
self._last_heal_check_ts = now
# 读取时也加锁,避免与增删设备并发冲突
with self._lock:
items = list(self._models.items())
for udid, model in items:
port = model.screenPort
if port <= 0:
continue
ok = self._health_check_mjpeg(port, timeout=0.8)
if not ok:
LogManager.warning(f"端口失活准备自愈udid={udid} port={port}")
self._restart_iproxy(udid)

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 609 KiB