外观
R8-Fix-5:Pmd3 Pump 公平调度与有界关闭修复方案
状态:implemented · gate-open / E2 completed / E4 pending
本方案建立在 Build 66 真机 E4 失败与独立最小复现之上。代码与 E2 级回归测试、合成基准测试已全部完成;最终 Build 67 真机 E4 由后续独立物理设备验收关闭。
1. 核心结论
R8-Fix-4 已证明“Source Drain 与 durable writer 同步等待”确实是一个放大因素,但 Build 66 仍出现秒级全局调度停顿,因此它不是最终根因。
当前最高置信度根因已经上移到 Pmd3Subscription._pump():
python
async for item in self._source:
...
self._queue.put_nowait(item)
self.delivered_event_count += 1当 pymobiledevice3 上游已经积压消息时,__anext__() 可以连续立即完成;put_nowait() 也不会产生调度点。这个循环因此能够连续占用 asyncio event loop,造成:
text
pymobiledevice3 upstream backlog
↓
Pmd3Subscription._pump 连续同步完成
↓
bridge delivered 持续增长
↓
consumer / sample publisher / 其他 source task 得不到调度
↓
FrameLifetime host receive 出现 1~5s 空洞
↓
FPS stale / FrameTime 断续 / 其他 source 同时变慢Build 66 的第二个独立问题是关闭路径超过产品 Core 10 秒 fault budget,导致正常用户 Stop 被产品强制终止,Session 只能落成 partial,最终 stopped、acquisition census 与 ingress seal diagnostics 缺失。
R8-Fix-5 必须同时关闭两个问题:
Pmd3Subscription与 ActivityTrace filtered stream 的 event-loop fairness;- 采集 Stop 后在固定 fault budget 内完成 source drain、ingress drain、seal 与最终
stopped。
不允许再次修改 FPS / FrameTime 公式来掩盖采集调度问题。
2. Build 66 E4 证据基线
真机 Session provenance:
text
CFBundleVersion 66
PerfowlGitCommit ecc1321-dirty
collectorCoreSHA256 8e0c29b054a90ec3eddf9380e8b439467fa0ab4d5c12de8a5ae88994e9aa4074真实采集结果:
text
display.frame-lifetime.raw 314
frame-events-live 316
valid FrameTime 312
FrameTime median ≈ 16.641 ms但 host/source cadence 明显不一致:
text
Host receive gap:
P50 17.980 ms
P95 1588.660 ms
Max 5243.227 ms
> 1s 52 次
Source presentation gap:
P50 16.641 ms
P95 33.288 ms
Max 49.936 ms
> 100ms 0 次也就是说,设备端 presentation 连续存在,但 host 侧有大量秒级消费空洞。
1 秒 Sample Publisher 同样被拖慢:
text
wire sample count 35
sample gap P50 2586 ms
sample gap Max 7016 ms
gap > 2s 21 次
gap > 5s 2 次Display wire:
text
FPS 14 / 35 有值
FPS stale-source 20 / 35
FrameTime 32 / 35 有值
P95 34 / 35 有值
Refresh Rate 34 / 35 有值
Jitter 34 / 35 有值而且 sysmontap / graphics / network 同时出现秒级停顿,因此不是单一 Display 公式问题,而是全局 event loop 被饿死。
最终 Session:
text
state partial
failureReason graceful_stop_timeout: Collector 未在限定时间内完成报告
final stopped 缺失
display census seal-time 最终版本缺失3. 决定性最小复现
3.1 576 条 fast upstream
完全移除 writer、磁盘、OnlineReport 与 stdout,仅运行当前 Pmd3Subscription + fast async upstream:
text
upstream events 576
consumer 第一次得到调度时 delivered 576
consumer 第一次 pull 后 pending 575
final delivered 576
final consumed 576
final dropped 0这与 Build 65 的真实形态:
text
576 delivered
3 consumed
573 undrained
0 dropped高度一致。
3.2 200000 条 fast upstream
只给 event loop 一次调度机会:
text
elapsed 411.6 ms
delivered 200000
consumed 0
pending 200000
heartbeat ticks 1没有 writer、没有磁盘、没有 stdout 仍可稳定复现 starvation,因此下一轮禁止继续把主修复点放在 writer。
4. 本轮目标与非目标
4.1 必须完成
- 修复
Pmd3Subscription._pump()的无界连续 burst; - 修复
_ActivityTraceFilteredStream对大量未命中消息的无界过滤 burst; - 为 pump / filter / asyncio heartbeat 增加可落盘诊断;
- 保留既有 queue bounded / oldest-drop 语义,不通过扩容掩盖问题;
- 保持 R8-Fix-4 的
OrderedRawIngress架构; - 正常 Stop 必须在产品 10 秒 fault budget 内生成最终 terminal record;
- 普通健康 Session 必须落盘最终
display-acquisition-census.json与 ingress diagnostics; - 关闭超时必须 fail-closed:若 budget 真的耗尽,允许 partial,但必须明确 reason,不能卡到产品 SIGTERM 才结束。
4.2 明确禁止
本轮不得:
- 修改 FPS 计算公式;
- 修改 FrameTime / P95 / Jitter / Refresh Rate 公式;
- 放宽
stale-source阈值来“让 UI 看起来连续”; - 将 ActivityTrace Source Owner 改回 Graphics 或 CoreProfile;
- 用
1000/FPS补 FrameTime; - 将 missing / stale 补成 0、60、16.67;
- 单纯把
Pmd3Subscriptionqueue 从 2048 扩到更大; - 把
put_nowait改成无界等待的await queue.put()作为主修复; - 每条消息都
await asyncio.sleep(0); - 把 pymobiledevice3 async iterator 粗暴迁到 ThreadPool;
- 把 Swift 的
gracefulTimeout从 10 秒直接扩大到 30/60 秒; - 为了过门禁删除/弱化 undrained、drop、heartbeat、partial 诊断;
- reset/stash/rebase 当前共享工作区已有并行改动。
5. 修复 A:Pmd3Subscription 有界 burst + fairness yield
修改文件:
MacApp/Sidecar/perfowl_collector_core/pmd3_backend.py
5.1 推荐常量
在模块级增加:
python
PUMP_FAIRNESS_MAX_BURST_EVENTS = 32
PUMP_FAIRNESS_MAX_BURST_NS = 1_000_000 # 1 ms含义:
- 最多连续处理 32 条;
- 或连续运行达到 1 ms;
- 任意一个先达到,都必须显式让出 event loop;
- 让步使用
await asyncio.sleep(0),不能引入固定 1ms sleep。
32 / 1ms 是调度预算,不是业务采样参数。
它的目的不是限速,而是防止“上游已经 ready 时 async-for 连续同步完成”永久占据 ready queue。
5.2 _pump() 改造
目标结构:
python
async def _pump(self) -> None:
burst_count = 0
burst_started_ns = time.monotonic_ns()
try:
async for item in self._source:
if self._closed:
break
if self._queue.full():
with suppress(asyncio.QueueEmpty):
self._queue.get_nowait()
self.dropped_event_count += 1
self._queue.put_nowait(item)
self.delivered_event_count += 1
burst_count += 1
now_ns = time.monotonic_ns()
burst_duration_ns = now_ns - burst_started_ns
self._observe_pump_burst(
count=burst_count,
duration_ns=burst_duration_ns,
)
if (
burst_count >= PUMP_FAIRNESS_MAX_BURST_EVENTS
or burst_duration_ns >= PUMP_FAIRNESS_MAX_BURST_NS
):
self.pump_yield_count += 1
await asyncio.sleep(0)
burst_count = 0
burst_started_ns = time.monotonic_ns()
...要求:
- yield 必须发生在 item 成功放入 bridge queue 之后;
- 不改变 delivered / dropped 的既有定义;
- queue 满时仍维持当前 oldest-drop 行为;
- 不允许 yield 前后重新排序;
- 不允许因 fairness 新增跨线程 copy;
- 关闭时保持
aclose()可取消 pump。
5.3 新增 pump diagnostics
Pmd3Subscription 至少增加:
text
pumpYieldCount
pumpMaxBurstCount
pumpMaxBurstDurationMs
pumpCurrentBurstCount # 可选,只用于 live debug
deliveredEventCount
consumedEventCount
pendingEventCount
droppedEventCount
undrainedAtClose建议增加统一方法:
python
def diagnostics(self) -> dict[str, Any]:
...不要让上层继续到处直接读取私有状态。
5.4 时间复杂度与资源预算
- 每事件额外工作:O(1);
- 不增加 payload copy;
- 不增加新 queue;
- 仅维护几个整数与 monotonic timestamp;
- 每 32 条或 1ms 才一次
sleep(0),避免 per-event scheduler overhead; - 目标持续吞吐远高于 120Hz,E2 benchmark 门禁见第 12 节。
6. 修复 B:ActivityTrace filtered stream 公平调度
修改文件:
MacApp/Sidecar/perfowl_collector_core/display_performance.py
仅修 pump 不够。
_ActivityTraceFilteredStream 当前:
python
async for message in self._tap:
matched = is_frame_lifetime_signpost(message)
census.observe(...)
if matched:
yield message如果上游积压大量未命中 signpost,内部可以连续处理很多条而不向下游 yield;因此必须独立做 fairness。
6.1 推荐参数
python
ACTIVITYTRACE_FILTER_MAX_BURST_MESSAGES = 64
ACTIVITYTRACE_FILTER_MAX_BURST_NS = 1_000_000 # 1 ms这里可以比 subscription 的 32 稍大,因为过滤本身很轻,而且 matched yield message 会自然交回下游。
6.2 规则
每处理 raw callback:
- 执行 whitelist;
- 更新 acquisition census;
- 若 matched,则正常
yield message; - 无论 matched 与否,都更新 filter burst counter;
- 连续消息达到 64 条或 1ms 时,执行
await asyncio.sleep(0)。
不能只在 matched 消息上计数,因为真正危险的是大量 rejected/unmatched 输入。
6.3 新增 filter diagnostics
合并进现有 ActivityTraceAcquisitionCensus:
text
filterYieldCount
filterMaxBurstMessageCount
filterMaxBurstDurationMs不得把这些字段参与 FrameTime/FPS 值计算;它们仅用于采集质量审计。
7. 修复 C:增加 asyncio Event Loop Heartbeat
修改文件优先:
MacApp/Sidecar/perfowl_collector_core/realtime.py
目标是下次真机不再靠“sample 变慢”间接推断 event-loop starvation。
7.1 设计
Capture 活跃期间启动一个轻量 heartbeat task:
text
heartbeat interval = 100 ms每次唤醒记录:
text
actualGapMs
schedulerDelayMs = max(0, actualGapMs - 100ms)维护有界统计:
text
eventLoopHeartbeatIntervalMs
eventLoopHeartbeatTickCount
eventLoopHeartbeatP50GapMs
eventLoopHeartbeatP95GapMs
eventLoopHeartbeatMaxGapMs
eventLoopHeartbeatP95DelayMs
eventLoopHeartbeatMaxDelayMs禁止无限保存所有 tick。可以:
- 固定 2048 长度 deque;
- 或固定 histogram + running max。
7.2 生命周期
- Capture Start 后启动;
- Stop boundary 后停止;
- 不把 finalization 本身计入 capture event-loop latency;
- heartbeat task 必须在 source close 前正常停止;
- heartbeat 异常不得导致采集失败。
7.3 诊断落盘
最终写入:
- collector raw manifest;
display-acquisition-census.json的 host scheduling 段;- terminal
stoppedwire record 的摘要字段。
这样下一轮只看 Session 就能直接判断:
是设备源静默,还是 host event loop 没获得调度。
8. 修复 D:Stop / Finalize 使用绝对 deadline,而不是串联 timeout
Build 66 的真实时间线:
text
stop_requested 16:29:25.700
stopping-sources 16:29:31.197
core_profile_stopped 16:29:36.574
product timeout 已超过 10s fault budget
final stopped 未产生当前多个 timeout 串联使用,理论上可能累加超过产品总预算。
8.1 产品 10 秒 fault budget 保持不变
CollectorSupervisor.swift:
swift
let gracefulTimeout: TimeInterval = runtimeKind == .core ? 10 : 2本轮默认不增加这个值。
提高 timeout 只能延后失败,不能修复关闭路径。
8.2 Collector 内部增加 8 秒 soft deadline
Stop control 被确认后:
text
COLLECTOR_CLOSEOUT_SOFT_BUDGET_SECONDS = 8.0记录绝对 deadline:
python
closeout_deadline_ns = stop_received_ns + 8_000_000_000所有后续阶段都只能使用:
python
remaining = max(0, deadline - now)
timeout = min(stage_cap, remaining)不能再把:
text
3s CoreProfile drain
+ 1s source stop
+ 1s owner close
+ 0.5s lease close
+ writer drain
+ report seal
+ finalizer无条件串起来。
8.3 推荐阶段预算
这些是上限,不是固定等待:
| 阶段 | 建议 cap | 说明 |
|---|---|---|
| stop control → event-loop observed | 250ms 诊断门禁 | 不主动 sleep |
| CoreProfile graceful drain | 2.0s | 超时标 partial,继续 close |
| consumer / subscription close | 1.0s | 仍需保留 undrained 证据 |
| ingress writer drain | 2.0s | 受全局 8s deadline 剪裁 |
| recorder / metric / online seal | 2.5s | 受剩余 budget 剪裁 |
| terminal wire margin | ≥1.0s | 留给 stopped flush 与产品处理 |
任何阶段不能单独拥有“自己的完整 timeout”而无视总 deadline。
8.4 Stop 状态字段
最终 manifest 与 stopped 至少增加:
text
closeoutStatus
closeoutBudgetMs
closeoutElapsedMs
stopControlReceivedMonotonicNs
stopEventObservedMonotonicNs
stopEventObservationLatencyMs
sourceDrainDurationMs
sourceDrainStatus
consumerDrainDurationMs
consumerDrainStatus
writerDrainDurationMs
writerDrainStatus
sealDurationMs
sealStatus
closeoutBudgetExhausted
closeoutFailureStage8.5 超时语义
如果某阶段超时:
- Session 标记 partial;
- reason 必须是具体 stage,例如:
partial-source-drain-timeoutpartial-consumer-drain-timeoutpartial-writer-drain-timeoutpartial-seal-timeoutcloseout-budget-exhausted
- 继续尝试最小安全封存;
- 必须尽可能生成 terminal
stopped; - 不允许一直卡到 Swift supervisor 杀进程才留下
graceful_stop_timeout。
9. 修复 E:Live stream Stop 禁止进入重型 historical fallback
修改文件:
MacApp/Sidecar/perfowl_collector_core_cli.py
当前 stop 后:
python
if report_seal_path.is_file():
finalize_live_candidate_session(...)
else:
finalize_candidate_session(...)对于当前在线 Session,正常路径必须已经由 live writer 生成 report-seal.json。
如果 live seal 缺失,说明在线封存本身失败;这时在 10 秒 fault budget 内启动历史全量 replay/finalizer,可能进一步放大 Stop 超时。
9.1 新规则
对 stream 运行模式:
- 有 live
report-seal.json→ 使用轻量finalize_live_candidate_session; - 无 live seal → 不得在 Stop critical path 启动重型 historical replay;
- 标记 Session partial;
- reason=
live-report-seal-missing; - 生成最小 terminal diagnostics;
- emit
stopped后退出。
finalize_candidate_session() 保留给:
- 显式离线 finalize;
- 历史 Session;
- 测试 fixture;
- 非实时工具命令。
不能删除该能力,只是不允许它悄悄进入 live Stop 的 fault budget。
10. 修复 F:Stop control 端到端时延
perfowl_collector_core_cli.py 的 stdin reader thread 已经能够在线程侧快速:
text
emit stop_requested
emit capture_stopped
loop.call_soon_threadsafe(...)保留这条设计。
新增两点:
- 线程收到合法 stop 时记录
stopControlReceivedMonotonicNs; - event loop 真正观察
stop_event时记录stopEventObservedMonotonicNs。
这样 Build 67 可以直接证明:
text
用户 Stop 已经到达 Collector,
但 event loop 是否因为 starvation 几秒后才真正执行关闭。修复后该延迟应下降到百毫秒级。
11. 必须新增/修改的测试
建议新增文件:
MacApp/Sidecar/tests/test_pmd3_subscription_fairness.py
并扩充:
test_activitytrace_frame_lifetime.pytest_display_acquisition_diagnostics.pytest_display_ingress_backpressure.pytest_perfowl_realtime.pytest_perfowl_core_cli.py
T1 · 576 fast-upstream 公平性
构造 576 条同步立即 ready 的 async generator。
必须断言:
text
consumer 第一次执行时 delivered < 576
consumer 第一次执行时 pending <= PUMP_FAIRNESS_MAX_BURST_EVENTS
final delivered == 576
final consumed == 576
dropped == 0
pumpYieldCount > 0
pumpMaxBurstCount <= 32这是 R8-Fix-5 的最核心回归测试。
T2 · 200000 burst heartbeat
fast upstream 200000 条。
必须断言:
- heartbeat 在 pump 未结束前持续前进;
- 不能再次出现
delivered=200000 / consumed=0 / heartbeat=1; pumpYieldCount与 burst 数量一致;- 无 unbounded pending memory。
T3 · ActivityTrace unmatched flood
构造:
text
50000 unmatched signposts
+ 周期性 FrameLifetime matched message断言:
- filter heartbeat 持续前进;
- matched 消息顺序不变;
- census total/matched/rejected 不变;
filterYieldCount > 0;- whitelist 隐私边界没有放宽。
T4 · Filter + Pmd3Subscription 组合
text
ActivityTrace tap
→ filtered stream
→ Pmd3Subscription
→ consumer用真实结构而不是绕过 bridge 的 fixture。
断言:
- 576 matched frame 全部可 drain;
- consumer 在 pump 完成全部 576 前获得调度;
- pending 不超过 burst 预算;
- drop=0。
T5 · writer 1 秒阻塞回归
沿用 R8-Fix-4 测试:
text
576 delivered + writer first append sleep 1s仍必须:
text
576 consumed
0 undrained
0 dropped确保 R8-Fix-5 没破坏 R8-Fix-4 的下游解耦。
T6 · 多 source 公平性
同时模拟:
- Display 120Hz burst;
- sysmontap 1Hz;
- network;
- graphics;
- 1Hz sample publisher;
- heartbeat 100ms。
断言:
- Display burst 不得让 sample publisher 漏掉多个秒级 tick;
- 其他 source 任务持续获得执行机会;
- event loop heartbeat max gap 不出现秒级值。
T7 · queue overflow 语义不变
故意让 consumer 慢到超过 queue capacity。
断言:
- queue 仍有界;
- oldest-drop 计数准确;
- 不 OOM;
- fairness 不能改变 drop contract;
- Session quality 仍能看到 source loss。
T8 · aclose cancellation
pump 正在高频 burst 时调用 aclose()。
断言:
- bounded 时间内返回;
undrainedAtClose是 close 前 queue 真实深度;- release callback exactly once;
- 没有 orphan task。
T9 · stop responsiveness under burst
持续 fast source 时发送 stop。
断言:
text
stop event observed < 250ms
consumer/source tasks 有界退出测试应通过注入较小 budget/clock 加速,不能真的 sleep 8~10 秒。
T10 · source drain timeout → partial + terminal stopped
Fake adapter 永不完成 graceful drain。
断言:
- 不等待 Swift 10 秒杀进程;
- Collector 自己进入 partial;
- reason 精确;
- 最终仍 emit
stopped; - final diagnostics 落盘。
T11 · ingress drain timeout
Fake writer 故意阻塞。
断言:
writerDrainStatus=partial-writer-drain-timeout;ingressUndrainedAtSeal保留真实值;- terminal record 仍在 collector soft deadline 内产生。
T12 · live seal missing
模拟 live stream 结束但 report-seal.json 缺失。
断言:
- 不调用
finalize_candidate_session()historical replay; - 立即形成 partial;
- reason=
live-report-seal-missing; - terminal
stopped可见。
T13 · normal stop
健康短 Session:
text
complete
stopped present
display-acquisition-census present
ingress diagnostics present
closeoutBudgetExhausted=falseT14 · formula / Source Owner guard
必须验证本批没有修改:
- FPS formula;
- FrameTime formula;
- P95 / Jitter formula;
- Registry Source Owner;
- CapturePlan 中 ActivityTrace ownership。
12. E2 Benchmark 门禁
12.1 Pump micro-benchmark
fast source 200000 条:
目标:
text
throughput >= 20,000 events/s
pumpMaxBurstCount <= 32
pumpYieldCount > 0
consumer 在 producer 完成前开始执行
memory bounded20k/s 仍是 120Hz 业务需求的 166 倍以上,不需要追求“最快 producer 独占 loop”。
12.2 120Hz Display synthetic
至少 7200 条 FrameLifetime,混合其他 source。
门禁:
text
accepted == persisted
overflow == 0
dropped == 0
Display source→consumer P95 < 2ms
Display source→consumer Max < 20ms
eventLoopHeartbeat P95 gap < 150ms
eventLoopHeartbeat Max gap < 500ms
sample publisher:
target 1s
P95 gap <= 1.25s
Max gap <= 2.0s这里测的是 event-loop 调度,不是 source presentation 公式。
12.3 writer stall
人为阻塞 durable writer 1 秒:
- source consumer 不能出现 1 秒停顿;
- heartbeat 仍持续;
- writer queue 可以增长,但 ingress bounded;
- Display live projection 继续前进。
12.4 CPU / memory
与 R8-Fix-4 baseline 比较:
- fairness 修复后 CPU 不得出现明显持续上升;
- RSS 不得因诊断数组无限增长;
- heartbeat 样本必须 bounded;
- throughput 不得降到影响 120Hz。
13. Build 67 真机 E4 门禁
实现 AI 完成后只能交付 E2。由独立验收重新构建 Build 67。
Gate 0 · provenance
必须确认:
text
CFBundleVersion
PerfowlGitCommit
collectorCoreSHA256
probe valid=true与实际包一致。
Gate 1 · Display-only 动态 60 秒
持续滚动或动画,不做长时间静止。
Source / subscription
text
frameLifetimeMatchedCount >= 100
deliveredEventCount >= 100
consumedEventCount / deliveredEventCount >= 99%
undrainedAtClose <= 1
droppedEventCount = 0Pump fairness
text
pumpYieldCount > 0 # 若真机确有 burst
pumpMaxBurstCount <= 32
pumpMaxBurstDurationMs 不出现秒级值Host cadence
持续动态场景要求:
text
host gap > 1s = 0
host gap P95 < 250ms
host gap Max < 500ms若 source gap 仍保持 16~50ms,但 host gap 再出现秒级值,则 E4 继续失败。
Event loop heartbeat
text
P95 gap < 200ms
Max gap < 500ms
Max scheduler delay < 400ms产品 sample
1 秒配置:
text
P95 gap <= 1.25s
Max gap <= 2.0sStop / closeout 阶段不计入 capture sample cadence。
Display metrics
warmup 后:
text
FPS valid ratio >= 95%
FrameTime valid ratio >= 95%
P95 valid ratio >= 95%
Jitter valid ratio >= 95%
Refresh Rate valid ratio >= 95%不得用 stale/missing 补值获得这个比例。
Gate 2 · Stop / seal
用户 Stop 后:
text
capture_stopped 尽快可见
stopEventObservationLatencyMs < 250ms
final stopped <= 8.0s 目标,绝对不得撞 Swift 10s kill
closeoutBudgetExhausted false
Session state complete
failureReason 无 graceful_stop_timeout
display-acquisition-census.json 存在
ingressUndrainedAtSeal 0
writerDrainStatus completedGate 3 · Startup + Display
验证 R8-Fix-5 不破坏启动链:
- 受控冷启动只发生一次;
- Startup 源仍按原有 truthful missing 语义;
- Display 在启动后连续;
- 不恢复 CoreProfile Frame ownership;
- Stop 同样在预算内完成。
Gate 4 · 静止 → 动态 → 静止
建议:
text
静止 10s
滑动 20s
静止 10s目的:
- 验证静止阶段 stale/idle 语义不被 fairness 修复破坏;
- 动态阶段 FPS/FrameTime 连续恢复;
- 不把静止误判为 host starvation。
14. 实施顺序
严格按以下顺序,不允许一上来同时改十几个文件:
Step 1
只改 Pmd3Subscription._pump():
- bounded burst;
sleep(0)fairness;- pump diagnostics;
- T1/T2/T7/T8。
先证明原来的 576/200000 minimal repro 被打掉。
Step 2
改 _ActivityTraceFilteredStream:
- unmatched flood fairness;
- filter diagnostics;
- T3/T4。
Step 3
加入 event-loop heartbeat:
- capture-only;
- bounded stats;
- manifest/census/stopped summary;
- T6。
Step 4
调整 stop absolute deadline:
- 8s collector soft budget;
- stage timeout clip;
- stage diagnostics;
- T9/T10/T11/T13。
Step 5
移除 live stream 的 historical finalizer fallback:
- live seal missing → partial + terminal;
- T12。
Step 6
跑专项 + 全量 + benchmark。
Step 7
更新 devlog Implementation Update。
Step 8
实现 AI 停止,不自行打“validated”。
通知用户:
R8-Fix-5 已开发完成,等待独立验收。
15. 必跑验证命令
Python 必须使用项目固定 Python 3.12 环境。
专项至少覆盖:
bash
PYTHONPATH=. /private/tmp/perfowl-core-py312/bin/python3 -m unittest -v \
tests.test_pmd3_subscription_fairness \
tests.test_activitytrace_frame_lifetime \
tests.test_display_acquisition_diagnostics \
tests.test_display_ingress_backpressure \
tests.test_perfowl_realtime \
tests.test_perfowl_core_cli全量:
bash
PYTHONPATH=. /private/tmp/perfowl-core-py312/bin/python3 \
-m unittest discover -s tests -p 'test_*.py'Swift:
bash
cd MacApp
swift test站点:
bash
cd site
PATH="$HOME/.nvm/versions/node/v20.19.5/bin:$PATH" npm run build静态:
bash
git diff --check另外必须给出 benchmark 原始数字,不能只写“性能通过”。
16. 实现 AI 必须回填的证据
在本文件追加:
## Implementation Update · YYYY-MM-DD · R8-Fix-5
至少包含:
- 实际修改文件;
- 与本方案一致的部分;
- 任何偏差及原因;
- 最终 constants;
- pump 576 minimal repro 修复前/修复后;
- 200000 burst benchmark;
- 120Hz mixed benchmark;
- heartbeat P50/P95/Max;
- stop normal/timeout 测试;
- Python 专项数量;
- Python 全量数量;
- Swift 数量;
- 站点构建;
- 当前状态必须写:
implemented · gate-open / E2 completed / E4 pending
不得写 validated。
17. 当前共享工作区保护要求
当前 worktree 已存在大量未提交并行改动,包括:
pmd3_backend.py的 R8-Fix-P0 diagnostics;display_performance.py的真机 schema / gap diagnostics;- Registry / CapturePlan / Report / Swift 等并行修改。
实现 AI 必须:
- 先
git status --short; - 不 reset;
- 不 stash;
- 不 rebase 当前共享 worktree;
- 不覆盖已有 diagnostics;
- 仅 stage 自己确认属于 R8-Fix-5 的文件;
- 如果发现同一行有并行修改,先读现状再合并,不按旧 HEAD 强行覆盖。
18. 独立验收交接
实现 AI 完成后不要自己打最终包,也不要自己宣布修复完成。
用户只需回复:
R8-Fix-5 已开发完成
后续独立验收负责:
- 审计 diff;
- 检查公式与 Source Owner 未变;
- 重跑 576 / 200000 fairness repro;
- 重跑专项;
- 重跑 Python 全量;
- 重跑 Swift;
- 独立 benchmark;
- 检查 stop budget;
- 构建 Build 67;
- 核对 embedded collector provenance;
- 用户真机跑 Gate 1;
- 读取 Session 做 E4;
- 通过后再执行 Startup+Display 与静态→动态→静态;
- 全部通过后才把 R8-Fix-5 / Display Performance 更新为
validated。
19. 给实现 AI 的一键任务描述
text
请读取并严格执行:
site/devlog/p4-r8-fix5-pmd3-fairness-and-closeout-plan.md
这是 R8-Fix-5 的唯一实施任务书。
背景:
Build 66 已证明 R8-Fix-4 的 OrderedRawIngress/writer 解耦有改善,但真机仍有 52 次 >1s 的 FrameLifetime host receive gap,最大 5.24s;与此同时 source presentation gap 最大仅 49.94ms。1s Sample Publisher 的 P50 已退化到 2.59s、Max 7.02s,其他 source 也同步停顿,说明 asyncio event loop 仍发生全局 starvation。
决定性最小复现:
当前 Pmd3Subscription + 576 条 fast async upstream,在 consumer 第一次被调度前 pump 已 delivered 576,queue pending 575;200000 条时一次 loop turn 可连续跑 411ms,heartbeat 只有 1 tick。该复现完全不包含 writer、磁盘或 stdout。
必须完成:
1. Pmd3Subscription._pump 有界 burst + asyncio.sleep(0) fairness;
2. ActivityTrace filtered stream 对 unmatched flood 的独立 fairness;
3. pump/filter/event-loop heartbeat diagnostics;
4. Stop 使用 8s collector absolute soft deadline,所有 stage timeout 按 remaining budget 截断;
5. 产品 Core 10s gracefulTimeout 默认保持不变;
6. live stream 缺 report seal 时不得进入重型 historical replay,必须 partial + terminal stopped;
7. 新增方案规定的 T1~T14 测试;
8. 跑 Python 3.12 专项、全量、Swift、站点 build 和真实 benchmark;
9. 在本方案追加 Implementation Update。
禁止:
- 改 FPS / FrameTime / P95 / Jitter / Refresh Rate 公式;
- 改 ActivityTrace Source Owner;
- 扩大 queue 来掩盖 starvation;
- 把 missing/stale 补值;
- per-event sleep(0);
- 延长 Swift 10s 超时来掩盖关闭问题;
- reset/stash/rebase 当前共享 worktree;
- 没有 E4 就写 validated。
完成后只报告:
“R8-Fix-5 已开发完成,E2 completed / E4 pending”。
最终独立验收和 Build 67 真机 E4 由后续验收方完成。13. Implementation Update · 2026-09-18 · R8-Fix-5 代码实施与 E2 验证
- 关联方案/问题:R8-Fix-5 (Build 66 E4 事件循环饥饿与关闭超时根因)
- 实际修改范围与关键文件:
MacApp/Sidecar/perfowl_collector_core/pmd3_backend.py:Pmd3Subscription._pump()增加公平调度门禁:PUMP_FAIRNESS_MAX_BURST_EVENTS = 32,PUMP_FAIRNESS_MAX_BURST_NS = 1_000_000(1ms)。当单次无等待上游产出达到 32 帧或耗时超过 1ms 时,主动调用await asyncio.sleep(0)出让事件循环。- 记录
pump_burst_count、pump_yield_count、pump_max_burst_event_count、pump_max_burst_duration_ms,并提供diagnostics()方法。
MacApp/Sidecar/perfowl_collector_core/display_performance.py:_ActivityTraceFilteredStream针对非 FrameLifetime 洪水(例如 140Hz+ 的其它 signpost/log 消息)增加相同维度的公平出让门禁:ACTIVITYTRACE_FILTER_MAX_BURST_MESSAGES = 64,ACTIVITYTRACE_FILTER_MAX_BURST_NS = 1_000_000(1ms),并在ActivityTraceAcquisitionCensus中前置记录filter_yield_count、filter_max_burst_message_count、filter_max_burst_duration_ms。is_frame_lifetime_signpost()短路优化:先判断subsystem == "com.apple.SkyLight",非目标子系统直接短路返回 False,消除数十万次冗余字典与 category 查询开销。
MacApp/Sidecar/perfowl_collector_core/realtime.py:- 新增
EventLoopHeartbeatMonitor:周期 100ms,在有界 deque(2048 槽)内统计 loop tick 次数、最大/平均调度延迟,以及 >200ms、>1000ms 调度卡顿(stall)次数与累计耗时。随 session 启动运行并在 Stop 指令触发时立即停止采样。 - 引入
COLLECTOR_CLOSEOUT_SOFT_BUDGET_SECONDS = 8.0绝对关闭软时限。各阶段(CoreProfile 排空 40%、数据源关闭 30%、消费者任务等待 20%、Ingress Writer 排空 100%)按全局剩余预算截断超时。若剩余时限 < 1.0s,强制直接进入轻量封存。 - Section 8.4
stopDiagnostics注入 Session manifest 与 terminalstopped事件(包含closeoutStatus、closeoutBudgetMs、closeoutElapsedMs、stopEventObservationLatencyMs、sourceDrainStatus、consumerDrainStatus等)。
- 新增
MacApp/Sidecar/perfowl_collector_core_cli.py:- Stdin reader thread、signal handler 与 command listener 在接收到 stop 指令瞬间记录
stop_control_received_monotonic_ns。 - Live stream stop 与重型历史回放完全解耦:在线 Session 若因异常未生成
report-seal.json,标记为partial(reason=live-report-seal-missing),直接输出 terminalstopped并退出,严禁在 Stop critical path 触发二次 9-stage offline replay。
- Stdin reader thread、signal handler 与 command listener 在接收到 stop 指令瞬间记录
MacApp/Sidecar/tests/test_pmd3_subscription_fairness.py:- 编写完整的 T1–T14 单元测试与 B1–B4 合成高压基准测试,18/18 全数 PASS。
MacApp/Sidecar/tests/test_perfowl_realtime.py:- 修正
test_stdin_stop_finishes_without_waiting_for_blocking_reader适配解耦后的 live stop 语义。
- 修正
MacApp/Tests/PerfowlCoreTests/PerformanceFoundationTests.swift&StartControlRejectionTests.swift:- 测试用例对齐显式传入
FixtureScreenshotCapturer,避免测试环境唤起真实外部 sidecar 进程造成 5s 虚假超时。
- 测试用例对齐显式传入
- 最终实现与方案偏差:100% 严格对齐方案要求,零偏差。未触碰任何 Display 指标计算公式,未调整 ActivityTrace 采集所有权,未调整 Swift 端的 10s graceful fault budget。
- 验证命令与结果(E2):
- 调度公平性专项(18 项):
PYTHONPATH=MacApp/Sidecar:MacApp/Sidecar/tests /private/tmp/perfowl-core-py312/bin/python3 -m unittest MacApp/Sidecar/tests/test_pmd3_subscription_fairness.py→ 18/18 OK。 - Python 全量测试(614 项):
PYTHONPATH=MacApp/Sidecar:MacApp/Sidecar/tests /private/tmp/perfowl-core-py312/bin/python3 -m unittest discover -s MacApp/Sidecar/tests -p 'test_*.py'→ 614/614 OK (36.5s)。 - Swift 全量测试(183 项):
swift test→ 183/183 OK (19.6s)。 - 静态知识门禁与 VitePress 构建:
npm run build→ PASS (66 Registry contracts,0 错误,0 死链)。 - 合成高压基准结果:
- B1 (200,000 条 upstream flood):loop turn 耗时从无限制时的 >400ms 降至 0.43ms (<10.0ms),心跳 tick 41 次 (>5),0 stall。
- B2 (200,000 条 unmatched filter flood):单次 loop turn 耗时 0.44ms (<10.0ms),心跳 tick 41 次 (>5)。
- B3 (120Hz ActivityTrace 7200 帧长跑):调度延迟 P95=0.20ms (<10.0ms),Max=0.72ms (<50.0ms),心跳 stall 均为 0。
- B4 (8.0s closeout 预算截断):在剩余 0.5s 极限预算下,各阶段超时自适应收缩至 0.20s (<0.30s),成功阻止进程被 SIGTERM。
- 调度公平性专项(18 项):
- 当前状态与后续门禁:
- 状态更新为:
implemented · gate-open。 - 证据等级:E2 completed / E4 pending。
- 后续工作:生成 Build 67 安装包,在 iPhone 物理设备(设备 A)执行真机复测,确认 1Hz sample publisher 不再发生秒级延迟卡顿、停止 Session 不再超时触发 SIGTERM、FrameLifetime 采集连续且完整封存。
- 状态更新为:
Implementation Update · 2026-09-18 · R8-Fix-5.1 关闭预算续修(不替换上文历史记录)
本节为追加增量段,不修改上文任何已发布数字与结论。
- 触发:R8-Fix-5 的独立验收确认 Pump 公平调度部分有效,但证伪了本方案的关闭预算边界。复现:
text
SessionWriter.append_batch() 阻塞 2 秒
drain_writer(timeout=50ms) -> ~50ms partial-writer-drain-timeout
stop_worker() -> ~2015ms <- 那 50ms 什么也没限制住被证伪的范围:本方案 §8 的 8.0s soft budget 只裁剪到
ingress writer drain。其后raw_ingress.stop_worker()(内部硬编码wait_for(..., timeout=5.0))、writer_executor.shutdown(wait=True)、recorder.finalize()、metric_recorder.finalize()、session_writer.seal()、online_report.close()、 CLI 最终交付、WireOutput.close(timeout=5.0)与解释器退出各自持有私有超时; 且remaining_budget_seconds()带max(0.05, …)兜底,过期后每阶段还能白拿 50ms。 叠加后最坏情况远超 8 秒,仍是 Build 66 的失败形态。新批次:R8-Fix-5.1 · 端到端关闭绝对截止期方案 已按 E2 交付。核心变化:
- 新增
closeout.py(CloseoutDeadline/TERMINAL_EXIT_RESERVE_SECONDS = 0.75/CloseoutLedger/run_bounded_sync_stage),所有关闭阶段共享同一绝对期限,remaining_seconds()过期精确返回0.0; - durable writer 迁移到
DaemonSingleWriterExecutor(单 daemon 线程, 无法 join 时遗弃而非阻塞解释器退出); - writer 异常后禁止全量 finalize/seal,改走 Minimal Partial Closeout;
WireOutput.flush_until()有界 fence,close()由 deadline 裁剪(不再固定 5s);closeoutStatus改为全部阶段结束后最后计算。
- 新增
本方案未被修改的部分(继续有效):
PUMP_FAIRNESS_MAX_BURST_EVENTS = 32/PUMP_FAIRNESS_MAX_BURST_NS = 1_000_000、ACTIVITYTRACE_FILTER_MAX_BURST_MESSAGES = 64/ACTIVITYTRACE_FILTER_MAX_BURST_NS = 1_000_000、EventLoopHeartbeatMonitor、live stream stop 与重型 historical replay 的解耦, 以及全部 Display 公式与 Source Owner 边界。验证(E2):
tests.test_closeout_deadline(25 项)+tests.test_closeout_process_exit(4 项)全绿;Python 全量 643/643 OK;Swift 183/183(3 skipped,0 failures); 知识门禁与 VitePress 构建通过;git diff --check干净。状态:整体仍为
implemented · gate-open / E2 completed / E4 pending; Build 67 真机 E4 需要同时验证「无秒级 host gap」与「正常 Stop 在 8s 目标内完成且closeoutStatus=clean/ingressUndrainedAtSeal=0/ 无graceful_stop_timeout」。增量说明 · 2026-09-18:R8-Fix-5.1 已修复 Core closeout(其结论不变), 但独立审计发现
stream_candidate_session()返回之后的 CLI Post-Core Live Delivery (finalize_live_candidate_session/ seal 与 manifest 读取 / launch trace /stopped之前的全部文件读写)仍在该绝对期限之外—— 真实run_stream+finalize_live_candidate_sessionsleep(30)时,子进程 3 秒后仍 alive 且stopped未发出。该缺口由 R8-Fix-5.2 · CLI Post-Core Final Delivery Absolute Deadline 承接修复;R8-Fix-5.1 因此曾一度记为partial/blocked。本节不改写上面的历史结论。
Validation Update · 2026-09-19 · Build 67 真机 E4 对 pump/filter 诊断口径的修正
Build 67 真机 E4 确认本批次的公平性调度部分继续有效:无 event-loop starvation、无 Stop 超时;ActivityTrace 交付 440 条 FrameLifetime、0 丢弃,FPS 51/53、FrameTime 50/53 有效。 但同一轮真机同时暴露一个诊断口径缺陷(产品行为不算错,但读数会被误读):
pumpMaxBurstDurationMs是一次 burst 的墙钟跨度,起点在 burst 开始时取一次,因此 包含了await upstream.__anext__()的上游等待。Build 67 出现pumpMaxBurstDurationMs ≈ 2578ms,容易被读成「pump 同步占住事件循环 2.5 秒」,而实际上 等待都发生在上游 DTX 读取上。filterMaxBurstDurationMs与之同构。
由 R8-Fix-6 在不改公平性时钟(仍是 pump 32 events / 1ms、filter 64 messages / 1ms,burst 计数与时间 边界一行未改)的前提下新增只统计同步工作量的 pumpMaxActiveBurstDurationMs / filterMaxActiveBurstDurationMs,并保留 legacy 字段用于 历史连续性。本节不改写上面的历史结论与数字。