环境
- 客户端测试版本:
1.0.4
- 平台:iOS
- 场景:VPN 保持连接,设备夜间闲置
- 观测日期:2026-09-09(北京时间,UTC+8)
本次没有确认 1.0.4 安装包与当前公开源码的精确提交映射。下方源码链接用于说明与设备日志吻合的当前实现路径:Hako 35c9bb675f1cf5b0befd4290809d06dead728768,Hako-Client b05832246fcac69d13ff16df871a8d53fd394c10。
问题概述
Hako 将每次 NEPacketTunnelProvider.sleep() 转发给 BoxService.Pause()。当前 Pause 在停止已注册的周期任务并重置一分钟回退定时器之后,无条件同步调用 debug.FreeOSMemory()。
Go 对 debug.FreeOSMemory() 的定义是:强制执行一次垃圾回收,然后尝试尽可能把内存归还给操作系统。即使不显式调用,运行时也会在后台逐步归还内存。
在本次 iOS 1.0.4 夜间测试中,系统出现持续的短周期 sleep/wake。因而这个用于降低 Network Extension footprint 的保护动作可能平均约每 8 秒执行一次。问题不是要求删除内存保护,而是当前实现没有区分长时间睡眠、真实内存压力和几秒一次的短暂 sleep/wake。
这里报告的是“Hako 对系统 sleep 的处理方式”,不主张频繁的 iOS sleep/wake 最初由 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 秒。
05:23–06:00 区间有 278 次 provider sleep;同期 core 健康检查日志持续产生,说明服务处于运行状态。
- 该区间
[Memory] probe admission 记录的 footprint 位于约 26.3–27.1 MiB,中位数约 26.7 MiB。
- 整份导出日志没有出现内存压力、threshold triggered 或内存阈值状态转换记录。
ExtensionProvider.sleep() 先同步调用 currentService?.pause(),返回后才写入 provider sleep 日志。因此,日志中的 sleep 时间戳位于 FreeOSMemory() 返回之后,不能用于测量单次 GC/内存归还耗时。需要额外的低频耗时统计或 Instruments profile 才能量化 CPU 成本。
脱敏日志样本见
issue-sample-ios-sleep-freeosmemory-1.0.4.log
与当前源码吻合的调用链
- Hako-Client 的
PacketTunnelProvider.sleep() 将系统 sleep 交给 ExtensionProvider。
ExtensionProvider.sleep() 先调用 currentService?.pause(),随后记录 sleep 并同步等待日志队列 flush。
BoxService.Pause() 每次依次执行:
- 增加 pause 计数;
pause.DevicePause();
- 重置 iOS 一分钟回退定时器;
debug.FreeOSMemory()。
当前源码注释说明保留 FreeOSMemory() 是为了降低 iOS Network Extension 在 jetsam 限制下的 footprint。这是合理目标,但代码没有最小执行间隔、当前 footprint 判断、内存压力判断或“已经在近期执行过”的状态。
为什么短周期 sleep 需要与内存保护分开处理
pause.DevicePause() 应立即停止健康检查等周期任务,这部分不需要延后。debug.FreeOSMemory() 则是不同类型的动作:它会主动触发完整 GC 和内存归还工作。
当 sleep 持续很久或 footprint 接近 Network Extension 预算时,主动归还页面可能降低 jetsam 风险;但当系统几秒后又 wake,并重新开始网络和代理工作时,反复强制 GC 可能产生:
- GC 扫描和清扫 CPU 时间;
- 内存页归还后很快重新分配或重新触碰的 churn;
- 与 wake 后健康检查、DNS 重建等分配活动交错;
- sleep 回调返回延迟增加;
- 高频重置 runtime 的自然后台回收节奏。
当前 footprint 较低不能证明 FreeOSMemory() 没有价值,因为较低 footprint 可能正是它的结果;同样,它也不能证明平均每 8 秒执行一次是必要的。需要在保持 jetsam 安全的前提下进行对照。
期望行为
pause.DevicePause() 继续在每次真实 sleep 时立即执行。
FreeOSMemory() 根据当前 footprint、内存压力、距离上次执行的时间或睡眠持续时间决定,而不是每次短暂 sleep 都无条件执行。
- 接近安全阈值或收到内存压力事件时,仍能立即主动回收。
- 高频 sleep/wake 被合并或限频,不影响服务关闭、内存压力处理和 jetsam 保护。
建议的最小改进方向
- 将暂停周期任务与主动内存回收拆成两个决策:Pause 始终通知
DevicePause(),内存回收增加独立条件。
- 为
FreeOSMemory() 增加每个 BoxService 的最小执行间隔;具体间隔应通过设备测试确定,不在 Issue 中预设固定最优值。
- 当 footprint 接近阈值或收到内存压力时绕过限频,保留即时保护能力。
- 可选方案是在确认设备保持 sleep 一段时间后再执行回收;新的延迟任务必须在 wake/close 时可取消,不能增加永久短周期轮询。
- 增加低频诊断计数:Pause 次数、实际 FreeOSMemory 次数、跳过原因、执行耗时分位数及执行前后 footprint。不要为每次回收增加一组高频日志。
建议验证
- 对比当前实现、仅限频实现、footprint/压力条件实现,测试多个相近夜晚。
- 记录 Packet Tunnel Extension 的 CPU 时间、GC CPU、实际
FreeOSMemory() 次数、执行耗时和每小时电量变化。
- 同时记录峰值 footprint、内存压力事件、jetsam、服务重启和配置加载成功率,确认没有用功耗优化换取稳定性下降。
- 覆盖高节点数量和健康检查突发、持续大流量、锁屏空闲、频繁短 sleep/wake、长时间睡眠以及从后台恢复。
- 验证 wake 和 stopTunnel 能取消尚未执行的延迟回收任务,服务重建后不会继承旧 generation 的回收动作。
边界
- 当前日志和源码可以确认高频调用路径,不能从现有时间戳计算单次
FreeOSMemory() 耗时。
- 当前没有 GC profile,不能承诺该项单独造成的电量比例。
- 较低 footprint 不能证明主动回收多余,也不能证明每次 sleep 执行都是必要的。
- 不建议直接删除
FreeOSMemory();应通过限频或压力条件保留 jetsam 安全边界。
- 本问题建议提交到 Hako 内核项目;Hako-Client 只负责转发系统生命周期回调和记录日志。
- 建议在“wake 触发全量健康检查”修复后再次复测,确认高频 sleep/wake 是否仍然存在以及本问题的独立收益。
环境
1.0.4本次没有确认 1.0.4 安装包与当前公开源码的精确提交映射。下方源码链接用于说明与设备日志吻合的当前实现路径:Hako
35c9bb675f1cf5b0befd4290809d06dead728768,Hako-Clientb05832246fcac69d13ff16df871a8d53fd394c10。问题概述
Hako 将每次
NEPacketTunnelProvider.sleep()转发给BoxService.Pause()。当前 Pause 在停止已注册的周期任务并重置一分钟回退定时器之后,无条件同步调用debug.FreeOSMemory()。Go 对
debug.FreeOSMemory()的定义是:强制执行一次垃圾回收,然后尝试尽可能把内存归还给操作系统。即使不显式调用,运行时也会在后台逐步归还内存。在本次 iOS 1.0.4 夜间测试中,系统出现持续的短周期 sleep/wake。因而这个用于降低 Network Extension footprint 的保护动作可能平均约每 8 秒执行一次。问题不是要求删除内存保护,而是当前实现没有区分长时间睡眠、真实内存压力和几秒一次的短暂 sleep/wake。
这里报告的是“Hako 对系统 sleep 的处理方式”,不主张频繁的 iOS sleep/wake 最初由 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 秒。05:23–06:00区间有 278 次 provider sleep;同期 core 健康检查日志持续产生,说明服务处于运行状态。[Memory] probe admission记录的 footprint 位于约 26.3–27.1 MiB,中位数约 26.7 MiB。ExtensionProvider.sleep()先同步调用currentService?.pause(),返回后才写入provider sleep日志。因此,日志中的 sleep 时间戳位于FreeOSMemory()返回之后,不能用于测量单次 GC/内存归还耗时。需要额外的低频耗时统计或 Instruments profile 才能量化 CPU 成本。脱敏日志样本见
issue-sample-ios-sleep-freeosmemory-1.0.4.log
与当前源码吻合的调用链
PacketTunnelProvider.sleep()将系统 sleep 交给ExtensionProvider。ExtensionProvider.sleep()先调用currentService?.pause(),随后记录 sleep 并同步等待日志队列 flush。BoxService.Pause()每次依次执行:pause.DevicePause();debug.FreeOSMemory()。当前源码注释说明保留
FreeOSMemory()是为了降低 iOS Network Extension 在 jetsam 限制下的 footprint。这是合理目标,但代码没有最小执行间隔、当前 footprint 判断、内存压力判断或“已经在近期执行过”的状态。为什么短周期 sleep 需要与内存保护分开处理
pause.DevicePause()应立即停止健康检查等周期任务,这部分不需要延后。debug.FreeOSMemory()则是不同类型的动作:它会主动触发完整 GC 和内存归还工作。当 sleep 持续很久或 footprint 接近 Network Extension 预算时,主动归还页面可能降低 jetsam 风险;但当系统几秒后又 wake,并重新开始网络和代理工作时,反复强制 GC 可能产生:
当前 footprint 较低不能证明
FreeOSMemory()没有价值,因为较低 footprint 可能正是它的结果;同样,它也不能证明平均每 8 秒执行一次是必要的。需要在保持 jetsam 安全的前提下进行对照。期望行为
pause.DevicePause()继续在每次真实 sleep 时立即执行。FreeOSMemory()根据当前 footprint、内存压力、距离上次执行的时间或睡眠持续时间决定,而不是每次短暂 sleep 都无条件执行。建议的最小改进方向
DevicePause(),内存回收增加独立条件。FreeOSMemory()增加每个 BoxService 的最小执行间隔;具体间隔应通过设备测试确定,不在 Issue 中预设固定最优值。建议验证
FreeOSMemory()次数、执行耗时和每小时电量变化。边界
FreeOSMemory()耗时。FreeOSMemory();应通过限频或压力条件保留 jetsam 安全边界。