环境
客户端测试版本: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。
与当前源码吻合的调用链
Hako-Client 的 PacketTunnelProvider.wake() 将系统 wake 交给 ExtensionProvider。
ExtensionProvider.wake() 调用核心 currentService?.wake()。
BoxService.Wake() 调用 pause.DeviceWake(),随后还会执行 resolver.ResetConnection()。
HealthCheck.process() 注册 ticker 时把 func() { go hc.check() } 作为 wake resume callback。
ticker 正常触发时会检查 lazy 与 lastTouch;wake resume callback 直接调用 hc.check(),没有经过该判断。
hc.check() 对 provider 中的代理启动 URLTest。
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 启动、尚未完成的后台健康检查。
保留故障切换能力,并区分“本轮未检查”和“节点不可用”。
建议的最小改进方向
wake 时只 reset ticker,不默认执行 go hc.check();或者为 wake-triggered check 增加每个 HealthCheck 的最小间隔与合并机制。
如需解锁后快速刷新,只在睡眠时间超过阈值,并且 provider 最近有真实业务使用或结果已过期时执行一次。
DevicePause 取消尚未完成的 wake-triggered 检查,避免检查跨越下一段睡眠。
将 probe admission 的逐条 info 日志改为限频汇总,例如每分钟输出延期次数、批次数和等待分位数。
对 sleep 路径的 FreeOSMemory() 以及 wake 路径的 DNS Reset 做独立限频或条件判断。
仅提高健康检查 interval 或启用 lazy 不能完全规避当前问题,因为 wake resume callback 直接执行 hc.check()。
建议验证
保持相同配置、网络和节点数量,分别测试当前实现与“wake 只恢复 ticker”的版本。
记录 DevicePause/DeviceWake 次数、wake-triggered check 次数、实际 URLTest 拨号数、延期数和 DNS Reset 次数。
比较 Packet Tunnel Extension 的 CPU 时间、系统唤醒、网络握手和每小时电量变化。
覆盖长时间睡眠后解锁、短暂 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
环境
1.0.4本次没有确认 1.0.4 安装包与当前公开源码的精确提交映射。下方源码链接用于说明与设备日志吻合的当前实现路径:Hako
35c9bb675f1cf5b0befd4290809d06dead728768,Hako-Clientb05832246fcac69d13ff16df871a8d53fd394c10。问题概述
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。wake → 下一次 sleep中位数约 3.865 秒。sleep → wake中位数约 2.278 秒。core 日志因达到容量限制已丢失 05:23 之前的前缀。保留下来的
05:23–06:00区间可以直接关联:[Memory] probe admission延期记录。verdict=3 background=true charges=6。由准入算法和
charges=6可推断,一次完整批次通常先放行约 6 个探测,再延期 57 个探测,即一次 wake 大约提交 63 个候选 URLTest。57 个延期探测没有进入实际拨号,但仍创建任务、轮询内存 footprint、等待定时器并逐条写日志。该数量是基于当前日志和算法的推断,不等同于 63 次实际网络连接。脱敏日志样本见
issue-sample-ios-wake-healthcheck-storm-1.0.4.log。与当前源码吻合的调用链
PacketTunnelProvider.wake()将系统 wake 交给ExtensionProvider。ExtensionProvider.wake()调用核心currentService?.wake()。BoxService.Wake()调用pause.DeviceWake(),随后还会执行resolver.ResetConnection()。HealthCheck.process()注册 ticker 时把func() { go hc.check() }作为 wake resume callback。lazy与lastTouch;wake resume callback 直接调用hc.check(),没有经过该判断。hc.check()对 provider 中的代理启动 URLTest。probeDeferred,并为每个延期探测输出一条 info 日志。影响
一次短暂 wake 会被放大为整批代理健康检查。在本次高频 sleep/wake 场景下,可能同时产生:
BoxService.Wake()对 DNS transport 的重复重置;BoxService.Pause()中debug.FreeOSMemory()的高频调用;已经开始的健康检查不会因为紧接着到来的 DevicePause 自动取消。因此,获准探测可能延续到下一段睡眠中;它是否进一步参与后续系统唤醒,需要 sysdiagnose、Energy Log 或网络抓包验证,当前日志不足以定论。
期望行为
建议的最小改进方向
go hc.check();或者为 wake-triggered check 增加每个 HealthCheck 的最小间隔与合并机制。FreeOSMemory()以及 wake 路径的 DNS Reset 做独立限频或条件判断。仅提高健康检查 interval 或启用
lazy不能完全规避当前问题,因为 wake resume callback 直接执行hc.check()。建议验证
边界
provider wake直接等同于 Hako 主动制造的一次系统唤醒。issue-sample-ios-wake-healthcheck-storm-1.0.4.log