Files
ios-location-track/docs/VERIFY_REPORT.md
T
jbjf6666 85e9a53750 docs: 新增 Windows 移植交接文档
docs/WINDOWS_PORT.md 覆盖:
- 逐文件可移植性审计(核心逻辑层约 90% 可直接复用)
- 核心难点:iOS 17+ 的 RSD 隧道无法在 Windows 复用(macOS 实现依赖 XPC +
  ObjC runtime 私有框架),改用 pymobiledevice3 的 userspace 隧道
- 已实测确认的命令与 API(--userspace 挂在 lockdown start-tunnel / mounter
  auto-mount 上,remote start-tunnel 不支持;类名是 UserspaceRsdTunnel)
- 窗口启动、数据目录、打包与分发(DMG → Inno Setup)、图标(.icns → .ico)
- macOS 侧踩过的坑:镜像挂载用 Python API、必须打包包元数据、不要把 distutils
  放进 excludes、AlreadyMountedError 当成功处理
- 设计约束(DVT 长连接、只能改经纬度、last_point 与 last_reported 分离等)
- 验收基线(复用现有测试套件)与建议的工作顺序
2026-10-16 01:05:09 +08:00

55 KiB
Raw Blame History

独立验证报告(task-4 · 对抗性审查)

  • 验证人:verifier(独立于 lead / web-ui-dev / device-dev,只读项目文件)
  • 验证时间:本机 macOS,Python 3.12.14(.venv),pymobiledevice3 11.29.1,Chrome headless
  • 验证方式:全部结论都由本人独立复跑/独立实现客户端得到,未采信任何既有测试结论
  • 我方实例端口:8877(8787 为 lead 实例,全程未触碰);所有临时脚本在 /tmp,验证结束后已关闭全部自建进程(lsof 确认 8877 无监听)
  • 真实设备:全程只做代码级/只读枚举核对,未设置定位、未改设备状态、未建隧道

0. 结论

状态:有阻塞问题(不可直接交付)。

功能主干是真实可用的:图形界面上手绘轨迹、加载路线、播放/暂停/继续/停止/进度、GPX 导入导出、真机 DVT 虚拟定位链路在本机跑通;82/82 自检与 19/19 图形界面验收我已独立复现通过。自绘地图引擎、坐标转换方向、静态文件穿越防护、WebSocket 分帧/并发广播等关键面均未发现缺陷。

但有 5 个必须修复才能交付的缺陷(D1–D5),其中 D2/D5 只需一次 HTTP 请求即可让地图永久空白或把手机定位钉住,D3 会在真实设备上让定位在两条轨迹间来回跳,D4 会让 iOS 17+ 慢隧道下「连接设备」永远报超时。

编号 严重度 一句话 位置
D1 高 play.seek 传 NaN → 之后所有推送都是非法 JSON(NaN 字面量),浏览器 JSON.parse 抛错,引擎进度被污染成 NaN core/playback.py:154-173 + core/protocol.py:71-84
D2 高 POST /api/config 无类型校验 → 一次 {"tile_sources":"x"} 之后所有瓦片请求永久 500(地图空白),{"send_hz":NaN} 让 welcome 变非法 JSON core/config_store.py:120-142、core/server.py:699-703
D3 高 播放线程僵尸化:stop 的 join 超时后旧线程仍活着,下一次 start 清掉 stop 标志 → 两个线程同时驱动设备 core/playback.py:144-152, 75-106
D4 高 前端心跳只认 pong,不认任何其它入站消息 → 慢隧道(README 自己写「最长约 1 分钟」)下 40s 强制断线,device.connect 结果丢失、90s 报超时 web/js/ws.js:163-167, 246-262、web/js/app.js:845
D5 高 HTTP/WS 无 Origin/Host 校验、无 token → 任意网页可改配置、单点定位、甚至关机 core/server.py:678-769

另有 6 个中危、7 个低危缺陷,见第 2 节。


1. 独立复现的官方测试(先证明"绿")

命令 结果 备注
./.venv/bin/python tests/selftest.py 通过 82 项,失败 0 项,用时 9.7s 自检自身用 8899 端口,未与 8787 冲突
./.venv/bin/python main.py --mock --no-browser --port 8877 {"ok":true,...,"mock":true,"device_count":1,...} 我自己起的实例
./.venv/bin/python tests/ui_acceptance.py --url http://127.0.0.1:8877/ --out /tmp/ui-verify 通过 19 项,失败 0 项(真实 headless Chrome,截图 /tmp/ui-verify) 页面无 JS 报错
./.venv/bin/python tools/selftest_location.py 通过 36 项,失败 0 项 定位层离线自检
./.venv/bin/python tools/screenshot.py http://127.0.0.1:8877/ /tmp/verify-shot.png --wait 8 退出码 0,/tmp/verify-shot.png 1920 KB 但见 D18-g(成功时也打印"Chrome 超时被终止")

图形界面人工核验(截图 2880×1800):顶部设备卡 + 深色专业风 + 自绘 canvas 高德底图(北京中文标注)+ 左侧轨迹设置 + 绘制工具条 + 底部播放条 + 右侧日志面板,界面确实存在且可用,满足"图形化界面"要求。


2. 缺陷清单(按严重程度,全部含复现命令与原始输出)

全部复现均在自建 8877 实例上完成;curl 示例可直接复制(把 8877 换成你自己的端口)。

D1(高)play.seek(progress=NaN) → 非法 JSON 推流 + 引擎进度 NaN 化

  • 位置:core/playback.py:154-173(seek)、core/protocol.py:71-76(clamp 不处理 NaN)、core/protocol.py:83-84(json.dumps 默认 allow_nan=True)
  • 复现(自写 RFC6455 客户端,/tmp/verify_adv_ws2.py B10):
w.send('{"type":"play.seek","progress":NaN,"id":"nan1"}')     # python json 接受 NaN 字面量
→ ack: {"type":"ack","id":"nan1","cmd":"play.seek","status":{"playing":true,"progress":nan,...}}   ← 没有报错
→ 之后 3 秒内 33 帧里有 18 帧含 NaN 字面量:
  {"type":"location","lng":NaN,"lat":NaN,"source":"play","progress":NaN,"index":0,"speed":13.9,"sent_at":1791982831.97}
  • 期望:progress 非有限数应回 400 {"ok":false,"error":"进度必须是 0~1 的数字"};且任何出站 JSON 都不允许出现 NaN/Infinity。
  • 影响:clamp(NaN,0,1) 返回 NaN(NaN<0、NaN>1 都是 False),于是 self.distance = total*NaN = NaN,此后 interpolate() 产出 NaN 坐标、status.progress=NaN。浏览器端 ws.js:131-141 的 JSON.parse 对 NaN 字面量直接抛 SyntaxError,每帧丢弃并报「服务端消息不是合法 JSON」;status 帧同样失效,界面进度/坐标停摆。 另:POST /api/config {"send_hz":NaN} 走同一条链(见 D2),说明该问题在序列化边界上普遍存在。
  • 最小修复:protocol.clamp 前加 if not math.isfinite(value): raise AppError(...)(或 seek 里显式 math.isfinite 校验);protocol.dumps 改 json.dumps(..., allow_nan=False) 作为兜底。
  • 附带证据:真实设备路径上 RealLocationProvider.set_location 会因 not (-90<=NaN<=90) 为真而抛中文错误(core/location_provider.py:520-521),即真机不会下发 NaN,但会静默把播放停掉;mock 路径则把 NaN 原样记日志并推送。

D2(高)POST /api/config 无类型/范围校验 → 瓦片接口永久 500、welcome 变非法 JSON

  • 位置:core/config_store.py:120-142(update() 只校验 key 白名单,不校验 value 类型)、core/server.py:699-703
  • 复现与原始输出:
$ curl -s -X POST --data '{"tile_sources":"x"}' -H "Content-Type: application/json" http://127.0.0.1:8877/api/config
{"ok":true,"config":{"tile_source":"amap",...,"tile_sources":"x",...}}          ← 接受了

$ curl -s -o /dev/null -w "HTTP %{http_code}\n" http://127.0.0.1:8877/api/tiles/amap/1/0/0.png
HTTP 500
$ curl -s http://127.0.0.1:8877/api/tiles/amap/1/0/0.png
{"ok":false,"error":"服务端错误:string indices must be integers, not 'str'","code":"internal"}   ← 地图此后永久空白

$ curl -s -X POST --data '{"send_hz":NaN}' -H "Content-Type: application/json" http://127.0.0.1:8877/api/config
{"ok":true,...}          ← 接受
$ curl -s http://127.0.0.1:8877/api/config | head -c 120
{"ok":true,"config":{...,"send_hz":NaN,...}}                                      ← 非法 JSON 已进入运行态
$ # 新客户端握手时收到的 welcome 里同样带 NaN:
welcome 帧片段: ..."tile_cache_max_mb":64,"send_hz":NaN,"default_speed_scale":1.0,...
含 NaN 字面量: True   → 浏览器 JSON.parse 抛异常,welcome(含 config/设备快照)整帧丢失
  • 期望:/api/config 局部更新必须做类型 + 范围校验(tile_sources 必须是对象、send_hz 必须是 1–20 的有限数、tile_cache_max_mb 必须是非负整数…),非法值回 400 + 中文,绝不允许写入运行态。
  • 备注:_merge()(config_store.py:92-101)只在 value 是 dict 时做合并,字符串会整体替换 tile_sources,所以 tile_source()(:147-152)随后 "amap" not in "x" → "x"["amap"] → TypeError。
  • 交互影响:D5 说明这个接口可被任意网页调用,因此 D2 是可被外部触发的持久 DoS。

D3(高)播放线程僵尸化 → 两个 worker 同时驱动设备

  • 位置:core/playback.py:144-152(stop_locked 里 self._thread = None 后 join(timeout=2.5),超时即放弃但不作废线程)、core/playback.py:75-106(start() 里 self._stop_flag.clear() 后新建线程)
  • 复现:/tmp/verify_playback_race.py(用真实 core/playback.py + 模拟"真机下发耗时 3s"的 provider,runner 语义与 core/server.py:30-64 AsyncRunner 一致)
路线1 1403 点 2803 m(北京);路线2 1464 点 2926 m(上海)
[0.30s] 播放线程 A = playback, active=True
[0.30s] 第二次 start(模拟用户再次点播放 / 前端重发 play.start)…
[2.81s] 新播放线程 = playback
[2.81s] 当前存活的 playback 线程:['playback', 'playback']        ← 两个线程同时存在

下发时存活的 playback 线程数 -> 下发记录:
  存活 1 个 playback 线程: 1 次 -> [(3.0, 39.908, 116.397)]
  存活 2 个 playback 线程: 4 次 -> [(5.81, 31.23, 121.47), (6.06, 39.923156, 116.412156),
                                    (8.87, 31.25, 121.49), (9.11, 39.928, 116.417)]
  • 期望:stop 必须保证旧线程真的结束(或在旧线程存活期间拒绝新的 start)。
  • 影响:真机上定位会在北京 ↔ 上海两条轨迹间来回跳(上表就是交替下发),且 active 属性、sent_count、进度全部错乱。触发条件现实存在:stop 时若有一次 set_location 在途且耗时 > 2.5s(RSD 抖动、设备卡顿、隧道半断),紧接着用户再点「播放」(前端命令超时后重试也会重发 play.start)。
  • 最小修复:给 _loop 传一个"代次 token"(start() 时自增,线程每轮校验自己的代次),或在 stop_locked 里放大 join 超时并在 start() 检测 thread.is_alive() 时拒绝启动。

D4(高)前端心跳只认 pong → 慢隧道下 device.connect 结果丢失

  • 位置:web/js/ws.js:163-167(只有 type === 'pong' 才刷新 lastPongAt/清 missedPings)、web/js/ws.js:246-262(missedPings >= 3 就地 sock.close())、web/js/app.js:845(device.connect 超时给到 90s)
  • 复现:用 node 直接跑真实的 web/js/ws.js,连一个 device.connect 会阻塞 60s 的假服务端(模拟 iOS 17+ native 隧道建立;服务端每 2s 还持续推 log 帧,和真实的 tunnel-reader 线程行为一致):
$ node /tmp/ws_client_stall_test.js          # 服务端 /tmp/stall_server.py,阻塞 60s 且持续推 log
[0.0s] open / welcome
[1.5s] -> 发送 device.connect(服务端将阻塞 60s)
[40.1s] error: 心跳超时,正在重建连接          ← 客户端单方面判定连接已死
[91.5s] device.connect 失败: code=timeout msg=命令超时(device.connect)   ← 服务端 61.5s 已经回了 ack
  • 期望:只要任何入站消息到达就说明链路活着(log/status/location 都应刷新存活时间);长命令执行期间不应被心跳掐断。
  • 影响:README §4 自己写「iOS 17+ 会自动建隧道,最长约 1 分钟」。真机首次连接时服务端 WS 线程被 tunnel.start()(core/tunnel.py:140-157,最长 75s)+ DVT connect 阻塞,期间只有 tunnel reader 线程广播的 log;前端 40s 就强制断线重连,device.connect 的 ack 发给已断开的 socket,界面最终弹「命令超时」,而服务端其实已经连上(provider_for 已置 simulating=True)。用户需要再点一次「连接设备」才成功。
  • 最小修复:_dispatch 里对任何消息都刷新 lastPongAt/missedPings(或至少统计"最近 N 秒内是否收到过任意帧");同时给 device.connect 这类长命令做"命令执行中暂停心跳判定"。

D5(高)本机服务无 Origin/Host 校验、无鉴权 → 任意网页可控设备

  • 位置:core/server.py:614-769(所有 REST,含 POST /api/location、/api/config、/api/devices/connect、/api/shutdown),无任何 Origin/Host/CSRF token 检查
  • 复现(带恶意 Origin 的简单请求 text/plain,浏览器跨站发这种请求不触发预检):
$ curl -s -X POST -H "Origin: https://evil.example" -H "Content-Type: text/plain" \
       --data '{"send_hz":3}' http://127.0.0.1:8877/api/config -D - -o /dev/null | head -1
HTTP/1.1 200 OK
$ curl -s -X POST -H "Origin: https://evil.example" -H "Content-Type: text/plain" \
       --data '{"lat":39.9,"lng":116.4}' http://127.0.0.1:8877/api/location
{"ok":true}                                    ← 手机被单点钉住
$ curl -s -X POST -H "Origin: https://evil.example" -H "Content-Type: text/plain" \
       --data '{}' http://127.0.0.1:8877/api/shutdown
{"ok":true}    → 2 秒后 curl /api/health 返回 HTTP 000,进程退出
  • 期望:拒绝带外部 Origin 的请求(仅允许同源/无 Origin),或给每个实例生成随机 token 并要求携带;/api/shutdown 至少要求 token。
  • 影响:用户只要在浏览器里打开一个恶意/被挂马的页面,它就能在后台把用户手机的 GPS 钉到任意坐标(且是"虚拟定位"类工具,用户很难察觉),或直接关掉服务。风险等级与"本机 127.0.0.1 服务"并不相称。
  • 附带:Host 头未校验,存在 DNS rebinding 面。

D6(中)瓦片全部回源失败时返回 HTTP 200,与契约「404 + JSON 错误」不符,且前端失败告警路径变成死代码

  • 位置:core/server.py:779-784(两个分支都写死 200)、core/tilecache.py:167-176(返回 status=404 + 占位 PNG)、契约 docs/PROTOCOL.md:26
  • 复现:
$ curl -s -o /dev/null -m 6 -w "直连 OSM: %{http_code}\n" https://tile.openstreetmap.org/1/0/0.png
直连 OSM: 000                                  ← 本机不可达
$ curl -s -o /tmp/osm_tile.bin -w "经服务端: HTTP %{http_code} type=%{content_type} size=%{size_download}\n" \
       http://127.0.0.1:8877/api/tiles/osm/1/0/0.png
经服务端: HTTP 200 type=image/png size=69        ← 契约要求 404
$ xxd /tmp/osm_tile.bin | head -1
00000000: 8950 4e47 0d0a 1a0a 0000 000d 4948 4452  .PNG........IHDR   ← 1x1 透明占位图
  • 期望:失败时按契约返回 404 + JSON(占位图不必返回)。
  • 影响:前端 web/js/map.js:430-470 依靠 img.onerror 做重试与「瓦片加载失败」提示;现在失败瓦片也走 onload 并标记 state='ok',重试与告警永远不会触发,用户只看到地图空白且没有任何提示(README §6 却说"换底图/走缓存")。

D7(中)play.stop 之后再 play.seek 会重新下发模拟定位(设备被重新钉住,界面显示"未播放")

  • 位置:core/playback.py:154-173(seek() 不检查 playing,_route 在 stop_locked 后仍保留)、core/server.py:342-344
  • 复现(/tmp/verify_adv_ws3.py C1,用服务端日志计数):
停止后累计下发次数 N1 = 120  最后一条: [模拟模式] 下发定位 lat=39.908036, lon=116.397072(第 64 次,模拟)
停止后 seek(0.5) 之后 N2 = 121  最后一条: [模拟模式] 下发定位 lat=39.917949, lon=116.416899(第 65 次,模拟)
seek 是否重新下发定位: True
ack.status: {'playing': False, 'paused': False, 'progress': 0.5, 'distance_done': 2025.92}
  • 期望:未播放(playing=false)时 play.seek 应回 ack ok:false, error:"当前没有正在播放的轨迹"(seek 里 route is None 才报错,而 route 一直保留)。
  • 影响:真机上「停止 → 手机恢复真实定位」之后,任何客户端(包括误触、脚本)调 play.seek 都会把手机重新钉在轨迹中途,而 UI 仍然显示"未播放/未模拟"。前端把进度条置灰了(app.js:1562),所以 GUI 正常操作不会触发;但这是协议层的语义漏洞。

D8(中)三处非中文/500 报错,违反契约「400 + 中文」

$ curl -s -o - -w " -> %{http_code}\n" "http://127.0.0.1:8877/api/logs?limit=abc"
{"ok":false,"error":"服务端错误:invalid literal for int() with base 10: 'abc'","code":"internal"} -> 500
$ curl -s -o - -w " -> %{http_code}\n" "http://127.0.0.1:8877/api/logs?limit=1e9"
{"ok":false,"error":"服务端错误:invalid literal for int() with base 10: '1e9'","code":"internal"} -> 500
$ curl -s -X POST --data '{"send_hz":"abc"}' -H "Content-Type: application/json" http://127.0.0.1:8877/api/config
{"ok":false,"error":"服务端错误:could not convert string to float: 'abc'","code":"internal"} -> 500
# WS 侧同源问题:
C2 log.list limit=abc  -> ok=False err=内部错误:ValueError: invalid literal for int() with base 10: 'abc'
  • 位置:core/server.py:670(int(limit) 无 try)、core/config_store.py:131-135(float(send_hz) 无 try)
  • 期望:非法参数 → 400 + code=bad_request/bad_limit 的中文提示,且响应里不出现 Python 异常类名。
  • 说明:这是契约符合性问题,不是安全漏洞(未泄漏栈,仅异常字符串)。

D9(中)--mock 模式设备卡显示「隧道 空闲」,与 PROTOCOL §4(mock 设备 tunnel="connected")矛盾;根因是共享对象被就地改写

  • 位置:web/js/app.js:400(welcome → applySessionInfo)与 :410(hello 的 ack → 又一次 applySessionInfo)同一条消息被处理两次;:461-465 就地改 S.device.tunnel,:466 之后 applyDevices() 又把同一个被改过的对象装回去
  • 复现(CDP 读真实 DOM + 钩 WebSocket 消息):
WS 消息流水: [{"type":"log"},
 {"type":"ack","cmd":"hello","id":"c1-kcdo","tunnel":"idle","devTunnel":"connected"},
 {"type":"ack","cmd":"devices.refresh","id":"c2-kf5l","devTunnel":"connected"},
 {"type":"ack","cmd":"log.list","id":"c3-4zak"},
 {"type":"ack","cmd":"devices.refresh","id":"c4-2ql9","devTunnel":"connected"},     ← 第二次 hello 副作用
 {"type":"ack","cmd":"log.list","id":"c5-lbk2"}]
卡片隧道行: 隧道空闲            ← 服务端 /api/devices 明明返回 "tunnel":"connected"
  • 期望:mock 模式下设备卡显示「隧道 已连接」(PROTOCOL.md:153),并且同一条消息只处理一次。
  • 附带影响:每次页面加载会多发一次 devices.refresh + log.list(上表 c1/c2 与 c4/c5 成对出现)。
  • 最小修复:applySessionInfo 加"同一条消息只处理一次"的幂等保护(例如按 msg.id 去重),S.device.tunnel 改成从服务端设备对象回读后再渲染。

D10(中)_kill_stale_tunnels() 会杀掉任何pymobiledevice3 … start-tunnel 进程(含其它实例 / 用户手工建的),注释与实现不符

  • 位置:core/tunnel.py:172-196(注释写"只清理本工具启动的同类进程")、core/tunnel.py:323-347(按 ps 命令行匹配 pymobiledevice3 + remote start-tunnel/lockdown start-tunnel 即 SIGTERM/SIGKILL)
  • 复现(纯读代码即可判定;进程匹配逻辑不含 PID 文件、不含本工具标识):
$ ./.venv/bin/python - <<'PY'
import sys; sys.path.insert(0,'.')
from core import tunnel
print(tunnel._find_stale_tunnel_pids())
PY
# 若此刻有任何人(其它实例、用户手动)跑着 pymobiledevice3 remote start-tunnel,PID 就会出现在这里 → 被 kill
  • 期望:要么只清理自己记录在案的子进程(PID 文件/父子关系),要么在界面上明确提示"将清理所有第三方隧道进程"并二次确认。
  • 影响:双开本工具、或用 tools/probe_location.py --rsd 手工建过隧道时,新实例会把别人的隧道掐断(iOS 17+ 只有一条 RSD 连接,表现为"隧道建好了定位立刻失效",README §6 已把它列为已知现象,但没说是本工具自己干的)。

D11(中)手写 WebSocket 的 3 处协议符合性问题

项 证据 期望(RFC 6455)
接受未掩码的客户端帧 B4 未掩码帧 -> {'type': 'ack', 'id': 'u1', 'cmd': 'ping', ...} §5.1 服务端必须 1002 关闭
Sec-WebSocket-Version: 8 仍返回 101 B3 Sec-WebSocket-Version: 8 -> HTTP/1.1 101 Switching Protocols §4.4 回 426 Upgrade Required
分片消息总量无上限 单帧限 8MB(实测 B6 声明 9MB 的帧 -> {'__close__': True, 'code': 1009} ✅),但 B5c 用 12 个 1MB 分片拼出 12MB 消息被完整接收处理 建议对累计分片长度也设上限
  • 位置:core/ws.py:130-162(不校验 masked)、core/ws.py:225-260(handshake 不校验 Version)、core/ws.py:185-200(_frag_data 只受单帧限制)
  • 影响:仅本机监听,属健壮性/合规问题;正常浏览器不受影响。

D12(低)/api/gpx/import 不支持 multipart,契约却写了

$ # multipart/form-data 上传同一个 GPX
multipart GPX 导入 -> 400 {"ok":false,"error":"GPX 文件解析失败,请确认文件格式","code":"bad_gpx"}
text/plain        -> 200 {"ok":true,...,"count":2}
application/gpx+xml -> 200 {"ok":true,...,"count":2}
  • 位置:core/server.py:727-741(直接 raw.decode() 后 ET.fromstring)、契约 docs/PROTOCOL.md:29
  • 建议:要么按契约解析 multipart(提取 part body),要么把契约改成"仅 text/plain 与 application/gpx+xml"。

D13(低)DeviceManager.refresh() 同步阻塞,且被 WS 命令线程与 3 秒监控线程调用

  • 位置:core/device.py:132-177 + :307(asyncio.run(_collect()),每个设备一次 create_using_usbmux(..., autopair=True, pair_timeout=12.0))、core/server.py:242-244(每次 hello 都 refresh)、core/device.py:123-129(监控线程每 3s refresh)
  • 实测(只读枚举,未建 lockdown 连接、未配对、未碰定位):
第1次枚举: 139.3 ms, 设备数=1
第2次枚举: 16.3 ms, 设备数=1
第3次枚举: 17.5 ms, 设备数=1
   serial=00008030-001551840A02402E connection_type=USB is_usb=True
  • 判断:健康设备下可接受(几十~几百毫秒);但未配对/被锁设备上 pair_timeout=12s 会逐台阻塞,而 hello 的前端超时只有 8s(app.js 传入 req(...) 默认 8000ms),监控线程也会被拖慢。建议把设备发现移到后台线程/AsyncRunner,hello 只回缓存快照。

D14(低)sanitize_route_payload 没有点数上限

B14 20 万点 play.start -> 3.07s ok=None
B15 path 100 万个点 -> ConnectionResetError(单帧 > 8MB,服务端按 1009 断开)
  • 位置:core/route_engine.py:511-544(parse_waypoints 直接吃下整个数组,只在 RouteEngine._densify 里有 MAX_PATH_POINTS=60000)
  • 建议:在 sanitize_route_payload 里对 len(path) 设上限(例如 20 万)并回中文错误,避免一次请求吃掉数百 MB。

D15(低)speeds 里的 NaN 被静默接受

B15 speeds 含 NaN  -> ok=None(成功)
  • 位置:core/route_engine.py:533-540:max(0.05, min(400.0, float('nan'))) → Python 的 min(400.0, nan) 返回 400.0,NaN 被静默当成 400 m/s。
  • 建议:math.isfinite 校验,非法则走 compute_speeds。

D16(低)瓦片缓存 _inflight 只增不减

  • 位置:core/tilecache.py:57, 120-126(每个瓦片 key 一个 threading.Lock,永不清理)。长时间浏览大量瓦片会缓慢增长内存。建议用 WeakValueDictionary 或定期清理。

D17(低)多实例共享同一份 config.json(后写覆盖)

  • 证据:lead 的 8787 实例与我 8877 实例读写同一个 /Users/jbjf/Desktop/location/config.json;我在 8877 上改配置后文件被改写(20:58、21:08 两次 mtime 变化)。
  • 影响:团队/多开场景下配置互相覆盖;--port 并未隔离数据目录。建议按端口派生配置文件或加文件锁。

D18(低)文档与实现不一致(逐条)

# 文档 实现/事实
a README.md:144 "离线自检:80 项单元 + 端到端" 实际 82 项(tests/selftest.py 输出)
b docs/DEVICE_NOTES.md:736,758-762 结论为"iOS 26.6.1 那台开发者模式未开、无镜像,真机端到端现在无法跑通",并要求用户先做两步手工操作 交付结论与 task 描述是"真机链路已实测跑通(RSD native 隧道 + DVT + 轨迹播放)";且现在 usbmux list 只有 1 台 USB 设备(00008030-001551840A02402E),文档里的第二台 Network 设备已不在列。DEVICE_NOTES §8 已过时,与最终结论冲突
c docs/PROTOCOL.md:83-86 示例瓦片源含 tencent、amap style=8 实现 core/config_store.py:13-46 是 amap(style=7)/amap_sat/arcgis_sat/osm,没有 tencent
d web/tile_sources.json 注释"仅作为前端离线兜底" 全仓库无任何代码读取它(grep -rn tile_sources.json 仅命中自身)
e docs/PROTOCOL.md:21 /api/devices/connect 示例 tunnel 含 "ios_major":26 实际返回 {"state":"idle","message":...,"rsd":null,"mode":null,"udid":null},无 ios_major;且 mock 模式下这里返回 idle,与 §4"mock 设备 tunnel=connected"自相矛盾(前端因此显示"隧道 空闲",见 D9)
f docs/PROTOCOL.md:20 device.select "回 device + tunnel" WS ack 只有 device
g README.md:151 截图命令 命令可用,但 tools/screenshot.py 在成功时也打印 Chrome 超时被终止(--virtual-time-budget + --screenshot 模式本就不会自己退出,被 timeout=max(60, wait*6) 杀掉后才拿到 PNG),易被误判为失败
h README.md:132-154 命令行工具清单 未提及 tests/real_gui_acceptance.py;scripts/run.sh 在 --mock 下仍会执行一段 usbmux 枚举(README 未说明)
i README.md/task-3 提到 resources/ 目录已不存在(任务书把它列为交付范围,但无内容)

3. 真机路径:与 pymobiledevice3 11.29.1 源码的逐条核对

全部在 /Users/jbjf/Desktop/location/.venv/lib/python3.12/site-packages/pymobiledevice3/ 内核对(未改动设备):

core/*.py 主张 源码出处 结论
RemoteServiceDiscoveryService((host, port)) remote/remote_service_discovery.py:103-110(address: tuple[str,int] 位置参数) ✅ 一致
rsd.product_version / rsd.udid :179-185(property,需 connect 后)、:318(connect 里赋值) ✅ 一致(provider 也在 connect 之后才读,location_provider.py:403-408)
DvtProvider(rsd) / DvtProvider(lockdown) 自动选服务名 services/dvt/instruments/dvt_provider.py:33-35、dtx_service_provider.py:80-97, 99-111 ✅ 一致
await dvt.connect() / await dvt.close() 长连接 dtx_service_provider.py:149-171 ✅ 一致
LocationSimulation(dvt) + await sim.connect() services/dvt/instruments/location_simulation.py:17-47、dtx_service.py:140-151 ✅ 一致
set(latitude, longitude) / clear() location_simulation.py:30-47(selector simulateLocationWithLatitude:longitude: / stopLocationSimulation) ✅ 一致,不是 simulate_location()
DtSimulateLocation(iOS<17 兜底) services/simulate_location.py:8-40;SERVICE_NAME 在 :17;每次 set/clear 新开服务(:13-14 注释) ✅ 一致;provider 确实在 connect 阶段真开一次服务校验(location_provider.py:486-494),避免"假成功"
lockdown.start_lockdown_developer_service(name) lockdown_service_provider.py:162 ✅ 存在
tunnel 输出解析 HOST PORT cli/remote.py:297-323:--script-mode 时 print(f"{address} {port}");非 script-mode 才打印 --rsd addr port ✅ core/tunnel.py:284-306 两种格式都覆盖,与实现一致
_kill_stale_tunnels 只清自己的进程 —— ❌ 见 D10
设备发现 MuxDevice.serial / .connection_type("USB"/"Network") usbmux.py:129-133;list_devices() 为 async(:456-463) ✅ core/device.py:243-265 用法正确(且字符串大小写按源码用 "USB"/"Network")

异常路径的中文化:core/location_provider.py:155-193 的 _wrap() 覆盖了 StartServiceError(其 str() 为空,需读 service_name/message)、InvalidService、开发者模式、未配对、连接被拒、超时;tools/selftest_location.py 36 项里明确断言"错误必须是 LocationProviderError 且含中文"。我另做了静态抽查:set_location 越界(location_provider.py:520-521)、未连接(:522-523)、RSD 端口不可达(_connect_dvt_rsd → _wrap)都会给出中文信息,未发现会抛 AttributeError 的路径。 唯一"非中文"来自上层:App.dispatch(core/server.py:206-209)把任意异常包成 "内部错误:{类名}: {str}",于是 D8 的三处报错会带英文原文(有中文前缀,不算裸栈)。


4. 交付完备性核对

交付项 命令 结果
scripts/run.sh bash scripts/run.sh --mock --no-browser --port 8888 ✅ 6 秒后 /api/health → HTTP 200(依赖检查、Python 检查、设备提示均正常输出)
Location.app 读 Info.plist + Contents/MacOS/launch ✅ 启动器逻辑自洽:从 .app 位置回退到 ~/Desktop/location,日志写 ~/Library/Logs/ios-location-app.log;CFBundleExecutable=launch 且可执行位正确(-rwx--x--x)。未实际双击(会拉起真实设备会话)
tools/probe_device.py --help ✅ 参数与 README:136-137 一致(--probe/--rsd/--udid/--json)
tools/probe_location.py --help ✅ 与 README:140-142 一致(--lat/--lon/--clear/--rsd/--duration);scripts/test-location.sh 39.9088 116.3975 参数顺序正确
tools/screenshot.py ... http://127.0.0.1:8877/ /tmp/verify-shot.png --wait 8 ✅ 退出码 0、产出 1920 KB PNG;⚠️ 见 D18-g
tools/selftest_location.py 直接运行 ✅ 36/36
tests/selftest.py、tests/ui_acceptance.py 见第 1 节 ✅ 82/82、19/19
README.md 其余命令 逐条比对参数 ✅ 与脚本一致(--mock/--port/--no-browser/--window、mounter list/auto-mount、remote start-tunnel --native --script-mode)
无外网依赖约束 grep 全部 web/ 资源引用 ✅ 只有本地 coord.js/ws.js/map.js/app.js/style.css + data: SVG favicon,无 CDN/字体外链
纯 stdlib 约束(后端) `grep '^import ^from' core/*.py main.py`

5. 验证过但没有问题的点(覆盖面说明)

  • 坐标转换方向正确(用 node 直接跑 web/js/coord.js): wgs84ToGcj02(116.3975, 39.9088) = [116.403744, 39.910204],与基准 116.4038/39.9101 相差 5.6e-5/1.0e-4 度;gcj02ToWgs84 回代误差 0([116.3975, 39.9088])。outOfChina 边界、toDisplay/fromDisplay 的 datum 分支均正确。
  • 静态文件穿越防护:7 种载荷(/../../../../etc/passwd、%2e%2e%2f、..%2f、/web/../core/server.py、/....//....//etc/passwd …)全部未泄漏文件,返回 200(index.html 回落)/404/400,leaked=False。
  • 瓦片参数校验:z=30、x 越界、非整数坐标 → 400 + 中文;POST 体 >64MB → 413。
  • HTTP 对抗输入:非法 JSON/数组/缺字段/NaN/Infinity/负数倍速/非法 mode/1MB 级 waypoints/畸形 name/超大 trkpt(10 万点)/XXE 外部实体(ET 拒绝,未泄漏 /etc/passwd)/空 GPX → 全部 400 + 中文,无 500、无栈泄漏(除 D8 的三处)。
  • WS 帧层:两段分片、分片中间夹控制帧、粘包(两个独立帧)、close 握手回显 1000、>8MB 帧回 close 1009、非法 JSON 回中文 bad_json、未知命令(有/无 id 分别回 ack ok:false/error)、play.speed/location.set/log.list 边界值校验,均符合契约。
  • 并发广播不串包:2 个客户端 + 30x 播放 4 秒,收帧 a=44/b=46,严格 JSON 解析失败 = 0,status.seq 单调递增。
  • 播放引擎正常路径:暂停后不再下发、继续恢复、倍速生效、seek 后位置过半、播放到终点、loop 回到起点、停止后推 source:"cleared";单点轨迹(0 长度)不会崩(loop=false 时正常结束并下发 1 个点)。
  • 前端 XSS 静态审查:4 处 innerHTML 汇点(设备卡、日志行、途经点行、modal)中,用户可控数据(设备名、GPS/GPX 名称、坐标)要么经 esc() 转义(app.js:749-753),要么用 textContent/.value;pushLog/toast 用 textContent。未发现可利用的注入点。
  • 瓦片 Image 释放:map.js:340-470 有 LRU(CACHE_LIMIT)、淘汰时 onload/onerror=null + img.src=''、_clearTiles() 全量释放、连续失败会暂停加载并给出告警(告警路径因 D6 无法触发,但代码逻辑本身正确)。
  • 设备发现字段用法与 11.29.1 源码一致(见第 3 节表格)。
  • mock 契约字段:/api/devices 返回 MOCK-0001 / 模拟设备 (iPhone 17 Pro) / iOS 26.0 / state=connected / tunnel=connected,与 PROTOCOL §4 一致(UI 显示问题见 D9)。

6. 未能验证的点与原因

  1. 真机端到端(RSD 隧道 + DVT 下发 + 轨迹播放):任务要求"绝不设置定位或改变设备状态",故未运行 tests/real_device_e2e.py --confirm、tests/real_gui_acceptance.py --confirm、tools/probe_location.py,也未启动任何隧道。真机部分只做了(a)源码 API 逐条核对、(b)只读 usbmux.list_devices() 计时。"真机已跑通"这一结论我无法独立背书,只能确认代码与 11.29.1 源码的接口完全吻合。同理,legacy(iOS<17)路径只做了代码审查,未实测。
  2. iOS<17 经典通道:本机两台设备里那台 iOS 16.5(Network)已不在 usbmux list 中,无法核对 dvt-lockdown/legacy 的真实行为。
  3. 动态 XSS 注入:只做了静态审查,未构造恶意 GPX 名称驱动真实浏览器验证 DOM 结果。
  4. 长时间稳定性:瓦片缓存淘汰(1GB 上限)、_inflight 增长、WS 连接长期挂机、设备监控线程 3s 轮询的累积开销均未做小时级压测。
  5. 非 Chrome 浏览器:前端只在 Chrome(headless 与 app 模式)验证;Safari/Firefox 未测(ws.js 里有针对子协议的兼容分支,但未实测)。
  6. 管理员隧道:sudo ./scripts/run.sh / osascript 提权路径需要密码且会建隧道,未实测(只核对了 _build_command 生成的命令字符串)。
  7. /api/shutdown 之后再启动:我验证了关机接口可被跨站调用,但未验证重启后的状态一致性。

7. 给 lead 的修复优先级建议

  1. D2 + D5 一起修(配置校验 + Origin/token 校验)——最小改动、直接消除"一次请求打挂地图/钉住手机"。
  2. D1(isfinite 校验 + allow_nan=False)——三行以内,消除非法 JSON 推流。
  3. D3(播放线程代次 token)——真机定位跳变的主因,改动集中在 core/playback.py。
  4. D4(任何入站消息都算存活)——一行改动,解决慢隧道下"连接设备"假超时。
  5. D6(瓦片失败按契约回 404)——注意会同时激活前端的失败重试/告警路径,改完建议重跑 tests/ui_acceptance.py。
  6. D7/D8/D9 作为第二梯队;D18 的文档同步建议在最终提交前一并处理(尤其 DEVICE_NOTES §8 与最终结论的矛盾)。

复现脚本:/tmp/verify_adv_http.py、/tmp/verify_adv_ws.py、/tmp/verify_adv_ws2.py、/tmp/verify_adv_ws3.py、/tmp/wsclient.py、/tmp/verify_playback_race.py、/tmp/stall_server.py、/tmp/ws_client_stall_test.js、/tmp/verify_coord.js(均在 /tmp,不影响交付目录)。 本报告只写 docs/VERIFY_REPORT.md 一个文件;config.json 因对抗性测试被 API 改动过,已用同一接口恢复为默认值(send_hz=5.0、tile_source=amap、tile_cache_max_mb=1024)。


复验(第二轮)

  • 触发:lead 针对 D1–D11 做了修复并 reopen task-4(revision 4→5),要求逐条复验 + 用对抗性思路找新引入的问题。
  • 复验基线:selftest 83/83、ui_acceptance 20/20(本人独立复跑,见下);真机部分仍只做代码级/只读核对。
  • 复验方式:全部用第一轮的同一批脚本 + 新增针对性脚本复跑,不采信 lead 的验证结论。
  • 自建实例:8877(mock);--host 0.0.0.0 场景另起 8888;D4 假服务端 8897;验证结束后全部关闭。
  • 副作用说明:CDP 底图切换测试把 config.json 的 tile_source 改成了 osm(前端会自动持久化),已用 POST /api/config {"tile_source":"amap"} 恢复默认值。

复验总览

编号 一轮结论 二轮复验 证据位置
D1 NaN→非法 JSON 高 ✅ 已关闭 §R2-1
D2 config 无校验 高 ✅ 已关闭 §R2-1
D3 播放线程僵尸化 高 ✅ 已关闭 §R2-2
D4 心跳只认 pong 高 ✅ 已关闭 §R2-3
D5 无 Origin/Host 校验 高 ✅ 已关闭 §R2-4
D6 瓦片失败回 200 中 ✅ 已关闭(前端告警路径实测被激活) §R2-5
D7 stop 后 seek 重下发 中 ✅ 已关闭 §R2-6
D8 三处非中文/500 中 ⚠️ 部分关闭(REST 已修,WS log.list 仍泄漏英文异常) §R3-1
D9 mock 显示「隧道 空闲」 中 ❌ 仍存在(修的是前端根本不用的 REST 路径,真实原因链未动) §R3-2
D10 误杀他人隧道 中 ✅ 已关闭(动态构造伪造进程验证) §R2-7
D11 WS 协议细节 中 ✅ 已关闭(1002 / 426 / 8MB 分片上限 1009) §R2-8
D12 multipart GPX 低 ❌ 仍 400(未声称修复) §R3-3
D13 refresh 阻塞 低 ✅ pair_timeout 12s→3s;hello 仍同步 refresh(可接受) §R3-3
D14 点数无上限 低 ✅ 5000/20 万双上限,413 中文;20 万点 path 仍可用 §R2-9
D15 speeds NaN 低 ✅ 回退为基准速度 13.9 §R2-9
D16 _inflight 只增不减 低 ❌ 仍在(未声称修复) §R3-3
D17 多实例共享 config 低 ❌ 仍在(未声称修复,属设计取舍) §R3-3
D18 文档不一致 低 ✅ README 83 项、DEVICE_NOTES §8.2 已加"被后续进展取代";❌ PROTOCOL 仍列 tencent、connect 的 tunnel 仍无 ios_major §R3-3
新增 N1–N5 — ⚠️ 2 个需要判断(clear 顺序、配置合并校验),3 个纯提示 §R4

复验结论:D1–D7、D10、D11 确认关闭;D8 只关了一半;D9 用户可见症状仍在;另有 1 个"顺序未被显式保证"的加固项(N1)。仍不建议直接交付,但剩余问题已从"高危"降级为"1 个中危 + 2 个局部问题"。


R1. 独立复跑的基线测试

命令 结果
./.venv/bin/python tests/selftest.py 通过 83 项,失败 0 项,用时 9.8s ✅(我独立复跑,不是采信)
./.venv/bin/python tests/ui_acceptance.py --url http://127.0.0.1:8877/ --out /tmp/ui-verify2 通过 20 项,失败 0 项 ✅(含新增的「点击播放后进入播放态」)
tools/selftest_location.py(一轮已跑 36/36) 未改动该模块,未复跑

R2. 已关闭项的证据(逐条可复现)

R2-1 D1 + D2

$ python /tmp/verify2_ws.py            # R4/R5/R9/R11 段
R4 seek(NaN) ack -> {'type':'ack','id':'nan1','cmd':'play.seek','ok':False,
                     'error':'进度不是有限数(不允许 NaN / Infinity)','code':'bad_number'}
R4 之后 3 秒:含 NaN 帧=0 严格解析失败=0
R5 progress=NaN/Infinity/-Infinity/"abc"/null/{}/[] -> 全部 ok=False 中文
R11 speeds=[NaN,1] -> ok=None route.speeds=[13.9, 1.0]        ← NaN 回退基准速度

D2(config_store._validate + _normalize,用临时配置文件测,不碰项目 config.json):

=== 非法配置必须全部被拒 ===            非法用例被接受:0 / 25
  ✓ send_hz NaN         -> send_hz不是有限数(不允许 NaN / Infinity)
  ✓ tile_sources 字符串  -> tile_sources 必须是「源名称 → 配置」的对象
  ✓ cache 63            -> 瓦片缓存上限需在 64 ~ 102400 MB 之间
  ✓ tile_sources url 缺 {z} -> 瓦片源 bad 的 url 必须包含 {z}/{x}/{y} 占位符
  …(22 条同类)
=== 合法配置是否被误拒 ===              合法用例被误拒:1 / 25
  ✓ 默认 4 个瓦片源整体提交 / tile_source 取 4 个源 / send_hz 1~20 / cache 64~102400
  ✓ window 200~10000 / mode_defaults 四模式 / 自定义瓦片源 / 多键合法组合 …(24 条)
  ✗ 误拒:{"tile_sources":{"my2":…},"tile_source":"my2"} 同一请求 -> 未知的瓦片源:my2   ← 见 N2
=== 坏配置文件自愈 ===  4/4 全部回退默认值并把 tile_source 归位 amap
$ curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8877/api/tiles/amap/1/0/0.png
200                     ← 修复后瓦片接口不再 500

R2-2 D3 代次机制(用真实 core/playback.py)

脚本 /tmp/verify2_playback2.py(慢 provider 每次下发 3s,模拟真机卡顿):

S1' 慢 provider(3s) + 0.3s 后第二次 start
  第二次 start 时存活线程: ['playback-4'](应为 1)      ← 不再双线程
  9 秒后存活线程: [](应为 0)                          ← 旧线程自行退出
  下发序列: [('set',39.908,3.2), ('set',31.23,4.12), ('set',31.2445,7.18), ('set',31.25,10.23)]
  北京点(旧代次)= 1 次;上海点(新代次)= 3 次             ← 旧代次只完成"在途那一次"
  → ✅ 通过:不再出现北京/上海交替下发
S3 seek 与 stop 并发 ×200:clear 之后又 set 的次数 = 0    ← 未复现残留竞态

对照第一轮:同样是这个脚本,第一轮实测 当前存活的 playback 线程:['playback','playback'] 且定位在北京/上海之间来回跳。

R2-3 D4 心跳(node 直接跑真实 web/js/ws.js)

假服务端 /tmp/verifier2_stall.py(8897):device.connect 阻塞 60s,期间每 2s 推一条 log(模拟 tunnel-reader 线程)。

[0.0s] open / welcome
[1.5s] -> 发送 device.connect(服务端将阻塞 60s,不回 pong)
[61.5s] device.connect 成功: {"type":"ack","id":"c2-jttw","cmd":"device.connect","device":{"udid":"FAKE"},...}
[100.0s] 结束观察(pending=0)

✅ 全程 0 次"心跳超时"、0 次重连,60s 的命令拿到了 ack。(第一轮同一测试在 40.0s 报「心跳超时,正在重建连接」,91.5s 报命令超时。)

R2-4 D5 Origin/Host(含"会不会误伤正常用法")

1 无 Origin(curl 正常用法)        -> HTTP 200
2 同源 Origin http://127.0.0.1:8877 -> HTTP 200
3 Origin http://localhost:8877      -> HTTP 200
4 Origin https://evil.example       -> HTTP 403 已拒绝来自 https://evil.example 的跨站请求…
5 Origin: null(沙箱/file://)      -> HTTP 403 已拒绝来自沙箱页面…
7 Host: evil.com(DNS rebinding)   -> HTTP 403 已拒绝 Host: evil.com 的请求…
9 Host: localhost:8877              -> HTTP 200
10 Host: [::1]:8877                 -> HTTP 200
POST /api/location|/api/shutdown|/api/config|/api/devices/connect (Origin: evil) -> 全部 403
POST /api/location (同源 Origin,正常用法) -> 200 {"ok":true}
WS 握手:同源 Origin -> 101;evil -> 403;Origin: null -> 403;无 Origin(原生客户端)-> 101

--host 0.0.0.0 场景(本人起 8888 实例):app.host 由 serve() 赋值(server.py:876 app.host = host),因此 Host: 192.168.1.50:8888 / mymac.local:8888 / LAN 浏览器真实场景(Host+Origin 都是局域网 IP)全部 200 ✅, 而同一实例上 Host: 192.168.1.50 + Origin: https://evil.example 仍 403 ✅。没有误伤。

R2-5 D6 瓦片 404 + 前端告警路径

$ curl -s -o /dev/null -w "osm -> HTTP %{http_code} 用时 %{time_total}s\n" -m 60 http://127.0.0.1:8877/api/tiles/osm/3/6/3.png
osm -> HTTP 404 用时 12.009s        ← 契约要求 404,已符合

前端(真实 Chrome + CDP Network 域,切换到本机不可达的 OSM 源并驱动地图重绘):

tile 请求(按源): {'osm': 12}
tile 响应(源,状态): {('osm', 404): 12}
告警 class: map-warn                 ← 不再 hidden
告警文本: 瓦片加载失败 2 张           ← onerror → 重试/告警路径确实被激活

R2-6 D7 停止后 seek

R6 停止后 seek -> ok=False err=当前没有正在播放的轨迹(请先点播放)   下发次数 3 -> 4(隔离复测为 +0)
# 隔离复测(单独脚本,stop 后统计 /api/logs 里 "下发定位 lat=" 条数):
  seek -> False 当前没有正在播放的轨迹(请先点播放)
  seek 之后累计下发 7 次(差值 0)

R2-7 D10 隧道进程清理(动态构造伪造进程)

D10-A 没有 pid 文件时:调用 _kill_stale_tunnels() 后伪造的 "pymobiledevice3 remote start-tunnel" 进程仍存活 = True → ✅ 未误杀
D10-B pid 文件记录该 PID:进程被清理(存活=False),pid 文件被删除 → ✅
D10-C pid 文件记录的是普通 sleep 进程:仍存活 = True → ✅ 未误杀
D10-D pid 文件里是已消失的 PID:无异常,文件被清理 → ✅

(另核对:tunnel.py:138 在启动隧道时 _write_pidfile([process.pid])、189/294 清理,机制不是死代码。)

R2-8 D11 WebSocket 协议

未掩码帧            -> {'__close__': True, 'code': 1002}     ✅ RFC §5.1
Sec-WebSocket-Version: 8 / 0 -> HTTP/1.1 426 Upgrade Required ✅(第一轮是 101)
分片累计 >8MB       -> 服务端 close 帧 op=8 code=1009 reason=b'fragmented message too large' ✅
单帧 9MB            -> close 1009 ✅(一轮已验)
并发广播 4s a=28/b=30 帧,严格 JSON 解析失败 0 ✅;close 回显 1000 ✅;粘包 ✅

R2-9 低危附带修复

route/build 6000 waypoints -> HTTP 413 {"error":"坐标点过多(6000 个,上限 5000 个):请减少途经点或改用 GPX 导入"}
route/build 100 waypoints  -> HTTP 200
play.start 20 万点 path    -> 2.55s 成功(GPX 大轨迹仍可播放)

R3. 仍未关闭 / 未修的问题

R3-1(中)D8 只关了一半:WS log.list 仍泄漏英文异常

  • 位置:core/server.py:355-356 _cmd_log_list 仍是 int(message.get("limit") or 200)(REST 的 /api/logs 已在 :713-719 加了 try/except,WS 命令路径漏了)。
  • 复现与原始输出:
$ # 用自写 RFC6455 客户端发 {"type":"log.list","limit":"abc"}
  limit='abc'    -> [{'type':'log',...},
                     {'type':'error','error':"内部错误:ValueError: invalid literal for int() with base 10: 'abc'",'code':'internal'}]
  limit='1e9'    -> 同上('1e9')
  limit='abc123' -> 同上
$ curl -s -o - -w " -> %{http_code}\n" "http://127.0.0.1:8877/api/logs?limit=abc"
{"ok":false,"error":"limit 必须是整数","code":"bad_request"} -> HTTP 400     ← REST 已修好
  • 期望:WS 侧同样回中文({"ok":false,"error":"limit 必须是整数","code":"bad_request"})。
  • 影响:前端固定传数字,只有 API 客户端能触发;属契约/健壮性问题(低危),但属于"说好修了却没修全",建议一并处理(3 行)。
  • 顺带:recent_logs() 自身也仍然直接 int(limit)(core/server.py:108)。

R3-2(中)D9 仍存在:mock 模式设备卡依然显示「隧道 空闲」

  • 现状(CDP 读真实 DOM,页面刚加载、未点任何按钮):
设备卡: ["型号=iPhone18,1","系统=iOS 26.0","连接=模拟","设备状态=已连接",
          "隧道=空闲","虚拟定位=未模拟","开发者模式=已开启"]
  • 服务端侧确实修好了(POST /api/devices/connect、POST /api/tunnel 都返回 state:"connected" / "模拟模式:无需建立 RSD 隧道"),但前端不调用 /api/tunnel(grep -rn "api/tunnel" web/ 无结果),而页面加载走的是 hello→welcome,这条链没改:
    • core/server.py:133 welcome_payload() 仍返回 self.tunnel.snapshot() → mock 下就是 {"state":"idle","message":"隧道未建立(iOS 17 及以上需要)",…}(实测 hello ack:tunnel = {"state":"idle",…},而同帧里 devices[0].tunnel = connected);
    • web/js/app.js:461-465 applySessionInfo() 仍就地把 S.device.tunnel = msg.tunnel.state(=idle),紧接着 applyDevices() 又把同一个被改过的对象装回 S.device;而且同一条 hello ack 被处理两次(:400 的 welcome 事件 + :410 的 ack 分支)。
  • 期望:mock 下设备卡显示「已连接」(PROTOCOL.md:153),或至少不要用隧道快照覆盖设备对象的权威字段。
  • 最小修复(任一即可,优先第 1 条):
    1. 服务端 welcome_payload() 复用 mock-aware 的隧道语义(与 _cmd_tunnel_ensure 同一处实现),使 hello ack 的 tunnel.state == "connected";
    2. 前端 applySessionInfo 不再用 msg.tunnel.state 覆盖 S.device.tunnel,并按 msg.id 去重避免同一消息处理两次。
  • 附:GET /api/tunnel 在 mock 下仍返回 idle(与 POST 不一致)。前端未使用,属契约一致性小问题。

R3-3(低)一轮中未声称修复、复验确认仍在的项

项 复验输出 说明
D12 multipart GPX multipart -> 400 {"error":"GPX 文件解析失败…"};text/plain -> 200 PROTOCOL.md:29 写了支持 multipart,仍未支持
D16 tilecache._inflight grep _inflight 仍只有 get/赋值,无清理 长跑内存缓慢增长
D17 多实例共享 config.json 本轮测试再次改写同一文件(前端自动持久化) 设计取舍,未改
D18-c PROTOCOL 瓦片源示例 仍含 tencent(实现里没有) 文档示例与实现不一致
D18-e /api/devices/connect 的 tunnel tunnel keys = ['message','mode','rsd','state','udid'],无 ios_major 与 PROTOCOL:21 示例不一致
D13 refresh 同步阻塞 pair_timeout=3.0(12→3 ✅);hello 仍同步 refresh 健康设备下几十 ms,可接受

R4. 新发现的问题(修复过程引入 / 本轮新观察到)

N1(中低,真机语义,建议加固)play.stop 的"恢复真实定位"与"在途下发"的相对顺序没有被显式保证

  • 位置:core/playback.py:139-175(stop() 里 join 超时从 2.5s 降到 0.6s,然后立刻起 playback-clear 线程调用 clear_location())。
  • 实验(同一脚本、只改"设备何时生效"的模型,模拟真机 DVT 的两种情况):
S2' stop 时的 set/clear 顺序(设备模型 effect_at=start,模拟真机:帧一发出设备就生效)
  stop() 返回耗时 0.61s
  事件序列: [('set', 39.908, 0.21), ('clear', None, 1.33)]
  → clear 之后的 set 次数 = 0   ✅ 设备最终已恢复

S2' stop 时的 set/clear 顺序(设备模型 effect_at=end,模拟 core/location_mock.py 的语义)
  stop() 返回耗时 0.60s
  事件序列: [('clear', None, 1.32), ('set', 39.908, 3.21)]
  → clear 之后的 set 次数 = 1   ⚠️ 设备最终停留在模拟位置
  • 判断:真机走 DVT 时,set 的帧在 3 秒前就已经写出(dtx/_sender.py:34-59 的 _send_lock 串行化帧写入,simulateLocationWithLatitude:longitude: 发出后 await 回复),clear 的帧随后写入 → 设备按 set→clear 顺序处理 → 最终状态是已恢复(effect_at=start 那一行)。但这依赖传输层时序,代码里没有任何"先等在途下发结束再 clear"的显式保证;而在 --mock 下(以及任何"回复到达才算生效"的实现下)可观测到 clear→set,最后停在模拟点。
  • 影响:真机大概率没问题;风险在于①换传输/换 provider 时可能悄悄变成"停止后仍被钉住";②mock 的日志会显示"已停止模拟,iPhone 恢复真实定位",但内部最后状态仍是模拟点,容易误导后续验收。
  • 最小修复:_clear_provider_async 里先 old_thread.join(timeout=6) 再 clear_location()(或把 clear 也塞进同一条串行下发队列),让顺序与传输无关。

N2(低)D2 的校验顺序会把"新增瓦片源 + 同时切换过去"这种合法请求误拒

✗ 误拒:{"tile_sources":{"my2":{...}}, "tile_source":"my2"} -> 未知的瓦片源:my2
  • 位置:core/config_store.py:157(update() 对 patch 里每个 key 用当前 self._data 校验,此时新源还没合并)。
  • 影响:前端只单独发 {"tile_source": key}(app.js:663),正常用法不受影响;属 API 边角。
  • 建议:先合并 tile_sources 再校验 tile_source。

N3(低)配置自动回退无任何提示

ConfigStore._repaired 被置位(config_store.py:69/96/109/114/117)但全仓库没有任何地方读取它 → 用户手改坏了 config.json 时会被静默重置。建议 main.py 启动时检测并 app.log("warn", …)。

N4(低)play.stop 的 0.6s 是"必然耗满"的等待

stop_locked() 在持有 self._lock 的情况下 thread.join(0.6s),而被 join 的播放线程此时通常正阻塞在 stale() 里 with self._lock(playback.py:233)→ 必然等满 0.6s 才继续。功能正确,若想让「停止」更快,可先释放锁再 join。

N5(提示)一次「停止 + 退出」会重复 clear 三次

in-process 复验(play.stop 后立刻 app.shutdown()):

provider 调用序列: [('set',39.908),('set',39.908),('set',39.9081),('set',39.9083),
                    'clear','clear','clear','close']
clear 次数: 3  close 次数: 1
→ ✅ 停止+退出后设备仍被恢复

三次 clear 分别来自:后台 clear 线程、shutdown() 里的 playback.stop()、provider.close() 内部的 clear。幂等无害,但日志会重复三遍。


R5. 二轮"验证过但没问题"的补充覆盖

  • 播放/停止/暂停/继续/seek 的完整 UI 流程:ui_acceptance 20/20,新增的「点击播放后进入播放态」也通过。
  • play.stop 的 ack 延迟从 2.2s 降到 0.60s(实测 3 次),且 clear 结果(成功/失败)都会写日志:成功 已停止模拟,iPhone 恢复真实定位,失败 恢复真实定位失败:<中文>(用会抛异常的 provider 实测到 warn)。
  • --host 0.0.0.0 局域网访问 + Origin/Host 双校验组合:正常访问 200、恶意 Origin 403(见 R2-4)。
  • 分片累计上限生效后,正常分片消息(两段、中间夹控制帧)依然可用。
  • 20 万点超长播放列表仍可用(2.55s),6000 途经点按新上限回 413 中文。
  • 隧道 pid 文件机制(写/读/清理)与"只杀自己记录的进程"行为(R2-7)。

R6. 仍未验证的点(与一轮相同)

  1. 真机端到端:受"不设定位/不改设备状态"约束,未运行 real_device_e2e.py/real_gui_acceptance.py,未建隧道。N1 的顺序结论来自源码级分析(DTX _send_lock + await 语义)+ 两种设备模型仿真,没有真机实测。若 lead 要闭环这一条,建议在真机上做一次:play.start → 0.3s 后 play.stop → 立刻看手机是否回到真实定位(这条由 lead 那边跑,我不占用唯一的 RSD 连接)。
  2. Wi-Fi/Network 设备与 iOS<17 legacy 路径(本机当前只有 1 台 USB 设备)。
  3. 小时级稳定性(瓦片 LRU 淘汰、_inflight 增长、WS 长挂)。
  4. Safari/Firefox(只测了 Chrome)。
  5. 管理员隧道(sudo/osascript 提权路径)。

R7. 复验用到的脚本(均在 /tmp,可重跑)

脚本 用途
/tmp/verify2_ws.py WS 侧全套(NaN seek、协议、上限、并发、粘包)
/tmp/verify2_playback2.py D3 代次 + clear 顺序 + clear 失败可见性(真实 core/playback.py)
/tmp/verifier2_stall.py + /tmp/verifier2_stall_test.js D4 心跳(node 跑真实 web/js/ws.js,端口 8897)
/tmp/wsclient.py 自写 RFC6455 客户端(未复用项目 core/ws.py)
内联脚本 D2 配置校验 25+25 例、D5 Origin/Host 矩阵、D10 伪造进程、CDP 底图 404 告警