Skip to content

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 必须同时关闭两个问题:

  1. Pmd3Subscription 与 ActivityTrace filtered stream 的 event-loop fairness;
  2. 采集 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;
  • 单纯把 Pmd3Subscription queue 从 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:

  1. 执行 whitelist;
  2. 更新 acquisition census;
  3. 若 matched,则正常 yield message;
  4. 无论 matched 与否,都更新 filter burst counter;
  5. 连续消息达到 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 stopped wire 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 observed250ms 诊断门禁不主动 sleep
CoreProfile graceful drain2.0s超时标 partial,继续 close
consumer / subscription close1.0s仍需保留 undrained 证据
ingress writer drain2.0s受全局 8s deadline 剪裁
recorder / metric / online seal2.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
closeoutFailureStage

8.5 超时语义 ​

如果某阶段超时:

  • Session 标记 partial;
  • reason 必须是具体 stage,例如:
    • partial-source-drain-timeout
    • partial-consumer-drain-timeout
    • partial-writer-drain-timeout
    • partial-seal-timeout
    • closeout-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(...)

保留这条设计。

新增两点:

  1. 线程收到合法 stop 时记录 stopControlReceivedMonotonicNs;
  2. 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.py
  • test_display_acquisition_diagnostics.py
  • test_display_ingress_backpressure.py
  • test_perfowl_realtime.py
  • test_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=false

T14 · 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 bounded

20k/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 = 0

Pump 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.0s

Stop / 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                 completed

Gate 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

至少包含:

  1. 实际修改文件;
  2. 与本方案一致的部分;
  3. 任何偏差及原因;
  4. 最终 constants;
  5. pump 576 minimal repro 修复前/修复后;
  6. 200000 burst benchmark;
  7. 120Hz mixed benchmark;
  8. heartbeat P50/P95/Max;
  9. stop normal/timeout 测试;
  10. Python 专项数量;
  11. Python 全量数量;
  12. Swift 数量;
  13. 站点构建;
  14. 当前状态必须写: 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 已开发完成

后续独立验收负责:

  1. 审计 diff;
  2. 检查公式与 Source Owner 未变;
  3. 重跑 576 / 200000 fairness repro;
  4. 重跑专项;
  5. 重跑 Python 全量;
  6. 重跑 Swift;
  7. 独立 benchmark;
  8. 检查 stop budget;
  9. 构建 Build 67;
  10. 核对 embedded collector provenance;
  11. 用户真机跑 Gate 1;
  12. 读取 Session 做 E4;
  13. 通过后再执行 Startup+Display 与静态→动态→静态;
  14. 全部通过后才把 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 与 terminal stopped 事件(包含 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),直接输出 terminal stopped 并退出,严禁在 Stop critical path 触发二次 9-stage offline replay。
    • 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。
  • 当前状态与后续门禁:
    • 状态更新为: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_session sleep(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 字段用于 历史连续性。本节不改写上面的历史结论与数字。

Perfowl · Performance Observer