fix(display): 大屏数字不再依赖帧回调(被遮挡时卡住)+ Console 关遮挡节流 + resync 冷却期排队;版本 dev_0.5.13
现场:清空本组后,大屏上的「单位总分」要等几十秒才降下来。 根因不在「清空」这条路:服务端在同一个事务里就把流水作废重写、算出整张榜单并推送, 与「撤销发布」逐条同构(results_clear.go / scoring.go:1057)。真正卡住的是那一格数字的 画法 —— 它用 requestAnimationFrame 逐帧滚动,而 Chromium 对被遮挡 / 后台的窗口会停掉 帧生产:数字停在旧值,卡多久 = 被遮挡多久,与后台算分无关。全场只有「单位总分」是滚动 显示的,奖牌榜 / 分项成绩 / 实时赛况都是纯文本,所以只有它看起来会延迟。 - 新增 web/src/display/animatedNumber.ts:缓出插值、兜底时长、是否直接对齐抽成纯函数; 进度越界的晚到单帧直接落到目标值(对应「窗口恢复可见瞬间跳到新值」) - AnimatedNumber.vue:加 duration+400ms 的兜底计时器(帧一个都不来也最迟约 1.3s 对齐到 新值);visibilitychange 变可见 / window focus 时立刻对齐,不再补滚一遍过时动画; 卸载时摘掉监听(大屏是长跑窗口) - console/main/index.js:主进程在 app ready 之前追加 disable-backgrounding-occluded-windows / disable-renderer-backgrounding; 两个 Console 窗口补 backgroundThrottling: false,与大屏窗口同口径 - web/src/shared/realtime.ts:requestResync() 冷却期内排队而不是丢弃 —— 原先丢掉之后 本地版本已经推进,心跳的 data.version > currentVersion 再也发现不了缺口, 那一版该做的重拉会永久缺失 测试 +11 例(display 9 / shared 2),含「命令行开关必须位于 app.whenReady() 之前」的位置断言。 四套自检 ×2 全绿(finalcheck_0513.log:前端 985 例 / Console 96 例 / 服务端 14 包,EXIT=0)。 无数据库迁移;已有成绩、名次、积分、榜单与导出件一个数字都不变。 文档:docs/升级说明.md 新增 dev_0.5.13 一节;docs/常见问题.md 新增 Q103。
This commit is contained in:
1 parent
b29b2b390a
commit
19e5630b39
13 files changed
+401
-26
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 309 KiB After Width: | Height: | Size: 309 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1.2 MiB |
@@ -1 +1 @@
|
||||
0.5.12
|
||||
0.5.13
|
||||
+16
-2
@@ -23,6 +23,14 @@ const { createTrayIcon } = require('./tray-icon')
|
||||
const { isAutostartEnabled, setAutostart } = require('./autostart')
|
||||
const { validateAccountField, buildPasswdArgs, runPasswd } = require('./account')
|
||||
|
||||
// 大屏常年挂在扩展屏上,窗口一旦被别的窗口盖住、或那块屏的输入源被切走,Chromium 会按
|
||||
// 「被遮挡 / 后台」处理并停掉该窗口的帧生产:requestAnimationFrame 回调不再到来,大屏上的
|
||||
// 数字动画就卡在旧值上(数据其实早就更新了,只是画不出来,一卡就是几十秒)。
|
||||
// 这两个开关关掉渲染进程的后台/遮挡节流,让帧回调继续产生,动画才能按时走完。
|
||||
// 必须在 app.whenReady() 之前追加,ready 之后再 appendSwitch 就不生效了。
|
||||
app.commandLine.appendSwitch('disable-backgrounding-occluded-windows')
|
||||
app.commandLine.appendSwitch('disable-renderer-backgrounding')
|
||||
|
||||
// 现场只允许一个实例:两个 Console 会互相抢同一个服务端端口和同一块大屏
|
||||
if (!app.requestSingleInstanceLock()) {
|
||||
app.quit()
|
||||
@@ -144,7 +152,10 @@ function main() {
|
||||
preload: path.join(__dirname, '..', 'renderer', 'preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
sandbox: true,
|
||||
// 与大屏窗口(displays.js)同口径:窗口被盖住或最小化时不要节流渲染进程,
|
||||
// 否则回到前台要等一拍界面才更新(状态、日志、大屏实例看板都画在这一层)
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -251,7 +262,10 @@ function main() {
|
||||
preload: path.join(__dirname, '..', 'renderer', 'shell-preload.js'),
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true
|
||||
sandbox: true,
|
||||
// 与大屏窗口(displays.js)同口径:窗口被盖住或最小化时不要节流渲染进程,
|
||||
// 否则回到前台要等一拍界面才更新(服务端管理面板的轮询与刷新都画在这一层)
|
||||
backgroundThrottling: false
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "scoreboard-console",
|
||||
"productName": "Scoreboard Console",
|
||||
"version": "0.5.12",
|
||||
"version": "0.5.13",
|
||||
"private": true,
|
||||
"author": "AskaEth",
|
||||
"description": "赛事积分榜 · 桌面宿主:守护 Go 服务端并把大屏投到扩展屏",
|
||||
|
||||
@@ -6,6 +6,43 @@
|
||||
|
||||
---
|
||||
|
||||
## dev_0.5.13
|
||||
|
||||
### 一句话
|
||||
|
||||
**纯界面 / 宿主程序的小修,没有任何数据迁移:升级后不用重配、不用重新导入规则包**,已有的成绩、名次、积分、榜单与导出件**一个数字都不变**。这一版修一处**现场看得见的延迟**:**大屏上的「单位总分」要等几十秒才更新**(清空本组、发布、调整之后都可能遇到)。
|
||||
|
||||
### ① 修:「大屏上的单位总分等几十秒才降」
|
||||
|
||||
**先说清楚它不是什么**:不是「清空没生效」、也不是「分数算错了」——**分数在服务端是当场就变了的**,数据库、榜单接口、导出件从你点下确认的那一刻就已经是新值。**卡住的只是大屏上那一格数字的画法。**
|
||||
|
||||
**真正的原因**:大屏上「单位总分」那一格是**逐帧滚动**过去的(一帧一帧把数字从旧值滚到新值),而浏览器对**被别的窗口盖住的窗口**会**停掉逐帧绘制**——数字就停在旧值上,**卡多久取决于被盖住多久**,跟后台算分一点关系都没有。所以你会看到「等几十秒才降」,而且**把盖住大屏的窗口挪开以后,数字是「啪」地一下跳到新值**,不是慢慢滚过去。
|
||||
|
||||
> 为什么只有这一格?因为**全场只有「单位总分」这个数字是滚动显示的**,奖牌榜、分项成绩、实时赛况里的数字都是直接写上去的——这也是为什么现场感觉「别的都立刻变、只有总分不动」。
|
||||
|
||||
**现在会怎样**:
|
||||
|
||||
1. 就算一帧都画不出来,**最迟约 1.3 秒**数字也会直接落到新值(不再无限期停在旧值);
|
||||
2. 大屏窗口**重新露出来 / 重新被点活**时,数字**立刻**是正确值,**不会再补滚一遍过时动画**;
|
||||
3. 宿主程序(Console)同时关掉了浏览器对「被遮挡 / 后台」窗口的降频,大屏更不容易掉帧。
|
||||
|
||||
### ② 顺带修:偶发「某一版变更漏掉、之后一直没补上」
|
||||
|
||||
大屏与管理面板在**短时间内连续发生多次变化**时,会向服务端要一次「补数据」。原先这个请求**在 3 秒冷却期内会被直接丢掉**,而界面此时已经认为「这一版处理过了」——于是该补的那一次**不会再补**,要等下一条无关的变化才顺带修正(现场表现可能是某块榜单的内容停在几秒前的样子)。现在改成**排队**:冷却一过立刻补发,不会再漏。
|
||||
|
||||
### ③ 升级后 5 分钟核对
|
||||
|
||||
1. 打开大屏的**积分榜**页,随便做一次会让分数变化的操作(例如「清空本组」,或发布一批成绩),**清空后不要动鼠标**:那一格数字应当在 **1~2 秒内**落到新值;
|
||||
2. 故意拿一个窗口**把大屏窗口完全盖住 10 秒**,再挪开:数字应当**立刻就是正确值**(不是从旧值滚一遍过来);
|
||||
3. 奖牌榜、分项成绩、实时赛况本来就是直接显示,行为与升级前一致;
|
||||
4. 其余照旧:**不需要重新导入规则包、不需要重配任何东西**;已有成绩、榜单与导出件**一个字不变**。
|
||||
|
||||
### ④ 详细说明在哪
|
||||
|
||||
《常见问题》Q103(大屏上的分数为什么过一会儿才变 / 后来一直不变了)。
|
||||
|
||||
---
|
||||
|
||||
## dev_0.5.12
|
||||
|
||||
### 一句话
|
||||
|
||||
@@ -715,4 +715,20 @@ Console 启动的多屏窗口默认每屏锁定一页。到「大屏控制」点
|
||||
|
||||
**怎么办**:从 `dev_0.5.12` 起,录入页会**记住你上次录的那个小项**,离开再回来还是它,按钮就在。想确认自己看的是哪个小项,看**右侧表格上方选中的那个小项名**、以及表里的人是不是你刚才那一组;也可以从仪表盘「当前赛程」点那一项的 **「录入」** 直接进到指定小项。记忆失效时(那个小项被删了、或浏览器不允许存本机数据,例如隐私模式)会**自动退回原来的默认挑法**,不影响使用。详见使用手册 4.1 / 4.7。
|
||||
|
||||
**Q103:大屏上的分数为什么过一会儿才变?有时候干脆一直不变?**
|
||||
|
||||
**先说结论:分早就变了,卡住的只是屏幕上那一格数字的画法。**
|
||||
|
||||
服务端在你点下确认的那一刻就把分算完并写库了——**积分榜接口、导出件、别的电脑上打开同一个榜单,看到的都是新值**。大屏上「**单位总分**」那一格是**逐帧滚动**过去的,而浏览器对**被别的窗口完全盖住的窗口**会**停掉逐帧绘制**,于是数字停在旧值上,**卡多久 = 被盖住多久**。
|
||||
|
||||
**怎么一眼认出来**:把盖住大屏的那个窗口挪开(或者按一下 Alt+Tab)——数字会**「啪」地一下跳到新值**,而不是慢慢滚过去。
|
||||
|
||||
**为什么只有这一格**:全场只有「单位总分」是**滚动显示**的,**单位奖牌榜、分项成绩、实时赛况里的数字都是直接写上去的**,所以它们一直是立刻变的。
|
||||
|
||||
**怎么办**:
|
||||
|
||||
- 从 `dev_0.5.13` 起已经修好:就算一帧都画不出来,**最迟约 1.3 秒**也会直接落到新值;大屏窗口**重新露出来 / 被点活**时立刻是正确值,不会再补滚一遍过时动画;
|
||||
- 日常使用上:**别用别的窗口把大屏盖住**(尤其别把管理面板拖到接大屏的那块显示器上);大屏那块屏播放中**不要切走输入源或关屏**,那也会让它判定为「看不见」而停止绘制;
|
||||
- 升级前的旧版本遇到这个现象时,**挪一下盖在它上面的窗口**即可立刻恢复显示,**不需要重新发布成绩、也不需要重算**。详见《升级说明》的 `dev_0.5.13` 一节。
|
||||
|
||||
> 顺带分清另一件事:「**撤销发布**」**保留**成绩内容(只把分收回来,改完重新发布即可);要**连成绩内容一起清掉**,那是工具栏上另一对按钮「**清空本组 / 清空本批**」(危险操作,要勾选确认,清整组时还会一并清掉按单位录的团体名次)。两者的对照见使用手册 4.7。
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* AnimatedNumber 的纯逻辑:缓出插值、兜底时长、以及「直接对齐不播动画」的判定。
|
||||
*
|
||||
* 抽出来的理由是「窗口被遮挡 → Chromium 停掉帧生产 → 数字卡在旧值」这条路径只能在真机上复现,
|
||||
* 组件里那点算术没法单测;这里全部是无副作用的纯函数。
|
||||
*/
|
||||
|
||||
/** 给最后一帧留的裕量:过了 duration 再加这点时间还没等到收尾帧,就认定帧回调不来了。 */
|
||||
export const FALLBACK_MARGIN_MS = 400
|
||||
|
||||
/** 把任意进度钳到 [0, 1];非有限值(时间戳异常)当 0,免得把显示值算成 NaN。 */
|
||||
export function clamp01(value: number): number {
|
||||
if (!Number.isFinite(value)) return 0
|
||||
return Math.min(1, Math.max(0, value))
|
||||
}
|
||||
|
||||
/** 缓出三次方:起步快、收尾慢。即抽取前组件里的 `1 - (1 - progress) ** 3`。 */
|
||||
export function easeOutCubic(progress: number): number {
|
||||
return 1 - (1 - clamp01(progress)) ** 3
|
||||
}
|
||||
|
||||
/**
|
||||
* 兜底时长 = duration + 裕量。
|
||||
*
|
||||
* 帧回调被 Chromium 停掉(窗口被判定为遮挡/隐藏)时,就按这个时间直接把显示值对齐到目标。
|
||||
* 计时器不受「停帧」影响(大屏窗口已设 backgroundThrottling:false,遮挡节流也在命令行里关了),
|
||||
* 所以最坏也就是这个时长内对齐,而不是「遮挡持续多久就卡多久」。
|
||||
*/
|
||||
export function fallbackDelayMs(durationMs: number): number {
|
||||
return Math.max(0, durationMs) + FALLBACK_MARGIN_MS
|
||||
}
|
||||
|
||||
/**
|
||||
* 这一帧该显示什么值。
|
||||
*
|
||||
* progress 被钳到 1 时**直接落到目标值**:窗口被遮挡几十秒后晚到的那一帧、以及恢复可见时
|
||||
* 补上的那一帧都走这条路径——进度早已越界,继续按缓动比例插值只会把一段过时的动画重放一遍。
|
||||
*/
|
||||
export function frameValue(
|
||||
from: number,
|
||||
target: number,
|
||||
elapsedMs: number,
|
||||
durationMs: number,
|
||||
): number {
|
||||
if (durationMs <= 0) return target
|
||||
const progress = clamp01(elapsedMs / durationMs)
|
||||
if (progress >= 1) return target
|
||||
return from + (target - from) * easeOutCubic(progress)
|
||||
}
|
||||
|
||||
/** 是否跳过动画直接对齐:减少动效、时长为非正、起止同值,这三种都不必滚。 */
|
||||
export function shouldSnap(
|
||||
from: number,
|
||||
target: number,
|
||||
durationMs: number,
|
||||
reduced: boolean,
|
||||
): boolean {
|
||||
return reduced || durationMs <= 0 || from === target
|
||||
}
|
||||
@@ -2,12 +2,18 @@
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { formatPoints } from '@/shared'
|
||||
import { prefersReducedMotion } from '../useReducedMotion'
|
||||
import { fallbackDelayMs, frameValue, shouldSnap } from '../animatedNumber'
|
||||
|
||||
const props = withDefaults(defineProps<{ value: number; duration?: number }>(), { duration: 900 })
|
||||
|
||||
const reduced = prefersReducedMotion()
|
||||
const shown = ref(props.value)
|
||||
let frame = 0
|
||||
let fallbackTimer: ReturnType<typeof setTimeout> | null = null
|
||||
// 本轮动画的两个端点:兜底对齐与「窗口又可见」都要拿它们把显示值一步拉到位
|
||||
let from = props.value
|
||||
let target = props.value
|
||||
let started = 0
|
||||
|
||||
// 整数分数滚动时不要出现小数抖动;权重分(非整数)才保留两位。
|
||||
const text = computed(() =>
|
||||
@@ -19,33 +25,64 @@ function stop(): void {
|
||||
cancelAnimationFrame(frame)
|
||||
frame = 0
|
||||
}
|
||||
if (fallbackTimer !== null) {
|
||||
clearTimeout(fallbackTimer)
|
||||
fallbackTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
function run(target: number): void {
|
||||
// 直接对齐到目标值:停掉帧与兜底计时器,显示值一步到位。
|
||||
function align(): void {
|
||||
stop()
|
||||
const from = shown.value
|
||||
if (reduced.value || props.duration <= 0 || from === target) {
|
||||
shown.value = target
|
||||
return
|
||||
}
|
||||
shown.value = target
|
||||
}
|
||||
|
||||
const started = performance.now()
|
||||
const step = (now: number): void => {
|
||||
const progress = Math.min(1, (now - started) / props.duration)
|
||||
const eased = 1 - (1 - progress) ** 3
|
||||
shown.value = from + (target - from) * eased
|
||||
if (progress < 1) {
|
||||
frame = requestAnimationFrame(step)
|
||||
} else {
|
||||
shown.value = target
|
||||
frame = 0
|
||||
}
|
||||
function step(now: number): void {
|
||||
const value = frameValue(from, target, now - started, props.duration)
|
||||
shown.value = value
|
||||
if (value === target) {
|
||||
align()
|
||||
return
|
||||
}
|
||||
frame = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
function run(next: number): void {
|
||||
stop()
|
||||
const current = shown.value
|
||||
target = next
|
||||
if (shouldSnap(current, next, props.duration, reduced.value)) {
|
||||
shown.value = next
|
||||
return
|
||||
}
|
||||
|
||||
from = current
|
||||
started = performance.now()
|
||||
frame = requestAnimationFrame(step)
|
||||
// 帧回调不来的兜底:被遮挡的窗口不产帧,只靠 rAF 收尾的话显示值会一直停在旧值上
|
||||
// (数据其实早就换了)。计时器不受停帧影响,到点直接对齐到目标。
|
||||
fallbackTimer = setTimeout(align, fallbackDelayMs(props.duration))
|
||||
}
|
||||
|
||||
// 窗口重新可见 / 重新获得焦点时立刻对齐:遮挡期间帧回调停发,屏一亮不该再补滚一遍过时动画。
|
||||
function onVisibilityChange(): void {
|
||||
if (document.visibilityState === 'visible') align()
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.addEventListener('focus', align)
|
||||
document.addEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
|
||||
watch(() => props.value, run)
|
||||
onUnmounted(stop)
|
||||
onUnmounted(() => {
|
||||
stop()
|
||||
// 大屏是长跑窗口,监听器必须随组件摘干净,否则反复重载会把闭包越攒越多
|
||||
if (typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||
window.removeEventListener('focus', align)
|
||||
document.removeEventListener('visibilitychange', onVisibilityChange)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
import { test } from 'node:test'
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import {
|
||||
FALLBACK_MARGIN_MS,
|
||||
clamp01,
|
||||
easeOutCubic,
|
||||
fallbackDelayMs,
|
||||
frameValue,
|
||||
shouldSnap,
|
||||
} from '../animatedNumber'
|
||||
|
||||
/**
|
||||
* AnimatedNumber 的两条口径:帧正常时照旧滚 900ms,帧回调不来(窗口被判定为遮挡/隐藏,
|
||||
* Chromium 会停掉帧生产)时不能永久停在旧值上。
|
||||
*
|
||||
* 组件本身依赖 rAF / DOM 事件,这里只测抽出来的纯逻辑,外加两条读源码的接线检查。
|
||||
*/
|
||||
|
||||
const COMPONENT_SOURCE = readFileSync(
|
||||
new URL('../components/AnimatedNumber.vue', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
const CONSOLE_MAIN_SOURCE = readFileSync(
|
||||
new URL('../../../../console/main/index.js', import.meta.url),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
test('progress >= 1 直接落到目标值(窗口恢复可见瞬间跳到新值,不重播旧动画)', () => {
|
||||
assert.equal(frameValue(120, 300, 900, 900), 300)
|
||||
// 被遮挡 30 秒后才来的那一帧:进度越界得再多也必须落到目标值
|
||||
assert.equal(frameValue(120, 300, 30_000, 900), 300)
|
||||
// 分数下降(大屏「单位总分」清空本组后就是这条路径)同样如此
|
||||
assert.equal(frameValue(300, 120, 30_000, 900), 120)
|
||||
assert.equal(frameValue(300, 120, 900, 900), 120)
|
||||
})
|
||||
|
||||
test('progress = 0 返回起始值,中途夹在两端之间且单调', () => {
|
||||
assert.equal(frameValue(120, 300, 0, 900), 120)
|
||||
|
||||
const mid = frameValue(120, 300, 450, 900)
|
||||
assert.ok(mid > 120 && mid < 300)
|
||||
|
||||
const rising = [0, 0.05, 0.1, 0.25, 0.5, 0.75, 0.9, 0.99].map((ratio) =>
|
||||
frameValue(0, 900, ratio * 900, 900),
|
||||
)
|
||||
for (let i = 1; i < rising.length; i += 1) assert.ok(rising[i]! > rising[i - 1]!)
|
||||
|
||||
const falling = [0, 0.1, 0.5, 0.9, 0.99].map((ratio) => frameValue(900, 0, ratio * 900, 900))
|
||||
for (let i = 1; i < falling.length; i += 1) assert.ok(falling[i]! < falling[i - 1]!)
|
||||
})
|
||||
|
||||
test('缓出三次方与抽取前的公式逐字一致,并把进度钳到 [0, 1]', () => {
|
||||
assert.equal(easeOutCubic(0), 0)
|
||||
assert.equal(easeOutCubic(1), 1)
|
||||
assert.equal(easeOutCubic(3), 1)
|
||||
assert.equal(easeOutCubic(-1), 0)
|
||||
assert.equal(easeOutCubic(0.5), 1 - 0.5 ** 3)
|
||||
// 时间戳异常时不能把显示值算成 NaN,否则屏上是「NaN 分」
|
||||
assert.equal(easeOutCubic(Number.NaN), 0)
|
||||
assert.equal(clamp01(2), 1)
|
||||
assert.equal(clamp01(Number.NaN), 0)
|
||||
})
|
||||
|
||||
test('时长为非正时不做插值,直接是目标值', () => {
|
||||
assert.equal(frameValue(120, 300, 0, 0), 300)
|
||||
assert.equal(frameValue(120, 300, 5_000, -1), 300)
|
||||
})
|
||||
|
||||
test('兜底时长严格大于 duration,且等于 duration + 具名余量', () => {
|
||||
assert.ok(FALLBACK_MARGIN_MS > 0)
|
||||
for (const duration of [0, 1, 400, 900, 5_000]) {
|
||||
assert.ok(fallbackDelayMs(duration) > duration)
|
||||
}
|
||||
assert.equal(fallbackDelayMs(900), 900 + FALLBACK_MARGIN_MS)
|
||||
// 默认 900ms 的动画必须在 1~2 秒内收敛,不能等到「遮挡持续多久」
|
||||
assert.ok(fallbackDelayMs(900) <= 2_000)
|
||||
})
|
||||
|
||||
test('reduced / duration<=0 / from===target 三种情况直接对齐,不播动画', () => {
|
||||
assert.equal(shouldSnap(120, 300, 900, true), true)
|
||||
assert.equal(shouldSnap(120, 300, 0, false), true)
|
||||
assert.equal(shouldSnap(120, 300, -1, false), true)
|
||||
assert.equal(shouldSnap(120, 120, 900, false), true)
|
||||
assert.equal(shouldSnap(120, 300, 900, false), false)
|
||||
})
|
||||
|
||||
test('组件不再自己算缓动,帧与兜底计时器都走抽出来的纯函数', () => {
|
||||
assert.match(COMPONENT_SOURCE, /import \{ fallbackDelayMs, frameValue, shouldSnap \} from '\.\.\/animatedNumber'/)
|
||||
assert.doesNotMatch(COMPONENT_SOURCE, /\*\* 3/)
|
||||
assert.match(COMPONENT_SOURCE, /fallbackDelayMs\(props\.duration\)/)
|
||||
assert.match(COMPONENT_SOURCE, /setTimeout\(align, /)
|
||||
})
|
||||
|
||||
test('组件卸载时摘掉两个「窗口又可见」监听器,并清掉兜底计时器', () => {
|
||||
assert.match(COMPONENT_SOURCE, /window\.addEventListener\('focus', align\)/)
|
||||
assert.match(COMPONENT_SOURCE, /document\.addEventListener\('visibilitychange', onVisibilityChange\)/)
|
||||
assert.match(COMPONENT_SOURCE, /window\.removeEventListener\('focus', align\)/)
|
||||
assert.match(
|
||||
COMPONENT_SOURCE,
|
||||
/document\.removeEventListener\('visibilitychange', onVisibilityChange\)/,
|
||||
)
|
||||
assert.match(COMPONENT_SOURCE, /clearTimeout\(fallbackTimer\)/)
|
||||
assert.match(COMPONENT_SOURCE, /onUnmounted\(/)
|
||||
})
|
||||
|
||||
// console 侧只有 scripts/check-syntax.js(只查语法),命令行开关「写错位置就静默失效」这类回归
|
||||
// 没人拦得住,所以在动画逻辑旁边把它一并钉住。
|
||||
test('Console 主进程关掉 Chromium 遮挡节流,且开关在 app.whenReady() 之前', () => {
|
||||
const occluded = CONSOLE_MAIN_SOURCE.indexOf("appendSwitch('disable-backgrounding-occluded-windows')")
|
||||
const renderer = CONSOLE_MAIN_SOURCE.indexOf("appendSwitch('disable-renderer-backgrounding')")
|
||||
// 用带调用点的完整写法定位,别让注释里的 app.whenReady() 把位置比错
|
||||
const ready = CONSOLE_MAIN_SOURCE.indexOf('app.whenReady().then(')
|
||||
|
||||
assert.ok(occluded !== -1)
|
||||
assert.ok(renderer !== -1)
|
||||
assert.ok(ready !== -1)
|
||||
// 命令行开关必须在 app ready 之前追加,晚一步就不生效
|
||||
assert.ok(occluded < ready)
|
||||
assert.ok(renderer < ready)
|
||||
})
|
||||
@@ -120,6 +120,7 @@ export class RealtimeClient {
|
||||
private watchdogTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private lastResyncAt = 0
|
||||
private resyncTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
constructor(options: RealtimeOptions) {
|
||||
this.url = options.url ?? defaultUrl()
|
||||
@@ -229,6 +230,8 @@ export class RealtimeClient {
|
||||
this.clearReconnectTimer()
|
||||
this.clearWatchdog()
|
||||
this.clearHandshakeTimer()
|
||||
// 排队中的 resync 属于这条连接,不能让它跑到(可能马上重建的)下一条连接上
|
||||
this.clearResyncTimer()
|
||||
|
||||
const socket = this.socket
|
||||
this.socket = null
|
||||
@@ -243,14 +246,47 @@ export class RealtimeClient {
|
||||
this.setStatus('idle')
|
||||
}
|
||||
|
||||
/** 客户端发现本地版本落后时主动补数据(心跳里 version 更大、或收到无法应用的增量)。 */
|
||||
/**
|
||||
* 客户端发现本地版本落后时主动补数据(心跳里 version 更大、或收到无法应用的增量)。
|
||||
*
|
||||
* 冷却期内的调用**排队而不是丢弃**:调用方(display/useScoreboard 的 applyChanges)处理增量时
|
||||
* 已经把本地版本推进过了,这一发 resync 一丢,心跳的 `data.version > currentVersion` 就再也
|
||||
* 发现不了缺口——那一版该做的重拉会永久缺失,只能等下一条无关变更才顺带补上。
|
||||
*/
|
||||
requestResync(): void {
|
||||
const now = Date.now()
|
||||
if (now - this.lastResyncAt < this.resyncCooldownMs) return
|
||||
this.lastResyncAt = now
|
||||
const elapsed = now - this.lastResyncAt
|
||||
if (elapsed < this.resyncCooldownMs) {
|
||||
// 同一时刻最多排一个:后面的调用只是确认「还欠一发」,不叠加计时器
|
||||
if (this.resyncTimer === null) {
|
||||
this.resyncTimer = setTimeout(() => {
|
||||
this.resyncTimer = null
|
||||
this.flushResync()
|
||||
}, this.resyncCooldownMs - elapsed)
|
||||
}
|
||||
return
|
||||
}
|
||||
// 马上就要发出去了,等于把欠的那发补上了,排队的计时器撤掉,免得背靠背发两发
|
||||
this.clearResyncTimer()
|
||||
this.flushResync()
|
||||
}
|
||||
|
||||
/** 真正发出 resync,并把 lastResyncAt 记成这次发出的时刻(冷却时长从这里起算)。 */
|
||||
private flushResync(): void {
|
||||
// 连接已经断开或还在重连时不发:重连的 hello 会带上 lastVersion,服务端照常补
|
||||
// catchup/snapshot;此时推进 lastResyncAt 反而会把重连后该发的那发压进冷却期
|
||||
if (this.currentStatus !== 'online') return
|
||||
this.lastResyncAt = Date.now()
|
||||
this.send('resync', {})
|
||||
}
|
||||
|
||||
private clearResyncTimer(): void {
|
||||
if (this.resyncTimer !== null) {
|
||||
clearTimeout(this.resyncTimer)
|
||||
this.resyncTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
private handleMessage(raw: unknown): void {
|
||||
if (typeof raw !== 'string') {
|
||||
this.handlers.onError?.(new Error('收到非文本 WebSocket 消息'))
|
||||
|
||||
@@ -162,3 +162,58 @@ test('没有快照基准时收到 catchup 先请求全量', () => {
|
||||
assert.ok(socket.sent.some((raw) => raw.includes('"resync"')))
|
||||
client.close()
|
||||
})
|
||||
|
||||
function resyncCount(socket: FakeSocket): number {
|
||||
return socket.sent.filter((raw) => raw.includes('"event":"resync"')).length
|
||||
}
|
||||
|
||||
// 现场「清空本组后大屏总分要过几十秒才降下来」的一条链路:applyChange 处理非 standings 增量时
|
||||
// 已经把本地版本推进过了,冷却期内这一发 resync 一丢,心跳(只比 version)就再也发现不了缺口。
|
||||
// 所以冷却期内的请求必须排队补发,但不能堆成多份。
|
||||
test('冷却期内的 resync 排队补发,不丢弃也不重复', async () => {
|
||||
installFakeWebSocket()
|
||||
const client = new RealtimeClient({
|
||||
role: 'display',
|
||||
url: 'ws://test/ws',
|
||||
resyncCooldownMs: 50,
|
||||
})
|
||||
client.connect()
|
||||
const socket = firstSocket()
|
||||
socket.open()
|
||||
socket.sent.length = 0
|
||||
|
||||
client.requestResync()
|
||||
// 紧接着的重复调用只是确认「还欠一发」,不叠加计时器
|
||||
client.requestResync()
|
||||
client.requestResync()
|
||||
assert.equal(resyncCount(socket), 1)
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 140))
|
||||
assert.equal(resyncCount(socket), 2)
|
||||
client.close()
|
||||
})
|
||||
|
||||
// 停掉的连接不能把排队的那发补到下一次连接上。
|
||||
test('close 清掉排队的 resync,重连后不会补发', async () => {
|
||||
installFakeWebSocket()
|
||||
const client = new RealtimeClient({
|
||||
role: 'display',
|
||||
url: 'ws://test/ws',
|
||||
resyncCooldownMs: 50,
|
||||
})
|
||||
client.connect()
|
||||
firstSocket().open()
|
||||
client.requestResync()
|
||||
client.requestResync()
|
||||
|
||||
client.close()
|
||||
client.connect()
|
||||
const second = FakeSocket.instances[1]
|
||||
assert.ok(second)
|
||||
second.open()
|
||||
second.sent.length = 0
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 140))
|
||||
assert.equal(resyncCount(second), 0)
|
||||
client.close()
|
||||
})
|
||||
Reference in new issue
Block a user