From 18caea5514fc3c99dd9d7eeb84052e0f87df4f20 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Tue, 4 Aug 2026 10:15:01 +0800 Subject: [PATCH 1/4] =?UTF-8?q?test(device-link):=20=E6=8F=A1=E6=89=8B?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E7=94=A8=E4=BE=8B=E6=94=B9=E6=9C=89=E7=95=8C?= =?UTF-8?q?=E7=AD=89=E5=BE=85,=E4=BF=AE=20main=20=E4=B8=8A=20Windows=20?= =?UTF-8?q?=E5=8D=95=E6=B5=8B=E7=BA=A2=E7=81=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `握手超时(open 后 hello-ack 一直不来)→ 强制断开走退避重连` 用固定 `await tick(50)` 等 15ms 握手看门狗 + 退避重连落地。Windows CI 两分片并跑时 事件循环调度远超名义毫秒数,50ms 内 watchdog 可能还没换连接,断言 `sockets.length >= 2` 拿到 1 → main HEAD (e931a575) 的 client-ci 红灯。 改成同文件已有的有界等待模式(与紧邻的「open 从未到来」用例一致): `for (let i = 0; i < 40 && h.sockets.length < 2; i++) await tick(10)`。 断言语义不变,只把"固定窗口"换成"等到发生或超时",上界 400ms。 纯测试改动,不动产品代码。 验证: `pnpm --filter @cindy/device-link test` 163 passed。 Signed-off-by: Chris Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- packages/device-link/src/__tests__/client.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts index 43b93dbf48e..1562560835a 100644 --- a/packages/device-link/src/__tests__/client.test.ts +++ b/packages/device-link/src/__tests__/client.test.ts @@ -3223,8 +3223,10 @@ describe('DeviceLinkClient', () => { await tick(); const first = h.current(); first.emit('open'); // upgrade 成功但对端不回 hello-ack(半开/服务假活) - await tick(50); - // watchdog 触发新建连接(测试窗口内后续连接可能再次超时,只断言 ≥2) + // 负载下(Windows CI 分片并跑)事件循环调度可能远超名义毫秒数:单次固定 tick(50) + // 不足以保证 15ms 握手看门狗 + 退避重连都已落地。有界等待到第二个 socket 出现, + // 断言语义不变(watchdog 必须触发新建连接;测试窗口内后续连接可能再次超时,只断言 ≥2)。 + for (let i = 0; i < 40 && h.sockets.length < 2; i++) await tick(10); expect(h.sockets.length).toBeGreaterThanOrEqual(2); expect(first.terminated || first.closed !== null).toBe(true); // 旧 socket 被回收 // 负载下(全量并跑)事件循环调度可能远超名义毫秒数:current() 拿到的 From 0ec299075413ba1e42f0f9ea2028eaa7ffd658ef Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:01:53 +0800 Subject: [PATCH 2/4] =?UTF-8?q?test(skillhub):=20=E9=94=81=E5=B9=B6?= =?UTF-8?q?=E8=A1=8C=E7=94=A8=E4=BE=8B=E6=94=B9=E6=96=AD=E8=A8=80=E4=B8=B4?= =?UTF-8?q?=E7=95=8C=E5=8C=BA=E9=87=8D=E5=8F=A0,=E5=8E=BB=E6=8E=89?= =?UTF-8?q?=E5=A2=99=E9=92=9F=E9=98=88=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `withLock > 不同 key 并行,耗时近似单次而非 N 倍` 断言 `elapsed < delay * 2.5`(20ms × 2.5 = 50ms)。三个 20ms sleep 串行是 60ms —— 阈值 离"串行"只差 10ms,等于拿调度抖动当被测行为。Windows CI 两分片并跑时实测 52ms, 直接把 main 打红(#1618 的 client-ci 就是被这条挡下的,与它的 diff 毫无关系)。 改成直接量"并行"本身:记每个临界区的进入 / 离开时刻,断言 `max(进入) < min(离开)` —— 存在一个瞬间三者同时在临界区内。串行执行下第二个的进入 必然晚于第一个的离开,这条一定不成立;而机器多慢都不影响它成立。 被测语义没变(不同 key 不互相串行),只是把判据从"跑得够快"换成"确实重叠"。 验证: `vitest run src/main/skillhub/registry/__tests__/lock.test.ts` 5 passed。 Signed-off-by: Chris Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- .../skillhub/registry/__tests__/lock.test.ts | 30 +++++++++++++------ 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/main/skillhub/registry/__tests__/lock.test.ts b/apps/desktop/src/main/skillhub/registry/__tests__/lock.test.ts index 06c46329bf4..d83d7317c2e 100644 --- a/apps/desktop/src/main/skillhub/registry/__tests__/lock.test.ts +++ b/apps/desktop/src/main/skillhub/registry/__tests__/lock.test.ts @@ -38,19 +38,31 @@ describe('withLock', () => { expect(counter).toBe(100); }); - it('不同 key 并行,耗时近似单次而非 N 倍', async () => { - const delay = 20; // ms - const start = Date.now(); + it('不同 key 并行:三个临界区在某一刻同时开着(而非首尾相接)', async () => { + // **不断言墙钟耗时**。原来是 `elapsed < delay * 2.5`(20ms × 2.5 = 50ms),Windows CI + // 两分片并跑时 52ms 就把 main 打红了 —— 三个 20ms 的 sleep 串行才 60ms,阈值离"串行" + // 只差 10ms,等于拿调度抖动当被测行为。 + // + // 改成直接量"并行"本身:记下每个临界区的进入 / 离开时刻,断言 + // `max(进入) < min(离开)`,即存在一个瞬间三者同时在临界区内。串行执行下 + // 第二个的进入必然晚于第一个的离开,这条一定不成立。与机器快慢无关。 + const enter: number[] = []; + const leave: number[] = []; + const section = async (): Promise => { + enter.push(Date.now()); + await new Promise((r) => setTimeout(r, 20)); + leave.push(Date.now()); + }; await Promise.all([ - withLock('key-a', () => new Promise((r) => setTimeout(r, delay))), - withLock('key-b', () => new Promise((r) => setTimeout(r, delay))), - withLock('key-c', () => new Promise((r) => setTimeout(r, delay))), + withLock('key-a', section), + withLock('key-b', section), + withLock('key-c', section), ]); - const elapsed = Date.now() - start; - // 并行时应该约 delay ms 完成(而非 3*delay) - expect(elapsed).toBeLessThan(delay * 2.5); + expect(enter).toHaveLength(3); + expect(leave).toHaveLength(3); + expect(Math.max(...enter)).toBeLessThan(Math.min(...leave)); }); it('前一个 task 失败,后续 task 仍可执行', async () => { From 0941a27022a6aaaa42cd893d04cfc0ca2765b565 Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:24:42 +0800 Subject: [PATCH 3/4] =?UTF-8?q?test:=20=E5=86=8D=E4=BF=AE=E4=B8=A4?= =?UTF-8?q?=E6=9D=A1=E6=8A=8A=20main=20=E6=89=93=E7=BA=A2=E7=9A=84=20Windo?= =?UTF-8?q?ws=20=E8=AE=A1=E6=97=B6=E5=9E=8B=E7=94=A8=E4=BE=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 同一根因的第三、四条(前两条见本 PR 前两个 commit)。 ① `packages/device-link` client.test.ts —— `getToken 挂起超过 getTokenTimeoutMs → 走 退避重连,不永久卡在 connecting` 用固定 `await tick(30)` 赌「10ms getToken 超时 + ≤5ms 退避 + 第二轮 getToken 都能在 30ms 内跑完」。Windows 两分片并跑时 socket 还没建出来 → `expected 1, received 0` (实测 #1616 的 client-ci)。改成与同文件其它用例一致的有界等待,断言语义不变。 ② `packages/maker-core` contacts/manager.test.ts —— `Test timed out in 5000ms` 这条**不是**墙钟断言,是纯正确性用例被默认超时判死。该文件每个用例都在 os.tmpdir() 里真开 better-sqlite3 落库(建目录 → 建表 → v1→v2 迁移 → FTS5 重建 → 删目录), Windows CI 两分片并跑、叠上 Defender 对新建文件的实时扫描,这些**同步** IO 会超过 vitest 默认的 5s。实测 #1618 的 client-ci 就是被它挡下的(#1618 的 diff 只碰 mobile HTML 预览,与 contacts 毫无关系);此前值班日志也记过同一文件 4 个用例同时 5s 超时。 packages/maker-core 没有自己的 vitest 配置,拿不到 apps/desktop 那份 `testTimeout: win32 ? 20_000 : 5_000`,所以按仓内既有写法(git-integration 系列用例的 `vi.setConfig`)在该文件单独放宽到 win32 30s。**只放宽时间,不放宽任何断言** —— 它测的是迁移正确性,从来不是"迁移够快"。 验证: - `pnpm --filter @cindy/device-link exec vitest run src/__tests__/client.test.ts` → 100 passed - `pnpm --filter @cindy/maker-core exec vitest run src/contacts` → 122 passed(6 文件) - `pnpm --filter desktop exec vitest run src/main/skillhub` → 281 passed(24 文件) - @cindy/device-link / @cindy/maker-core / desktop 三个 package 的 typecheck 均通过 Signed-off-by: Chris Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- packages/device-link/src/__tests__/client.test.ts | 5 ++++- .../src/contacts/__tests__/manager.test.ts | 13 ++++++++++++- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts index 1562560835a..603706fc915 100644 --- a/packages/device-link/src/__tests__/client.test.ts +++ b/packages/device-link/src/__tests__/client.test.ts @@ -3139,7 +3139,10 @@ describe('DeviceLinkClient', () => { client.start(); await tick(5); expect(sockets.length).toBe(0); // 第一轮卡在 getToken,没建 socket - await tick(30); // 10ms 超时 + ≤5ms 退避后第二轮拿到 token + // 负载下(Windows CI 分片并跑)事件循环调度可能远超名义毫秒数:单次固定 tick(30) 不足以 + // 保证 10ms getToken 超时 + ≤5ms 退避 + 第二轮 getToken 都已落地。有界等待到 socket + // 出现,断言语义不变(挂死的第一轮必须被超时掀掉、第二轮必须真的建出连接)。 + for (let i = 0; i < 40 && sockets.length < 1; i++) await tick(10); expect(sockets.length).toBe(1); sockets[0].ack(); expect(client.getStatus()).toBe('online'); diff --git a/packages/maker-core/src/contacts/__tests__/manager.test.ts b/packages/maker-core/src/contacts/__tests__/manager.test.ts index 4e420ce2f9e..3e839814bbe 100644 --- a/packages/maker-core/src/contacts/__tests__/manager.test.ts +++ b/packages/maker-core/src/contacts/__tests__/manager.test.ts @@ -4,9 +4,20 @@ import * as fs from 'node:fs'; import * as os from 'node:os'; import * as path from 'node:path'; -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import DatabaseCtor from 'better-sqlite3'; +// 本文件每个用例都在 os.tmpdir() 里真开 better-sqlite3 落库(建目录 → 建表 → v1→v2 迁移 +// → FTS5 重建 → 删目录)。Windows CI 上两个分片并跑,叠上 Defender 对新建文件的实时扫描, +// 这些**同步** IO 会超过 vitest 默认的 5s —— 于是这个纯正确性用例以「Test timed out in +// 5000ms」把 main 打红(2026-08-04 实测:manager.test.ts 的 v1→v2 迁移用例;此前值班日志 +// 也记过同一文件 4 个用例同时 5s 超时)。 +// packages/maker-core 没有自己的 vitest 配置,拿不到 apps/desktop 那份 +// `testTimeout: win32 ? 20_000 : 5_000`,所以在这里按仓内既有写法(git-integration 系列 +// 用例的 vi.setConfig)单独放宽。**只放宽时间,不放宽任何断言** —— 它测的是迁移正确性, +// 从来不是"迁移够快"。 +vi.setConfig({ testTimeout: process.platform === 'win32' ? 30_000 : 5_000 }); + import { MakerContactsManager } from '../manager.js'; import type { Logger } from '../../interfaces/logger.js'; From 04e174f248da775cdecd66a5fa2eff2e3428839b Mon Sep 17 00:00:00 2001 From: Chris <4436110+zqchris@users.noreply.github.com> Date: Tue, 4 Aug 2026 11:52:21 +0800 Subject: [PATCH 4/4] =?UTF-8?q?test(device-link):=20=E5=BF=83=E8=B7=B3?= =?UTF-8?q?=E5=83=B5=E6=AD=BB=E7=94=A8=E4=BE=8B=E6=94=B9=E6=9C=89=E7=95=8C?= =?UTF-8?q?=E7=AD=89=E5=BE=85(=E7=AC=AC=E4=BA=94=E6=9D=A1=E6=89=93?= =?UTF-8?q?=E7=BA=A2=20main=20=E7=9A=84=E8=AE=A1=E6=97=B6=E5=9E=8B?= =?UTF-8?q?=E7=94=A8=E4=BE=8B)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `心跳:连续无 pong 超限 → terminate + 重连` 用固定 `await tick(40)` 赌「8ms ping 周期 × 2 轮都能在 40ms 内跑完」。刚合并的 main(0e1e7910)的 client-ci 就是被它挡下的: `expected false to be true`(terminate 还没发生)。改成有界等待到 terminate 真的发生。 同时对整个 client.test.ts 做了一次清扫,结论记在 issue 里(见 PR 描述):全文件共 35 处 `await tick(>=10)`,其中约 15 处属本类(固定等待后断言"某事已发生",慢机器会假失败), 另有约 15 处是反向形态(等一段时间断言"某事没发生"),后者不能用轮询修 —— 它要求真实经过 的时间**小于**某个阈值,慢机器上等越久越危险,只能调被测时间参数,属另一件事。 本 PR 只收正在发作的那几条,不把整份清扫塞进来。 验证: `pnpm --filter @cindy/device-link test` → 163 passed(6 文件)。 Signed-off-by: Chris Signed-off-by: Chris <4436110+zqchris@users.noreply.github.com> --- packages/device-link/src/__tests__/client.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/device-link/src/__tests__/client.test.ts b/packages/device-link/src/__tests__/client.test.ts index 603706fc915..714e269aefe 100644 --- a/packages/device-link/src/__tests__/client.test.ts +++ b/packages/device-link/src/__tests__/client.test.ts @@ -2830,8 +2830,10 @@ describe('DeviceLinkClient', () => { const first = h.current(); first.ack(); - // ping 周期 8ms,pongMissLimit=1:第 2 个周期(~16ms)触发僵死 - await tick(40); + // ping 周期 8ms,pongMissLimit=1:第 2 个周期(~16ms)触发僵死。 + // 负载下(Windows CI 分片并跑)固定 tick(40) 不足以保证两个 ping 周期都已跑完 —— + // 有界等待到 terminate 真的发生,断言语义不变(僵死必须被判出来并进重连)。 + for (let i = 0; i < 40 && !first.terminated; i++) await tick(10); expect(first.terminated).toBe(true); // 已进入重连(新 socket 已创建或定时器排队中) expect(h.client.getStatus()).toBe('connecting');