From 3390eb5e8df39d7124729604ffd3cb31ea28768f Mon Sep 17 00:00:00 2001 From: lanyuanxiaoyao Date: Wed, 27 May 2026 16:33:39 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E5=BC=BA=E5=8C=96=20CPU/memory=20checke?= =?UTF-8?q?r=20=E9=94=99=E8=AF=AF=E5=A4=84=E7=90=86=E3=80=81timeout=20?= =?UTF-8?q?=E9=81=B5=E5=AE=88=E5=92=8C=E5=BF=AB=E7=85=A7=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Memory checker: reader 与 ctx.signal race,abort 返回 memory/timeout,reject 保持 memory/snapshot - CPU checker: 第二次快照异常返回 cpu/snapshot,计算前校验空数组/核心数不一致/非有限值/负 delta - CPU 计算: 零 delta 安全处理,observation 不含 NaN/Infinity - 文档: CPU 互补描述修正,Memory timeout 约束说明 - 测试: +18 覆盖 timeout、异常和边界输入 --- docs/user/checkers/cpu.md | 14 +- docs/user/checkers/memory.md | 2 +- src/server/checker/runner/cpu/calculate.ts | 39 ++++++ src/server/checker/runner/cpu/execute.ts | 43 +++++- src/server/checker/runner/memory/execute.ts | 69 ++++++++-- .../checker/runner/cpu/calculate.test.ts | 67 +++++++++- .../server/checker/runner/cpu/execute.test.ts | 124 ++++++++++++++++++ .../checker/runner/memory/execute.test.ts | 60 +++++++++ 8 files changed, 394 insertions(+), 24 deletions(-) diff --git a/docs/user/checkers/cpu.md b/docs/user/checkers/cpu.md index 215f452..0520f26 100644 --- a/docs/user/checkers/cpu.md +++ b/docs/user/checkers/cpu.md @@ -13,13 +13,13 @@ ## expect 校验项 -| 字段 | 说明 | 必填 | 默认值 | -| --------------------- | ----------------------------------------------------------------------------- | ---- | ------ | -| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 | -| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补(`idlePercent = 100 - usagePercent`) | 否 | 无 | -| `maxCoreUsagePercent` | 单核心最高使用率,使用 `ValueMatcher` | 否 | 无 | -| `minCoreUsagePercent` | 单核心最低使用率,使用 `ValueMatcher` | 否 | 无 | -| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 | +| 字段 | 说明 | 必填 | 默认值 | +| --------------------- | ----------------------------------------------------------------------------------------------- | ---- | ------ | +| `usagePercent` | 总体 CPU 使用率,范围 `0-100`,使用 `ValueMatcher` | 否 | 无 | +| `idlePercent` | 总体 CPU 空闲率,与 `usagePercent` 互补,两者之和恒为 100(`idlePercent + usagePercent = 100`) | 否 | 无 | +| `maxCoreUsagePercent` | 单核心最高使用率,使用 `ValueMatcher` | 否 | 无 | +| `minCoreUsagePercent` | 单核心最低使用率,使用 `ValueMatcher` | 否 | 无 | +| `durationMs` | 完整执行耗时校验,使用 `ValueMatcher` | 否 | 无 | 所有百分比字段范围为 `0-100`,表示所有可见逻辑 CPU 的总体比例,不是"核心数 × 100"。 diff --git a/docs/user/checkers/memory.md b/docs/user/checkers/memory.md index a420ce7..12f87fb 100644 --- a/docs/user/checkers/memory.md +++ b/docs/user/checkers/memory.md @@ -98,7 +98,7 @@ Memory checker 通过 `systeminformation` 库读取系统内存数据,在 Linu - **Swap 字段**:当系统未配置交换分区时,`swapTotalBytes` 为 `0`,`swapUsagePercent` 为 `null`(非 `0`)。 - **`buffcacheBytes`**:反映 Linux 的 buffers + cache 用量,在其他平台上可能为 `null`。 -Memory checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。 +Memory checker 是即时读取(非采样),无需 `sampleDuration`,执行速度远快于 CPU checker。虽然读取本身很快,但仍受 target `timeout` 约束——若底层系统调用悬挂或阻塞超过 `timeout`,checker 会返回 `memory/timeout` failure。 ## 跨平台注意事项 diff --git a/src/server/checker/runner/cpu/calculate.ts b/src/server/checker/runner/cpu/calculate.ts index 15965cc..5a74dd5 100644 --- a/src/server/checker/runner/cpu/calculate.ts +++ b/src/server/checker/runner/cpu/calculate.ts @@ -67,6 +67,45 @@ export function readCpuSnapshot(): CpuCoreSnapshot[] { })); } +export function validateCpuSnapshots(before: CpuCoreSnapshot[], after: CpuCoreSnapshot[]): null | string { + if (before.length === 0 || after.length === 0) { + return "CPU 快照为空"; + } + + if (before.length !== after.length) { + return `CPU 快照核心数不一致: before=${before.length}, after=${after.length}`; + } + + for (let i = 0; i < before.length; i++) { + const bTimes = before[i]!.times; + const aTimes = after[i]!.times; + + for (const [name, value] of Object.entries(bTimes)) { + if (!Number.isFinite(value)) { + return `CPU 快照包含非有限值: before[${i}].times.${name}=${value}`; + } + } + for (const [name, value] of Object.entries(aTimes)) { + if (!Number.isFinite(value)) { + return `CPU 快照包含非有限值: after[${i}].times.${name}=${value}`; + } + } + + const idleDelta = aTimes.idle - bTimes.idle; + const userDelta = aTimes.user - bTimes.user; + const niceDelta = aTimes.nice - bTimes.nice; + const sysDelta = aTimes.sys - bTimes.sys; + const irqDelta = aTimes.irq - bTimes.irq; + const coreTotalDelta = userDelta + niceDelta + sysDelta + idleDelta + irqDelta; + + if (coreTotalDelta < 0) { + return `CPU 快照包含负数 delta: core[${i}] totalDelta=${coreTotalDelta}`; + } + } + + return null; +} + function round1(value: number): number { return Math.round(value * 10) / 10; } diff --git a/src/server/checker/runner/cpu/execute.ts b/src/server/checker/runner/cpu/execute.ts index 18e5a9a..28744a7 100644 --- a/src/server/checker/runner/cpu/execute.ts +++ b/src/server/checker/runner/cpu/execute.ts @@ -5,7 +5,7 @@ import type { CpuCoreSnapshot, CpuStats, CpuTargetConfig, ResolvedCpuExpectConfi import { errorFailure } from "../../expect/failure"; import { checkValueExpectation } from "../../expect/value"; import { parseDuration } from "../../utils"; -import { calculateCpuStats, readCpuSnapshot } from "./calculate"; +import { calculateCpuStats, readCpuSnapshot, validateCpuSnapshots } from "./calculate"; import { checkIdlePercent, checkMaxCoreUsage, checkMinCoreUsage, checkUsagePercent } from "./expect"; import { normalizeTargetExpect } from "./normalize"; import { cpuCheckerSchemas } from "./schema"; @@ -65,10 +65,9 @@ export class CpuChecker implements CheckerDefinition { // 采样等待,支持 AbortSignal 取消 const aborted = await waitForDuration(t.cpu.sampleDurationMs, ctx.signal); - const after = aborted ? null : this.readSnapshot(); - const durationMs = Math.round(performance.now() - start); - - if (aborted || after === null) { + let after: CpuCoreSnapshot[]; + if (aborted) { + const durationMs = Math.round(performance.now() - start); return { detail: null, durationMs, @@ -80,7 +79,41 @@ export class CpuChecker implements CheckerDefinition { }; } + try { + after = this.readSnapshot(); + } catch (error) { + const durationMs = Math.round(performance.now() - start); + return { + detail: null, + durationMs, + failure: errorFailure( + "cpu", + "snapshot", + `CPU 快照读取失败: ${error instanceof Error ? error.message : String(error)}`, + ), + matched: false, + observation: null, + targetId: t.id, + timestamp, + }; + } + + const validationError = validateCpuSnapshots(before, after); + if (validationError !== null) { + const durationMs = Math.round(performance.now() - start); + return { + detail: null, + durationMs, + failure: errorFailure("cpu", "snapshot", validationError), + matched: false, + observation: null, + targetId: t.id, + timestamp, + }; + } + const stats = calculateCpuStats(before, after); + const durationMs = Math.round(performance.now() - start); const result = checkStats(stats, t.expect, durationMs); const observation: Record = { diff --git a/src/server/checker/runner/memory/execute.ts b/src/server/checker/runner/memory/execute.ts index b7386a1..59d705b 100644 --- a/src/server/checker/runner/memory/execute.ts +++ b/src/server/checker/runner/memory/execute.ts @@ -45,23 +45,40 @@ export class MemoryChecker implements CheckerDefinition { return `usage ${usageStr}%, total ${totalStr}`; } - async execute(t: ResolvedMemoryTarget, _ctx: CheckerContext): Promise { + async execute(t: ResolvedMemoryTarget, ctx: CheckerContext): Promise { const timestamp = new Date().toISOString(); const start = performance.now(); - let data: Systeminformation.MemData; - try { - data = await this.reader(); - } catch (error) { + if (ctx.signal.aborted) { const durationMs = Math.round(performance.now() - start); return { detail: null, durationMs, - failure: errorFailure( - "memory", - "snapshot", - `内存数据读取失败: ${error instanceof Error ? error.message : String(error)}`, - ), + failure: errorFailure("memory", "timeout", "内存读取超时:signal 已取消"), + matched: false, + observation: null, + targetId: t.id, + timestamp, + }; + } + + let data: Systeminformation.MemData; + try { + data = await raceWithSignal(this.reader(), ctx.signal); + } catch (error) { + const durationMs = Math.round(performance.now() - start); + const isTimeout = + error instanceof AbortError || (error instanceof Error && error.message === MEMORY_TIMEOUT_MESSAGE); + return { + detail: null, + durationMs, + failure: isTimeout + ? errorFailure("memory", "timeout", "内存读取超时") + : errorFailure( + "memory", + "snapshot", + `内存数据读取失败: ${error instanceof Error ? error.message : String(error)}`, + ), matched: false, observation: null, targetId: t.id, @@ -181,3 +198,35 @@ function formatBytes(bytes: number): string { function formatNumber(value: number): string { return Number.isInteger(value) ? String(value) : String(Number(value.toFixed(1))); } + +const MEMORY_TIMEOUT_MESSAGE = "Memory read aborted by signal"; + +class AbortError extends Error { + constructor() { + super(MEMORY_TIMEOUT_MESSAGE); + this.name = "AbortError"; + } +} + +function raceWithSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new AbortError()); + + return new Promise((resolve, reject) => { + function onAbort() { + reject(new AbortError()); + } + + signal.addEventListener("abort", onAbort, { once: true }); + + promise.then( + (value) => { + signal.removeEventListener("abort", onAbort); + resolve(value); + }, + (error: unknown) => { + signal.removeEventListener("abort", onAbort); + reject(error instanceof Error ? error : new Error(String(error))); + }, + ); + }); +} diff --git a/tests/server/checker/runner/cpu/calculate.test.ts b/tests/server/checker/runner/cpu/calculate.test.ts index 1c6c0bf..dd9522b 100644 --- a/tests/server/checker/runner/cpu/calculate.test.ts +++ b/tests/server/checker/runner/cpu/calculate.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test"; import type { CpuCoreSnapshot } from "../../../../../src/server/checker/runner/cpu/types"; -import { calculateCpuStats } from "../../../../../src/server/checker/runner/cpu/calculate"; +import { calculateCpuStats, validateCpuSnapshots } from "../../../../../src/server/checker/runner/cpu/calculate"; function makeCore(user: number, nice: number, sys: number, idle: number, irq: number): CpuCoreSnapshot { return { times: { idle, irq, nice, sys, user } }; @@ -110,3 +110,68 @@ describe("calculateCpuStats", () => { expect(stats.usagePercent).toBe(60); }); }); + +describe("validateCpuSnapshots", () => { + test("合法 snapshot 返回 null", () => { + const before = [makeCore(100, 0, 0, 900, 0)]; + const after = [makeCore(200, 0, 0, 800, 0)]; + expect(validateCpuSnapshots(before, after)).toBeNull(); + }); + + test("空 before snapshot", () => { + const after = [makeCore(0, 0, 0, 0, 0)]; + expect(validateCpuSnapshots([], after)).toBe("CPU 快照为空"); + }); + + test("空 after snapshot", () => { + const before = [makeCore(0, 0, 0, 0, 0)]; + expect(validateCpuSnapshots(before, [])).toBe("CPU 快照为空"); + }); + + test("核心数不一致", () => { + const before = [makeCore(0, 0, 0, 0, 0)]; + const after = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)]; + expect(validateCpuSnapshots(before, after)).toBe("CPU 快照核心数不一致: before=1, after=2"); + }); + + test("before 包含 NaN time 值", () => { + const before = [{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 0 } }]; + const after = [makeCore(0, 0, 0, 0, 0)]; + const error = validateCpuSnapshots(before, after); + expect(error).toContain("非有限值"); + expect(error).toContain("before[0]"); + }); + + test("after 包含 Infinity time 值", () => { + const before = [makeCore(0, 0, 0, 0, 0)]; + const after = [{ times: { idle: Infinity, irq: 0, nice: 0, sys: 0, user: 0 } }]; + const error = validateCpuSnapshots(before, after); + expect(error).toContain("非有限值"); + expect(error).toContain("after[0]"); + }); + + test("负数 total delta", () => { + const before = [makeCore(1000, 0, 0, 0, 0)]; + const after = [makeCore(100, 0, 0, 0, 0)]; + const error = validateCpuSnapshots(before, after); + expect(error).toContain("负数 delta"); + }); + + test("零 delta 合法", () => { + const before = [makeCore(100, 0, 0, 100, 0)]; + const after = [makeCore(100, 0, 0, 100, 0)]; + expect(validateCpuSnapshots(before, after)).toBeNull(); + }); + + test("零 delta 不产生除零错误", () => { + const before = [makeCore(100, 0, 0, 100, 0)]; + const after = [makeCore(100, 0, 0, 100, 0)]; + const stats = calculateCpuStats(before, after); + expect(Number.isFinite(stats.usagePercent)).toBe(true); + expect(Number.isFinite(stats.idlePercent)).toBe(true); + expect(Number.isFinite(stats.maxCoreUsagePercent)).toBe(true); + expect(Number.isFinite(stats.minCoreUsagePercent)).toBe(true); + expect(stats.usagePercent).toBe(0); + expect(stats.idlePercent).toBe(0); + }); +}); diff --git a/tests/server/checker/runner/cpu/execute.test.ts b/tests/server/checker/runner/cpu/execute.test.ts index c323a72..fc00b78 100644 --- a/tests/server/checker/runner/cpu/execute.test.ts +++ b/tests/server/checker/runner/cpu/execute.test.ts @@ -210,6 +210,130 @@ describe("CpuChecker execute", () => { expect(result.failure?.path).toBe("timeout"); }); + test("第二次 snapshot 抛错返回 cpu/snapshot failure", async () => { + const before = [makeCore(0, 0, 0, 10000, 0)]; + let callCount = 0; + const reader: SnapshotReader = () => { + callCount++; + if (callCount === 1) return before; + throw new Error("second snapshot failed"); + }; + const checker = new CpuChecker(reader); + + const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" }; + const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 })); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("cpu"); + expect(result.failure?.path).toBe("snapshot"); + expect(result.observation).toBeNull(); + }); + + test("空 snapshot pair 返回 cpu/snapshot failure", async () => { + const reader: SnapshotReader = () => []; + const checker = new CpuChecker(reader); + + const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" }; + const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 })); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("cpu"); + expect(result.failure?.path).toBe("snapshot"); + }); + + test("核心数不一致返回 cpu/snapshot failure", async () => { + let callCount = 0; + const snapshots = [[makeCore(0, 0, 0, 100, 0)], [makeCore(0, 0, 0, 100, 0), makeCore(0, 0, 0, 100, 0)]]; + const reader: SnapshotReader = () => { + const result = snapshots[Math.min(callCount, snapshots.length - 1)]!; + callCount++; + return result; + }; + const checker = new CpuChecker(reader); + + const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" }; + const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 })); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("cpu"); + expect(result.failure?.path).toBe("snapshot"); + expect(result.failure?.message).toContain("核心数不一致"); + }); + + test("非有限 CPU time 值返回 cpu/snapshot failure", async () => { + let callCount = 0; + const snapshots: CpuCoreSnapshot[][] = [ + [makeCore(0, 0, 0, 100, 0)], + [{ times: { idle: NaN, irq: 0, nice: 0, sys: 0, user: 100 } }], + ]; + const reader: SnapshotReader = () => { + const result = snapshots[Math.min(callCount, snapshots.length - 1)]!; + callCount++; + return result; + }; + const checker = new CpuChecker(reader); + + const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" }; + const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 })); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("cpu"); + expect(result.failure?.path).toBe("snapshot"); + expect(result.failure?.message).toContain("非有限值"); + }); + + test("负数 CPU time delta 返回 cpu/snapshot failure", async () => { + const before = [makeCore(1000, 0, 0, 0, 0)]; + const after = [makeCore(100, 0, 0, 0, 0)]; + const reader = makeSnapshotReader(before, after); + const checker = new CpuChecker(reader); + + const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" }; + const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 })); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("cpu"); + expect(result.failure?.path).toBe("snapshot"); + expect(result.failure?.message).toContain("负数 delta"); + }); + + test("零 delta snapshot 返回稳定安全值", async () => { + const before = [makeCore(100, 0, 0, 100, 0)]; + const after = [makeCore(100, 0, 0, 100, 0)]; + const reader = makeSnapshotReader(before, after); + const checker = new CpuChecker(reader); + + const target: RawTargetConfig = { cpu: {}, id: "cpu-test", type: "cpu" }; + const resolved = checker.resolve(target, makeResolveContext({ defaultTimeoutMs: 5000 })); + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + expect(result.observation).toMatchObject({ + idlePercent: 0, + maxCoreUsagePercent: 0, + minCoreUsagePercent: 0, + usagePercent: 0, + }); + }); + test("includePerCore=true 时输出 perCoreUsagePercent", async () => { const before = [makeCore(0, 0, 0, 0, 0), makeCore(0, 0, 0, 0, 0)]; const after = [makeCore(8000, 0, 0, 2000, 0), makeCore(2000, 0, 0, 8000, 0)]; diff --git a/tests/server/checker/runner/memory/execute.test.ts b/tests/server/checker/runner/memory/execute.test.ts index fc6bc33..95b4d0f 100644 --- a/tests/server/checker/runner/memory/execute.test.ts +++ b/tests/server/checker/runner/memory/execute.test.ts @@ -153,6 +153,66 @@ describe("MemoryChecker execute", () => { expect(result.observation).toBeNull(); }); + test("signal 已 abort 时返回 timeout failure", async () => { + const reader = () => Promise.resolve(makeMemData()); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + + const controller = new AbortController(); + controller.abort(); + const result = await checker.execute(resolved, { signal: controller.signal }); + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("memory"); + expect(result.failure?.path).toBe("timeout"); + expect(result.observation).toBeNull(); + }); + + test("pending reader 被 signal abort 后返回 timeout failure", async () => { + const reader = () => + new Promise(() => { + // 故意永不 resolve,模拟悬挂的 reader + }); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + + const controller = new AbortController(); + const executePromise = checker.execute(resolved, { signal: controller.signal }); + + controller.abort(); + + const result = await executePromise; + + expect(result.matched).toBe(false); + expect(result.failure?.phase).toBe("memory"); + expect(result.failure?.path).toBe("timeout"); + expect(result.observation).toBeNull(); + }); + + test("reader 在 abort 前 resolve 时返回正常结果", async () => { + const data = makeMemData({ active: 4294967296, total: 8589934592 }); + const reader = () => Promise.resolve(data); + const checker = new MemoryChecker(reader); + + const target: RawTargetConfig = { id: "mem-test", memory: {}, type: "memory" }; + const resolved = checker.resolve(target, makeResolveContext()); + resolved.expect = { usagePercent: { lte: 85 } }; + + const ctx = { signal: new AbortController().signal }; + const result = await checker.execute(resolved, ctx); + + expect(result.matched).toBe(true); + expect(result.failure).toBeNull(); + expect(result.observation).toMatchObject({ + totalBytes: 8589934592, + usagePercent: 50, + }); + }); + test("detail 格式", async () => { const data = makeMemData({ active: 4294967296, total: 8589934592 }); const reader = () => Promise.resolve(data);