fix(island): 幽灵胶囊自愈 + 叫人双广播不降级 + 淡出/帧限流兜底(继续修"点不动")

用户反馈"叫人点不动"复盘:状态机层面没问题(真机点击日志能看到"收到用户动作 expand"),
问题在窗口/渲染层面。本轮修掉三个真实缺陷:

1) 幽灵胶囊:岛渲染进程一旦崩溃/卡死,Windows 会把最后一帧留在屏幕上 —— 岛还挂在最上方、
   窗口也还在,但点击全部丢失,而客户端没有任何自愈机制(实测:崩掉渲染进程后点击不再产生
   任何动作)。现在渲染进程每 5s 上报心跳,主进程 20s 收不到、或收到 render-process-gone /
   unresponsive 时,**先隐藏死窗口**再重建并重新下发状态与外观;10 分钟内最多重建 3 次,
   超出则隐藏并告警,避免持续崩坏时无限重建。
2) 叫人双广播降级:服务端 createCall 同时发 notification:new(班级房间)与 call:new(user 房间),
   两条 DTO 同 id,后到的那条会把"叫人"覆盖成普通通知 —— 胶囊显示成
   "新消息 / 系统管理员 · 点击查看"(用户截图正是如此),紧急叫人还会被降级成"课中只排队、
   不立即展开"。现在按 id 合并:类型 call 优先、优先级取更高(URGENT > HIGH > NORMAL)。
3) 淡出竞态:岛重新变为可见时没有取消进行中的淡出,那次淡出会继续把透明度降到 0 并 hide()
   窗口,出现"状态是胶囊/展开、窗口却不可见"的错位(冒烟"岛外鼠标穿透"用例就是被它打红的)。
   现在可见路径总是 cancelFade + 恢复透明度。

另加两条兜底:
- 命中矩形兜底:渲染进程还没上报岛体矩形时,主进程按当前形态/停靠位置自己算一个,
  杜绝"岛可见却一直不接收鼠标"。
- 形变兜底:实测打包版某些时刻(细缝态、刚启动)rAF 会被 Chromium 压到约 1 帧/秒,
  此时"点击后形变"看起来就是没反应;现在形变开始 1.2s 后仍未收敛就直接 snap 到目标几何。

验证:pnpm verify:desktop 81/81(新增 3 条回归:双广播不降级 / 紧急叫人双广播 /
渲染进程崩溃后自愈;离线二次启动 73/73);打包版 verify:packaged 81/81、packaged=true、
二次启动 73/73,全绿。
顺带:细缝用例改为轮询等几何收敛(帧被限流时固定 sleep 会误判为"细缝尺寸不对"),
课表时间轴失败时打印各卡片状态(便于定位),并清理了库里 3 条遗留测试探针课表
(它们会污染客户端"今天"的课表)。
This commit is contained in:
LoveChengke committed 2026-09-26 20:47:01 +08:00
1 parent a7b0e9c2b3
commit 337d6eae53
6 files changed
+330 -11

No files matched your search

@@ -213,6 +213,10 @@ const progress = ref(0);
let rafHandle: number | null = null;
let lastFrameAt = 0;
/** 形变兜底定时器:rAF 被系统限流时用它 snap 到目标几何(见 startFrameLoop) */
let morphFallbackTimer: number | null = null;
/** 兜底等待时长:正常形变约 300~500ms,超出这个时间说明帧被限流了 */
const MORPH_FALLBACK_MS = 1200;
function syncTargets(snap = false): void {
const size = targetSize();
@@ -237,6 +241,24 @@ function syncTargets(snap = false): void {
function startFrameLoop(): void {
if (rafHandle !== null) return;
lastFrameAt = performance.now();
// 形变兜底:某些情况下(Windows 把置顶透明小窗判定为"被遮挡/后台"、GPU 进程刚重启等)
// requestAnimationFrame 会被 Chromium 压到 1 帧/秒 —— 实测打包版细缝态出现过 1.1fps。
// 那种情况下"点击后形变"看起来就是**没反应**(用户反馈的"点不动")。因此这里挂一个定时器,
// 到点若还没收敛就直接 snap 到目标几何:宁可少一段动画,也不能让点击看上去无效。
if (morphFallbackTimer !== null) window.clearTimeout(morphFallbackTimer);
morphFallbackTimer = window.setTimeout(() => {
morphFallbackTimer = null;
if (rafHandle === null) return; // 已经收敛
if (rafHandle !== null) window.cancelAnimationFrame(rafHandle);
rafHandle = null;
const size = targetSize();
springs.width.snap(size.width);
springs.height.snap(size.height);
springs.radius.snap(targetRadius(size));
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
progress.value = mode.value === 'expanded' ? 1 : 0;
refreshInteractive();
}, MORPH_FALLBACK_MS);
const frame = (now: number): void => {
const delta = now - lastFrameAt;
lastFrameAt = now;
@@ -270,6 +292,10 @@ function startFrameLoop(): void {
springs.radius.snap(targetRadius(size));
morph.value = { width: size.width, height: size.height, radius: springs.radius.value };
progress.value = mode.value === 'expanded' ? 1 : 0;
if (morphFallbackTimer !== null) {
window.clearTimeout(morphFallbackTimer);
morphFallbackTimer = null;
}
refreshInteractive();
rafHandle = null;
};
@@ -457,6 +483,10 @@ function applyAppearance(next: IslandAppearance | null | undefined): void {
onMounted(async () => {
window.addEventListener('mousemove', (event) => updateInteractive(event.clientX, event.clientY));
// 心跳:主进程据此判断本渲染进程是否还活着。一旦卡死/崩溃,主进程会隐藏这扇"幽灵窗口"
// 并重建(否则 Windows 会把最后一帧留在屏幕上:岛看着还在,怎么点都没反应)。
bridge?.alive?.();
window.setInterval(() => bridge?.alive?.(), 5000);
bridge?.onState((next) => {
state.value = next;
syncTargets();