planP4E2implemented · gate-open2026-09-21

P4 Startup 三阶段架构重构路线

目标顺序

本批固定按三个阶段推进,不并行混改:

  1. Phase A · Dedicated Startup
  1. Phase B · Full Prewarm
  1. Phase C · Hybrid Total

Phase A 当前批准范围

必须实现

明确不做

Phase A 产品结构

Long-lived owner
├─ ProcessControl / DeviceInfo / normal metrics
└─ Long-lived CoreProfile (FrameTime 等,按 serial kperf 使用)

Dedicated Startup owner
└─ Startup CoreProfile lifecycle-only
   └─ RealtimeLaunchTraceDecoder(沿用现有)

两者 DTX owner 不同,但 device-side kperf recording 仍串行。

Phase A 验收

至少锁住:

  1. startup owner identity != long-lived owner identity;
  2. startup provider/lease 独立;
  3. startup adapter probe 只绑定 startup owner;
  4. long-lived CoreProfile adapter 不接收 lifecycle filters;
  5. SOURCE_ACTIVE 发生在 launch 前;
  6. startup stop/close 不关闭 long-lived owner;
  7. startup source failure 后长期 Session 仍能正常结束;
  8. Startup 未选时不创建 Dedicated Startup owner;
  9. Startup+FrameTime 不出现并行 kperf recording;
  10. Total 与阶段语义保持现状,不新增 fallback。

后续门禁

Phase A 通过 E2/E4 后才进入 Phase B;Phase B 通过后才进入 Phase C。

---

Phase A 实现结果(2026-09-21,E2)

基线 commit:46c6d55(main)。本节为增量记录,不回改上文任何已写内容。

实现文件

文件角色
perfowl_collector_core/startup_channel.py新增:正式 DedicatedStartupChannel(独立 provider lease / Pmd3AsyncBackend / DtxSessionOwner / CoreProfileRawAdapter)
perfowl_collector_core/realtime.py长生命周期 runtime 接入 dedicated channel 并过滤 CoreProfile;App Ready 语义;failure isolation
perfowl_collector_core/capture_policy.py长期 preparation / recording 集合移除 pmd3.core-profile.raw
perfowl_collector_core_cli.pyCLI adapter 选择不再自动包含长期 CoreProfile

Dedicated Startup owner 模型

Long-lived owner        Dedicated Startup owner
├─ Sysmon               └─ CoreProfile lifecycle-only
├─ Networking              └─ RealtimeLaunchTraceDecoder(沿用)
├─ Energy
├─ Graphics
├─ ActivityTrace FrameLifetime(FrameTime)
└─ GPU Counter

关键实现点:stream_candidate_session() 入口的 without_startup_coreprofile() 无条件把 CoreProfileRawAdapter 从 long-lived adapter_types 中剔除。即使旧调用方显式传入,也不会回到长期 owner——这是正式架构约束,不是临时 workaround。设备侧 kperf 仍保持 single-owner 串行语义:两个 DTX owner 不等于两个并发 recording。

为什么 CoreProfile 从长期 owner 移除

CoreProfile 在长期 owner 上意味着一次 tap 同时承担 Startup lifecycle 与长生命周期 FrameTime,即旧的 unified startup-and-frame 模式:Startup 结束不能停、FrameTime 失败会污染 Startup、Startup 失败会污染 FrameTime。Phase A 把它收归 Dedicated Startup channel 后,两者成为独立 failure domain。

后果:unified startup-and-frame 在 runtime 已不可达(launch_trace.py 的纯分类函数仍能算出该字符串,但没有调用点会把它喂给一个长期 tap)。

为什么 FrameTime 不再受 CoreProfile failure 影响

FrameTime 现由 ActivityTrace FrameLifetime 提供,与 Startup CoreProfile 是两个独立采集体系。因此:

App Ready 为什么 sourceChannelArmed = false

Phase A 的时序是「用户点 Start → 开 dedicated provider → 开 dedicated DTX owner → prepare CoreProfile → SOURCE_ACTIVE → launch/resume」。App 准备阶段 dedicated provider 尚未打开,所以诚实值就是 false;提前建立 dedicated transport/owner 属于 Phase B(Full Prewarm),本批明确不做。App Ready 实际输出:

decoderProfileAvailable = true
sourceChannelArmed      = false
startupSource           = "dedicated-coreprofile-on-start"

Phase B 为什么暂不实现

Full Prewarm 要求 dedicated provider / owner 在 App 准备阶段就建立,会改变 kperf 占用窗口与失败语义,需要独立的 E4 稳定率证据。Phase A 先把架构与失败隔离做对,再谈预热。

测试命令与结果

cd MacApp/Sidecar
/usr/local/bin/python3 -m py_compile perfowl_collector_core/startup_channel.py \
  perfowl_collector_core/realtime.py perfowl_collector_core_cli.py          # OK
PYTHONPATH=. /usr/local/bin/python3 -m unittest tests.test_startup_dedicated_channel
cd tests && /usr/local/bin/python3 -m unittest test_perfowl_realtime
cd MacApp/Sidecar && PYTHONPATH=. /usr/local/bin/python3 -m unittest discover -s tests -p 'test_*.py'
范围结果
tests.test_startup_dedicated_channel + tests.test_coreprofile_start_error10 / 10 OK
tests.test_capture_policy + tests.test_capture_selection18 / 18 OK
tests.test_frame_census_and_fatal_diagnostics14 / 14 OK
tests.test_frametime_continuation15 / 15 OK
tests.test_unified_core_profile8 / 8 OK
test_perfowl_realtime41 / 41 OK
相关模块(source preparation / capture plan / CLI / launch trace / startup bootstrap / startup timing / launch mapping / source schema+shape audit)全 OK
全量 Sidecar regression838 tests / OK / FAIL=0 / ERROR=0 / 122.35s

最后一个 contract-error 测试如何迁移

原 test_contract_error_in_core_profile_is_not_degraded_and_raises 把 FakeCore 塞进 long-lived adapter_types;Phase A 之后长期 owner 永远过滤 CoreProfile,该测试因此不再触发注入点。

迁移方式:错误注入点移到 DedicatedStartupChannel——FakeCore.start() 在 dedicated channel 内抛 RuntimeError("pymobiledevice3 CoreProfile config contract changed"),测试走真实 Startup 路径(launch_trace_enabled = true,controlled launch)。断言该 non-degradable 错误向上传播为 RuntimeError,而不是被降级成 startup_source_unavailable 后继续运行。同时补一条配对用例:core-profile-source-busy 这类可降级错误仍然降级且不杀整个 Session。测试意图保持不变——「协议/contract 破坏 ≠ 运行时资源暂时不可用」。

因架构变更而迁移的既有测试

以下模块在 46c6d55 基线上全绿、在当前树上失败,均为 Phase A 架构后果而非回归(用 git archive 46c6d55 基线探针确认):

  1. test_capture_policy / test_capture_selection——把「长期集合含 CoreProfile」改为 assertNotIn。
  2. test_frame_census_and_fatal_diagnostics.test_08——CoreProfile subclass 不再被长期 owner prepare:A/B 两段改为 captured_*_filters == []、effectiveFrameFilter == []、coreProfileStartCount == 0;C 段(非法实验 env fail-closed 抛 ValueError)保持不变。
  3. test_frametime_continuation.test_13——frame-only 不再开 tap:starts == 0、options == []、mode == "none"。
  4. test_unified_core_profile——unified 前提消失:2/5/8/9 迁移为 lifecycle-only 契约;test_3(unified 单 tap 在 startup 完成后不停止)整条删除(该模式不可达,且与 test_startup_only_stops_and_drains_core_profile 冗余),原地留注释说明原因。

补充事实(供后续 AI 直接引用):startup_semantic_complete 事件只在 CORE_PROFILE_UNIFIED_SINGLE_TAP 实验分支中 emit;生产路径的完成信号是 core_profile_rotation + launch_trace + launch-trace-live.json。

未做 / 门禁

Implementation Update(2026-09-22 · Dedicated launch readiness evidence 一致性修复)

Phase A 主架构通过独立审计后,审计方提出两个 evidence / readiness 问题,本批只修这两项,不新增架构能力、不做 Phase B/C、不改 Total 口径。

1. prepared collector 的 launch barrier 语义错误

原 realtime.py prepared 主路径在 launch_requested 与 launch_request_envelope 里写死 device-app-prewarmed-coreprofile-v2。Phase A 的真实时序是

App Ready -> Dedicated channel 尚未打开 -> Start -> open/arm Dedicated channel -> SOURCE_ACTIVE -> launch

因此该字符串声称的「App prewarm 阶段 CoreProfile 已 active」是伪报。新增正式常量

DEDICATED_ATTRIBUTION_RAW_READY_BARRIER_MODE = "process-control-dedicated-attribution-raw-ready-v1"

sourceActivationReady=true 改为仅在 Dedicated source 确实通过 readiness barrier 后才写入:判据是 prelaunch bridge wait_ready() 成功的 witness(t3_core_profile_started_ns is not None),而不是「prewarm_control 存在且 launch_trace 开启」。降级路径(launch_trace 被关闭)自动落到 None。

suspended 路径的 process-control-suspended-attribution-raw-ready-v2 保持不变,两条路径最终区分:

suspended path        -> process-control-suspended-attribution-raw-ready-v2
prepared + Start path -> process-control-dedicated-attribution-raw-ready-v1

旧值 device-app-prewarmed-coreprofile-v2 仅作为 LEGACY_PREWARMED_COREPROFILE_BARRIER_MODE 保留在 launch_trace.py 供历史 Session 兼容读取,不进入 dynamic attribution 必需集合(历史 Session 的离线判定不变),生产路径不再 emit(有静态守卫测试)。

2. live / offline 的 dynamic attribution 判定收敛为单一集合

原四处离线链路各自手写一套 barrier 集合(launch_trace.derive_launch_trace_session、launch_mapping、startup_source_schema_audit、startup_source_shape_audit)。现统一为 launch_trace.py 的

DYNAMIC_ATTRIBUTION_BARRIER_MODES  +  barrier_requires_dynamic_target_attribution(barrier)

四个离线入口全部改为调用该 helper,新 Dedicated barrier 因此自动被 replay / report / mapping / 两份 audit 识别为 require_dynamic_target_attribution = true,与 live 侧一致(live 侧本来就对所有 controlled launch 要求 dynamic attribution)。同时给 launch_mapping 与 derive_launch_trace_session 的 per-barrier sourceConfigObserved 补了 Dedicated 分支(lifecycle-only 源配置 + traceSourceReady + dynamicTargetAttributionReady),保持 fail-closed 而不是把新 barrier 漏成宽松判定。

3. App rebind 的 readiness 伪报

wait_for_start_with_rebind() 内 App A→B rebind 后的 app_ready 原来是 sourceArmed=decoder_profile_available(等于把 decoder 可用当成源已武装)。改为与首次 app_ready 一致:sourceChannelArmed = false、sourceArmed = source_channel_armed and decoder_profile_available。Phase A 在 Start 之前 Dedicated channel 未 open/arm,因此所有 App Ready / Rebind evidence 都必须满足

startupSource         = dedicated-coreprofile-on-start
sourceChannelArmed    = false
sourceArmed           = false
decoderProfileAvailable = 真实 decoder 状态

4. 本批新增回归测试

编号场景位置
A首次 app_ready 三项 readiness 为 Falsetest_prewarmed_start_reuses_one_provider_and_launches_once_unsuspended 扩展
BA→B rebind 后 app_ready 仍为 Falsetest_prewarmed_app_rebind_reemits_unarmed_app_ready(新增)
Cprepared Start 后 launchBarrierMode = dedicated v1 且 sourceActivationReady = true(同时校验落盘 raw record)同 A 扩展
D离线 replay 对新 barrier:require_dynamic_target_attribution = true;构造 attribution 缺失 fixture 要求 fail-closedtest_dedicated_barrier_offline_replay_accepts_exact_source_config / ..._fails_closed_without_dynamic_attribution(新增)
Esuspended barrier 仍是 v2 且仍被识别为 requiredtest_dynamic_attribution_barrier_set_is_shared_and_stable(新增)
F产品路径不再 emit device-app-prewarmed-coreprofile-v2test_prepared_launch_never_emits_legacy_prewarmed_coreprofile_barrier(新增,静态源码守卫)

5. 测试命令与结果(2026-09-22)

cd MacApp/Sidecar
PYTHONPATH=. /usr/local/bin/python3 -m unittest discover -s tests -p 'test_*.py'
git diff --check                      # clean
范围结果
test_perfowl_realtime43 / 43 OK(基线 41,+2)
test_perfowl_launch_trace48 / 48 OK(基线 45,+3)
test_perfowl_launch_mapping / startup_source_schema_audit / startup_source_shape_audit / test_startup_dedicated_channel8 / 6 / 5 / 7,全 OK
全量 Sidecar regression(第 1 次,负载 68–97)843 tests / FAIL=1(仅 test_g_rejected_flood 阈值线上抖动;见下节归因)
全量 Sidecar regression(第 2 次,负载 ~32)843 tests / OK / FAIL=0 / ERROR=0 / 119.865s(基线 838,+5)

py_compile 通过,git diff --check clean。

6. 已知 flake 归因(不放宽阈值)

全量中 test_pump_filter_active_fairness.test_g_rejected_flood_keeps_message_bound_fairness 曾失败于 assertGreater(reasons['message-limit'], 3123),属于已在项目记忆中记录的负载敏感 flake,与本批改动无关。归因证据:用 git archive 75ac8f5 建未改动基线探针树,与本批树做 5 轮交错配对(base→ship→base→ship…),两树每轮都失败,且读数随负载单调漂移——

round1 base 47.57 message-limit=3072   ship 45.28 3002
round2 base 43.71 3076                 ship 41.17 3088
round3 base 39.56 3094                 ship 36.71 3095
round4 base 34.17 3100                 ship 31.59 3091
round5 base 32.59 3097                 ship 32.38 3088

负载下降时读数正向逼近阈值 3123(记忆记录:负载 ~4.5 时两树均过),active-time 计数随负载同步下降,证明是宿主调度暂停落在 64-message burst 内所致,而非 Phase A/本批改动。测试文件在两树间 diff -q 逐字节相同。阈值未放宽,也未跳过该测试。。

Implementation Update(2026-09-22 · Phase B · Dedicated Startup Full Prewarm)

Phase A 主架构(75ac8f5)与 readiness evidence 一致性修复(edaf2f8)均通过独立审计后,本批按路线图第 2 阶段落地 Phase B · Full Prewarm:把 Dedicated Startup 的 provider / DTX owner / CoreProfile adapter probe / coreprofilesessiontap service transport 从 Start click 前移到既有的 device-connect → App-select 准备生命周期,使 Start click 只承担 round activation、SOURCE_ACTIVE barrier 与 cold launch。

本批只改 Python Collector Sidecar:未改任何 Swift 代码,未改 launch_trace.py,未改 RealtimeLaunchTraceDecoder,未改 lifecycle FSM,未改 Startup Total 口径,未进入 Phase C。

B.1 什么前移、什么不前移

阶段Phase A(现状)Phase B(本批)
设备连接后仅 ProcessControl / DeviceInfo不变
App 选定(app_ready 之前)无任何 Startup 准备provider lease + DTX owner + adapter probe + coreprofilesessiontap transport
Start clickopen provider → owner → probe → prepare → prepare_transport → start()round activation(prepare 绑当前 round target)→ start() → SOURCE_ACTIVE → cold launch

前移的只有「通道存在且可写」这一件事。没有前移的:CoreProfile.start()、adapter 占用、raw 事件订阅、kperf,以及任何 launch 动作。

B.2 安全边界:transport_prepared != recording_active

这是本批唯一的红线,因此把它做成类型级事实而不是调用方纪律:

B.3 App Ready 的新正式语义(取代本文档 Phase A 段的旧语义)

Phase A 段(§「App Ready 为什么 sourceChannelArmed = false」)记录的是 Phase A 的事实,按 D2 不回改;Phase B 起正式语义如下(startup_readiness_fields() 是唯一计算点):

startupSource           = "dedicated-coreprofile-prewarmed"   # 旧值 "dedicated-coreprofile-on-start"
sourceChannelArmed      = true     # provider/owner/transport 已准备好,Start 可直接激活
decoderProfileAvailable = 真实 decoder 状态(不变)
coreProfileChannelReady = true     # 与 sourceChannelArmed 同源,不再与长期 owner 挂钩
recordingActive         = false    # 传输热了,但 CoreProfile 未 start

device_ready.coreProfileChannelReady 仍为 false:长期 owner 依旧不持有 Startup CoreProfile slot。recordingActive 是 sourceChannelArmed 的诚实对照项;launch 路径上的 sourceActivationReady 依旧只由 prelaunch bridge wait_ready() 成功的 witness(t3_core_profile_started_ns is not None)决定,语义未变。

新增 manifest 字段一道回答「Start 到底用了什么」:

startupPrewarmUsed      bool     # Start 是否复用了 App 阶段预热的传输
startupPrewarmStatus    "ready" | "failed" | "skipped" | "not-requested"
startupPrewarmFallback  null | "on-demand" | "on-demand-after-prewarm-failure"
startupPrewarmReason    失败/跳过的可归因原因(成功为 null)
startupPrewarmTimingsMs 分阶段预热耗时

B.4 Start 复用与 on-demand 回退

startup_transport_ready() == true
    -> 复用同一 provider lease / DTX owner / 已热 service transport(不重开、不再 prepare_transport)
startup_transport_ready() == false
    -> 关闭半成品 channel,走**未改动的 Phase A 序列**(open -> prepare -> start),
       fallback = "on-demand"                     # 本来就没预热
                | "on-demand-after-prewarm-failure" # 预热失败,仅重试一次

App A→B rebind 复用同一 channel,只重新绑定 round target 与 sourceEpoch;若新 App 关闭了 Startup,则就地 close 预热 channel,绝不激活。

device reconnect / route change:每个 capture cycle 顶部复位预热状态(not-requested),cycle 收尾在 closeout 中以 dedicated-startup-cycle-complete 关闭并置空 startup_channel,因此重连不会把上一代 reconnect_generation 的 channel 当作可复用对象。该行为由既有 cycle 结构保证,未新增专用用例(见 B.10)。

B.5 失败域:可降级 vs 必须 fail-fast

复用既有分类器 is_degradable_core_profile_error(),不新增第二套判据:

预热期错误分类行为
core-profile-source-busy / another recording owns kperf / kperf is busy 等degradablestartupPrewarmStatus="failed" + startup_prewarm_unavailable 事件 + app_ready 如实上报「未武装」,Start 时 on-demand 重试一次;Session 不受影响,FrameTime 标记不受牵连
TypeError / KeyError / AttributeError / AssertionError / NameError 及其他 contract 破坏不可降级关闭半成品 channel 后原样抛出,与 Start-time arm 路径一致,绝不被洗成「Startup unavailable」

B.6 不变式(本批未触碰)

launch barrier            = process-control-dedicated-attribution-raw-ready-v1   # prepared + Start 路径,未改
suspended 路径 barrier    = process-control-suspended-attribution-raw-ready-v2   # 未改
Startup tap filters       = lifecycle-only(frame_time=False);不含 0x31800000
Startup Total 口径        = 未改(launch_trace.py / RealtimeLaunchTraceDecoder 未改动)

B.7 Phase B 计时证据(E2 合成 fixture,宿主内进程伪造,不代表真机延迟)

方案要求新增「Start click 剩余成本去了哪里」的可观测证据。新增两个事件,startup_activation 在 launch 序列结束后由单一位置发出(只诊断,不回读进采集行为或 Total):

startup_prewarm_ready   providerOpenMs / ownerOpenMs / probeMs / totalOpenMs
                        transportPrepareMs / totalPrewarmMs
                        transportPrepared=true, recordingActive=false
startup_activation      startToRecordingStartMs / recordingStartMs / sourceReadyMs
                        startToSourceReadyMs / startToLaunchRequestMs / startToLaunchAcknowledgedMs
                        prewarmUsed / prewarmFallback / prewarmStatus / transportPrepared

一次 fixture Session 的实测读数(3 次采样同量级,下述为一次典型值):

观测点预热路径无预热(legacy)
providerOpenMs / ownerOpenMs / probeMs0.003 / 0.013 / 0.010—(未预热)
transportPrepareMs / totalPrewarmMs0.025 / 0.108—(未预热)
app_ready.sourceChannelArmedtrue该路径不发布 app_ready
app_ready.recordingActivefalse—
transport_prepares / starts1 / 10 / 1
startToRecordingStartMs → startToSourceReadyMs0.060 → 0.994null(无 Start 锚点)
startToLaunchRequestMs → ...AcknowledgedMs182.860 → 182.873null
startupPrewarmStatus / Fallbackready / nullnot-requested / on-demand

读数解读必须克制:totalPrewarmMs 只有约 0.1 ms 是因为 fixture 全为进程内假对象,它证明成本被搬到了 App 准备阶段,不证明真机上的绝对收益;startToLaunchRequestMs 约 182.9 ms 由 fixture 自身等待支配,同样不构成真机延迟声明。真机收益只能在 E4 用同一设备、同一 App、同一冷启动条件下做「Phase A vs Phase B」对照得出,本批不做。

B.8 自动化测试映射(方案 §26 的 12 条)

编号断言位置
B-01app_ready 之前 startup_channel_opened + startup_prewarm_ready 已发布;此刻 transport_prepares=1、starts=0test_prewarmed_start_reuses_one_provider_and_launches_once_unsuspended
B-02Start 复用同一 provider/owner/transport:provider-open 仍为 2、transport_prepares=1、starts=1同上
B-03时序 start_accepted ≤ recording_started ≤ source_ready ≤ launch_requested ≤ launch_acknowledged同上
B-04App A→B rebind 复用同一 channel(startup_channel_opened=1、startup_prewarm_ready=1、transport_prepares=1),且 App A 从未被 launchtest_prewarmed_app_rebind_reuses_channel_and_stays_armed(由 Phase A 用例改名迁移)
B-05device reconnect 不吃上一代 channel:cycle 顶部复位 + closeout dedicated-startup-cycle-complete 关闭置空由既有 cycle 结构覆盖,无专用新用例
B-06Start 关闭 Startup:传输热过一次但 starts=0、无 core-start、startup_activation 各 recording 阶段为 nulltest_startup_disabled_at_start_never_starts_the_prewarmed_transport(由 Phase A 用例改名迁移)
B-07无预热调用方回退 on-demand:transport_prepares=0、starts=1、fallback="on-demand"、status="not-requested"test_b07_no_prewarm_falls_back_to_the_on_demand_arm(新增)
B-08瞬时预热失败:app_ready 报 failed 且不武装,startup_prewarm_unavailable 已发、startup_prewarm_ready 未发;Start 仅重试一次(starts=1、startup_channel_opened=2、fallback="on-demand-after-prewarm-failure");Session completedtest_b08_transient_prewarm_failure_degrades_then_retries_once_on_demand(新增)
B-09contract 破坏 fail-fast:异常原样抛出,不被洗成 startup_prewarm_unavailable + app_ready;只尝试一次预热且 starts=0test_b09_contract_error_during_prewarm_fails_fast(新增)
B-10Startup 占用 / 失败与 ActivityTrace FrameTime 隔离test_startup_busy_is_isolated_from_activitytrace_frametime、test_startup_prelaunch_failure_degrades_instead_of_aborting(Phase A 用例,按新语义更新断言)
B-11kperf 未提前占用:app_ready 时刻 starts=0、lifecycle_consumed=false、recordingActive=false同 B-01 用例 + DedicatedStartupChannelPrewarmTests 通道级断言
B-12barrier 未变:仍为 process-control-dedicated-attribution-raw-ready-v1,落盘 raw record 同步核对同 B-01 用例

manifest 侧的新增断言单列一条:test_b08_manifest_reports_prewarm_verdict(ready / Used=true / Fallback=null / 五项计时键存在且 ≥0 / startup_prewarm_ready 事件含 dedicatedOwner、transportPrepared、recordingActive=false)。

通道级契约另由 test_startup_dedicated_channel.py::DedicatedStartupChannelPrewarmTests 4 条锁定(prewarm() 只热传输 / start() 是唯一激活点 / close() 复位 / open_timings 为快照)。

B.9 测试命令与结果(2026-09-22)

cd MacApp/Sidecar
PYTHONPATH=. /private/tmp/perfowl-core-py312/bin/python3 -m unittest discover -s tests -p 'test_*.py'
git diff --check            # clean
范围结果
test_perfowl_realtime47 / 47 OK(基线 43,+4)
test_startup_dedicated_channel11 / 11 OK(基线 7,+4)
test_perfowl_launch_trace / test_capture_policy / test_capture_selection / test_source_preparation / test_startup_bootstrap / test_perfowl_launch_mapping / startup_source_schema_audit / startup_source_shape_audit / test_unified_core_profile / test_coreprofile_start_error / test_perfowl_startup_timing / test_perfowl_core_cli48 / 4 / 14 / 8 / 9 / 8 / 6 / 5 / 8 / 3 / 4 / 11,全 OK
全量 Sidecar regression(负载 2.30)851 tests / OK / FAIL=0 / ERROR=0 / 120.636s(基线 843,+8)
全量 Sidecar regression(第 1 次,负载 6.30)851 tests / OK(首跑即全绿,未触发任何已知 flake)

py_compile 通过(4 个改动文件),git diff --check clean。本批未放宽任何阈值,也未跳过任何用例。

B.10 未做 / 遗留门禁

Phase B Final Acceptance Follow-up(2026-09-22 · B-05 Device Reconnect Isolation)

背景与门禁结论

Phase B 独立验收已确认 Full Prewarm 主架构成立:Dedicated provider / DTX owner / transport 已在 App Ready 前完成,recordingActive=false;Start 后才执行 CoreProfile recording activation,且继续满足 SOURCE_ACTIVE → launch、FrameTime 隔离、contract fail-fast、on-demand fallback 等既有约束。真机对照判定为 B-PARTIAL:预热确实移除了约 200–500ms 的 provider/owner/transport 初始化成本,但 recording start → Raw source ready 仍有约 2–4s 抖动,因此不能宣称强性能收益。

当前 Phase B 唯一未关闭的验收项是方案 B-05:device reconnect / reconnect generation 变化后,Dedicated Startup 不得复用上一代 channel / provider lease / DTX owner。现有代码从 cycle closeout 看具备正确结构,但缺少 Dedicated Startup 专用回归测试,因此在补测通过前 Phase B 不正式关闭,也不进入 Phase C。

本批只做测试,不改产品架构

目标:新增一条 Dedicated Startup reconnect isolation 回归,证明同设备 App rebind 与 device reconnect 是两套不同语义。

必须覆盖:

  1. Generation 1 建立 Dedicated channel A,完成 transport prewarm;
  2. cycle / reconnect 结束时 A 必须执行 close,provider lease A 与 owner A 都被关闭;
  3. Generation 2 必须创建新的 Dedicated channel B、provider lease B、DTX owner B,并重新 prepare_transport();
  4. A 与 B 的对象身份必须不同;
  5. 新 owner 的 reconnect_generation 必须等于新的 generation;
  6. 旧 PID / target generation / sourceEpoch / App 状态不得泄漏到新 round;
  7. Generation 2 的 app_ready 必须来自新 channel;
  8. 同 device 的 App A→B rebind 仍应复用同一个 Dedicated channel;只有 device reconnect / generation change 才重建;
  9. App Ready 前仍必须保持 recordingActive=false,不得因补测改变 kperf 边界;
  10. 不允许修改 fairness 阈值、Phase B 产品逻辑、Startup Total、Phase C 或其他无关模块,除非测试实际暴露产品 bug。

推荐测试位置

优先放在:

MacApp/Sidecar/tests/test_perfowl_realtime.py

如果只需验证 DedicatedStartupChannel owner/lease generation,则可在:

MacApp/Sidecar/tests/test_startup_dedicated_channel.py

但最终测试必须覆盖到 cycle/reconnect 语义,而不仅是单个 channel constructor 参数。

验收命令

cd MacApp/Sidecar
PYTHONPATH=. /usr/local/bin/python3 -m unittest -v tests.test_startup_dedicated_channel
PYTHONPATH=. /usr/local/bin/python3 -m unittest discover -s tests -p 'test_*.py'
git diff --check

全量当前基线为 851 tests。若只出现已记录的 test_g_rejected_flood_keeps_message_bound_fairness 负载敏感 flake,禁止调整阈值,继续按 base / ship 交错配对归因。

提交要求

直接继续当前 main,不新建 branch / worktree。只提交本批测试与本节开发记录;推荐 commit:

test(startup): cover dedicated prewarm reconnect isolation

完成后停止,不进入 Phase C,交由独立验收 AI 做最终 Phase B Gate。

---

B-05 补测实现与验收记录(2026-09-22 · 追加段)

关联与范围

关键文件

文件变更量角色
MacApp/Sidecar/tests/test_perfowl_realtime.py+450 / −0新增 DedicatedStartupReconnectIsolationTests(2 用例)+ 顶部 1 行 import
MacApp/Sidecar/perfowl_collector_core/realtime.py+9 / −0每 capture cycle 复位 launch_requested_ns(偏差项)
site/devlog/p4-startup-three-stage-architecture-refactor-plan.md本节开发记录(C6/C7)
docs/architecture/perfowl-startup-three-stage-architecture-refactor-v1-2026-09-22.html重新渲染/archive/ 归档事实源(C2)
site/devlog/index.md时间线节点 + 门禁行B-05 关闭(C2/C3)

测试设计

两个用例共用一个 harness(DedicatedStartupReconnectIsolationTests._run,由 scenario 选择驱动脚本)。二者都跑真实的 stream_candidate_session,只把 provider lease / DTX owner / resolver / CoreProfile adapter 换成 double,并通过 startup_channel_factory 注入 DedicatedStartupChannel 的记录子类——生产类本身不被替换,子类只在 open() 之后快照 owner / route_id / adapter(close() 会把它们清空,不先快照就只剩 None)。因此断言针对的是产品接线,而不是一个假 channel。

逐项覆盖(方案 10 条)

方案项断言实测证据
1 Generation 1 建立 channel A 并完成 transport prewarmstartup_prewarm_ready #1 + transport_prepares 首次route=fixture-route-2,transportPrepared=true,recordingActive=false
2 cycle 结束时 A 必须 close,lease A / owner A 都关闭channel A:state="closed"、is_open=False、transport_prepared=False、owner is None;lease A.closed==1;owner A.close_reasons==["dedicated-startup-cycle-complete"]4 个 lease 全部 closed==1(每个 generation 各一对,含 Session 长驻 lease)
3 Generation 2 新建 channel B / lease B / owner B 并重新 prepare_transport()len(channels)==2、transport_prepares==2、startup_channel_opened==2cycle 2 route=fixture-route-4,totalPrewarmMs 独立测量
4 A 与 B 对象身份不同first is not second、owner/route/adapter 三组 is not—
5 新 owner 的 reconnect_generation 等于新 generationowner A==0、owner B==1owner 创建序列 [0,0,1,1](长驻 owner 与 Dedicated owner 各一)
6 旧 PID / target generation / sourceEpoch / App 状态不得泄漏三次 prewarm/prepare target 全部 pid is None + identityStatus="prelaunch-unbound";42 不出现在任何 target;cycle 2 prewarm 的 sourceEpoch==2 > cycle 1 的 1见「偏差」:launch_requested_ns 曾是唯一真实泄漏点,已修
7 Generation 2 的 app_ready 来自新 channel事件顺序 opened#1 < app_ready#1 < opened#2 < app_ready#2;两次 app_ready 均 sourceChannelArmed=true / coreProfileChannelReady=true / startupPrewarmStatus="ready"startup_readiness_fields() 是对当前 channel 的实时读取,armed 只可能来自 B
8 同 device 的 App A→B rebind 复用同一个 channel仅 1 个 channel / 1 个 owner / 1 个 lease 对;transport_prepares==1;prepared_targets 第 1 项 bundleID=fixture-a、第 2 项 fixture-b(原地 retag)两次 app_ready 的 requestID 分别为 request-a / request-b,同一 owner reconnect_generation==0
9 App Ready 前 recordingActive=false两次 app_ready 与两次 startup_prewarm_ready 的 recordingActive 均为 False;starts_at_prewarm_ready==[0,1]、starts_at_app_ready==[0,1](starts 是 Session 级单调计数,第 2 个值就是第一代的 Start)预热未 claim adapter / 未 start / 未订阅 raw
10 不越界改动未改 fairness 阈值 / Total / barrier / Phase C唯一产品改动为 1 行变量复位(下节)

额外覆盖(不在 10 条内但同源):startup_activation 两个 generation 各一条;manifest.status == "completed";末尾 generation 的 startupPrewarmTimingsMs 六个阶段齐全且非负;两个 channel 各自发布独立的 open_timings。

相对原方案的偏差(1 处,必修)

方案允许「测试实际暴露产品 bug 时按文档边界最小修复」。补测确实暴露一处跨代状态泄漏:

后果(修复前实测):

  1. startup_activation.startToLaunchRequestMs 由 _stage_ms(t2_start_received_ns, launch_requested_ns) 算出 −1448.755 ms——向验收方发布一个不可能的负时长;
  2. RealtimeMetricProjector(launch_requested_monotonic_ns=…) 在 cycle 2 仍拿到上一代锚点,_consume_startup_observation() 的 start is None 短路失效,会把新 generation 的 lifecycle 观测指标锚到上一代 launch。

修复为 1 行:在既有每-cycle 复位块(startup_recording_start_begin_ns / startup_recording_started_ns / t_launch_acknowledged_ns 之后)加 launch_requested_ns: Optional[int] = None,并附注释说明为何是 None 而非沿用旧值。所有读取点本就按 None 处理(_stage_ms 返回 None、投影器短路、flush 截止时间回退到 time.monotonic_ns()),因此新 generation 的 Start 相对阶段从「错误的负值」变为「诚实缺失」。

修复后实测:cycle 1 startToLaunchRequestMs=475.742(正),cycle 2 null;其余阶段 cycle 2 全部 null。未改 fairness 阈值、未改 Phase B 架构、未改 Total 口径、未进入 Phase C。

验证命令与结果(证据等级 E2)

cd MacApp/Sidecar
PYTHONPATH=. /usr/local/bin/python3 -m unittest -v tests.test_startup_dedicated_channel     # 11/11 OK
PYTHONPATH=. /usr/local/bin/python3 -m unittest tests.test_perfowl_realtime.DedicatedStartupReconnectIsolationTests   # 2/2 OK ×5 次连续
PYTHONPATH=. /usr/local/bin/python3 -m unittest discover -s tests -p 'test_*.py'             # ship 全量
git diff --check

全量三次对照(git archive f7d9a00 MacApp fixtures 导出的审计基线 vs ship,同一台机器、交错执行):

运行用例数耗时失败集合
base(f7d9a00,无本批改动)851172.8stest_pmd3_subscription_fairness.test_benchmark_pump_throughput_with_fairness;test_pump_filter_active_fairness.test_g_rejected_flood_keeps_message_bound_fairness
ship #1853235.5s上述 g_rejected_flood;test_pmd3_subscription_fairness.test_benchmark_filter_stream_throughput;test_realtime_consumer_fairness.DisplayPipelineBenchmark.test_benchmark_shipping_policy_steady_120hz_7200_frames;test_perfowl_xctrace_capture.test_managed_runner_times_out_and_reaps_process_group
ship #2853205.2sg_rejected_flood;pump_throughput_with_fairness;filter_stream_throughput;test_filter_stream_yields_on_unmatched_flood;xctrace 同上一项

失败集合逐次漂移:ship 两次运行的交集只有 3 项,各自还有对方没有的项。归因证据:

  1. 基线也失败:base 全量在第 1 次运行就有 2 项失败,均属已记录 flake 家族;test_pmd3_subscription_fairness 的 filter_stream_throughput 在隔离运行下 base 3/3 失败(1160.9 / 1397.8 / 1246.8 ms,门限 500ms)、ship 3/3 失败(1341.6 / 1125.6 / 1145.9 ms),同一断言、区间重叠 ⇒ 由机器负载而非代码决定。
  2. 打中的断言在轮换:DisplayPipelineBenchmark.test_benchmark_shipping_policy… base 3/3 失败但断言轮换(901 != 900 → burst max 89.9ms → 902 != 900),ship 2/3 失败同样轮换(burst p99 14.327ms → PASS → burst max 106.688ms)。
  3. 失败模块与改动无调用关系:4 个失败模块与 stream_candidate_session 均无调用(test_pmd3_subscription_fairness 只 import COLLECTOR_CLOSEOUT_SOFT_BUDGET_SECONDS / EventLoopHeartbeatMonitor);diff -q 断言这 4 个测试文件在 base 与 ship 之间逐字节相同——测试侧改动不可能造成它们失败。反过来,真正调用 stream_candidate_session 的 6 个模块(test_closeout_process_exit / test_sample_publisher_decoupling / test_frametime_continuation / test_frame_census_and_fatal_diagnostics / test_device_condition_scope / test_unified_core_profile)在两次 ship 全量中全部通过。
  4. xctrace 用例单独采样:base 3/9 失败、ship 3/9 失败,同为 FileNotFoundError——_run_command(..., timeout_seconds=0.2) 只给子解释器 0.2s 去写 pid 文件,负载高时被先杀掉;与本批改动无关,属新归因的既有负载竞争。

结论:0 项回归。两份 ship 全量中本批新增的 2 个用例均通过(test_perfowl_realtime 未出现在任何失败集合中)。

当前状态

implemented(B-05 补测与最小修复完成,E2)。Phase B 的 B-01…B-12 加上本节的 B-05 回归后,Phase B 侧门禁证据链完整;正式关闭 Phase B Gate 与是否进入 Phase C 由独立验收 AI 判定。

遗留项 / 后续门禁