Skip to content

R8-Fix-5.1:端到端关闭绝对截止期修复方案 ​

状态:implemented · gate-open / E2 completed / E4 pending

本方案是 R8-Fix-5 的续修批次。 R8-Fix-5 的 Pump 公平调度部分经独立验收确认有效;但同一轮独立验收同时证伪了 R8-Fix-5 的关闭预算:COLLECTOR_CLOSEOUT_SOFT_BUDGET_SECONDS = 8.0 并未覆盖 Stop → Python 进程退出的端到端路径。本批次只修关闭预算,不触碰任何指标公式。

1. P0:8 秒预算不是端到端预算 ​

R8-Fix-5 引入的 8.0s soft budget 实际只裁剪了若干前置阶段:

text
CoreProfile graceful drain
source stop
consumer drain
ingress writer drain        <- 到此为止

其后的每一个阶段都仍然拥有自己的、互不相关的超时:

text
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 final delivery
WireOutput.close(timeout=5.0)      # 又一个独立 5s
Python 解释器退出

把这些阶段串起来,最坏情况远不止 8 秒,而 Swift 宿主的 CollectorSupervisor.swift 依旧以 Process.isRunning 为准watchdog 10 秒, 因此 Build 66 的失败形态从未真正被修复,只是把失败点往后推。

2. 独立验收给出的决定性复现 ​

text
SessionWriter.append_batch()     阻塞 2 秒
drain_writer(timeout=50ms)       ->  ~50ms   partial-writer-drain-timeout
stop_worker()                    ->  ~2015ms <- 那 50ms 什么也没限制住

drain_writer 诚实地在 50ms 后返回 partial,但紧接其后的 stop_worker() 又自行等待了 2 秒(等待那个仍在同步 append 里的 writer 线程)。 结论:只要任何一个后续阶段还持有私有超时,前面的 deadline 就形同虚设。

3. 根因 ​

3.1 预算没有向下传递 ​

每个阶段各自 asyncio.wait_for(..., timeout=<私有常量>),没有任何单一对象 持有「Stop 时刻 + 总预算」。remaining_budget_seconds() 更进一步:

python
return max(0.05, rem_ns / 1_000_000_000.0 if rem_ns > 0 else 0.0)

一旦预算耗尽,每个后续阶段还能再白拿 50ms —— 名义 8 秒、实际无界。

3.2 bounded wait 无法停止一个已经在跑的 Python 线程 ​

python
await asyncio.wait_for(loop.run_in_executor(executor, fn), timeout=t)

超时只会取消 asyncio 侧的等待,fn 仍在执行线程里。更致命的是 durable writer 当时跑在 ThreadPoolExecutor(max_workers=1) 上,其 worker 是 非守护线程:CPython 解释器退出时会 join 所有非守护线程,所以即使关闭协程 按时返回,进程依然会一直挂到那次同步写结束为止 —— 正好撞上宿主 watchdog。

3.3 正常封存链在异常关闭后被继续执行 ​

writer 已经无法在预算内 drain 且 join 的情况下,原路径仍然继续跑 recorder.finalize() / metric_recorder.finalize() / session_writer.seal()。 被遗弃的 writer 线程可能仍在写同一个 Raw / metric / report 文件,这不仅是慢, 而是竞态。

3.4 终端输出另有一个硬编码 5 秒 ​

WireOutput.close(timeout=5.0) 在关闭链之后又 join 了 5 秒; 8s + 5s = 13s,宿主 10s watchdog 必杀。

4. 目标与非目标 ​

4.1 必须完成 ​

  • Stop → Python 进程退出(worst case) < 9 秒,正常路径 P95 < 3 秒;
  • 所有关闭阶段共享同一个绝对 deadline;
  • 为终端退出(emit stopped / wire flush / 解释器退出)预留固定 reserve;
  • 被遗弃的 writer 不得阻止解释器退出;
  • 关闭超时必须 fail-closed:允许 partial,但 reason 必须精确,且禁止在 异常关闭后继续执行正常封存链;
  • 诊断必须能回答「哪一阶段吃掉了预算」。

4.2 明确禁止(本批次不动) ​

  • FPS / FrameTime / P95 FrameTime / Jitter / Refresh Rate 公式;
  • FrameLifetime 语义;
  • ActivityTrace 与 CoreProfile 的 Source Owner;
  • CapturePlan / MetricRegistry;
  • Swift 端 gracefulTimeout = 10;
  • R8-Fix-5 的 pump = 32 events / 1ms 与 filter = 64 messages / 1ms 公平门禁;
  • 放宽 stale-source;
  • 用 1000/FPS 补 FrameTime;
  • 把 missing / stale 补成 0 / 60 / 16.67;
  • 扩容 queue、删除 undrained / drop 诊断、把 partial 改写成 completed;
  • 恢复历史回放进入 live Stop 路径;
  • reset / stash / clean / checkout 当前共享脏工作区。

5. 设计:单一绝对截止期 ​

新增 MacApp/Sidecar/perfowl_collector_core/closeout.py:

python
COLLECTOR_CLOSEOUT_SOFT_BUDGET_SECONDS = 8.0
TERMINAL_EXIT_RESERVE_SECONDS = 0.75

CloseoutDeadline 在 stop control frame 被观察到的那一刻创建一次:

text
deadline_ns = stop_control_received_ns + budget

并向下传给每一个阶段。关键不变量:

  1. remaining_seconds() 绝不发明时间:过期即返回精确的 0.0, 不允许 max(0.05, …) 之类的兜底;
  2. 重阶段(drain / finalize / seal)永远不得花掉 terminal reserve, 即只能用 heavy_remaining_seconds();
  3. 全部是 O(1) monotonic 运算,无轮询线程、无定时器、无后台任务。

阶段超时统一由截止期裁剪:

python
deadline.stage_timeout(requested, reserve_seconds)   # 永不返回负数、永不放大
deadline.heavy_stage_timeout(requested)             # 自动保留 exit reserve

6. Terminal Exit Reserve ​

预算的最后 0.75s 属于:

text
最终 diagnostics 组装
最小 partial 状态
emit terminal `stopped`
stdout 终端 flush
从 run_stream 返回
asyncio 拆除
解释器退出

这样 8.0 + 0.75 仍远小于 10s watchdog,保证宿主至少留有 1 秒余量。

7. CloseoutLedger:可审计的阶段账本 ​

新增 CloseoutLedger,按顺序记录每个阶段:

text
name / startedNs / endedNs / durationMs / status / timeoutMs / detail

对外提供:

text
summary()          -> closeoutStageOrder + closeoutStages + closeoutStageStatuses
first_timeout()    -> 第一个 timed-out / abandoned 阶段
status_of(name)    -> 某阶段最终状态(取最后一次记录)

阶段状态词表全站统一:

text
completed / skipped / timed-out / failed / forced / abandoned

8. 有界同步阶段:run_bounded_sync_stage ​

python
async def run_bounded_sync_stage(
    *, name, deadline, function, executor, args=(), kwargs=None,
    requested_seconds=None, reserve_seconds=0.0, ledger=None,
    skip_status=None, skip_detail=None,
) -> BoundedStageResult

语义:

  • timeout_seconds = deadline.stage_timeout(requested, reserve);
  • timeout_seconds <= 0 → 直接记 skipped(closeout-deadline-exhausted), 不提交任何任务;
  • 超时 → timed-out,future.cancel()(对已在线程中执行的任务是 no-op, 这是刻意的:我们只需要停止等待);
  • 异常 → failed,错误信息写进 ledger,不向调用方抛。

关键认识:asyncio.wait_for 本身不能停住已经在跑的 Python 线程; 真正保证进程能退出的是被执行任务的守护线程属性。

9. DaemonSingleWriterExecutor ​

新增 single_writer_executor.py:与原先 ThreadPoolExecutor(max_workers=1) 等价的单写者语义,但:

  • 一个 queue.Queue + 一个 daemon=True 线程;
  • 仍然严格只有一个写者,Raw → metric → report 追加保序、不并发;
  • submit() 只入队并返回真正的 concurrent.futures.Future, 任务在被 worker 认领前保持 PENDING,因此 cancel_futures=True 仍可取消「已入队但未开始」的工作;
  • shutdown(wait=True):正常路径,队列已排空、worker 空闲,立即返回;
  • shutdown(wait=False):异常路径,标记 abandoned,丢弃仍排队的工作, 不 join;守护 worker 被遗弃,解释器可自由退出。

abandon_executor(executor) 提供对任意 executor 的 best-effort 遗弃。

10. writer 超时的质量语义 ​

  • writer 正常 drain + worker 正常停止 → writerStopStatus = completed, executor 正常 join;
  • writer 无法在剩余预算内 drain / join → writerStopStatus = abandoned、 writerExecutorAbandoned = true,Session 封为 partial, reason 精确到阶段(writer-abandoned-at-closeout-deadline / partial-writer-drain-timeout);
  • abandoned 只作为异常关闭的兜底,健康 Session 永不依赖它。

11. 禁止全量封存:Minimal Partial Closeout ​

一旦确认 writer 异常,正常封存链被禁止(而非仅被降速):

text
recorder.finalize()            -> skipped
metric_recorder.finalize()     -> skipped
session_writer.seal()          -> skipped
online_report.close()          -> 不执行

改为:

  • 保留全部诊断、accepted / persisted / undrained 计数、最后durable sequence;
  • 生成只读的 minimal partial manifest:不 flush、不 hash、不 close 任何文件, rawSHA256 = null + rawSHA256Status = "unavailable-abnormal-closeout" (mid-flight digest 绝不冒充 Session 哈希);
  • Session 状态 partial + dataCompleteness = "partial";
  • reason 取自 _MINIMAL_CLOSEOUT_REASON_BY_STAGE:
text
writer-drain      -> partial-writer-drain-timeout
writer-stop       -> writer-abandoned-at-closeout-deadline
raw-finalize      -> partial-raw-finalize-timeout
metric-finalize   -> partial-metric-finalize-timeout
report-seal       -> partial-report-seal-timeout

Live Stop 走到这里就没有 report seal,CLI 侧按既有 live-report-seal-missing 语义呈现。

12. finalize / seal 的时限控制 ​

  • raw-finalize / metric-finalize / report-seal 全部通过 run_closeout_sync_stage 跑在独立的、可遗弃的 daemon closeout executor 上;
  • 该 executor 与 durable writer executor 分离:writer 保持单写者不变量, closeout 的 finalize/seal 不会与半写的 Raw 追加交错;
  • 任一阶段超时 → degrade_to_minimal(stage, reason),此后不再放行任何重阶段;
  • session_writer.seal / online_report.seal 与 report close 合并为 同一个 report-seal 阶段(它们写同一批 report 文件)。

13. CLI 预算 ​

perfowl_collector_core_cli.py 增加:

python
TERMINAL_WIRE_FLUSH_MAX_SECONDS = 0.50
TERMINAL_WIRE_CLOSE_MAX_SECONDS = 0.25

_terminal_exit_deadline(stop_event) 用与 core 同一个stop_control_received_monotonic_ns 构造 CloseoutDeadline, 从而终端 flush、wire close 与解释器退出共享同一预算。 若从未收到 stop control(parent EOF / signal / 错误路径),预算从当前时刻起算。

14. WireOutput.close(timeout=5) 的修复 ​

  • emit_line() 返回单调递增 sequence;
  • writer 线程记录 last_flushed_sequence;
  • 新增 flush_until(sequence, timeout) -> bool:真正的 fence, 超时返回 False 并记 timed-out 诊断,绝不静默成功、绝不无界 join;
  • close(*, timeout) 的 timeout 由截止期裁剪(不再是固定 5 秒), 且只 join(timeout);writer 线程本身是 daemon,join 不上就遗弃。

15. Terminal wire flush fence ​

Live Stop 终端路径记录:

text
terminalStoppedSequence
terminalStoppedEmittedMonotonicNs
processExitBeginMonotonicNs
wireFlushStatus / wireFlushDurationMs

并发出有界的 closeout_exit 记录。断言不变量:

text
processExitBeginMonotonicNs >= terminalStoppedEmittedMonotonicNs
wireFlushStatus ∈ {completed, timed-out, skipped}

16. stopped 已发出 ≠ closeout 已完成 ​

「收到 stop」「发出 stopped」「进程退出」是三件不同的事。 本批次显式区分,并把它们分别记录到诊断里,避免再次用「stopped 已发出」 掩盖「进程还没退出」。

17. Event loop heartbeat 的 P50 / P95 / Max ​

EventLoopHeartbeatMonitor.snapshot() 补充:

text
eventLoopSchedulerDelayP50Ms / P95Ms / MaxMs
eventLoopTickGapP50Ms / P95Ms / MaxMs
eventLoopHeartbeatTickCount
eventLoopStallsOver200Ms / Over1000Ms

统计量保存在有界 deque(maxlen=2048)中,长时间 Session 不会增长内存。

18. stopDiagnostics 完整性 ​

最终 stopDiagnostics 至少包含:

text
stopControlReceivedMonotonicNs / stopObservedMonotonicNs / stopEventObservationLatencyMs
closeoutDiagnosticsPhase / closeoutBudgetMs / closeoutElapsedMs / closeoutRemainingBudgetMs
closeoutTerminalExitReserveMs / closeoutStatus / closeoutFailureStage / closeoutFailureReason
closeoutBudgetExhausted / closeoutAbnormal / closeoutMinimalPartial
sourceDrainStatus / sourceDrainDurationMs / sourceStopStatus
consumerDrainStatus / consumerDrainDurationMs / ingressCloseStatus
writerDrainStatus / writerDrainDurationMs / writerStopStatus / writerStopDurationMs
writerWorkerStatus / writerWorkerStopDurationMs / writerExecutorAbandoned
rawFinalizeStatus / rawFinalizeDurationMs / metricFinalizeStatus / metricFinalizeDurationMs
rawManifestWriteStatus / sealStatus / sealDurationMs
wireFlushStatus / wireFlushDurationMs
terminalStoppedEmittedMonotonicNs / processExitBeginMonotonicNs
ingressAcceptedAtClose / ingressPersistedAtClose / ingressUndrainedAtSeal / dataCompleteness
closeoutStageOrder / closeoutStages / closeoutStageStatuses / closeoutStageTimeoutCount

null 一律表示「尚未观测」,不得写成 0。

19. closeoutStatus 最后计算 ​

closeoutStatus 只在所有受管阶段都已 completed / skipped / timed-out 之后 统一算一次(build_stop_diagnostics(phase="final")), 即 Section 21 的 late binding。中途写下「clean」又在后一阶段挂住, 正是让 Session 报 clean 却仍被宿主杀进程的直接原因。

20. 必须新增/修改的测试(A–G) ​

新增 MacApp/Sidecar/tests/test_closeout_deadline.py 与 MacApp/Sidecar/tests/test_closeout_process_exit.py。

编号场景必须断言
Aappend_batch() 阻塞 2s + 50ms drain 预算整个 writer closeout < 300~400ms;writerWorkerStatus ∈ {abandoned, timed-out};accepted ≠ persisted;Session = partial;不得再出现 drain 50ms + stop 2s
B真实 subprocess:writer job sleep(30s)Python 进程退出 < 1s(不受阻塞 job 影响)
C正常 writeraccepted == persisted;closeoutStatus = clean;executor 正常 join
Drecorder.finalize() 慢有界;partial;closeoutFailureStage = raw-finalize
Eseal 慢有界;partial;closeoutFailureStage = report-seal;report 未被 close
Fwire writer 阻塞terminal flush 有界;closeout_exit 仍发出;进程可退出
G宿主契约worst-case closeout + wire close 仍留 ≥1s watchdog 余量

另需覆盖:deadline 算术(没有免费的 50ms)、ledger 汇总、 daemon executor 的遗弃 / 取消语义。

21. 公平性回归与效率门禁 ​

  • 不得使 R8-Fix-5 的 pump = 32 events / 1ms、filter = 64 messages / 1ms 公平门禁失效:test_pmd3_subscription_fairness.py 必须继续全绿;
  • 性能门禁必须给出变更前后原始数字(120Hz synthetic、writer stall、 CPU / 内存),不得只写「性能通过」;
  • 关闭路径本身必须保持 O(1):不得引入轮询线程、定时器或 per-event 开销。

22. E2 门禁(G1–G17) ​

text
G1  stop → 进程退出 < 9s(worst case)/ 正常 P95 < 3s
G2  所有阶段共享同一 CloseoutDeadline(代码审计 + ledger 证据)
G3  remaining_seconds() 过期返回精确 0.0,无兜底 floor
G4  重阶段不得花费 TERMINAL_EXIT_RESERVE_SECONDS
G5  OrderedRawIngress.stop_worker() 无私有超时,且被 deadline 裁剪
G6  worker 无法 join 时被遗弃而非 join(daemon)
G7  writer 异常 → 禁止全量 seal,Session 封 partial
G8  minimal partial manifest 不含可用 rawSHA256
G9  WireOutput.close 不再硬编码 5s
G10 terminal wire flush 有 fence,超时记 timed-out
G11 stopped ≠ closeout 完成,三者分开记录
G12 heartbeat 提供 P50/P95/Max 且样本 bounded
G13 stopDiagnostics 字段完整,null 不写成 0
G14 closeoutStatus 在全部阶段结束后最后计算
G15 测试 A–G 全部存在且通过
G16 公平性测试全绿,公式 / Source Owner / CapturePlan 未被修改
G17 Python 全量、Swift 全量、站点构建、git diff --check 全部通过

23. 不可接受的「假修复」 ​

text
- 只把 stop_worker 的 5s 降到 2s,而 executor.shutdown(wait=True) 依然阻塞;
- 只在 asyncio.wait_for 超时后就宣称「同步线程已取消」;
- writer 超时后仍然继续执行完整 seal;
- 用扩大 queue / 延长 Swift 超时来掩盖关闭问题。

24. 共享工作区保护 ​

当前 worktree 存在并行未提交改动(frame_derivation.py、metric_engine.py、 online_report.py、recording.py、test_perfowl_core_cli.py、 CollectorSupervisor.swift、构建脚本等)。本批次:

  • 先 git status --short;
  • 不 reset / 不 stash / 不 rebase;
  • 不覆盖并行改动;同文件冲突先读现状再合并;
  • 仅 stage 本批次确认归属的文件。

Implementation Update · 2026-09-18 · R8-Fix-5.1 代码实施与 E2 验证 ​

  • 关联方案/问题:R8-Fix-5.1(End-to-End Closeout Absolute Deadline), 直接回应 R8-Fix-5 独立验收给出的「drain 50ms + stop_worker 2s」P0 复现。
  • 实际修改范围与关键文件:
    • 新增 MacApp/Sidecar/perfowl_collector_core/closeout.py: COLLECTOR_CLOSEOUT_SOFT_BUDGET_SECONDS = 8.0、 TERMINAL_EXIT_RESERVE_SECONDS = 0.75、阶段状态词表、 CloseoutDeadline(remaining_seconds() 过期精确返回 0.0,无 max(0.05, …) 兜底)、 CloseoutStage / BoundedStageResult / CloseoutLedger、 run_bounded_sync_stage()。
    • 新增 MacApp/Sidecar/perfowl_collector_core/single_writer_executor.py: DaemonSingleWriterExecutor(单 daemon 线程 + queue.Queue, shutdown(wait=False) 遗弃而非 join)与 abandon_executor()。
    • MacApp/Sidecar/perfowl_collector_core/realtime_ingress.py: drain_writer() / stop_worker() 移除私有固定超时,改为接受 deadline / reserve_seconds,由 CloseoutDeadline 裁剪; 新增 _resolve_stage_timeout()(既无 timeout 又无 deadline 时显式报错); 新增 drain_status / worker_stop_status / undrained_at_seal 属性与 writerDrainTimeoutMs / writerWorkerStatus / writerWorkerStopDurationMs / writerWorkerStopTimeoutMs 诊断。
    • MacApp/Sidecar/perfowl_collector_core/realtime.py: 引入 CloseoutDeadline + CloseoutLedger,init_closeout_budget() 只创建一次; 移除 remaining_budget_seconds() 的 max(0.05, …) floor; 新增 heavy_remaining_seconds() / closeout_stage_budget() / build_stop_diagnostics(phase) / ensure_closeout_executor() / run_closeout_sync_stage() / _seal_and_close_report(); source-drain / source-stop / consumer-drain / ingress-close / writer-drain / writer-stop / raw-finalize / metric-finalize / report-seal 全部走 ledger + deadline; writer-stop 在异常路径遗弃 executor; 新增 Minimal Partial Closeout(degrade_to_minimal()、 minimal_partial_manifest(),禁止进一步重阶段); closeoutStatus 改为在全部阶段结束后最后计算; EventLoopHeartbeatMonitor.snapshot() 增加 scheduler delay 与 tick gap 的 P50/P95/Max、tick count 与 stall 计数(有界 deque,maxlen=2048)。
    • MacApp/Sidecar/perfowl_collector_core/recording.py:仅追加只读属性 appended_event_count / appended_byte_count 供 minimal partial manifest 使用 (未触碰并行改动段)。
    • MacApp/Sidecar/perfowl_collector_core_cli.py: _WireOutput.emit_line() 返回 sequence,新增 last_flushed_sequence / flush_until(seq, timeout);close(*, timeout) 由截止期裁剪(不再固定 5s); 新增 TERMINAL_WIRE_FLUSH_MAX_SECONDS = 0.50 / TERMINAL_WIRE_CLOSE_MAX_SECONDS = 0.25 / _terminal_exit_deadline(); run_stream 终端路径记录 terminalStoppedSequence / terminalStoppedEmittedMonotonicNs / processExitBeginMonotonicNs / wireFlushStatus / wireFlushDurationMs 并发出有界 closeout_exit 记录。
    • MacApp/Sidecar/tests/test_closeout_deadline.py(新增): Test A / C / D / E + deadline 算术、ledger、daemon executor、 run_bounded_sync_stage 单元门禁。
    • MacApp/Sidecar/tests/test_closeout_process_exit.py(新增): Test B(真实 subprocess,阻塞 writer sleep(30s) 不阻止进程退出)、 Test F(阻塞 wire 不阻止进程退出)、Test G(宿主 watchdog 契约)。
    • MacApp/Sidecar/tests/test_realtime_ingress.py、 MacApp/Sidecar/tests/test_display_ingress_backpressure.py、 MacApp/Sidecar/tests/test_perfowl_realtime.py: 适配 stop_worker() 的显式超时/截止期签名,并把 T8 改写为 「整个 writer closeout(drain + stop)必须落在 400ms 内」; 终端 wire 断言纳入新增的 closeout_exit 记录与 closeout-exit 遥测字段。
  • 最终实现与方案偏差:与方案一致,无偏离。未修改任何指标公式、 Source Owner、CapturePlan、MetricRegistry 与 Swift gracefulTimeout; R8-Fix-5 的 pump = 32 / 1ms、filter = 64 / 1ms 公平门禁保持原样。
  • 验证命令与结果(E2):
    • Python 全量(643 项): PYTHONPATH=. /private/tmp/perfowl-core-py312/bin/python3 -m unittest discover -s tests -p 'test_*.py' → 见下节 Validation Update 实测结论。
    • 关闭专项:tests.test_closeout_deadline(25 项)+ tests.test_closeout_process_exit(4 项) 全绿;其中 Test A 在 0.30s 硬预算下实测整个 writer closeout 远小于 400ms, Test B 在 writer 阻塞 30s 的 subprocess 中实测进程存活时间远小于 8s。
    • 静态:git diff --check 干净。
  • 当前状态与后续门禁:
    • 状态:implemented · gate-open;证据等级:E2 completed / E4 pending。
    • 后续工作:随 Build 67 一起做真机 E4,确认用户 Stop 后 final stopped 在 8s 目标内产生、closeoutStatus=clean、 writerDrainStatus=completed、ingressUndrainedAtSeal=0, 且不再出现 graceful_stop_timeout 与宿主 SIGTERM。

Validation Update · 2026-09-18 · Independent audit found CLI post-core deadline gap ​

本节是独立验收结论的如实记录,不回改上面已经发布的 Implementation Update 历史。

  • 独立 clean worktree 复跑 Python:613 / 613 PASS。 上节记录的 643 是共享脏工作区数字(其中包含并行批次尚未提交的测试文件), 不作为本批次验收依据。
  • Swift(独立副本):182 tests / 3 skipped / 0 failures。
  • 站点:Metric Knowledge Gate PASS、VitePress PASS。
  • 独立 P0 复现(真实 perfowl_collector_core_cli.run_stream() + finalize_live_candidate_session() 换成 sleep(30)):父进程等待 3 秒后 Python 子进程仍然 alive;wire 只输出到 stop_requested + capture_stopped, stopped 从未发出。
  • 因此 R8-Fix-5.1 的状态更正为 partial / blocked: Core closeout 已闭环,但 Stop → Python Process EXIT 并未真正落在同一个绝对期限里 —— stream_candidate_session() 返回之后的 CLI Post-Core Final Delivery 完全在 deadline 之外。本批次不得被描述为已通过验收。
  • 修复由 R8-Fix-5.2 承接: R8-Fix-5.2 · CLI Post-Core Final Delivery Absolute Deadline。
  • 同时更正上节的一处事实错误:终端 wire flush fence 实际未生效。 _WireOutput.emit_line() 确实返回 sequence,但 emit() 函数体没有 return sequence,于是 terminal_stopped_sequence 恒为 None、 flush_until(None, …) 的 TypeError 被 suppress(Exception) 吞掉, wireFlushStatus 恒为 skipped —— 即 stopped 在进程退出前没有任何 有界保证到达宿主。该缺陷已由 R8-Fix-5.2 修复,并被 Test I / Test M 固定为门禁。

Validation Update · 2026-09-18 · R8-Fix-5.2 修复后复验(本轮) ​

详细方案、实现清单与字段语义见 R8-Fix-5.2 · CLI Post-Core Final Delivery Absolute Deadline。

  • 修复要点:CLI 侧把 Post-Core Final Delivery 收敛为单一受管阶段 live-delivery,跑在 DaemonSingleWriterExecutor(thread_name_prefix="perfowl-cli-closeout") 上,复用同一个 _terminal_exit_deadline(stop_event)(锚定原始 stop_control_received_monotonic_ns);stopped 在任何失败形态下都必须发出, 并携带 closeoutStatus=partial / closeoutFailureStage=live-delivery / closeoutFailureReason;manifest["stopDiagnostics"] 作为 core 历史事实不回改。
  • 独立 P0 复现的正向结果(真实 subprocess + 真实 run_stream): finalize_live_candidate_session 阻塞 30s、关闭预算压缩到 0.30s 时, Stop → Process Exit = 0.3012s,wire 上仍然看到 stop_requested / capture_stopped / stopped / closeout_exit, 且 cliCloseoutWorkerThreadAlive=true、cliCloseoutExecutorAbandoned=true —— 即「慢线程仍 alive、进程已退出」被同时证明。 最坏组合(slow delivery + stdout 阻塞):Stop → Process Exit = 0.3633s, perfowl-core-wire-writer 与 perfowl-cli-closeout 两条阻塞线程均作为 daemon 被遗弃。 生产 8.0s 预算下的最坏值仍为 8.0s + 终端 teardown,Swift 10s watchdog 保持 ≥1s 余量。
  • 测试:test_closeout_process_exit.py 扩到 15 项(Test B/F/G/H/I/J/K/L/M/N + 静态门禁),全部通过;R8-Fix-5.2 专项(test_closeout_deadline + test_closeout_process_exit + test_perfowl_realtime + test_live_seal + test_perfowl_core_cli + test_display_ingress_backpressure)105 / 105 OK。
  • Python 全量:654 / 654 OK(共享工作区,含并行批次测试文件; 独立 clean worktree 数字以验收方复跑为准)。
  • Swift:183 tests / 3 skipped / 0 failures; CollectorSupervisor.swift 仍为 runtimeKind == .core ? 10 : 2。
  • 站点:知识门禁 PASS、VitePress 构建 PASS(0 错误、0 死链)。
  • 静态不变性:git diff --check 干净;本批次未修改 perfowl_collector_core/ 下任何文件,未修改任何指标公式、Source Owner、CapturePlan、MetricRegistry、 Swift 10s、pump 32/1ms、filter 64/1ms。
  • 当前状态:implemented · gate-open;E2 completed / E4 pending。 后续:随 Build 67 做真机 E4,验证 terminalCloseout.liveDeliveryStatus == "completed"、 liveDeliveryDurationMs 远低于 500ms、cliCloseoutExecutorAbandoned == false、 overallCloseoutStatus == "clean"、无 graceful_stop_timeout 与宿主 SIGTERM。

Validation Update · 2026-09-19 · Build 67 真机 E4 暴露的 closeout 分类缺陷 ​

本批次建立的单一绝对 CloseoutDeadline 与 CloseoutLedger 在 Build 67 真机 E4 上继续 成立(Stop → Process Exit 未再撞 Swift 10s watchdog)。但同一轮真机暴露了一个本批次 未覆盖的分类缺陷:

text
dataCompleteness       = complete
ingressUndrained       = 0
consumerDrainStatus    = timed-out   (≈500.824ms)
coreCloseoutStatus     = timed-out
overallCloseoutStatus  = partial
closeoutFailureStage   = null
closeoutFailureReason  = null

数据 100% 完整却被判 partial。根因是 consumer drain 的 500ms 窗口把辅助 sleeper (stream-target-monitor 睡 0.75s、stream-sample-publisher 睡 ~1.0s)与真正的 Source Consumer 混在同一份 tasks 里等待——睡 750ms/1000ms 的任务在任何短于自身睡眠 的窗口里都不可能醒来。注意:这不是本批次 deadline 逻辑的缺陷,CloseoutDeadline 的 裁剪语义与 remaining_seconds() 过期精确返回 0.0 的规则均未被改动,也没有放宽任何 窗口。

由 R8-Fix-6 按 task 语义拆分 source_consumer_tasks / auxiliary_tasks 修复,并把 auxiliary-stop 登记为 advisory 阶段(超时只记录、不降级判决)。本节不改写上面的历史结论与数字。

Perfowl · Performance Observer