外观
Display Performance · ActivityTrace FrameLifetime 全量切源开发记录
执行状态:
implemented · gate-openR0-R6 已完成(详见对应章节)。R7 首轮真机联调失败并定位根因后,进入 R7-Fix-A ~ R7-Fix-I 修复子阶段:当前完成 R7-Fix-A(Display Performance 5/5 Registry 闭环)、R7-Fix-B(Start control_rejected 静默吞掉修复)、R7-Fix-C/D(清理旧 CoreProfile FrameTime ownership)与 R7-Fix-E/F(跨层 Start Contract 集成测试),代码层(E2)修复与测试已全部完成。等待 R7-Fix-G(真机 Gate-0)——该阶段需要用户连接真实 iOS 设备执行,无法由 AI 协作者独立完成。
R0 · 冻结 POC Fixture(2026-09-17)
R0 目标
无真机条件下建立确定性 FrameLifetime 开发基础。创建匿名化 ActivityTrace FrameLifetime fixture,包含 POC 验证的 golden 统计值,使后续 R1-R7 的 decoder、projector、adapter 开发全部可离线测试。
R0 实际修改
新增文件
| 文件 | 用途 |
|---|---|
MacApp/Sidecar/tests/fixtures/activitytrace-framelifetime/golden-5s-90frames.json | 匿名化 golden fixture,90 个 FrameLifetime signpost 事件 |
MacApp/Sidecar/tests/test_activitytrace_frame_lifetime.py | 31 项 R0 fixture 验证测试 |
site/devlog/p4-display-performance-activitytrace-frametime-cutover-plan.md | 本开发记录 |
Fixture 规格
text
Schema: perfowl.activitytrace-framelifetime-fixture/1
Events: 90
Timebase: 125/3 (典型 A-series)
Signpost: com.apple.coreanimation / IOMFBServer / FrameLifetimeGolden 统计值
text
totalEvents: 90
chainedPairs: 59 / 89 adjacent pairs
medianFrameTimeMs: 49.948458
refreshIntervalNs: 16666666
refreshRateHz: 60.00
duration: ≈ 4.5sFixture 事件字段
json
{
"index": 0,
"subsystem": "com.apple.coreanimation",
"category": "IOMFBServer",
"name": "FrameLifetime",
"event_type": "signpost",
"begin_time": "<mach ticks>",
"end_time": "<mach ticks>",
"prev_frame": "<mach ticks>",
"refresh_interval": "<mach ticks>",
"frame_seed": 1000,
"buffer_count": 2,
"skip_request": 0,
"display_id": 1,
"surface_id": 100
}链式连续性设计
- Events 0-30(Phase 1):31 个事件,
prev_frame ≠ previous.end_time(模拟 warmup/间断) - Events 31-89(Phase 2):59 个事件,
prev_frame == previous.end_time(完美链式连续) - 总计 59 个 chained pairs,与 POC "59/59 PASS" 一致
R0 测试结果
text
R0 专项测试: 31/31 PASS (0.009s)
全量 Python 回归: 485/485 PASS (11.397s)测试覆盖 6 个维度:
- FixtureSchemaTests (6 项):schema 版本、匿名化标记、事件数、timebase
- EventFieldTests (8 项):必填字段、subsystem/category/name 一致性、时间单调性
- ChainContinuityTests (3 项):精确 59 对链匹配、Phase1 无链、Phase2 全链
- FrameTimeStatisticsTests (5 项):FrameTime 计数、正值、median 匹配、范围
- RefreshRateTests (3 项):60Hz 转换、golden 一致性
- AnonymizationTests (4 项):无 UDID、无 Bundle ID、无设备名、无序列号
- TimebaseConversionTests (2 项):已知转换验证、fixture 持续时间
R0 偏差
| 项 | 方案值 | 实际值 | 原因 |
|---|---|---|---|
| medianFrameTimeMs | 49.926 | 49.948 | Mach tick 整数量化 (125/3 timebase),偏差 0.022ms,在 0.1ms 容限内 |
| 持续时间 | ~5s | ~4.5s | Phase 1 的 31 个事件间距稍紧凑,不影响 golden 统计 |
R0 可复用经验
prev_frame与前一帧end_time的关系是 FrameLifetime 链式连续性的核心判据。- Mach tick 整数量化使 ms 级精度出现可预期的微偏差,decoder 测试应使用
assertAlmostEqual(places=3)而非精确相等。 - Fixture 生成脚本使用固定随机种子 (
random.seed(20260917_02)),保证输出可复现。
R0 遗留
无。
R0 → R1 门禁
text
[x] Fixture 文件存在且通过 schema 验证
[x] 31 项 R0 测试全绿
[x] 全量 Python 回归无新增失败 (485/485)
[x] 无隐私数据泄露
[x] R0 devlog 写入并同步时间线R0 PASS 后方可进入 R1(实现 ActivityTrace Adapter)。
R1 · ActivityTrace Adapter 实现(2026-09-17)
R1 目标
落地首个非侵入 ActivityTrace FrameLifetime 专用采集适配层:
- 严格仅保留
com.apple.coreanimation / IOMFBServer / FrameLifetime - 其他第三方 App log、os_log、系统隐私 signpost 在采集源头直接丢弃(persisted = 0)
- 实现动态时间基转换(支持任意 numer/denom 整数除法,禁止写死 125/3)
- 落地 FrameTime 连续性规则(prev_frame 链匹配、sourceEpoch/reconnect/targetGeneration/displayID/foreground 边界断链判定)
- 落地 Schema Drift fail-closed 机制(缺字段或无法转为整数时置
unsupported-schema) - 完成 Adapter start/stop 生命周期与 golden fixture 端到端回放验证
- 不修改 UI、CoreProfile、生产 FrameTime 产品链
R1 实际修改
新增文件与核心类
MacApp/Sidecar/perfowl_collector_core/display_performance.py:ActivityTraceFrameLifetimeConfig:冻结 POC 配置语义(onlySignposts=1, signpostsAndLogs=0, targetPID=-3, predicate="",支持动态machTimebaseNumer/Denom)。is_frame_lifetime_signpost(raw):严格白名单检查器,仅放行subsystem == "com.apple.coreanimation"且category == "IOMFBServer"且name == "FrameLifetime"。extract_frame_lifetime_fields(raw):类型化字段提取与 schema 校验,解析 9 项必填字段(begin_time, end_time, prev_frame, refresh_interval, frame_seed, buffer_count, skip_request, display_id, surface_id)。FrameLifetimeDecoder:有状态解码器,维护连续性锚点,产出perfowl.display-frame-event/2规范化事件。_ActivityTraceFilteredStream:异步流包装器,源头过滤非 FrameLifetime 消息。ActivityTraceFrameLifetimeRawAdapter:_RawStreamAdapter实现类,adapter_id = "pmd3.activity-trace.frame-lifetime.raw",event_type = "display.frame-lifetime.raw",scope = "foreground-device",selector = "onlySignposts"。
联动文件
MacApp/Sidecar/perfowl_collector_core/adapters.py:导出ActivityTraceFrameLifetimeRawAdapter与ActivityTraceFrameLifetimeConfig。MacApp/Sidecar/perfowl_collector_core/orchestrator.py:在ADAPTER_TYPES静态注册表登记ActivityTraceFrameLifetimeRawAdapter。MacApp/Sidecar/perfowl_collector_core/__init__.py:导出新适配器与解码器类。MacApp/Sidecar/tests/test_activitytrace_frame_lifetime.py:追加 26 项 R1 专项单元测试(Test 32 ~ Test 57)。
R1 测试结果
text
专项测试 (R0 + R1): 57/57 PASS (0.047s)
全量 Python 回归: 511/511 PASS (12.789s)测试覆盖维度:
- ActivityTraceConfigTests (3 项):默认 12 项配置、自定义 timebase、参数覆盖
- WhitelistPrivacyTests (4 项):subsystem、category、name 独立白名单拦截,任意用户隐私日志丢弃
- FieldExtractionAndSchemaTests (3 项):类型化提取、缺少字段 fail-closed、非整数字段 fail-closed
- TimebaseAndRateConversionTests (4 项):任意 dynamic timebase、60Hz 转换、120Hz 转换、非法 zero refresh interval 保护
- ContinuityRulesTests (8 项):prev_frame 链匹配 valid、mismatch discontinuity、sourceEpoch reset、reconnectGeneration reset、targetGeneration reset、displayID reset、foreground/background transition reset、invalid timestamp ordering
- AdapterLifecycleAndStreamingTests (4 项):capability/probe、start/stop 生命周期、混合流源头白名单拦截(其他 signpost 产出 = 0)、90 帧 golden fixture 端到端全量回放(59 帧 valid,median FrameTime 49.948ms,60Hz 刷新率)
R1 偏离方案的地方
无。完全按照任务书第 4、6、7、8、9、10、52、57 条规范落地。未改动 UI、CoreProfile 及既有 FrameTime 产品链。
R1 失败尝试
在初期运行 test_activitytrace_frame_lifetime.py 时,未显式设置 PYTHONPATH=MacApp/Sidecar 导致 ModuleNotFoundError,通过加上正确的 PYTHONPATH 规范解决。
R1 可复用经验
_ActivityTraceFilteredStream在迭代器级别直接短路非目标 signpost,避免了为丢弃的事件分配序列号或分配内存,确保“其他 signpost persisted = 0”在物理层彻底实现。- 在
FrameLifetimeDecoder中将断链判定(boundary change)与链式匹配(chain match)分离,使得 warmup、valid、discontinuity 和 not-foreground 四种状态语义界限极其清晰。
R1 剩余问题
本阶段 Adapter 产出标准化的 display.frame-lifetime.raw 原始事件,尚未接入实时指标聚合器(FPS 滑动窗口、P95、Jitter 等由 R2 实现)。
R1 → R2 门禁
text
[x] 只保留 FrameLifetime,其他 signpost 过滤后 persisted = 0
[x] schema drift fail-closed 机制有效(缺少必填字段输出 unsupported-schema)
[x] start/stop 生命周期正确
[x] golden fixture 端到端回放 90 帧全部通过(59 帧 valid,median 49.948ms)
[x] 57 项专项测试全绿
[x] 511 项全量 Python 回归无新增失败
[x] Swift 回归测试完成
[x] devlog 回写并同步时间线
[x] 站点构建 0 错误 0 死链R1 验收合格,可进入 R2(DisplayPerformanceProjector)。
R2 · DisplayPerformanceProjector 实现(2026-09-17)
R2 目标
完成 Display Performance 核心聚合计算器(DisplayPerformanceProjector):
- 落地 Trailing 1 秒滑动窗口 FPS 计算(禁止
1000/FrameTime,真正统计 1 秒内呈现帧数); - 落地静止画面时间桥(
estimatedDisplayNowNs = lastPresentationTimestampNs + max(0, hostNowNs - lastPresentationHostReceiveMonotonicNs)),解决静态页面 FPS=0 淘汰问题,无帧时输出0.0且quality = "candidate-idle"; - 落地 FrameTime one-shot freshness(无新帧时置
missing-no-new-frame,严禁重复旧值); - 落地 10 秒滚动窗口 P95(nearest-rank
ceil(0.95 * n))与 P50(median);小样本(n < 5)置warmup; - 落地 Frame Pacing Jitter(
P95 - P50); - 落地 Refresh Rate 提取与 VSync 转换;
- 落地多 Display 隔离(避免外部副屏污染主屏序列)与退后台/重连/Epoch 切换清空机制。
R2 实际修改
核心类与代码
MacApp/Sidecar/perfowl_collector_core/display_performance.py:DisplayPerformanceSnapshot:不可变快照 dataclass,包含 5 项指标(frames_per_second,frame_time_ms,frame_time_p95_ms,refresh_rate_hz,frame_pacing_jitter_ms)及其独立 quality 状态。DisplayPerformanceProjector:consume(event, host_receive_monotonic_ns):支持接收RawEventEnvelope、字典载荷或解码器事件;提取时间戳、帧耗时与刷新率;自动识别 primary display 并隔离副屏;检测sourceEpoch/reconnectGeneration变化并重置。snapshot(host_now_ns, foreground=True):根据宿主单调时间驱动窗口滑动与指标计算;退后台清空并输出not-foreground;源不活跃输出missing。
MacApp/Sidecar/perfowl_collector_core/__init__.py:导出DisplayPerformanceProjector与DisplayPerformanceSnapshot。MacApp/Sidecar/tests/test_display_performance.py:新增 17 项 R2 专项单元测试。
R2 测试结果
text
R2 专项测试: 17/17 PASS (0.005s)
R0+R1 专项测试: 57/57 PASS (0.048s)
全量 Python 回归: 528/528 PASS (11.982s)专项测试覆盖维度(Test 1 ~ Test 17):
- FPSWindowTests (4 项):Trailing 1s 满帧 60.0 FPS、静止画面时间桥推进至 0.0 FPS(candidate-idle)、源未就绪 missing、突发脉冲窗口精确统计;
- FrameTimeFreshnessTests (3 项):首点 one-shot 消费、次点无新帧
missing-no-new-frame、多次轮询坚决不重复旧值、新帧到达即时刷新; - P95AndJitterTests (4 项):小样本(n < 5)warmup、nearest-rank 20 样本 P95 精确检验、P50 与 Jitter(P95 - P50)精确计算、10 秒外历史点过期剔除;
- RefreshRateAndMultiDisplayTests (2 项):119.88Hz 高刷刷新率跟踪、外部副屏(displayID=2)自动隔离;
- BoundaryAndForegroundTests (3 项):退后台全指标清空与
not-foreground标记、reconnectGeneration 递增清空窗口、sourceEpoch 递增清空窗口; - GoldenFixtureEndToEndProjectorTests (1 项):90 帧 golden fixture 接入投影器端到端模拟连续采样,最终快照 P95 与 Jitter 完全收敛。
R2 偏离方案的地方
无。完全按照规范落地全部数学公式与边界。
R2 失败尝试
无。
R2 可复用经验
- 使用
estimatedDisplayNowNs仅驱动时间窗口的前进与旧点淘汰,而不参与 FrameTime 差分计算,既解决了静态页面 FPS 不归零的痛点,又遵守了“严禁合成 FrameTime 时间戳”的红线。 - 将
DisplayPerformanceSnapshot设计为可直接转出as_metrics_dict()与as_qualities_dict(),使 R3/R4 与RealtimeMetricProjector的集成成为无侵入的极简替换。
R2 剩余问题
DisplayPerformanceProjector 已独立测试就绪,下一步 R3 将在平台 Profile 与 Registry / CapturePlan 中正式挂载新指标族。
R2 → R3 门禁
text
[x] FPS 1s 窗口与静态页面 FPS=0 candidate-idle 测试通过
[x] FrameTime one-shot freshness 规则测试通过(不重复旧值)
[x] P95 nearest-rank 与 P50/Jitter 测试通过
[x] Refresh Rate 与多 Display 隔离测试通过
[x] 退后台清空与重连重置测试通过
[x] 17 项 R2 专项测试全绿
[x] 528 项全量 Python 回归无新增失败
[x] devlog 回写并同步时间线
[x] 站点构建 0 错误 0 死链R2 验收合格,可进入 R3(Registry / CapturePlan 正式切源)。
R3 · Registry / CapturePlan 正式切源(2026-09-17)
R3 目标
在 Registry / CapturePlan / CLI / 平台支持层将 ios.foreground.fps 与 ios.frame.time 正式切到 ActivityTrace:
MetricCatalog(Swift)与ios-platform-profiles.candidate.v1.json(Python):ios.foreground.fps与ios.frame.time的 source 正式登记为activitytrace-framelifetime/pmd3.activity-trace.frame-lifetime.raw;CapturePlan:采集ios.foreground.fps或ios.frame.time时,自动激活 ActivityTrace source,严禁再依赖pmd3.core-profile.raw才能采集这两个指标;- 纯静态或普通帧采集中,默认不再拉起 CoreProfile 作为 FrameTime source;
- 保持全仓 63 指标契约严格闭环,0 别名欺骗,全自动化门禁脚本全部通过。
R3 实际修改
核心文件变更
| 文件 | 修改内容 |
|---|---|
MacApp/Sources/PerfowlCore/PerformanceModels.swift | 将 ios.foreground.fps 与 ios.frame.time 的 source 更新为 "activitytrace-framelifetime",更新 sourceKey 与 definitionVersion |
site/data/metric-contracts.json | 镜像同步 Swift Registry 中的 ios.foreground.fps 与 ios.frame.time 定义,保持 63 个指标精确对齐 |
MacApp/Sidecar/perfowl_collector_core/ios-platform-profiles.candidate.v1.json | 新增 display-performance 家族(adapter: pmd3.activity-trace.frame-lifetime.raw,包含 ios.foreground.fps、ios.frame.time);graphics 移出 ios.foreground.fps;core-profile-frame 移出 ios.frame.time |
MacApp/Sidecar/perfowl_collector_core/metric_engine.py | 更新 ios.foreground.fps 公式为 "count(presentationTimestamp in trailing 1 second)",公式版本 "activitytrace-framelifetime-window-fps.v1" |
MacApp/Sidecar/perfowl_collector_core/platform_support.py | 维持 63 指标自审门禁,支持 10 个数据源家族 |
MacApp/Sidecar/perfowl_collector_core/capture_policy.py | 将 ActivityTraceFrameLifetimeRawAdapter 纳入全量准备适配器列表 |
MacApp/Sidecar/perfowl_collector_core_cli.py | 将 ActivityTraceFrameLifetimeRawAdapter 纳入预备适配器执行次序 |
MacApp/Sidecar/tests/test_capture_selection.py | 验证 ios.foreground.fps 与 ios.frame.time 严格激活 ActivityTrace 适配器,坚决不激活 CoreProfile |
MacApp/Sidecar/tests/test_capture_plan.py | 验证 ios.frame.time 与 ios.foreground.fps 在 CoreProfile 失败时仍能正常通过 ActivityTrace 就绪 |
MacApp/Sidecar/tests/test_capture_policy.py | 同步更新 ActivityTraceFrameLifetimeRawAdapter 的准备与记录断言 |
MacApp/Sidecar/tests/test_perfowl_platform_support.py | 断言更新为 10 个源家族、63 个指标契约并与 Swift 完全匹配 |
MacApp/Sidecar/tests/test_perfowl_core_cli.py | 断言 probe 结果返回 10 个源家族、63 个指标契约 |
R3 测试结果
text
R3 专项/切源测试:
- test_capture_selection.py: 11/11 PASS (0.005s)
- test_capture_plan.py: 5/5 PASS (0.004s)
- test_capture_policy.py: 3/3 PASS (0.000s)
- test_perfowl_platform_support.py: 3/3 PASS (0.011s)
- test_perfowl_core_cli.py: 11/11 PASS (0.073s)
- metric-knowledge-gate.py: PASS (63 Registry contracts, Canonical ownership, 10-element markers)
全量回归测试:
- 全量 Python 回归: 531/531 PASS (12.186s)
- 全量 Swift 回归: 171/171 PASS (22.834s, 3 skipped, 0 failures)
- 站点构建: npm run build PASS (16.93s, 0 错误 0 死链)R3 偏离方案的地方
无。完全遵循方案与 AGENTS.md 约束,严格保持 63 项指标契约闭环。
R3 失败尝试
在初期构思中曾尝试将 ios.frame.time.p95、ios.display.refresh_rate、ios.frame.pacing_jitter 同时注册进 MetricCatalog(扩展为 66 指标)。由于这会波及全仓所有的硬编码契约检查(site/knowledge/index.md、HomeExtras.vue、历史报告快照),经严格评估后,按照“保持 63 指标契约在整体架构中依然闭环”的主指令,将这 3 项派生字段保留在 DisplayPerformanceSnapshot 聚合层供 UI 与报告使用,Registry 正式层精准切接 ios.foreground.fps 与 ios.frame.time,确保全架构零破损平滑迁移。
R3 可复用经验
在架构切源过程中,先在底层建立清晰解耦的源家族(display-performance),并在 CapturePlan 中直接解绑高开销旧源依赖,可以实现各层测试的高内聚与高确定性。
R3 剩余问题
进入 R4,彻底剥离运行时主路径上的 CoreProfile 轮转与续命逻辑(handoff_to_frame_continuation 等),并将 DisplayPerformanceProjector 直接接入 RealtimeMetricProjector。
R3 → R4 门禁
text
[x] Swift MetricCatalog 与 Python platform profiles 正式切源至 ActivityTrace
[x] CapturePlan 激活 display 指标时不依赖 CoreProfile
[x] metric-knowledge-gate 63 指标契约校验 100% 通过
[x] 全量 Python 531/531 测试通过
[x] 全量 Swift 171/171 测试通过
[x] 站点构建 0 错误 0 死链R3 验收合格,可进入 R4(彻底移除 CoreProfile Frame Runtime 依赖)。
R4 · 删除 CoreProfile Frame Runtime(2026-09-17)
R4 目标
彻底解除运行时对 CoreProfile Frame Runtime 的依赖:
- 生产运行中 CoreProfile 在启动阶段(Startup Trace)完成后彻底关停,不再拉起 Epoch 2 轮转(Rotation Restart),不再触发 drain-then-restart 续命链路;
RealtimeMetricProjector彻底断开对 CoreProfile live kd_buf 的帧解码依赖,core-profile.chunk.raw事件被明确旁路,不再产生实时帧指标;- live
ios.frame.time、ios.foreground.fps及新增 Display 指标 100% 由DisplayPerformanceProjector(ActivityTrace)独占提供; - 保证 CoreProfile 的启停、失败、丢弃或缺席绝不影响 Display Performance 指标的正常采集与产出;
- 保留对遗留对比实验与测试的兼容性,默认策略
STARTUP_FRAME_CONTINUATION_POLICY = CORE_PROFILE_NO_FRAME_CONTINUATION。
R4 实际修改
核心文件变更
MacApp/Sidecar/perfowl_collector_core/realtime.py:- 引入
CORE_PROFILE_NO_FRAME_CONTINUATION = "no-frame-continuation"并将其设为STARTUP_FRAME_CONTINUATION_POLICY生产默认策略。 RealtimeMetricProjector.__init__():初始化self._display_projector = DisplayPerformanceProjector()。RealtimeMetricProjector.consume():接入display.frame-lifetime.raw驱动_display_projector.consume();将core-profile.chunk.raw对RealtimeFrameDecoder的 live 解码旁路,不再由 CoreProfile kd_buf 产出实时帧。RealtimeMetricProjector.snapshot():当_display_projector.is_active时,直接采用DisplayPerformanceSnapshot产出framesPerSecond,frameTimeMs,frameTimeP95Ms,displayRefreshRateHz,framePacingJitterMs及其对应 quality(valid/candidate-idle/missing-no-new-frame/not-foreground),并保持 fallback 兼容旧测试。consume(adapter):在 CoreProfile 启动轮转点,当STARTUP_FRAME_CONTINUATION_POLICY == CORE_PROFILE_NO_FRAME_CONTINUATION时,平稳关闭 adapter,设置frame_continuation_status = "not-required",标记status = "startup-closed",不再拉起 Epoch 2。flush_startup_core_profile():在CORE_PROFILE_NO_FRAME_CONTINUATION下不再激活mark_core_profile_handoff_active(True),彻底消除切源中间态的伪 handoff 波动。
- 引入
MacApp/Sidecar/perfowl_collector_core/display_performance.py:- 为
DisplayPerformanceProjector添加@property def is_active(self) -> bool,准确暴露底层是否已收到有效源输入。
- 为
MacApp/Sidecar/tests/test_realtime_display_cutover.py:- 新增 5 项 R4 专项解耦回归测试:
test_01_display_event_drives_realtime_frame_metrics:验证display.frame-lifetime.raw驱动实时 FPS 与 FrameTimetest_02_core_profile_chunk_is_ignored_for_live_frame_metrics:验证core-profile.chunk.raw被旁路且不再产生帧耗时test_03_frame_time_one_shot_freshness:验证 one-shot 机制,无新帧时精准输出missing-no-new-frametest_04_background_transition_marks_not_foreground:验证退后台精准输出not-foregroundtest_05_default_continuation_policy_is_no_frame_continuation:验证生产默认策略为no-frame-continuation
- 新增 5 项 R4 专项解耦回归测试:
R4 测试结果
text
R4 专项测试:
- test_realtime_display_cutover.py: 5/5 PASS (0.001s)
- test_frametime_continuation.py: 15/15 PASS (1.948s)
- test_perfowl_realtime.py: 38/38 PASS (3.380s)
全量回归测试:
- 全量 Python 回归: 536/536 PASS (11.828s)
- 全量 Swift 回归: 171/171 PASS (21.472s, 3 skipped, 0 failures)
- 站点构建: npm run build PASS (16.27s, 0 错误 0 死链)R4 偏离方案的地方
无。完全按照任务书规范,将生产环境的 CoreProfile Frame Runtime 彻底解耦,并确保旧测试通过显式策略配置继续生效。
R4 失败尝试
在初期修改 realtime.py 的 snapshot 逻辑时,曾将 fallback 分支中的 missing-source-handoff 错误覆盖为通用 missing,导致旧 handoff 边界测试失败;通过恢复 fallback 分支的 handoff 状态逻辑,并在主分支彻底由 DisplayPerformanceProjector 接管,实现了新老路径的完全安全隔离。
R4 可复用经验
在对复杂采集运行时进行切源时,采用“生产默认走新解耦源 + 遗留逻辑保留在特定 policy 下”的双轨隔离模式,既能彻底斩断生产路径上的历史技术债(3.34 GiB/3min 的吞吐崩溃),又无需破坏经过严格验证的单测防护网。
R4 剩余问题
实时路径已完全切至 ActivityTrace,接下来进入 R5(离线报表与 Session 归档),使 frame-report.json 与 manifest.json 在离线分析中也以 ActivityTrace FrameLifetime 作为首要事实源。
R4 → R5 门禁
text
[x] RealtimeMetricProjector 彻底断开 CoreProfile live frame 解码
[x] 生产默认策略切换为 CORE_PROFILE_NO_FRAME_CONTINUATION
[x] 5 项 R4 专项切源解耦测试全绿
[x] test_frametime_continuation 与 test_perfowl_realtime 全绿
[x] 全量 Python 536/536 PASS
[x] 全量 Swift 171/171 PASS
[x] 站点构建 0 错误 0 死链R4 验收合格,可进入 R5(离线 Frame Report v4 与 Session 产物适配)。
R5 · Frame Report v4(2026-09-17)
R5 目标
完成离线分析与在线报表系统向 ActivityTrace FrameLifetime 全量适配,升级为 Frame Report v4:
frame_derivation.py:支持离线读取display.frame-lifetime.raw与perfowl.display-frame-event/2事件,解码输出frame-events.jsonl,生成包含 P50, P90, P95, P99, Max, Jitter (P95 - P50) 及 Refresh Rate 统计的frame-report.json;- 明确区分算法版本:ActivityTrace 来源标识为
frame-report.candidate.v4,source = "activitytrace-framelifetime";CoreProfile 遗留数据保持frame-report.candidate.v3,保证历史兼容; online_report.py:_looks_like_frame识别presentationTimestampNs,_FrameAccumulator支持 ActivityTrace 帧输入并持久化source,确保在 seal 以及崩溃 checkpoint 恢复时保持统计与 v4 算法版本完全一致;realtime.py:实时链路中display.frame-lifetime.raw解码帧实时转发入OnlineReportSession在线累加器;- 实现双向校验一致性:online streaming == offline batch replay;checkpoint recovery == uninterrupted 连续累加。
R5 实际修改
核心文件变更
MacApp/Sidecar/perfowl_collector_core/frame_derivation.py:derive_core_profile_session():当未发现 CoreProfile raw 时,优先检测并读取display.frame-lifetime.raw或perfowl.display-frame-event/2;analyze_display_frames():检测输入帧格式,对 ActivityTrace 帧使用真实的frameTimeMs解码值,严格遵循warmup与断链保护;- 输出元数据:对 ActivityTrace 产生
algorithmVersion = "frame-report.candidate.v4",source = "activitytrace-framelifetime",sourceWindow = "activitytrace-session",sourceContinuity = "framelifetime-chain";遗留 CoreProfile 产生frame-report.candidate.v3。
MacApp/Sidecar/perfowl_collector_core/online_report.py:_looks_like_frame():新增对presentationTimestampNs字段的直接识别;_FrameAccumulator:适配presentationTimestampNs/frameTimeMs;to_state()与restore_state()新增source序列化字段;封存时 ActivityTrace 帧生成algorithmVersion = "frame-report.candidate.v4"与source = "activitytrace-framelifetime"。
MacApp/Sidecar/perfowl_collector_core/realtime.py:consume(adapter):将display.frame-lifetime.raw的解码帧追加到live_decoded_frames并异步推送到OnlineReportSession。
MacApp/Sidecar/tests/test_frame_report_v4.py:- 新增 4 项核心测试:
test_01_activitytrace_offline_derivation_v4:验证离线display.frame-lifetime.raw解析生成的frame-report.json与frame-events.jsonl;test_02_online_streaming_frame_accumulator_v4:验证在线累加器 sealing 产出frame-report.candidate.v4;test_03_online_checkpoint_recovery_parity:验证在断点持久化与恢复场景下的准确率与算法版本一致性;test_04_coreprofile_legacy_preserves_v3:验证遗留 CoreProfile 数据回放保持frame-report.candidate.v3100% 兼容。
- 新增 4 项核心测试:
R5 测试结果
text
R5 专项测试: 4/4 PASS (0.015s)
全量 Python 回归: 540/540 PASS (13.153s)
全量 Swift 回归: 171/171 PASS (22.518s)R5 偏离方案的地方
无。算法版本区分与报表结构完全按照规范落地。
R5 可复用经验
- 在
_FrameAccumulator.to_state()中保存source字符串,消除了从 checkpoint 恢复后由于缺少事件上下文可能导致的算法版本漂移问题。 - 离线派生器与在线累加器共享相同的 P50/P90/P95/P99/Max/Jitter 计算分位数口径,确保在线产物与离线重放 100% 对齐。
R5 遗留
无。
R5 → R6 门禁
text
[x] frame_derivation.py 支持 display.frame-lifetime.raw 离线派生
[x] online_report.py 在线累加器产出 frame-report.candidate.v4
[x] checkpoint 恢复与在线累加保持一致性
[x] 遗留 CoreProfile 会话保留 frame-report.candidate.v3 兼容
[x] 4 项 R5 专项测试全绿
[x] 全量 Python 540/540 PASS
[x] 全量 Swift 171/171 PASS
[x] 站点构建 0 错误 0 死链R5 验收合格,可进入 R6(Swift + UI 集成)。
R6 · Swift + UI 集成(2026-09-17)
R6 目标
完成 macOS SwiftUI 客户端与 Workbench、Session Review 对 Display Performance 的全量接入:
CollectorMetrics与PerformanceSample:补齐frameTimeP95Ms,displayRefreshRateHz,framePacingJitterMs强类型定义与 wire event 逆序列化;WorkbenchMetricCatalog:引入.displayPerformance面板定义,映射 5 项指标(ios.foreground.fps,ios.frame.time,ios.frame.time.p95,ios.display.refresh_rate,ios.frame.pacing_jitter);- 偏好平滑迁移:实现
migratePreferences与migratePreferenceStrings,用户此前保存的.fps或.frameTime自动迁移合并为.displayPerformance; DisplayPerformancePanel:实现专属 SwiftUI 面板,集成图表模式("FPS" 模式、"帧耗时" 模式、"全部" 模式),各指标读数支持点击隐藏/显示曲线与交互聚焦;- 抑制旧卡片重复:在 Workbench 实时工作台和 Session Review 离线回放中,只要
displayPerformance生效或存在呈现数据,坚决不重复展示独立的 FPS 和 Frame Time 卡片; - 编写专项 Swift 测试验证 wire decode、偏好迁移与契约完整性。
R6 实际修改
核心文件变更
MacApp/Sources/PerfowlCore/PerformanceModels.swift:CollectorMetrics:新增frameTimeP95Ms,displayRefreshRateHz,framePacingJitterMs字段并提供= nil默认参数;PerformanceSample:新增对应 3 项只读属性,并在init?(wireEvent:)中完成赋值。
MacApp/Sources/PerfowlCore/WorkbenchModels.swift:WorkbenchMetricID:新增case displayPerformance;WorkbenchSeriesValue:新增case frameTimeP95,case displayRefreshRate,case framePacingJitter;WorkbenchMetricCatalog.panels:新增displayPerformance面板定义,包含 5 项 series(display-fps,display-frame-time,display-p95,display-jitter,display-refresh-rate);WorkbenchMetricCatalog.metricIDsByGroup:新增.displayPerformance映射列表;- 新增
migratePreferences(_:)与migratePreferenceStrings(_:)迁移辅助方法。
MacApp/Sources/PerfowlCore/PerformanceSessionController.swift:scopedCapturePlan、prepareCollector与startDraftSession统一调用WorkbenchMetricCatalog.migratePreferences,自动迁移旧偏好。
MacApp/Sources/PerfowlApp/PerfowlWorkbenchView.swift:accent与value(from:):补齐 3 项新增指标的取值逻辑与自适应配色;WorkbenchMetricPanel:挂接DisplayPerformancePanel;DisplayPerformancePanel:实现支持图表模式切换("FPS", "帧耗时", "全部")、单指标点击过滤、自适应数值格式化与 quality tag 显示的高性能面板;visibleWorkbenchPanels:统一过滤抑制独立.fps和.frameTime,彻底消除旧卡片重复显示。
MacApp/Sources/PerfowlApp/SessionReviewViews.swift:metricCharts:当存在displayPerformance且会话有呈现数据时,严格过滤掉独立的.fps与.frameTime。
MacApp/Tests/PerfowlCoreTests/DisplayPerformanceSwiftTests.swift:- 新增 6 项专项 Swift 测试:
testOldWireDecodeWithoutNewDisplayFieldsSetsNil:测试旧 wire event 逆序列化向后兼容;testNewWireDecodeWithDisplayFieldsPopulatesAllFiveMetrics:测试新 wire event 全部 5 项指标正确解析;testSavedFPSPreferenceMigratesToDisplayPerformance:测试保存的 fps 偏好自动迁移;testSavedFrameTimePreferenceMigratesToDisplayPerformance:测试保存的 frameTime 偏好及两者共存时去重迁移;testDisplayPerformancePanelDefinitionMatchesProductContract:测试面板 5 项 series 与单位、作用域契约;testDisplayPerformanceMetricIDsMapping:测试产品指标 Registry 映射。
- 新增 6 项专项 Swift 测试:
R6 测试结果
text
R6 专项 Swift 测试: 6/6 PASS (0.011s)
全量 Swift 回归: 177/177 PASS (20.992s, 3 skipped)
全量 Python 回归: 540/540 PASS (11.846s)R6 偏离方案的地方
无。UI、面板结构、偏好迁移与去重规则完全按照任务书落地。
R6 可复用经验
- 在
WorkbenchMetricCatalog中集中封装migratePreferences,使得 Controller(采集计划)与 View(展示面板)能够复用完全一致的偏好迁移与去重规则。 DisplayPerformancePanel提供的图表模式(FPS / 帧耗时 / 全部)解决了因 FPS(~60)与 FrameTime(~16ms)量级及单位不一致导致多轴挤压的可读性问题。
R6 遗留
无。
R6 → R7 门禁
text
[x] CollectorMetrics 与 PerformanceSample 承载全部 5 项 Display 指标
[x] Swift wire decode 兼容新旧格式
[x] saved fps / frameTime 偏好平滑迁移至 displayPerformance
[x] DisplayPerformancePanel 支持图表模式(FPS / 帧耗时 / 全部)
[x] 旧 FPS / FrameTime 卡片在 Workbench 与 Session Review 中不再重复展示
[x] 6 项 R6 Swift 专项测试全绿
[x] 全量 Swift 177/177 PASS
[x] 全量 Python 540/540 PASS
[x] 站点构建 0 错误 0 死链R6 验收合格,可进入 R7(真机兼容与长稳门禁)。
R7 · 真机兼容与长稳门禁(2026-09-17)
R7 目标
在脱机代码、聚合器、适配层、离线报表及客户端 UI 全部完成(R0~R6 100% 绿灯)的基础上,建立 R7 真机 E4 验收基准与硬门禁矩阵,明确后续真机长时测试规范。
R7 真机验收矩阵规范
| 测试维度 | iOS 15 | iOS 16 | iOS 17 | iOS 18 |
|---|---|---|---|---|
| 5 秒 Schema 检验 | 待真机跑测 | 待真机跑测 | POC 已通过 | 待真机跑测 |
| 5 分钟动态场景 | 待真机跑测 | 待真机跑测 | 待真机跑测 | 待真机跑测 |
| 前后台切换 (fg → bg → fg) | 待真机跑测 | 待真机跑测 | 待真机跑测 | 待真机跑测 |
| Display-only 模式 | 待真机跑测 | 待真机跑测 | 待真机跑测 | 待真机跑测 |
| Startup + Display 模式 | 待真机跑测 | 待真机跑测 | 待真机跑测 | 待真机跑测 |
| 10~20 分钟长稳跑测 | 待真机跑测 | 待真机跑测 | 待真机跑测 | 待真机跑测 |
R7 真机硬门禁(§58)
- 动态场景呈现:
FrameLifetime > 0且连续链式匹配率满足prev_frame mismatch = 0;存在明确丢帧或断链 gap 时,严禁合成跨 gap FrameTime。 - CoreProfile 启停边界:
- Display-only 模式:
startCount = 0(完全不启动 CoreProfile); - Startup + Display 模式:
startCount = 1,restartCount = 0,启动语义完成后 CoreProfile 平稳关停,bytes 绝不继续增长。
- Display-only 模式:
- 带宽与吞吐红线:ActivityTrace FrameLifetime 吞吐 P95 < 0.2 MiB/s(相较旧 CoreProfile 18~30 MiB/s 彻底下降 100~500 倍)。
- Session 稳定性:会话状态
completed,firstFatal = nil,droppedEventHighWater = 0。 - 研发对比模式:切源完成后可保留受控的 compare mode(10~20 秒,默认 OFF),仅用于同场对比验证,严禁长期常开。
R7 当前状态与结论
- 代码与测试交付状态:R0 至 R6 全部按期交付,40 项最低单元测试清单 100% 覆盖闭环,Python 540 项与 Swift 177 项测试全绿,VitePress 站点 0 错误 0 死链。
- 真机 E4 门禁状态:
implemented · gate-open。真机长时矩阵待后续真机联调阶段关闭。
R7 首轮重新打包真机联调失败记录(2026-09-17)
现象
- 点击开始采集后目标 App 没有发生 controlled process-cold relaunch;
- Session 没有产生实时采集数据;
- 从用户侧表现看像是“已经点击开始,但采集链没有真正进入运行态”。
静态根因定位
本轮确认存在一个足以完全解释上述现象的 CapturePlan / Registry 合同断裂:
WorkbenchMetricCatalog.defaultSelection仍包含旧.fps;- R6 的
migratePreferences会把.fps/.frameTime自动迁移为.displayPerformance; .displayPerformance当前展开 5 个 product metricID:ios.foreground.fpsios.frame.timeios.frame.time.p95ios.display.refresh_rateios.frame.pacing_jitter
- 但当前 Swift
PerfowlMetricRegistry.definitions与 Pythonios-platform-profiles.candidate.v1.json的display-performancesource contract 只正式注册前两项;后三项没有进入 Registry / Pythonall_metric_ids(); start控制帧到达 Sidecar 后,在真正执行ProcessControl.launch(kill_existing=True)之前先校验metricIDs ⊆ all_metric_ids(),因此会返回control_rejected: invalid_capture_plan_metric_set;- 由于 rejection 发生在
wire_session_id绑定之前,其回包没有当前 Session ID,而 SwiftrunPrepared()当前只消费record.sessionID == plan.sessionID的 record,导致这条关键拒绝消息被静默过滤,UI 没有暴露真正失败原因。
因此本轮的因果链是:
text
默认 .fps
→ migratePreferences
→ .displayPerformance
→ CapturePlan 请求 5 个 metricID
→ 其中 3 个不属于当前 Registry / Python source contract
→ Sidecar start 校验失败
→ control_rejected(invalid_capture_plan_metric_set)
→ 未进入 request_launch()
→ App 不冷启动
→ 未进入采集态
→ 无 sample为什么 R0-R6 离线测试没有拦住
现有 R6 Swift 测试验证了 .displayPerformance 确实映射到 5 个 ID,但没有验证下面三个跨层不变量:
text
Workbench requested metricIDs
⊆ Swift PerfowlMetricRegistry
== Python all_metric_ids()
并且每个 product metricID 都有 source contract因此 UI / wire 字段 / 偏好迁移测试可以全部 PASS,但真正产品 start 握手仍会在真机前置控制面失败。
同轮发现的第二组切源未收口项
静态检查还发现旧 CoreProfile FrameTime 控制逻辑仍残留:
capture_plan.py::scoped_feature_flags()仍通过ios.frame.*推导frame_time_enabled;launch_trace.py::core_profile_capture_mode()仍保留frame-only/startup-and-frame;capture_policy.py::recording_adapters()注释与 source ownership 仍写着普通 FrameTime 需要 CoreProfile。
这些残留不是本次“完全不冷启动/无 sample”的第一根因,但会在第一根因修复后继续破坏“Display 只走 ActivityTrace、CoreProfile 只服务 Startup”的 R4 架构边界,必须一起清理并补真机门禁。
首轮失败结论
text
R7 FIRST PRODUCT-CHAIN E4 ATTEMPT:
FAIL
PRIMARY ROOT CAUSE:
CapturePlan metric set > Registry/source contract
COLD LAUNCH FAILURE:
EXPLAINED — start rejected before ProcessControl.launch
NO SAMPLE FAILURE:
EXPLAINED — Session never entered accepted recording state下一次 R7 重测前硬门禁
text
[x] .displayPerformance 5 个 product metricID 全部进入 Swift Registry(R7-Fix-A)
[x] Python all_metric_ids() 与 Swift Registry 对齐(R7-Fix-A)
[x] display-performance source contract 覆盖 5/5 metricID(R7-Fix-A)
[x] Workbench 所有 group metricID 都是 Registry 子集(R7-Fix-A,新增全量不变量测试)
[ ] start control rejection 在 Session ID 尚未绑定时也能被 Swift 显式显示(R7-Fix-B,未开始)
[ ] 默认选择经过 migratePreferences 后的 CapturePlan 可通过 Python start 校验(R7-Fix-A 已具备前提条件,正式跨层集成测试留给 R7-Fix-E/F)
[ ] Display-only: CoreProfile startCount = 0(R7-Fix-C/D,未开始)
[ ] Startup + Display: CoreProfile mode = startup-only, startCount = 1, restartCount = 0(R7-Fix-C/D,未开始)
[ ] start_accepted 后必须观测 launch_requested → launch_acknowledged → attached(R7-Fix-G 真机验证,未开始)
[ ] 上述门禁通过后才重新执行 5 秒 / 5 分钟 R7 真机验证(待 R7-Fix-B/C/D/E/F 全部收口)R7-Fix-A · Display Performance 5/5 Metric Contract 闭环(2026-09-17)
目标
修正上一节记录的第一根因:让 ios.frame.time.p95 / ios.display.refresh_rate / ios.frame.pacing_jitter 三项 Display Performance metricID 从 Swift Registry、Python Source Contract 到 Python fallback Registry 全部正式闭环,使 .displayPerformance 的 5 个 product metricID 真正满足 Workbench requested metricIDs ⊆ Swift Registry == Python all_metric_ids()。
实际修改
MacApp/Sources/PerfowlCore/PerformanceModels.swift:在PerfowlMetricRegistry.definitions中新增 3 条正式MetricDefinition(ios.frame.time.p95/ios.display.refresh_rate/ios.frame.pacing_jitter),source均为activitytrace-framelifetime,scope均为.foregroundDevice,公式描述与DisplayPerformanceProjector(R2 已实现)完全一致,未新增任何 CoreProfile source。Registry 总数 63 → 66。MacApp/Sidecar/perfowl_collector_core/ios-platform-profiles.candidate.v1.json:display-performancefamily 的metricIDs从 2 项补齐为 5 项,全部映射到既有的pmd3.activity-trace.frame-lifetime.rawadapter,未新增 adapter。MacApp/Sidecar/perfowl_collector_core/capture_plan.py:all_metric_ids()的离线 fallback 集合同步补齐ios.foreground.fps/ios.frame.time.p95/ios.display.refresh_rate/ios.frame.pacing_jitter,避免 source 资源文件缺失时 fallback 与正式 Registry 再次漂移。MacApp/Sidecar/perfowl_collector_core/platform_support.py:模块级自审门禁_SELF_AUDIT的硬编码期望值从 63 更新为 66(该门禁在 import 时立即失败,是本次发现范围最广的连锁验证点)。- 更新全部因 Registry 总数变化而需要同步的测试与派生产物:
- Python:
test_capture_plan.py(all_metric_ids()计数、CapturePlan().hash黄金向量、resolved.coverage计数)、test_perfowl_core_cli.py、test_online_report.py、test_perfowl_platform_support.py(新增test_display_performance_family_owns_all_five_metrics跨语言不变量测试); - Swift:
CapturePlanTests.swift(requestedMetricIDs.count、plan.hash黄金向量、AppReadinessToken计数)、PerformanceFoundationTests.swift(definitions.count)、SessionStoragePolicyTests.swift(另一处独立的plan.hash黄金向量); DisplayPerformanceSwiftTests.swift:新增testEveryWorkbenchGroupMetricIDResolvesToARegistryDefinition(覆盖全部 Workbench group,不止.displayPerformance)与testDisplayPerformanceGroupIsFullyBackedByTheRegistry(显式断言 5/5 metricID 存在于 Registry 且source == "activitytrace-framelifetime")—— 这两项正是上一节记录里"R0-R6 测试没有拦住"的缺口,直接对应根因分析中列出的跨层不变量。
- Python:
- 站点侧联动(AGENTS.md C5/C6 门禁要求):
site/scripts/metric-knowledge-gate.py:EXPECTED_REGISTRY_COUNT63 → 66;site/knowledge/metrics/display-performance.md:补齐metric:frontmatter 正式领有 3 个新 metricID,新增第 15 节完整十要素记录(scope/unit/底层服务与通道/采集命令与库归属/精确公式/质量语义/证据等级/版本历史);site/knowledge/calculation/metric-contract-index.md:补充 #64-#66 三行合同镜像与 v3 版本历史;site/data/metric-contracts.json:通过python3 scripts/metric-knowledge-gate.py --write-snapshot重新生成。
根因验证
platform_support.py 模块级自审门禁在补齐 JSON contract 后、修正期望值前会立即抛出 RuntimeError,精确复现了上一节因果链第 4-5 步描述的"Registry/Source Contract 不一致导致 start 校验失败"的静态前置条件;补齐 3 项定义并对齐期望值后自审通过,证明"CapturePlan metric set > Registry/source contract"这一根因在本批次已在代码层消除(真机 E4 验证留给 R7-Fix-G)。
测试结果
text
Python 全量回归(venv-py312):541/541 PASS(12.0s,较 R6 的 540 新增 1 项跨语言不变量测试)
Swift 全量回归(swift test):179/179 PASS,3 skipped(19.6s,较 R6 的 177 新增 2 项 Registry 完整性测试)
站点构建:npm run build 0 错误 0 死链;metric-knowledge-gate.py PASS(66 Registry contracts)证据等级:E2(构建级,单元测试 + 跨语言哈希黄金向量比对)。真机 E4 对齐留给 R7-Fix-G。
与原方案偏差
无实质偏差。按方案 3.2-3.6 节执行;额外主动同步了方案未列出、但 AGENTS.md C5/C6 强制要求联动的站点知识库文件(metric-contracts.json、metric-contract-index.md、display-performance.md、metric-knowledge-gate.py),避免 Registry 数量变化后构建门禁破裂。
可复用经验
- Registry 总数是一个隐式的跨系统不变量:本次改动触发了 6 个不同文件里的硬编码计数(Python 4 处、Swift 3 处、站点 1 处),其中
platform_support.py的模块级自审门禁最有价值——它在import时就 fail-closed,比任何单元测试更早暴露不一致。 - "UI 层测试通过" 不等于 "Registry 层闭环":R6 的
testDisplayPerformanceMetricIDsMapping只验证了metricIDsByGroup字典本身包含 5 个字符串,从未验证这些字符串在PerfowlMetricRegistry.definitions里有对应定义。新增的testEveryWorkbenchGroupMetricIDResolvesToARegistryDefinition把这类断层变成了通用回归测试,覆盖全部 Workbench group 而不只是本次出问题的这一个。 - 两条黄金向量哈希(
CapturePlanTests.swift与SessionStoragePolicyTests.swift)各自独立硬编码了同一个CapturePlan.allSupported.hash;未来 Registry 变更需要记得两处都更新,或考虑收敛为单一 source-of-truth 常量以降低遗漏风险(记入遗留风险)。
遗留风险
- 两处独立硬编码的
CapturePlan.allSupported.hash黄金向量(CapturePlanTests.swift:20、SessionStoragePolicyTests.swift:20)未收敛为单一常量,后续 Registry 变更时存在遗漏其中一处的风险。 site/knowledge/calculation/metric-contract-index.md中ios.foreground.fps(第 19 行)与ios.frame.time(第 23 行)两行仍记录着 R0-R6 切源前的旧 source(dvt.graphics/frame-pipeline+ CoreProfile),与当前 Registry 的activitytrace-framelifetime不一致;这是 R0-R6 批次遗留的既有 staleness,不在本次 R7-Fix-A 范围内,但建议尽快单独批次修正,避免与本次新增的 #64-#66 行(已使用正确 source)造成同页口径不一致。- R7-Fix-A 只证明了"代码层"契约闭环(E2),尚未证明真机
start握手真的会 accept 这个 CapturePlan(需要 R7-Fix-G 真机验证)。
下一阶段 Gate
R7-Fix-A 完成,Registry/Source Contract 层闭环,Gate 开启进入 R7-Fix-B(修复 control_rejected 被 sessionID gate 静默吞掉的问题)。
R7-Fix-B · Start control_rejected 不再被 sessionID gate 静默吞掉(2026-09-17)
目标
修正 R7 首轮联调记录中第二个静态断点:control_rejected 在 wire_session_id 绑定前到达 Swift 端时被 runPrepared() 的 sessionID gate 直接丢弃,导致 UI 在 CapturePlan 校验失败后无限等待、不给出任何错误提示。
实际修改
MacApp/Sidecar/perfowl_collector_core_cli.py(只读确认,未修改):核实listen_for_commands()中wire_session_id = args.session_id在 prewarm 模式下初始为None,直到start命令校验完全通过(第 599 行wire_session_id = session_id)才绑定;期间所有control_rejected(invalid_capture_plan_metric_set/invalid_capture_plan_hash/app_not_prepared/start_already_requested/invalid_start*等)都携带sessionID: null,且该命令循环在 emit 后continue,进程不会退出——这精确复现了任务书 4.1 节描述的断点,本批次判定 Python 侧无需改动(不属于"临时绕过校验",而是握手阶段的正常前置状态)。MacApp/Sources/PerfowlCore/PerformanceSessionController.swift:runPrepared()的事件循环加标签recordLoop:,sessionID 过滤条件从record.sessionID == plan.sessionID放宽为record.sessionID == nil || record.sessionID == plan.sessionID(与文件中已有的start()/report_sealed分支保持一致的既有模式,非新发明);跨 Session 的非 nil sessionID 仍然被拒绝,不放宽真实数据隔离。- 在同一
case .record分支内,收到kind == "control_rejected"或kind == "error"后立即supervisor.forceStop()并break recordLoop——因为真实 Sidecar 不会在拒绝后自行退出,不主动断开会导致for await永久阻塞在 stream 上。 consume(_:plan:)新增case "control_rejected":分支,设置sessionFailure;循环结束后既有的integrityFailure收尾逻辑会自动调用fail(_:),一次性设置state = .failed、statusMessage、lastError,无需额外代码路径。- 新增
static func controlRejectionMessage(reason:) -> String:把 Sidecar 的机器可读 reason code(如invalid_capture_plan_metric_set)翻译成可读中文提示;未知 reason code 仍然原样展示,不会因为翻译表遗漏而回退成静默无提示。
MacApp/Tests/PerfowlCoreTests/StartControlRejectionTests.swift(新增文件):testControlRejectionMessageTranslatesKnownReasonCodes:纯函数单测,覆盖已知/未知 reason code 与nil三种输入。testControlRejectedDuringStartFailsSessionInsteadOfHangingForever:端到端集成测试,用一个有状态的 Python fixture 进程模拟真实 Sidecar 的 prewarm →prepare_app_accepted→app_ready→start→control_rejected(sessionID省略,emit 后继续循环监听而不退出)全流程,驱动真实PerformanceSessionController.prepareCollector()+start(),断言:拒绝可见(state == .failed、lastError含"不支持的指标"字样)、不再无限等待(在有界轮询内收敛)、且同批注入的"来自其他 sessionID 的 sample"记录仍被正确过滤(samples.isEmpty),一次性覆盖任务书 4.5 节 Test 1-3 中除纯 Python CLI 命令循环之外的全部断言。
根因验证
复现路径与 R7 首轮失败记录完全一致:fixture 在 start 命令上返回 sessionID: null 的 control_rejected 并继续监听(不退出进程)。修复前的 guard record.sessionID == plan.sessionID else { continue } 会让这条记录连同后续所有记录一起被跳过,for await 永久阻塞在 stream 上直到测试自身的轮询超时——即"点击开始后卡住"的确切机制。修复后同一 fixture 在 0.8~2 秒内使 Session 进入 .failed 状态,验证了根因判断与修复方式均正确。
测试结果
text
Swift 全量回归(swift test):181/181 PASS,3 skipped(约 20s,较 R7-Fix-A 的 179 新增 2 项)
Python 全量回归(venv-py312):541/541 PASS(未改动 Python,数量与 R7-Fix-A 持平)
站点构建:未涉及站点内容变更,本阶段未重跑(上一阶段已确认 0 错误 0 死链,本阶段代码变更不影响站点门禁)证据等级:E2(构建级,端到端 Swift 集成测试对真实 PerformanceSessionController + 有状态 fixture 进程)。真机 E4 对齐留给 R7-Fix-G。
与原方案偏差
- 方案 4.3 节建议给 Start 命令引入显式
requestID往返关联(Swift 发requestID→ Python 原样带回)。核实后发现更简单、风险更小的修复已经解决了本次故障:既然control_rejected唯一的问题是"因 sessionID 门禁被丢弃",放宽门禁 + 显式处理该 kind 就已经充分;引入requestID关联需要同时改 Swift 发送端、Pythonemit()与 wire schema,收益(未来可能出现的乱序响应关联)与本次故障无关,留作独立的、未来才需要时再做的加固项,而非本次必须项。 - 方案 4.4 节建议 Python payload 增加
unknownMetricIDs字段,UI 展示具体哪些指标不支持。本批次改为在 Swift 侧对已知 reason code 做静态文案翻译(controlRejectionMessage),未新增 wire 字段。原因:CollectorWireEvent是一个已有大量字段、以"全部可选属性 + 手写 memberwise init"维护的大结构体,新增字段需要同步更新其显式init(...)(已确认存在,未使用编译期合成)与所有调用点;而现有message字段已经携带精确的机器可读 reason code,翻译表已能消除"只显示内部 error key"的问题。把unknownMetricIDs列为遗留项,价值仅在于"具体列出哪几个 metricID",暂不阻塞本阶段收口。
可复用经验
- 修 bug 前先确认"进程会不会自己退出":
control_rejected之所以会导致无限等待而不是快速失败,根本原因不是 sessionID 门禁本身,而是 Sidecar 拒绝后仍然存活并继续监听——门禁只是让这条本该被看到的失败消息消失了。任何"以为流会自然结束"的假设都要先用真实/fixture 进程验证。 - 复用既有模式比引入新概念更安全:
record.sessionID == nil || record.sessionID == plan.sessionID这个写法在同一文件的其他三处(start()、report_sealed分支)已经存在,说明这是该协议里"握手阶段允许 nil sessionID"的既定约定;runPrepared()只是遗漏了这个约定,而不是需要发明新机制。 - 端到端集成测试的成本主要在"正确模拟协议状态机",而不是断言本身:写一个能正确响应
requestID/appGeneration/deviceGeneration回显、且不携带会触发captureCoverageFailure的账本字段的 Python fixture,比断言逻辑本身耗时多得多;一旦 fixture 正确,三项测试断言(可见/不挂起/隔离不失效)可以在同一次运行里一次性验证,不需要三个独立 fixture。
遗留风险
unknownMetricIDs结构化字段未实现(见"与原方案偏差"第 2 条):当前 UI 只能看到"包含不支持的指标"这句翻译文案,无法看到具体是哪个 metricID,之后如需要更精确的诊断展示需要单独批次补齐 wire 字段。requestID往返关联未实现(见"与原方案偏差"第 1 条):如果未来出现"同一 Session 短时间内发出多个 start 尝试,响应需要精确关联到具体请求"的场景,需要重新评估是否要补上这条加固。- Python 侧命令循环(
listen_for_commands内start/prepare_app分支的control_rejected路径)尚无直接的 Python 单元测试覆盖(任务书 4.5 节 Test 1);该函数深度嵌套在main_stream闭包内,提取为可独立测试单元需要额外重构,本批次未做,只做了代码走读确认。这是本阶段测试覆盖的已知缺口。 PerformanceSessionController.swift中还有一条未触及的旧路径(start()方法对应的、非 prewarm 的supervisor.start(configuration:)直接启动分支,约第 900-920 行)同样存在for await循环,其 sessionID 门禁已经是nil容错版本,但未验证它在收到control_rejected时是否也会正确终止而不是无限等待;由于该路径是 xctrace 官方启动兜底的旧模式,不是 R7 controlled-cold-launch 主链路,本次未纳入范围,记为后续可选加固项。
下一阶段 Gate
R7-Fix-B 完成,Start 握手阶段的失败对 UI 可见且不再无限等待。Gate 开启进入 R7-Fix-C/D(清理旧 CoreProfile FrameTime ownership:capture_plan.py::scoped_feature_flags()、launch_trace.py::core_profile_capture_mode()、capture_policy.py 注释与激活逻辑)。
R7-Fix-C/D · 清理旧 CoreProfile FrameTime ownership(2026-09-17)
目标
修正任务书 5/6 节指出的"第二组切源未收口项":产品层仍有一条数据通路可以把 ios.frame.* metricID 解读成"需要启动 CoreProfile",与 R3-R6 已确立的"Display/FrameTime 全部由 ActivityTrace FrameLifetime 提供、CoreProfile 只服务 StartupTiming"架构相悖。核心风险场景是 R7 Gate-0 的第三个真机测试(StartupTiming + Display Performance 同时勾选):如果不修,CoreProfile 会被错误分类为 startup-and-frame(一个不会在启动完成后停止、长期占用 CoreProfile tap 的模式),而不是正确的 startup-only。
实际修改
MacApp/Sidecar/perfowl_collector_core/capture_plan.py::scoped_feature_flags():frame_time键的推导从any(mid.startswith(FRAME_METRIC_PREFIX) for mid in ids)改为恒定False,并加注释说明原因;这是本次唯一必要的生产代码改动——frame_time_enabled经由perfowl_collector_core_cli.py:512(prepare_app命令)与realtime.py:1772(同一 flags 字典的第二个读取点)向下游传播,是产品侧唯一把"用户选择的 metricID"翻译成这个布尔值的地方。MacApp/Sidecar/perfowl_collector_core/capture_policy.py:删除过期注释"CoreProfile is required for ordinary FrameTime regardless of startup."(替换为准确描述"此处只是 prepare 能力、不代表 activate”的说明);确认并删除未被任何生产代码调用的死代码属性frame_time_enabled(恒为True,无调用方,仅测试直接断言它本身)。MacApp/Sidecar/tests/test_capture_selection.py:- 修正
test_feature_flags_from_selection中断言scoped_feature_flags(("ios.frame.time",))["frame_time"]为真的旧断言,改为断言ios.frame.time/ios.frame.time.p95/ios.frame.pacing_jitter三者均为False。 - 新增
test_startup_plus_display_performance_never_reaches_startup_and_frame_mode:完整复现 R7 Gate-0 第三场景(StartupTiming + 全部 5 项 Display Performance metricID),断言scoped_feature_flags()["frame_time"] == False、core_profile_capture_mode(...)结果为"startup-only"而非"startup-and-frame",同时CoreProfileRawAdapter与ActivityTraceFrameLifetimeRawAdapter都在执行期 adapter 集合中(两者应并存,只是各自服务不同指标族)。 - 新增
test_display_only_selection_never_activates_core_profile:Display-only 场景断言core_profile_capture_mode(...) == "none"且CoreProfileRawAdapter完全不在执行期 adapter 集合中,对应 Gate-0 第一场景的coreProfileStartCount = 0硬门禁。
- 修正
MacApp/Sidecar/tests/test_capture_policy.py:删除对已移除的policy.frame_time_enabled属性的断言。
根因验证与关键决策(本次未做的改动)
最初尝试了任务书 6 节建议的更彻底方案——把 core_profile_capture_mode() 的签名简化为只接受 launch_trace_enabled(连同 realtime.py 中全部 7 处 frame_time_enabled=frame_time_enabled, 调用参数一起删除)。执行后跑全量 Python 测试立即发现 tests/test_unified_core_profile.py、tests/test_frametime_continuation.py、tests/test_frame_census_and_fatal_diagnostics.py、tests/test_perfowl_realtime.py 等文件中有大量测试直接以关键字参数调用 stream_candidate_session(frame_time_enabled=True, ...) 或 core_profile_capture_mode(frame_time_enabled=True, ...),用于独立验证 "frame-only" / "startup-and-frame" 两种模式下引擎自身的正确性(例如"frame-only 模式下不得实例化 LaunchTraceDecoder")——这是与产品策略无关的、对底层引擎四态状态机的通用能力测试。删除该参数会直接导致这些测试报 TypeError: unexpected keyword argument。
判定:core_profile_capture_mode() / stream_candidate_session() 是引擎级通用函数,理应支持全部 4 种理论模式并被独立测试覆盖,这是防止底层机制腐化的合理测试面;真正需要修的是产品层——"当前产品永远不会把 frame_time_enabled=True 传给它"这件事,而这恰好只取决于 scoped_feature_flags() 这一个函数。于是撤销了 launch_trace.py 与 realtime.py 的改动(git checkout -- 两个文件),只保留 capture_plan.py 的最小修改。这精确对应任务书 6.3 节反复强调的原则:"prepare != activate"——引擎准备好支持某种模式不等于产品会激活它;本次修复发生在"产品是否激活"这一层,而不是"引擎是否能够支持"那一层。
测试结果
text
Python 全量回归(venv-py312):543/543 PASS(11.4s,较 R7-Fix-B 的 541 新增 2 项)
Swift 全量回归(swift test):181/181 PASS,3 skipped(未改动 Swift,与 R7-Fix-B 持平)
站点构建:0 错误 0 死链(本阶段未改动站点内容,复跑确认门禁仍绿)证据等级:E2(构建级)。真机验证 coreProfileStartCount / coreProfileCaptureMode 实际取值留给 R7-Fix-G。
与原方案偏差
- 未改动
launch_trace.py::core_profile_capture_mode()的函数签名,未删除realtime.py中的frame_time_enabled=frame_time_enabled调用参数。原方案 6 节的建议签名(只接受launch_trace_enabled)会破坏一批合法的引擎级单元测试(见上一节"根因验证与关键决策"),因此改为在数据源头(scoped_feature_flags())把frame_time恒定为False,效果等价(生产路径下core_profile_capture_mode永远不会被传入frame_time_enabled=True),但风险显著更低、且不破坏引擎级测试覆盖。 - 未触碰
perfowl_collector_core_cli.py:718处非 prewarm/stream直接调用路径上的frame_time_enabled=True硬编码初始值。该路径是 SwiftPerformanceSessionController中 xctrace App Launch 官方启动的旧版回退分支(run(plan:...),非 R7controlled-cold-launch主链路runPrepared()),任务书"建议修改文件范围"未列出该调用点;由于其影响面需要独立验证(是否仍有依赖该路径的历史测试、真实调用频率),本批次未处理,记入遗留风险。
可复用经验
- "先跑测试再判断方案对不对"比"先读懂全部代码再动手"更快更safe:
realtime.py有 4000+ 行、core_profile_capture_mode有 7 个调用点,逐行读懂所有上下文的成本很高;直接做出(看似正确的)签名简化改动、跑全量测试、让测试套件告诉我"这里还有一类我没想到的合法用途",比继续读代码找出所有隐藏依赖更快定位问题边界。 - 同一个函数可能同时是"引擎能力"和"产品策略"的交界点,两者需要用不同层级的测试分别覆盖:
core_profile_capture_mode()保留全部 4 个分支满足"引擎能否正确处理任意组合"的测试需求;scoped_feature_flags()恒定frame_time=False满足"产品会不会把引擎推到不该去的状态"的测试需求。删除引擎的能力来"修"产品策略问题,是把两个不同层级的关注点搞混了。 - 复现真机故障场景比断言内部实现细节更有价值:新增的两个测试直接使用 Display Performance 的完整 5-metricID 集合 + StartupTiming,而不是抽象的
("ios.frame.time",)——这样测试失败时的报错信息直接对应任务书里描述的 Gate-0 真机场景,而不需要读者再做一次"这个抽象输入对应哪个产品场景"的心智翻译。
遗留风险
perfowl_collector_core_cli.py:718(非 prewarmstream直接调用路径)的frame_time_enabled=True硬编码初始值未清理,理论上如果该路径被驱动进同时选择 Startup 与 Frame 指标的场景,仍可能进入startup-and-frame模式;由于该路径是否仍在产品中被实际触发、以及其现有测试覆盖面尚未逐一核实,留待后续单独批次判断是否需要修正。capture_plan.py中的FRAME_METRIC_PREFIX常量与"ios.frame."前缀匹配逻辑虽然不再被scoped_feature_flags()使用,但仍保留在__all__导出与模块常量中(未被其他文件引用);是否应该一并删除或重新赋予其他用途,留待下次触碰此文件时决定,本次不做无关的额外清理。
下一阶段 Gate
R7-Fix-C/D 完成,生产路径不再可能把 CoreProfile 错误分类为 frame-only / startup-and-frame。Gate 开启进入 R7-Fix-E/F(跨层 Product Start Contract 集成测试:Registry ⊆ Workbench 不变量、默认 selection migration 通过 Python 校验、controlled-cold-launch 恰好执行一次的假 ProcessControl Resolver 测试)。
R7-Fix-E/F · 跨层 Product Start Contract 集成测试(2026-09-17)
目标
任务书 7/8 节要求建立"过去 Swift 177/177 与 Python 540/540 都通过,但没有测试真正的产品请求闭环"这类断层的防回归测试:四条不变量(Workbench 请求集合 ⊆ Swift Registry;Swift Registry == Python all_metric_ids();每个可请求 metricID 都有 source contract;默认 Workbench selection 经 migratePreferences 后可通过 Python start 校验)+ 一条 Start/Launch 状态机不变量(controlled-cold-launch 恰好执行一次)。
实际盘点:四条不变量中三条已被前序阶段覆盖
在动手写新测试前先逐条核对现状,避免重复造轮子:
- 不变量 1(Workbench ⊆ Swift Registry):R7-Fix-A 新增的
testEveryWorkbenchGroupMetricIDResolvesToARegistryDefinition(DisplayPerformanceSwiftTests.swift)已经遍历WorkbenchMetricCatalog.metricIDsByGroup全部 group(不只是 Display Performance),逐个断言在PerfowlMetricRegistry.definitions中有定义。已闭环,无需新增。 - 不变量 2(Swift Registry == Python
all_metric_ids()):R7-Fix-A 更新后的test_metric_contract_exactly_matches_swift_registry(test_perfowl_platform_support.py)用正则从PerformanceModels.swift源码里提取全部id: "ios...."字面量,与 Pythonaudit_metric_source_contracts()做逐项差集比较(missingMetricIDs/unexpectedMetricIDs均需为空)。已闭环,无需新增。 - 不变量 3(每个可请求 metricID 都有 source contract):
all_metric_ids()与source_contracts_by_metric()都直接遍历同一份ios-platform-profiles.candidate.v1.json的metricSourceContracts列表构建——按代码结构这是一个定义层面必然成立的不变量(不存在"有 metricID 但没有 contract"的构造路径),因此不需要额外的运行时断言测试;仅在此记录该结论供后续审阅者复核,而不是省略验证。 - 不变量 4(默认 selection 经 migratePreferences 后可通过 Python 校验):唯一真正缺失的一环——这正是 R7 首轮真机失败的精确复现路径(
WorkbenchMetricCatalog.defaultSelection含.fps→migratePreferences→.displayPerformance→ 5 个 product metricID)。之前所有测试都验证了"迁移逻辑本身正确"或"Registry 里有这几个 ID",但没有一个测试从defaultSelection出发走完整条链路。
实际修改
MacApp/Tests/PerfowlCoreTests/DisplayPerformanceSwiftTests.swift:新增testDefaultSelectionMigratesToDisplayPerformanceAndEveryProductMetricIDIsRegistered——从WorkbenchMetricCatalog.defaultSelection(确认包含遗留.fps)出发,经migratePreferences得到迁移后集合(确认包含.displayPerformance且不再含.fps/.frameTime),再经productMetricIDs(for:)得到最终 product metricID 列表,逐项校验都在PerfowlMetricRegistry.definitions里有定义;最后用这份 ID 列表构造一个真实CapturePlan,断言其registryContractHash与独立计算的CapturePlan.registryHash(for:)一致(镜像 Python 侧perfowl_collector_core_cli.py在start命令里对registryContractHash/capturePlanHash的比对逻辑)。这是产品默认路径第一次被端到端断言"从零配置到可提交的 CapturePlan 全程合法"。- 未新增 Phase F(Start/Launch 状态机)测试代码。核实后发现
tests/test_perfowl_realtime.py中已存在至少 5 个测试(test_prewarmed_start_reuses_one_provider_and_launches_once_unsuspended等,行号 579/756/882/1006/1122/1248 一带)使用一个FakeResolver类模拟ProcessControl:request_launch()每次调用都会把(bundle_identifier, cold, start_suspended)追加到launch_calls列表,随后逐一断言FakeResolver.launch_calls == [("com.example.fixture", True, False)]——True对应cold=True(即kill_existing=True,controlled-cold-launch语义),列表长度为 1 即证明"恰好执行一次"。这精确对应任务书 8 节要求的测试形状,判定为已有覆盖,无需重复。
根因验证
新增的不变量 4 测试在 R7-Fix-A 完成之前会失败(因为 .displayPerformance 展开的 5 个 metricID 中有 3 个不在 Registry 里);在当前已完成 R7-Fix-A 的代码状态下通过。这验证了两件事:(a) 该测试确实能捕捉本次故障的根因形状;(b) R7-Fix-A 的修复确实使默认路径重新变得合法。
测试结果
text
Swift 全量回归(swift test):182/182 PASS,3 skipped(22.2s,较 R7-Fix-C/D 的 181 新增 1 项)
Python 全量回归(venv-py312):543/543 PASS(未新增测试,复核确认 launch-count 覆盖已存在)
站点构建:0 错误 0 死链(本阶段未改动站点内容,复跑确认门禁仍绿)证据等级:E2(构建级)。真机验证"默认选择首次点击开始即可成功进入采集"留给 R7-Fix-G。
与原方案偏差
未新增 Phase F 的 Python 假 ProcessControl Resolver 测试代码,因为核实后发现功能等价的测试已经存在(见"实际修改"第 2 条)。这不是跳过任务书要求,而是在动手写代码前先核对现状避免重复;已有测试的断言形状(launch_calls 长度为 1、cold=True)与任务书 8 节描述的不变量完全一致。
可复用经验
- 四条"不变量"里有三条已经被前面几个阶段顺带覆盖,只有认真逐条核对现状(而不是看到任务书列表就照单全部新写)才能发现真正的缺口在哪——盲目为每一条都新写一个测试,会制造大量与已有测试重复、只是措辞不同的断言,增加维护成本而不增加保护力。
- "端到端默认路径"测试的价值不在断言本身有多复杂,而在于它精确复现产品会真实触达的第一条路径(新用户首次点击开始 = 使用
defaultSelection)。此前的测试都在验证"迁移函数对不对""Registry 里有没有这几个 ID"这类局部正确性,唯独没有一个测试把"用户从零配置点击开始"这条链路完整跑一遍——局部正确不等于端到端正确,这正是 R7 真机故障能绕过 R0-R6 全部测试的根本原因。
遗留风险
无新增遗留风险;不变量 3(source contract 完整性)依赖代码结构保证而非运行时断言,如果未来 all_metric_ids() 或 source_contracts_by_metric() 的实现改为不再共享同一份 _load_contracts() 遍历,该隐式保证可能失效,需要在那次改动时重新评估是否需要补一条显式测试。
下一阶段 Gate
R7-Fix-E/F 完成,代码层(E2)全部四条跨层不变量与 Start/Launch 状态机不变量均已闭环或确认既有覆盖。Gate 开启进入 R7-Fix-G(真机 Gate-0:Display-only / Startup-only / Startup+Display 三轮 30 秒内验证),该阶段需要用户连接真实 iOS 设备执行,无法由本批次独立完成。