Skip to content

[iOS][功耗] 高频 NEProvider wake 无条件触发全量健康检查,造成后台探测与日志风暴 #4

Description

@wangwei354

环境

  • 客户端测试版本:1.0.4
  • 平台:iOS
  • 场景:VPN 保持连接,设备夜间闲置
  • 观测日期:2026-09-09(北京时间,UTC+8)
  • 日志记录:开启

本次没有确认 1.0.4 安装包与当前公开源码的精确提交映射。下方源码链接用于说明与设备日志吻合的当前实现路径:Hako 35c9bb675f1cf5b0befd4290809d06dead728768,Hako-Client b05832246fcac69d13ff16df871a8d53fd394c10

问题概述

iOS 调用 NEPacketTunnelProvider.wake() 后,Hako 会通过 pause.DeviceWake() 恢复所有已注册的健康检查 ticker。当前健康检查注册的 resume callback 不只是重置 ticker,还会无条件立即执行一次 hc.check()

在本次 1.0.4 夜间日志中,系统短时间内反复调用 sleep/wake。每次 wake 随后都会产生一批固定规模的后台 URLTest:少量探测通过内存准入,其余探测等待约 300 ms 后以 probeDeferred 结束。该行为与是否到达健康检查 interval、是否满足 ticker 路径中的 lazy 条件无关。

这里报告的是“Hako 对 wake 的放大行为”,不主张最初的 iOS 系统唤醒由 Hako 产生。

设备日志证据

按北京时间分析 2026-09-09 00:00–06:00

  • 01:01:14–05:59:57 共记录 2146 组 provider sleep/wake
  • 平均每小时约 431 次,平均每 8.35 秒一组。
  • wake → 下一次 sleep 中位数约 3.865 秒。
  • sleep → wake 中位数约 2.278 秒。
  • 该时段 App 日志除 sleep/wake 外没有 UI 帧、配置更新或状态轮询记录。

core 日志因达到容量限制已丢失 05:23 之前的前缀。保留下来的 05:23–06:00 区间可以直接关联:

  • 279 次 wake。
  • 15,737 条 [Memory] probe admission 延期记录。
  • 15,737 条记录全部落在某次 wake 后 0–1.2 秒内,没有未匹配项。
  • 其中 275 次 wake 都对应恰好 57 条延期记录。
  • 延期记录在 wake 后 0.301–0.618 秒出现,中位数约 0.306 秒。
  • 所有记录均为 verdict=3 background=true charges=6
  • 同区间 core 日志共 15,747 条,延期记录占 99.936%;普通 TCP/UDP 业务记录只有 10 条。
  • 该 37 分钟区间写入约 2.93 MB core 日志。

由准入算法和 charges=6 可推断,一次完整批次通常先放行约 6 个探测,再延期 57 个探测,即一次 wake 大约提交 63 个候选 URLTest。57 个延期探测没有进入实际拨号,但仍创建任务、轮询内存 footprint、等待定时器并逐条写日志。该数量是基于当前日志和算法的推断,不等同于 63 次实际网络连接。

脱敏日志样本见 issue-sample-ios-wake-healthcheck-storm-1.0.4.log

与当前源码吻合的调用链

  1. Hako-Client 的 PacketTunnelProvider.wake() 将系统 wake 交给 ExtensionProvider
  2. ExtensionProvider.wake() 调用核心 currentService?.wake()
  3. BoxService.Wake() 调用 pause.DeviceWake(),随后还会执行 resolver.ResetConnection()
  4. HealthCheck.process() 注册 ticker 时把 func() { go hc.check() } 作为 wake resume callback。
  5. ticker 正常触发时会检查 lazylastTouch;wake resume callback 直接调用 hc.check(),没有经过该判断。
  6. hc.check() 对 provider 中的代理启动 URLTest。
  7. iOS 内存准入在无法继续放行时,让后台探测等待约 300 ms 后返回 probeDeferred,并为每个延期探测输出一条 info 日志。

影响

一次短暂 wake 会被放大为整批代理健康检查。在本次高频 sleep/wake 场景下,可能同时产生:

  • 少量获准 URLTest 的拨号、TLS 和证书验证开销;
  • 大量被延期任务的 goroutine、100 ms 等待定时器和 footprint 查询;
  • BoxService.Wake() 对 DNS transport 的重复重置;
  • BoxService.Pause()debug.FreeOSMemory() 的高频调用;
  • 每个延期探测一条 info 日志带来的文件属性查询、打开、seek、写入和关闭;
  • sleep 路径同步等待日志队列 flush。

已经开始的健康检查不会因为紧接着到来的 DevicePause 自动取消。因此,获准探测可能延续到下一段睡眠中;它是否进一步参与后续系统唤醒,需要 sysdiagnose、Energy Log 或网络抓包验证,当前日志不足以定论。

期望行为

  • DeviceWake 应恢复定时调度,但短暂或连续 wake 不应无条件触发每个 provider 的全量健康检查。
  • 是否需要 wake 后立即检查,应考虑实际睡眠时长、最近真实业务活动、lazy 状态、最近检查时间和物理网络路径是否变化。
  • 多次密集 wake 应合并为有限次数的恢复工作。
  • 进入 DevicePause 时,应能够取消或停止仅由上一轮 wake 启动、尚未完成的后台健康检查。
  • 保留故障切换能力,并区分“本轮未检查”和“节点不可用”。

建议的最小改进方向

  1. wake 时只 reset ticker,不默认执行 go hc.check();或者为 wake-triggered check 增加每个 HealthCheck 的最小间隔与合并机制。
  2. 如需解锁后快速刷新,只在睡眠时间超过阈值,并且 provider 最近有真实业务使用或结果已过期时执行一次。
  3. DevicePause 取消尚未完成的 wake-triggered 检查,避免检查跨越下一段睡眠。
  4. 将 probe admission 的逐条 info 日志改为限频汇总,例如每分钟输出延期次数、批次数和等待分位数。
  5. 对 sleep 路径的 FreeOSMemory() 以及 wake 路径的 DNS Reset 做独立限频或条件判断。

仅提高健康检查 interval 或启用 lazy 不能完全规避当前问题,因为 wake resume callback 直接执行 hc.check()

建议验证

  1. 保持相同配置、网络和节点数量,分别测试当前实现与“wake 只恢复 ticker”的版本。
  2. 记录 DevicePause/DeviceWake 次数、wake-triggered check 次数、实际 URLTest 拨号数、延期数和 DNS Reset 次数。
  3. 比较 Packet Tunnel Extension 的 CPU 时间、系统唤醒、网络握手和每小时电量变化。
  4. 覆盖长时间睡眠后解锁、短暂 sleep/wake、Wi-Fi/蜂窝切换、首选节点失效和用户主动测速。

边界

  • 当前证据不能确定最初是什么事件唤醒了 iOS。
  • 不能把一次 provider wake 直接等同于 Hako 主动制造的一次系统唤醒。
  • 不能把 63 个候选 URLTest 全部计算成实际网络连接。
  • 当前仅确认存在大量可避免的后台工作,没有测得该问题单独造成的具体电量百分比。
  • 如果维护者认为本问题与 Hako [iOS][功耗优化] 暂停恢复时的健康检查考虑 lazy 状态,减少非必要批量探测 #2 的暂停/恢复健康检查行为同源,可以合并处理;本报告补充的是 1.0.4 真机日志中 wake 路径的高频固定批量证据。

issue-sample-ios-wake-healthcheck-storm-1.0.4.log

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions