d06e116c15
- What:
1. `ThreeDCoordinator` 新增 **epoch**(显示声明代次)与 `_Inflight` 记录:`work()` 不再直接写
`_composed` mailbox,只 `return` 合成好的帧;结果在 GUI 线程的 `_collect_inflight()` 里被接受,
且必须同时满足 `item.epoch == self._epoch` 与 `_identity_is_current(identity)` 才会入队。
`activate()`/`begin_session()`/`set_visible(False)`/`show_rest()`/`close()` 统一走新的
`_invalidate()`:epoch+1、清空两块 mailbox、清 `_last_request_id`/`_last_composed_identity`/
`_last_field_frame`/`_settings_dirty`,并**尝试**取消 future。
2. **未成功取消的 future 仍占用唯一合成槽**:`_inflight` 只在 `future.done()` 时由 `_collect_inflight()`
释放,所以反复隐藏/显示不会在线程池里排起第二个合成;`CancelledError` 单独吞掉(取消不是失败,
不产生诊断),其它异常仍记成 `field_unavailable` 诊断(文案由 "compose raised" 回到 "compose failed")。
3. **request_id 单调**:`submit()` 拒绝小于本 epoch 已见值的 request_id(>0 时)并计 `stale` + 给
`stale_frame` 诊断;`_identity_is_current()` 同样比较,所以"晚到的旧结果"也进不了屏幕。
4. 新增 `ThreeDCoordinator.epoch` 与 `last_presented()`(帧号 + 单调时刻,供后续面板帧计数用);
构造参数新增关键字 `clock`(默认 `time.monotonic`)与 `stale_grace_s`(默认 1.0,本提交尚未使用)。
- Why: 审查报告 P1/R1 的第一条。`Future.cancel()` 无法中断已经开始运行的函数,而旧代码在
`_cancel_inflight()` 里就把 `_inflight` 置空并只比较 generation —— 于是"停止显示"之后旧 work 结束仍然
`offer` 进 `_composed`,下一次 tick 取到它并把状态改回 live(审查用可控后台任务复现:
`valid_after_show_rest=false` → 放行旧 compose → `valid_after_old_worker_finishes=true`、`state=live`)。
同一个洞也适用于"停止重算后定时器仍继续 tick"的场景。把接受点移到 GUI 线程后,"发布前校验"和
"接收时校验"是同一处代码,epoch 让"这一刻的显示声明"成为可比较的事实。
- Verification:
· `test_scene3d_coordinator.py` +7(24 通过):合成已进入后被 `show_rest`/`set_visible(False)`/
`begin_session` 撤销 → 旧结果永不 present 且 state 不为 live、epoch 递增、槽位在 work 真正结束后释放;
`ManualExecutor` 复现"提交后立即取消" → `CancelledError` 不产生诊断且槽位可复用;
20 轮 begin_session→submit→tick→show_rest 循环里最后一个画面上永远没有有效测量、`max_in_flight==1`;
request_id 逆序在入口被拒(`stale` + `stale_frame`)且画面停在较新的请求上,
"较旧请求的结果晚到"也在接收侧被丢弃。
· 审查复现脚本对照(`.repro/probe_stage6_r1.py` → `probe_stage6_r1.json`):
`stopped_frame_returns` 由 `{valid_after_old_worker_finishes: true, state: "live"}` 变为
`{false, "no_data", epoch_bumped: true, slot_free_again: true}`;
`request_id_not_checked` 由 `{old_submit: "accepted", shown_request: 1}` 变为
`{old_submit: "stale", shown_request: 2, diagnostic: "stale_frame"}`。
· apps/studio 全量 pytest 退出码 0(含真 GL 冒烟用例)。
- Not in this change: `stale_grace_s`/`clock` 只落地参数与 `last_presented()` 的记录,**失效状态的迁移
(`stale` 状态、宽限到期回参考模型)留给下一提交**;adapter 侧的字段级有效性、设备几何身份、
资产加载请求号、二维后端统一均未动;审查报告里那句"用 `coordinator._inflight` 当 Future 用"的复现
脚本无法再原样运行(槽位现在是 `_Inflight` 记录),改用上面那份等价探针。