外观
FTI-010 指标缺失与 FrameTime 长尾断流统一修复方案
交付给其他 AI 的唯一执行方案。 本方案以
internal/field-test-issues.md的 FTI-010 为问题台账,以最新真机 Session07c7e86c-8886-45f6-8c1d-118a05769156为主要证据,并合并 FTI-008 的 FrameTime 连续性问题。实施时优先按本文执行,不要继续把“Unified CoreProfile 单 Tap、start=1/restart=0/sourceEpoch=1”视为不可修改硬边界;该边界已被最新真机证据证伪。
0. 本轮目标与禁止项
本轮目标分为 6 条轨道,其中前两条是 P0,必须先完成:
- P0-A · FrameTime 长尾恢复:解决 StartupTiming + FrameTime 同选时,FrameTime 只出现数秒后永久 missing;
- P0-B · Sample Publisher 解耦:解决只选择 FrameTime / Energy / Battery / Thermal / GPU Counter 等非 sysmontap Source 时,Raw/Decoder 已有值但
sampleCount=0; - P1-C · Thermal Status:停止依赖当前设备不提供的
IOPMPowerSource.ThermalStatus字段,寻找真实系统 Thermal State Source; - P1-D · GPU Counter:打通 schema 之后的真实 counter data row 协议;
- P2-E · Battery:按冻结门禁在拔掉供电条件下验证真实电流/电压/功率;
- P2-F · Energy Usage:在明确进入 CapturePlan 且 Sample Publisher 已修复的条件下回归。
全程禁止:
text
- 禁止用 0 代替 missing
- 禁止重复发布最后一个旧 FrameTime
- 禁止用 1000 / FPS 伪造 FrameTime
- 禁止用 Battery Temperature 推导 Thermal Status
- 禁止猜测 GPU Counter 两整数消息的业务含义
- 禁止同时启动两个 CoreProfile/kperf recording
- 禁止为“有 sample 心跳”偷偷强制启动 CPU/Memory Source1. 最新 E4 证据:FrameTime 是 Source 长尾断流,不是 UI/Decoder 问题
1.1 最新 Session
Session:07c7e86c-8886-45f6-8c1d-118a05769156
顶层 Session:
text
startedAt = 2026-09-16T14:39:34.915Z
endedAt = 2026-09-16T14:40:24.982Z
sampleCount = 43Collector Core:
text
startedAt = 2026-09-16T14:39:37.957Z
endedAt = 2026-09-16T14:40:24.427Z
duration ≈ 46.48 s
status = completed
identity = verifiedCapturePlan 同时包含:
text
StartupTiming
FrameTime
CPU Usage / CPU Core
Memory
FPS / GPU Usage
Thread / CSwitch / Wakeups / Disk
Network因此本轮 sysmontap 正常工作,不能用“没有 Sample Publisher heartbeat”解释本轮 FrameTime 中途消失。
1.2 产品 Sample 时间轴
metrics.csv 共 43 行:
text
sample 0 14:39:41.354 FTime 33.252 ms FPS 0
sample 1 14:39:42.512 FTime 33.676 ms FPS 40
sample 2 14:39:43.462 FTime 33.198 ms FPS 40
sample 3 14:39:44.308 FTime 16.544 ms FPS 59
sample 4 14:39:45.296 FTime 16.385 ms FPS 59
sample 5 14:39:45.988 FTime 33.973 ms FPS 59
sample 6 14:39:46.740 FTime 33.298 ms FPS 60
sample 7 14:39:47.729 FTime missing FPS 33
sample 8 14:39:48.730 FTime missing FPS 26
sample 9 14:39:49.810 FTime missing FPS 50
...
sample 42 14:40:22.818 FTime missing FPS 37Manifest:
text
frameTimeMissingSampleCount = 36
frameTimeMissingWhileGraphicsFPSPositiveCount = 22
frameTimeMissingWhileGraphicsFPSAtLeast20Count = 22结论:App 在 FrameTime missing 期间仍持续渲染,不是静止页面造成的合法无新帧。
1.3 CoreProfile 真正停止的是 binary 数据
text
coreProfileCaptureMode = startup-and-frame
coreProfileStartCount = 1
coreProfileRestartCount = 0
coreProfileStopCount = 1
sourceEpochCount = 1
coreProfileObservedChunkCount = 51
coreProfileObservedByteCount = 187381682
hostDroppedEventHighWater = 0
droppedEventHighWater = 0以首个 CoreProfile chunk 为 T=0:
text
Startup semantic complete ≈ T+6.070 s
最后一个大块 binary ≈ T+7.998 s
binary 在 startup complete 后只继续 ≈1.928 s
Session Stop 控制包 ≈ T+45.388 s最后阶段 Raw 形态:
text
T+6.104 3.91 MB frame-continuation
T+6.232 0.95 MB frame-continuation
T+6.897 16.78 MB frame-continuation
T+7.174 0.69 MB frame-continuation
T+7.824 16.78 MB frame-continuation
T+7.998 2.20 MB frame-continuation
T+7.999 242 B control
------ 约 37 秒无 binary ------
T+45.388 262 B / 262 B / 242 B control这说明“51 个 CoreProfile chunk”不能被误解为 CoreProfile 一直健康;后半段只有 Stop/控制消息。
1.4 Decoder 在有数据区间是健康的
frame-events-live.jsonl:
text
Presented Frame = 85
有效 FrameTime interval = 84
真实时间覆盖 ≈ 2.0756 s
最大 Presented Frame gap ≈ 99.97 ms
>500 ms gap = 0
>1000 ms gap = 0所以链路是:
text
CoreProfile binary 正常
-> Presented Frame 连续
-> Frame decoder 正常
-> FrameTime 正常
-> Unified Tap 后续停止 binary
-> Presented Frame 消失
-> FrameTime freshness gate 正确输出 missing-no-new-frame不要修改 freshness gate;它是在诚实暴露底层断流。
1.5 与历史 Session 形成重复证据
text
3be2f750 frame-only binary 基本持续至 Session Stop
203ad5ba startup+frame binary ≈7.70 s 后停止
54f82b38 startup+frame binary ≈10.16 s 后停止,Session 继续约 111 s
07c7e86c startup+frame binary ≈7.998 s 后停止,Session 继续约 46 s因此不再把“Startup+Frame Unified long-tail stall”当成单轮偶发。
2. P0-A 最终修复架构:启动联合 Tap + 启动完成后切到 Frame-only Tap
2.1 为什么不继续坚持 Build57 Unified 单 Tap
Build57 的目标是消除旧 startup→frame rotation 造成的 5 s 左右 gap,因此冻结:
text
startCount = 1
restartCount = 0
sourceEpoch = 1
Startup semantic complete 后 CoreProfile 不 stop这个设计在单测层成立,但当前 E4 已重复证明:Startup+Frame 联合 Tap 在真机上可能于 8~10 秒后不再输出 binary,并造成剩余整个 Session FrameTime 永久 missing。
因此旧“严禁 rotation”规则由本方案正式 supersede。
2.2 不能原样回退 Build51
Build51 历史 20 轮 E4:
text
frame-only 源恢复 = 20/20
rotation gap P50 = 5152.740 ms
rotation gap P95 = 5292.041 ms
restart -> first frame chunk P50 = 1239.140 ms
restart -> first frame chunk P95 = 1303.487 ms它证明串行重启是可靠可行的,但 5 s gap 太大。
本方案不是“回退 Build51”,而是混合两代优点:
text
T0
联合 Tap = lifecycle filters + Presented Frame
-> 启动阶段同时得到 Startup + FrameTime
Startup semantic complete
-> 冻结启动报告
-> 串行停止旧联合 Tap
-> 有界排空/关闭 subscription
-> 同一 DTX owner 上新建 Frame-only Tap
-> sourceEpoch + 1
-> 持续 FrameTime 到 Session Stop任何时刻最多一个 CoreProfile/kperf recording。
2.3 优先复用现有代码,不重写 CoreProfile 生命周期
当前代码已经具备:
text
realtime.py
ALLOW_CORE_PROFILE_ROTATION_FALLBACK = False
startup_rotation_requested
startup_rotation_drain_wait_complete
startup_rotation_complete
request_stop_for_drain()
adapter.stop()
adapter.restart(... frame_time=true, launch_trace=false ...)
sourceEpoch advance
core_profile_rotation telemetry
adapters.py
CoreProfileRawAdapter.request_stop_for_drain()
CoreProfileRawAdapter.wait_for_drain()
CoreProfileRawAdapter.restart()
_CoreProfileChunkStream stop/drain contract第一原则:激活并收敛现有路径,不新增第二套 rotation 状态机。
2.4 将“fallback”改成正式 Continuation Policy
不要只把:
python
ALLOW_CORE_PROFILE_ROTATION_FALLBACK = False机械改成 True 后结束。
将语义改成清晰产品策略,例如:
python
STARTUP_FRAME_CONTINUATION_POLICY = "rotate-to-frame-only"允许未来保留:
text
unified-single-tap # 仅实验/对照
rotate-to-frame-only # 当前生产默认生产默认必须是 rotate-to-frame-only,直到 E4 证明某个新的 Unified 配置可以长窗稳定。
2.5 切换触发时机
只在以下条件全部成立时触发:
text
mode == startup-and-frame
AND startup semantic complete
AND frame_time_enabled == true
AND capture_closing == false
AND stop_event 未触发
AND 本 Session 尚未执行过 continuation cutoverStartup semantic complete 定义继续使用现有:
text
liveComplete == true
OR totalDurationMs != null
AND requiredMissingPhases empty
AND status not partial/incomplete/failed不得为了更早切换伪造启动完成。
2.6 Startup 报告必须先冻结,再切 Source
顺序固定:
text
1. mark startup semantic complete
2. 生成/持久化 closure
3. publish startup report
4. startup report 进入 immutable/frozen 状态
5. 才允许 request stop old CoreProfile新 Frame-only epoch 的任何 bytes 都不得进入 startup phase decoder,也不得修改 Startup report。
2.7 排空策略:先求正确,再用 E4 缩短 gap
第一版实施不要猜测 Apple stop 尾包语义,先使用现有安全边界:
text
STARTUP_CORE_PROFILE_DRAIN_IDLE_SECONDS = 0.75 s
STARTUP_CORE_PROFILE_DRAIN_MAX_SECONDS = 4.0 s
STARTUP_CORE_PROFILE_RESTART_MAX_SECONDS = 5.0 s目标先从“后 30~100 秒永久无 FrameTime”恢复为“最多一个诚实的 rotation missing 窗口 + 后续长期恢复”。
同时必须新增足够遥测,使下一轮 E4 能决定是否安全收紧 drain:
text
handoffRequestedNs
handoffStopAckNs
handoffDrainCompleteNs
handoffRestartBeginNs
handoffRestartCompleteNs
handoffFirstBinaryNs
handoffFirstPresentedFrameNs
handoffFirstFrameTimeNs
handoffDrainStatus
handoffPostStopChunkCount
handoffGapMs
restartToFirstBinaryMs
restartToFirstFrameTimeMs只有真机证明 post-stop tail 不需要 4 s 后,才能降低 DRAIN_MAX_SECONDS;不能为了追求连续性直接删 drain。
2.8 Restart 必须有 kperf release 退避重试
项目已有历史证据:旧 CoreProfile owner Stop 后设备释放 kperf 可能异步,立即重开会遇到 core-profile-source-busy。
因此 continuation restart 要有一次统一 helper,例如:
text
attempt 1: immediately
attempt 2: +0.25~0.5 s
attempt 3: +0.5~1.0 s
attempt 4: +1~2 s每次必须创建 fresh Tap/config,不复用已停止 Tap。
要求:
text
- 总重试窗口受 STARTUP_CORE_PROFILE_RESTART_MAX_SECONDS 约束
- Stop/capture_closing 随时优先取消重试
- 失败后 FrameTime 明确 degraded/missing,不拖垮整个 Session
- 不影响 CPU/Memory/FPS/Network 等其它 Source不要复制多套 busy retry;若已有可复用的 CoreProfile retry helper,统一抽取。
2.9 Source Epoch 边界必须显式失效旧 FrameTime
当前 RealtimeMetricProjector.consume_core_profile() 在 epoch 变化时会重建 RealtimeFrameDecoder,但实施 rotation 时必须确认并补齐:
text
_latest_frame_time_ms = None
_latest_frame_quality = "missing"
_latest_frame_timestamp_ns = None
_last_published_frame_timestamp_ns = None原因:旧 epoch 最后一帧绝不能在新 epoch 刚启动、尚未收到 Presented Frame 时被重新展示。
同时:
text
新 epoch 第一条 Presented Frame 只能建立基线
至少第二个 Presented Frame 才形成真实 frameTime interval
跨 epoch 禁止计算 timestamp delta2.10 Rotation gap 的产品语义
切换窗口:
text
frameTimeMs = null
quality = "missing-source-handoff"恢复后:
text
quality = candidate / 实际 decoder quality禁止:
text
重复旧值
线性插值
FPS 反推
跨 epoch 间隔离线 Frame Report 需要保留 source gap / epoch 信息,不把 handoff gap 计成一帧超长卡顿。
3. P0-A 可选安全网:Unified binary stall watchdog
即使生产默认改为 post-startup rotation,也建议保留只读 liveness telemetry,避免未来策略改变后再次静默断流。
状态:
text
lastCoreProfileBinaryHostNs
lastPresentedFrameHostNs
lastGraphicsFPS
lastGraphicsFPSHostNs诊断条件可定义为:
text
startup 已完成
AND frameTime requested
AND Graphics FPS >= 20
AND 最近 graphics 数据新鲜
AND CoreProfile binary 超过阈值未更新第一版只做 telemetry/diagnostic,不要自动反复 restart。每个 Session 最多允许一次正式 startup→frame cutover,防止 recovery loop 抢占 kperf。
建议事件:
text
core_profile_liveness
status = healthy / stalled / recovered
binaryAgeMs
graphicsFPS
sourceEpoch4. P0-B Sample Publisher 解耦
4.1 已知根因
当前 RealtimeMetricProjector.consume():
python
if event.event_type == "core-profile.chunk.raw":
self.consume_core_profile(event)
return None
...
if event.event_type != "sysmontap.raw" or event.target_pid is None:
return None所以非 sysmontap Source 虽然已经更新 _latest / Frame/GPU 状态,最终产品 snapshot 仍只能被下一条 sysmontap.raw 发布。
真实证据 3be2f750:
text
CoreProfile chunks = 153
Presented Frame = 487
有效 FrameTime = 486
product sampleCount = 04.2 正确架构
拆开“Source ingest”和“Product sample cadence”:
text
Source tasks
-> consume/update latest state
Independent Product Sample Clock (默认沿现有约 1 Hz 产品节奏)
-> projector.snapshot(now, target identity)
-> emit samplesysmontap.raw 变成普通 Source,而不是全产品隐含 heartbeat。
4.3 实施要求
新增类似:
python
RealtimeMetricProjector.snapshot(...)把当前 consume() 中 588 行之后“拼装 metrics dict”的逻辑提取到 snapshot;consume() 只负责:
text
- startup observation
- generation/foreground state
- RawMetricEngine.consume
- frame/gpu state update
- target process metadata update新增单一 sample publisher task:
text
while recording:
wait product interval
if target identity 已验证:
sample = projector.snapshot(...)
emit sample注意:
- 不要每个 Source 各自 emit sample,避免多倍采样频率;
- 不要因没有 sysmontap 就伪造 process metric 值;未启动 Source 的字段保持 null/not-requested/missing;
- FrameTime 继续使用 freshness gate:同一个 Presented Frame 不可跨多个 tick 重复;
- GPU Counter 也需要 freshness/age gate;
- device-only 指标允许在没有 process source 的 Session 中被产品 sample 发布;
- CapturePlan 决定“Source 是否启动”,Sample Clock 只负责“何时发布 snapshot”。
4.4 与 FTI-004 的关系
修复后必须满足:
text
只选 FrameTime -> 只启动 CoreProfile + 必要控制源 -> 有 sample
只选 Energy -> 只启动 Energy + 必要控制源 -> 有 sample
只选 Thermal/Battery -> 只启动 Device Conditions -> 有 sample
只选 GPU Counter -> 只启动 GPU Counter -> 有 sample不能再要求用户顺带勾 CPU/Memory。
5. P1-C Thermal Status
5.1 已定位断点
最近多个真机 Session 的 device.condition.raw 有:
text
Temperature
VirtualTemperature
Voltage
InstantAmperage
ExternalConnected
IsCharging
...但没有:
text
ThermalStatus当前 Metric Engine 固定:
python
thermal_raw = _number(row.get("ThermalStatus"))
thermal_status = thermal_raw if thermal_raw in (0,1,2,3) else None因此当前 IOPMPowerSource 不是这台设备/iOS 上可用的 Thermal Status 真值源。
5.2 实施原则
先做 Source Discovery,不允许先写映射:
text
1. 枚举 DVT / diagnostics / IORegistry 可读的真实 thermal state 候选
2. 保存原始字段和值域
3. 在至少 normal + 人为升温场景观察是否变化
4. 明确语义/单位/枚举合同
5. 再修改 metric registry + decoder + UI若当前环境找不到可信真实 Source:
text
MetricAvailability = unavailable/unsupported-for-device
reason = thermal-source-unavailable优于长期展示一个永远 missing 但标记 available 的卡片。
不得使用 Temperature / VirtualTemperature 阈值自行推导 0~3。
6. P1-D GPU Counter
已知两个真机 Session:
text
18833f61: raw 117 = device-info 1 + schema 1 + unsupported-message 115
54f82b38: raw 1053 = device-info 1 + schema 1 + unsupported-message 1051说明:
text
CapturePlan OK
Source OK
DTX channel OK
device-info OK
schema OK
data message 持续到达
可信 counterValues data row = 0实施顺序:
text
G1. 对 unsupported-message 做“形状 census”,记录类型、长度、元素类型、相邻 schema 时序,不解释业务含义
G2. 获取可信协议证据:pymobiledevice3 上游实现 / Apple 私有 selector 调用证据 / 真机 wire 对照
G3. 确认 configureCounters:counterProfile:interval:windowLimit:tracingPID: 参数合同
G4. 仅在字段含义有证据后实现 decoder
G5. schema key 数量与 data row value 数量必须严格匹配,否则整行 missing/degraded禁止把当前两整数 list 直接猜成 timestamp/cycles/counter values。
7. P2-E Battery
当前合同:
text
ExternalConnected == false
AND IsCharging == false才输出:
text
Battery Current
Battery Voltage
Battery Power当前观察 Session 均为:
text
ExternalConnected = true
IsCharging = false所以 null + external-power-connected 是正确结果。
E4:
text
拔掉 USB/供电
使用 Wi-Fi/无线连接
确认 IsCharging=false
连续采集 >= 2 min验收:current/voltage/power 至少产生稳定真实值;若 Raw 有值而产品仍 null,再定位 decoder/product mapping。
8. P2-F Energy Usage
历史 Session 5e04d1d2... 已证明 Energy Source 能返回:
text
energy.cost
energy.cpu.cost
energy.gpu.cost
energy.networking.cost
energy.location.cost
energy.display.cost
energy.overhead且 energy.cost=-10 等 sentinel 已被正确保持 missing。
最近 5 次没有真正选择 Energy,因此不能判 Source 故障。
Sample Publisher 修复后做两组:
text
E1: Energy only
E2: Energy + CPU Usage两组都应产生产品 sample;同一 Raw 的 Energy 数值应一致,不允许依赖 CPU 是否选择。
9. 代码修改清单
9.1 MacApp/Sidecar/perfowl_collector_core/realtime.py
P0-A:
- 将
ALLOW_CORE_PROFILE_ROTATION_FALLBACK重命名/升级为正式 continuation policy; - Startup+Frame 生产默认
rotate-to-frame-only; - 保留初始联合 filters;
- startup semantic complete 后冻结报告并触发一次串行 handoff;
- restart busy 增加受控退避;
- 增加 handoff/liveness 遥测;
- 切换期间 FrameTime quality 明确
missing-source-handoff; - Stop 与 handoff race 保证 Stop 优先且不再开启新 Tap。
P0-B:
- 抽取
projector.snapshot(); - 新增独立 sample publisher task;
consume()不再把sysmontap.raw当成产品唯一发布触发器。
9.2 MacApp/Sidecar/perfowl_collector_core/adapters.py
- 复用
CoreProfileRawAdapter.restart(); - 若需要,将“fresh Tap restart + busy retry”抽成统一 helper;
- 不允许同时保留旧/new 两个 started recording;
- 不破坏 Stop/drain 现有 Session finalization。
9.3 MacApp/Sidecar/perfowl_collector_core/launch_trace.py
- 不回退 FTI-006 continuation decoder 修复;
- startup report freeze 后新 epoch 不再进入 startup semantic mutation;
- Frame-only 新 epoch 使用新 decoder 基线,不跨 epoch 拼 FTime。
9.4 MacApp/Sidecar/perfowl_collector_core/metric_engine.py
- Thermal 暂不伪造;只有真实新 Source 合同确认后修改;
- GPU Counter 只有协议证据确认后修改。
9.5 Swift
P0-A/P0-B 尽量保持现有 wire shape,因此 UI 不应需要大改。
如新增 quality:
text
missing-source-handoff
thermal-source-unavailable只增加解释文案,不把 missing 当 0 绘制。
10. 必须新增/调整测试
10.1 FrameTime handoff 单测
扩展:
text
MacApp/Sidecar/tests/test_perfowl_realtime.py
MacApp/Sidecar/tests/test_unified_core_profile.py
MacApp/Sidecar/tests/test_frametime_continuation.py至少覆盖:
- Startup+Frame 初始 Tap filters 同时含 lifecycle + Presented Frame;
- semantic complete 前不切换;
- semantic complete 后只切换一次;
- 旧 Tap stop 完成后才 restart;
- restart filters 只含 Frame filter;
sourceEpoch从 1 -> 2;- 新 epoch 到来时旧 FrameTime 状态被清空;
- 新 epoch 第一帧不产生跨 epoch interval;
- 第二帧开始恢复真实 FrameTime;
- startup report 在切换后不可变;
- Stop 发生于 drain/retry 时不得启动新 Tap;
- restart busy 可退避恢复,最终失败则 FrameTime degraded,但 Session 继续;
- frame-only 模式不走 startup handoff;
- startup-only 不启动 frame-only continuation。
旧测试中硬编码:
text
startCount=1
restartCount=0
sourceEpoch=1用于 Startup+Frame 的断言必须按新生产策略更新;Frame-only 仍应保持单 epoch。
10.2 Sample Publisher 单测
新增至少:
text
FrameTime-only: CoreProfile 有 frame -> 无 sysmontap -> sampleCount > 0
Energy-only: Energy Raw -> 无 sysmontap -> sample 中 Energy 有值
DeviceCondition-only: Battery/Thermal Raw -> sample 正常发布
GPUCounter-only: 若 decoder 有可信值 fixture -> sample 正常发布并验证:
text
1 Hz publisher 不因 5 个 Source 同时到达变成 5 Hz
同一 FrameTime 不跨多个 tick 重复
未启动 Source 字段保持 null/missing11. 真机 E4 验证矩阵
E4-A · 最新 Bug 直接回归
使用与 07c7e86c... 相同组合,持续 >= 60 s,并持续操作 App 保证 FPS 活跃。
必须验证:
text
Startup 有值
前启动窗口 FrameTime 有值
handoff 后 Frame-only Tap 成功启动
handoff 后 FrameTime 恢复并持续到 Stop
不再出现“前 7 个有值、后 30+ 个永久 missing”硬证据:
text
coreProfileStartCount >= 1
coreProfileRestartCount = 1
sourceEpochCount = 2
rotation/handoff 事件完整
第二 epoch 有持续 binary
第二 epoch 有 Presented Frame
第二 epoch 有 FrameTimeE4-B · 30 秒 / 5 分钟长窗
分别运行 30 s、5 min。
若 Graphics FPS >= 20 且持续交互:
text
不允许第二 epoch binary 在 Session 中途永久停止
不允许 FrameTime 在 handoff 恢复后永久 missing记录:
text
handoffGapMs
restartToFirstBinaryMs
restartToFirstFrameTimeMs
post-handoff longestPresentedFrameGapMs
missingWhileFPS>=20 count
lastBinaryToStopMsE4-C · Frame-only
只选 FrameTime(在 Sample Publisher 修复后不额外勾 CPU):
text
sampleCount > 0
CoreProfile 不发生 startup handoff
FrameTime 持续E4-D · Startup-only
只选 StartupTiming:
text
Startup 正常
不创建 Frame-only continuationE4-E · Energy-only / DeviceCondition-only
验证 Sample Publisher 解耦。
12. 关闭门禁
FrameTime P0-A 可关闭条件
必须全部满足:
text
[ ] 最新复现组合 60 s 不再永久断值
[ ] 5 min 持续交互不再发生第二 epoch 永久 binary stall
[ ] handoff 期间只出现诚实 missing,不出现旧值重复
[ ] startup report 与切换前一致且切换后冻结
[ ] 单 kperf owner,无 busy 泄漏
[ ] Stop 无挂死/二次启动
[ ] Frame-only / Startup-only 无回归rotation gap 允许暂时存在,但必须被量化并标记 missing-source-handoff。若 P95 仍接近历史 5 s,则后续单独立优化项;不能因为 gap 尚未最优而继续接受整场 FrameTime 永久丢失。
Sample Publisher P0-B 可关闭条件
text
[ ] FrameTime-only 无 sysmontap 仍有 sample
[ ] Energy-only 无 sysmontap 仍有 sample
[ ] DeviceCondition-only 无 sysmontap 仍有 sample
[ ] 采样频率保持产品约定,不因 Source 数量放大
[ ] 未请求 Source 不被偷偷启动13. 实施顺序(给执行 AI)
严格按以下顺序,不要并行大改:
text
Step 1 给最新 07c7e86c Session 写回归 fixture/断言
Step 2 激活 startup-complete -> frame-only 串行 handoff
Step 3 补 epoch reset + busy retry + handoff telemetry
Step 4 跑 Python 全量测试,修正旧 Unified 单 Tap 断言
Step 5 构建真机包,先做 60 s E4-A
Step 6 若 FrameTime 长尾恢复,再做 5 min E4-B
Step 7 独立实施 Sample Publisher 解耦
Step 8 做 FrameTime-only / Energy-only / DeviceCondition-only E4
Step 9 GPU Counter 与 Thermal 分别按证据轨道推进,禁止猜协议
Step 10 Battery 拔线复测,Energy 做 only vs +CPU A/B不要在 Step 2 尚未真机证明 FrameTime 恢复前,同时重构 GPU Counter/Thermal 协议。 P0-A 的目标是先恢复当前用户最直接遇到的 FrameTime 长尾可用性。
14. 最终期望架构
text
CapturePlan
|
+-- Source Activation(严格 scoped,不启动未选择 Source)
|
+-- CoreProfile
| Startup+Frame:
| combined tap during launch
| -> freeze startup
| -> serial handoff
| -> frame-only tap for long tail
|
| Frame-only:
| frame-only tap directly
|
+-- Other Sources
sysmontap / graphics / network / energy /
device conditions / gpu counter
所有 Source -> Projector latest state
|
v
Independent Product Sample Clock
|
v
product sample这同时修复 FTI-010 暴露的两个架构错位:
- CoreProfile startup+frame 不应为了“单 Tap 理想连续性”牺牲整场 FrameTime 长尾;
- 产品 Sample 不应为了发布节奏强绑定 sysmontap Source。
15. 实施进展记录 · 2026-09-17
对应 AGENTS.md C6/C7 方案实施记录硬门禁。
15.1 进展概览与状态台账
| 步骤 | 任务内容 | 状态 | 证据等级 | 备注 |
|---|---|---|---|---|
| Step 1 | 给最新 07c7e86c Session 写回归 fixture/断言 | completed | E2 | test_frametime_continuation.py 中的 StartupToFrameOnlyHandoffRegressionTests 已修正 KD_BUF 4 条记录定位门禁并通过 |
| Step 2 | 激活 startup-complete -> frame-only 串行 handoff | completed | E2 | 生产默认改为 rotate-to-frame-only,保证单一 kperf recording 串行切换 |
| Step 3 | 补 epoch reset + busy retry + handoff telemetry | completed | E2 | sourceEpoch 边界重置 Projector 状态,增加退避重试与切换期完整遥测 |
| Step 4 | 跑 Python 全量测试,修正旧 Unified 单 Tap 断言 | completed | E2 | Sidecar 437 项测试全绿,Swift 167 项测试全绿 |
| Step 5 | 构建真机包,先做 60 s E4-A 回归 | completed | E4 | 真机 Session 727c4179 验证通过:presentedFrameCount=884,Epoch 2 产出 755 帧持续至 Stop,长尾断流彻底修复 |
| Step 6 | 若 FrameTime 长尾恢复,再做 5 min E4-B 长窗验证 | completed | E4 | 真机 Session 38249077 验证通过:294.72s 持续长窗,切换仅 3.36s,第二 epoch 持续存活并产出 182MB binary 与 16.4ms 帧时间 |
| Step 7 | 独立实施 Sample Publisher 解耦 | completed | E2 | P0-B 架构拆分:snapshot() 与 consume() 解耦,独立 1Hz publisher 定时器,7 项单测全绿 |
| Step 8~10 | Energy/Battery/Thermal/GPU 验证与协议推进 | pending | - | 按证据轨道推进 |
15.2 §10.1 14 项单测覆盖闭环验收
方案 §10.1 明确要求的 14 项覆盖指标已全部达成闭环:
- Startup+Frame 初始 Tap filters 同时含 lifecycle + Presented Frame:
test_startup_flush_publishes_total_then_restarts_frame_source(assertGreater(len(options[0]['filters']), 1)); - semantic complete 前不切换:
test_startup_flush_publishes_total_then_restarts_frame_source(assertLess(total_index, frame_index)); - semantic complete 后只切换一次:
test_startup_flush_publishes_total_then_restarts_frame_source(FakeCore.restarts == 1); - 旧 Tap stop 完成后才 restart:
drain_index < frame_index; - restart filters 只含 Frame filter:
assert(options[-1]['filters'] == [0x31800000]); sourceEpoch从 1 -> 2:test_frametime_recovers_after_source_epoch_handoff_not_permanently_missing;- 新 epoch 到来时旧 FrameTime 状态被清空:
projector._latest_frame_time_ms = None重置; - 新 epoch 第一帧不产生跨 epoch interval:新 epoch 首帧
frameTimeMs is None(warmup); - 第二帧开始恢复真实 FrameTime:
frames2b[0]["frameTimeMs"]恢复真实 candidate; - startup report 在切换后不可变:
test_startup_report_frozen_across_continuation_chunks; - Stop 发生于 drain/retry 时不得启动新 Tap:
test_11_stop_during_drain_does_not_start_new_tap; - restart busy 可退避恢复,最终失败则 FrameTime degraded,但 Session 继续:
test_12a_restart_busy_recovers_via_backoff&test_12b_restart_busy_exhausted_degrades_and_continues_session; - frame-only 模式不走 startup handoff:
test_13_frame_only_mode_does_not_perform_startup_handoff; - startup-only 不启动 frame-only continuation:
test_14_startup_only_mode_does_not_start_frame_continuation。
15.3 真机 E4 回归与长窗验证实测数据
严格遵循隐私红线(设备与应用统一匿名化为“设备 A / 目标 App A”):
15.3.1 E4-A · 60 秒真机直接回归 (727c4179-e91c-4362-b2b5-bdceafd7661b)
- 测试环境:设备 A (iOS 17.0,ProductType: iPhone15,4),目标 App A (冷启动
controlled-cold-launch),Build 61 签名打包工具; - 采样时长:
61.47 s,生成 55 个产品 samples; - 核心遥测指标:
coreProfileStartCount = 1coreProfileRestartCount = 1sourceEpochCount = 2(sourceEpochs = [1, 2])coreProfileObservedByteCount = 1,865,796,872 bytes(1.86 GB,441 chunks)coreProfilePostStartupObservedBytes = 1,677,568,376 bytes(388 chunks)presentedFrameCount = 884(此前断流 Session 仅 85)presentedFrameCoverageSeconds = 44.23 s(此前断流 Session 仅 2.08 s)handoffGapMs = 5,733.48 msrestartToFirstBinaryMs = 1,175.69 msrestartToFirstFrameTimeMs = 1,669.14 ms
- 两阶段流转事实:
- Epoch 1(启动窗口):产出 129 帧,启动报告完整捕获
totalDurationMs = 1990.88 ms(candidate); - 切换过程:
flush-requested->drain->frame-source-restarted->frame-source-active,遥测完整闭环; - Epoch 2(Frame-only 长尾):产出 755 帧与 754 个有效
frameTimeMs(均值约 16ms 左右,真实捕获 451ms/182ms 瞬时卡顿),持续输出直到 Session Stop!
- Epoch 1(启动窗口):产出 129 帧,启动报告完整捕获
- 最终聚合:
Avg(FTime) = 40.67 ms,Avg(FPS) = 24.59,Stutter[%] = 41.05%,Jank(/10min) = 301.08均为 candidate。 - 结论:彻底攻克并关闭 FTI-010 / FTI-008 的“前 7 秒有值、后续永久断流”缺陷!
15.3.2 E4-B · 5 分钟真机长窗稳定性 (38249077-882e-4a12-9225-00e0fb18ec88)
- 测试环境:设备 A (iOS 17.0),目标 App A (冷启动),采集持续 300 秒;
- 采样时长:
294.72 s,生成 291 个产品 samples; - 核心遥测指标:
coreProfileStartCount = 1,coreProfileRestartCount = 1,coreProfileStopCount = 1sourceEpochCount = 2(sourceEpochs = [1, 2])handoffGapMs = 3,364.64 ms(平滑完成串行切换,仅 3.36 秒)restartToFirstBinaryMs = 1,183.80 msrestartToFirstFrameTimeMs = 1,659.10 mscoreProfilePostStartupObservedBytes = 182,635,921 bytes(182 MB,49 chunks)- Epoch 2 在 5 分钟全程持续活跃,Session 结束时的最后三帧 frameTime 分别为
16.60 ms、16.51 ms、16.45 ms;
- 结论:确认切换至 Frame-only Tap 后的第二 epoch binary 不会在长窗中途提前 stall,长尾连续性得到验证。
15.4 验证结果
- Sidecar Python 专项与全量测试:
439 passed in 14.53s(E2) - Swift Core/App 全量单元测试:
167 passed in 22.81s(E2) - 真机 60 秒 E4-A 回归测试:Session
727c4179验证通过 (E4) - 真机 5 分钟 E4-B 长窗测试:Session
38249077验证通过 (E4) - 文档构建:
npm run build耗时 13.19s,0 错误、0 死链。
15.5 Step 7 · P0-B Sample Publisher 解耦实施记录
- 实施日期:2026-09-17
- 关联根因:原架构中
RealtimeMetricProjector.consume()只有在收到sysmontap.raw且包含目标进程行时才拼装并返回 sample 字典,导致用户单选 FrameTime、Energy、Battery、Thermal、GPU Counter 等非 sysmontap Source 时虽然底层 decoder 正常吐出数据但产品sampleCount=0。 - 修改范围与关键文件:
MacApp/Sidecar/perfowl_collector_core/realtime.py:RealtimeMetricProjector:将 sample 拼装逻辑抽取为独立方法snapshot(monotonic_ns=None, ...),consume()专注于 Source 状态更新(startup observation、代际状态、engine.consume、frame/gpu 状态、进程元数据),返回None;- 新增
bind_target_identity(pid, name, generation, verified),解耦对 sysmontap 进程发现的强依赖; stream_candidate_session:移除 ingest 循环中对 sample 的 emit 调用;新增以恒定interval_ms(默认 1Hz)运行的独立stream-sample-publisher协程任务,由单一定时器调用projector.snapshot()并emit("sample", ...)。
MacApp/Sidecar/tests/test_sample_publisher_decoupling.py(新增):- 落实 §10.2 要求的全部 7 项单测(FrameTime-only、Energy-only、DeviceCondition-only、GPUCounter-only、恒定频率不倍增、FrameTime freshness 不跨 tick 重复、未请求指标保持 null/missing),100% 通过。
MacApp/Sidecar/tests/test_perfowl_realtime.py&test_frametime_continuation.py:- 适配既有单测的
snapshot()显式采集调用;修复test_startup_ring_experiment对 pytest 的非预期依赖。
- 适配既有单测的
- 闭环验证证据:
- Sidecar 439 项单元测试全绿 (E2);
- Swift 167 项单元测试全绿 (E2);
- 达成全部 Section 10.2 闭环要求。