diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts index d07028e1d5..e143b2d832 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterClaudeSubscription.test.ts @@ -88,6 +88,167 @@ describe('claude subscription snapshot hydration race', () => { expect(current?.updatedAt).toBe(2); }); + it('does not clobber the persisted row with a window-less snapshot when hydration failed', async () => { + const broadcaster = await import('../usageBroadcaster'); + // 冷缓存 hydration 读库失败 → 内存为空; 一笔 status-only headers 快照 + // (仅 rateLimitStatus, 无任何窗口)到达。merge 无旧值可保 → 全空快照会被 + // 无条件 upsert, 抹掉持久化行里的有效窗口(与 codex 侧同形状的覆盖事故)。 + mocks.queryOne.mockRejectedValue(new Error('db busy')); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'allowed', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).not.toHaveBeenCalled(); + }); + + // 与 codex 侧同一条: 读库失败不得把缓存标记成已加载, 否则本进程之后再也无法落库。 + it('retries hydration after a transient read failure instead of giving up', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, sevenDay: null, rateLimitStatus: 'allowed', + source: 'unified-headers', updatedAt: 1, + }); + expect(mocks.exec).not.toHaveBeenCalled(); + + mocks.queryOne.mockResolvedValue(null); + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 42 }, source: 'unified-headers', updatedAt: 2, + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.fiveHour?.utilization).toBe(42); + }); + + // 与 codex 侧同一条: 重试读到的旧行不得顶掉 hydration 失败期间收到的新快照。 + it('keeps snapshots received while hydration was failing', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 42 }, source: 'unified-headers', updatedAt: 2, + }); + expect(mocks.exec).not.toHaveBeenCalled(); + + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ + fiveHour: { utilization: 5 }, + scoped: [{ utilization: 12, modelDisplayName: 'Opus' }], + source: 'oauth-endpoint', + updatedAt: 1, + }), + }); + const current = await broadcaster.readClaudeSubscriptionUsageSnapshot(); + // 内存里的 42% 胜出, 同时补上库里独有的 scoped(端点源才有, headers 源没有)。 + expect(current?.fiveHour?.utilization).toBe(42); + expect(current?.scoped?.[0]?.modelDisplayName).toBe('Opus'); + }); + + // 与 codex 侧同款: owner 缺失时 IIFE 同步走完, 句柄不能在它的 finally 里清 —— + // 否则会被外层赋值写回, 之后永远复用这个已 resolve 的 Promise, 再也不查库。 + it('reads the database once the owner becomes available', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.getCurrentUserId.mockReturnValue(null as unknown as string); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 10 }, source: 'unified-headers', updatedAt: 1, + }); + expect(mocks.queryOne).not.toHaveBeenCalled(); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 登录后必须重新查库。跨 owner 变化的那一笔按既有世代语义会被丢弃(它属于换号前 + // 的上下文), 下一笔恢复正常落库。 + mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.queryOne.mockResolvedValue(null); + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 20 }, source: 'unified-headers', updatedAt: 2, + }); + expect(mocks.queryOne).toHaveBeenCalled(); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: { utilization: 30 }, source: 'unified-headers', updatedAt: 3, + }); + expect(mocks.exec).toHaveBeenCalled(); + }); + + it('persists a rejected status even without windows (与 codex 侧 reached 标记同口径)', async () => { + const broadcaster = await import('../usageBroadcaster'); + // rejected 是权威的「请求已被拒」信号(isClaudeSubscriptionAlerting 直接据此告警), + // 缺窗口时也必须落库 —— 否则重启后 chip 不知道当前正被限流。 + mocks.queryOne.mockResolvedValue(null); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'rejected', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).toHaveBeenCalled(); + }); + + // 反向转换同样必须落库: 库里是 rejected、后续 allowed 的 status-only 事件没有窗口, + // 按内容判会被拦下, 库里就永远停在 rejected —— 重启后 chip 挂着一个假的限流警告。 + it('persists an allowed transition that clears a persisted rejected status', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'rejected', + source: 'unified-headers', + updatedAt: 1, + }), + }); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'allowed', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.rateLimitStatus).toBe('allowed'); + }); + + it('persists a status-only snapshot merged onto hydrated windows (regression guard)', async () => { + const broadcaster = await import('../usageBroadcaster'); + // hydration 正常命中 → status-only 增量并入已有窗口, 照常落库且窗口保留。 + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ + fiveHour: { utilization: 54, resetsAt: 1_786_355_999 }, + source: 'oauth-endpoint', + updatedAt: 1, + }), + }); + + await broadcaster.recordClaudeSubscriptionUsageSnapshot({ + fiveHour: null, + sevenDay: null, + rateLimitStatus: 'allowed', + source: 'unified-headers', + updatedAt: 5, + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.fiveHour?.utilization).toBe(54); + }); + it('discards an in-flight hydration result when clear wins the race', async () => { const broadcaster = await import('../usageBroadcaster'); const dbRead = deferred<{ snapshot: string } | null>(); diff --git a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts index 7da2e445ed..757c5bd052 100644 --- a/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts +++ b/apps/desktop/src/main/__tests__/usageBroadcasterCodexAccount.test.ts @@ -10,10 +10,19 @@ const mocks = vi.hoisted(() => ({ queryOne: vi.fn(), exec: vi.fn(async () => undefined), getCurrentUserId: vi.fn(() => 'user-1'), + /** 广播到 renderer 的 payload —— 并发用例据此断言不会闪出空快照。 */ + broadcasts: [] as unknown[], })); vi.mock('electron', () => ({ - BrowserWindow: { getAllWindows: () => [] }, + BrowserWindow: { + getAllWindows: () => [{ + isDestroyed: () => false, + webContents: { + send: (_channel: string, payload: unknown) => { mocks.broadcasts.push(payload); }, + }, + }], + }, })); vi.mock('../logger', () => ({ createLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }), @@ -58,6 +67,7 @@ describe('codex account usage source slots', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); it('keeps app-server windows when a WHAM snapshot arrives (no cross-source overwrite)', async () => { @@ -155,6 +165,7 @@ describe('codex app-server limit buckets', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); // 2026-07-25 用户实报的真实污染行: app 槽被模型专属促销桶(Spark)占据, @@ -244,6 +255,7 @@ describe('codex bucket edge cases (review follow-up)', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); const BUCKET_A = { @@ -349,6 +361,7 @@ describe('codex stale bucket pruning', () => { mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); it('prunes buckets whose windows expired long ago, keeping the latest one', async () => { @@ -384,12 +397,262 @@ describe('codex stale bucket pruning', () => { }); }); +describe('empty snapshot must not clobber the persisted row', () => { + beforeEach(() => { + vi.resetModules(); + mocks.queryOne.mockReset().mockResolvedValue(null); + mocks.exec.mockReset().mockResolvedValue(undefined); + mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; + }); + + // 2026-08-11 用户实报的真实覆盖事故: 一条全 null 的 windowless app-server 事件 + // 落在**空的内存缓存**上(hydration 未命中), merge 无旧值可保, 全 null 桶被 + // 无条件 upsert 落库 —— 持久化行里的有效窗口 / credits / planType 永久丢失, + // 且对消费方是静默失败(JSON 可解析、字段都在、值全 null)。 + const NULL_SPARSE_EVENT = { + limitId: 'codex', + limitName: null, + primary: null, + secondary: null, + credits: null, + planType: null, + rateLimitReachedType: null, + source: 'codex-app-server', + }; + + it('skips persistence when hydration failed', async () => { + const broadcaster = await import('../usageBroadcaster'); + // 冷缓存 hydration 读库失败(db busy 等) → 内存为空, 但持久化行还躺着好数据。 + mocks.queryOne.mockRejectedValue(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + // 全 null payload 不得 upsert —— 否则库里的有效行被抹掉且不可恢复。 + expect(mocks.exec).not.toHaveBeenCalled(); + }); + + // 读库失败必须保留重试机会: 若把未成功的 hydration 标记成「已加载」, 之后所有刷新 + // 都会在 ensure 开头短路、被落库守卫永久跳过 —— 一次瞬时 db busy 就让本进程再也 + // 无法持久化任何额度数据。 + it('retries hydration after a transient read failure instead of giving up', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 库恢复后, 下一笔完整快照必须能重新读库并正常落库。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + await broadcaster.recordCodexAccountUsageSnapshot({ + limitId: 'codex', + primary: { usedPercent: 91, windowMinutes: 300, resetsAt: 1_800_000_000 }, + source: 'codex-app-server', + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(91); + }); + + // 重试成功时读到的行比内存旧 —— hydration 失败期间收到的观测因守卫未能落库, 只活在 + // 内存里。直接赋值会让 UI 回退到旧额度, 且那些观测永远等不到落库时机。 + it('keeps snapshots received while hydration was failing', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot({ + limitId: 'codex', + primary: { usedPercent: 91, windowMinutes: 300, resetsAt: 1_800_000_000 }, + source: 'codex-app-server', + }); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 重试读到的是更旧的持久化行(82%) —— 不得顶掉内存里的 91%。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + const payload = await broadcaster.readCodexAccountUsageSnapshot(); + expect(payload?.appServerBuckets?.codex?.primary?.usedPercent).toBe(91); + + // 且这份观测在下一笔事件时随 payload 一并落库, 不会永久停在内存里。 + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(91); + }); + + // 上一条只覆盖了「失败期间收到完整快照」。稀疏事件留下的是一个非空、却全 null 的 + // 同名桶 —— 若按桶键整体覆盖, 持久化桶里的窗口会被它抹掉并在下一笔事件写回库, + // 正好复现本次要防的损坏。必须逐桶走常规 merge。 + it('merges a sparse bucket received while hydration was failing field by field', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockRejectedValueOnce(new Error('db busy')); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 重试读到同一 limitId 的有效桶(82 / 55) —— 窗口不得被内存里的全 null 桶顶掉。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + const payload = await broadcaster.readCodexAccountUsageSnapshot(); + expect(payload?.appServerBuckets?.codex?.primary?.usedPercent).toBe(82); + expect(payload?.appServerBuckets?.codex?.secondary?.usedPercent).toBe(55); + + // 而且下一笔事件写回库时窗口仍在, 不会把损坏落盘。 + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(82); + }); + + // owner 缺失时 IIFE 在首个 await 之前同步走完 —— 句柄若在它的 finally 里清, 会被 + // 外层赋值写回, 之后 ensure 永远复用这个已 resolve 的 Promise, 再也不查库, 于是 + // hydrated 永远为 false, 本进程之后所有落库都被守卫跳过。 + it('reads the database once the owner becomes available', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.getCurrentUserId.mockReturnValue(null as unknown as string); + + await broadcaster.recordCodexAccountUsageSnapshot(APP_SERVER_SNAPSHOT); + expect(mocks.queryOne).not.toHaveBeenCalled(); + expect(mocks.exec).not.toHaveBeenCalled(); + + // 用户登录后必须重新查库, 并恢复正常落库。 + mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.queryOne.mockResolvedValue(null); + await broadcaster.recordCodexAccountUsageSnapshot(APP_SERVER_SNAPSHOT); + + expect(mocks.queryOne).toHaveBeenCalled(); + expect(mocks.exec).toHaveBeenCalled(); + }); + + it('skips persistence when the owner is not initialized yet', async () => { + const broadcaster = await import('../usageBroadcaster'); + // 启动早期 getCurrentUserId 尚不可用 → hydration 被跳过, 内存为空。 + mocks.getCurrentUserId.mockReturnValue(null as unknown as string); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + expect(mocks.exec).not.toHaveBeenCalled(); + }); + + // 权威的「已达限额」标记本身就是要落库的状态: 它没有窗口是正常的(如 credits + // 耗尽), 且 isCodexWindowlessFallback 明确把它当权威值 —— merge 会正当地把旧窗口 + // 清成 null。goal-host 的 getAccountLimit 从持久化的 rateLimitReachedType 判 + // limited, 漏存会让重启后暂停的目标直接重新撞进同一个限额。 + const CREDITS_DEPLETED_EVENT = { + limitId: 'codex', + primary: null, + secondary: null, + rateLimitReachedType: 'credits_depleted', + source: 'codex-app-server', + }; + + it('persists an authoritative rate-limit-reached marker even without windows', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + + await broadcaster.recordCodexAccountUsageSnapshot(CREDITS_DEPLETED_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.rateLimitReachedType).toBe('credits_depleted'); + }); + + it('persists a reached marker on a cold but readable database', async () => { + const broadcaster = await import('../usageBroadcaster'); + // 库里本来就没有行(首次安装) —— 读成功即底子可信, 照常落库。 + mocks.queryOne.mockResolvedValue(null); + + await broadcaster.recordCodexAccountUsageSnapshot(CREDITS_DEPLETED_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + }); + + it('still persists windowless events merged onto hydrated windows (regression guard)', async () => { + const broadcaster = await import('../usageBroadcaster'); + // hydration 正常命中: windowless 稀疏事件按契约并入已有桶, 窗口保留 → 照常落库。 + mocks.queryOne.mockResolvedValue({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.primary?.usedPercent).toBe(82); + expect(persisted.secondary?.usedPercent).toBe(55); + }); + + // 合法的「限额解除」与事故的空壳形状完全一致 —— 按 payload 内容判会把它一并拦下, + // 库里的 reached 标记就再也去不掉: goal-host 据此判 limited=true 且没有重置时间, + // 目标被无限期挂起。判据必须落在「merge 底子是否可信」上。 + it('persists a legitimate clear that removes a previously reached marker', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue({ + snapshot: JSON.stringify({ ...CREDITS_DEPLETED_EVENT, webSnapshot: null }), + }); + + await broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.rateLimitReachedType ?? null).toBeNull(); + }); + + // codexWebUsageResponseToSnapshot 明确接受只有 plan_type / credits 的 WHAM 响应, + // tooltip 也展示这两项。web-only 账号的首份快照没有任何窗口, 不能因此不落库 —— + // 否则重启或离线启动就丢了套餐与余额。 + it('persists a web snapshot carrying only plan and credits', async () => { + const broadcaster = await import('../usageBroadcaster'); + mocks.queryOne.mockResolvedValue(null); + + await broadcaster.recordCodexAccountUsageSnapshot({ + primary: null, + secondary: null, + credits: { hasCredits: true, unlimited: false, balance: '12.50' }, + planType: 'prolite', + source: 'openai-web', + accountId: 'acc-1', + }); + + expect(mocks.exec).toHaveBeenCalled(); + const lastExecParams = (mocks.exec.mock.calls.at(-1) as unknown[] | undefined)?.[1] as unknown[]; + const persisted = JSON.parse(lastExecParams[1] as string); + expect(persisted.webSnapshot?.planType).toBe('prolite'); + expect(persisted.webSnapshot?.credits?.balance).toBe('12.50'); + }); + + // codex 侧原先把 loaded 置位放在 await 之前, 并发的第二笔 record 会立刻返回并在 + // **空内存**上 merge —— 这正是产出全 null payload 的路径(claude 侧早有 load-promise + // 防住)。可观测的后果是向 renderer 广播一份空快照(chip 闪空), 之后才被 hydration + // 覆盖回去。串行化后两笔都等同一次读完成, 不存在这个中间态。 + it('never broadcasts an empty snapshot while hydration is still in flight', async () => { + const broadcaster = await import('../usageBroadcaster'); + let resolveRead!: (value: { snapshot: string } | null) => void; + mocks.queryOne.mockReturnValue(new Promise((res) => { resolveRead = res; })); + + const first = broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + const second = broadcaster.recordCodexAccountUsageSnapshot(NULL_SPARSE_EVENT); + resolveRead({ snapshot: JSON.stringify(APP_SERVER_SNAPSHOT) }); + await Promise.all([first, second]); + + // 每一次广播都必须带着已 hydrate 的窗口, 不能出现窗口为空的中间态。 + expect(mocks.broadcasts.length).toBeGreaterThan(0); + for (const payload of mocks.broadcasts as Array<{ primary?: { usedPercent?: number } | null }>) { + expect(payload?.primary?.usedPercent).toBe(82); + } + const current = await broadcaster.readCodexAccountUsageSnapshot(); + expect(current?.secondary?.usedPercent).toBe(55); + }); +}); + describe('sparse rate-limit updates without a limitId', () => { beforeEach(() => { vi.resetModules(); mocks.queryOne.mockReset().mockResolvedValue(null); mocks.exec.mockReset().mockResolvedValue(undefined); mocks.getCurrentUserId.mockReturnValue('user-1'); + mocks.broadcasts.length = 0; }); const SPARK = { diff --git a/apps/desktop/src/main/usageBroadcaster.ts b/apps/desktop/src/main/usageBroadcaster.ts index 16a2c1d35d..3eddde000a 100644 --- a/apps/desktop/src/main/usageBroadcaster.ts +++ b/apps/desktop/src/main/usageBroadcaster.ts @@ -328,7 +328,22 @@ export interface CodexAccountUsagePayload extends RateLimitSnapshot { } let codexAccountUsageOwner: string | null = null; -let codexAccountUsageLoaded = false; +/** + * 冷缓存 hydration 是否**成功读到过库**(读到空行也算)。 + * + * 落库守卫的判据 —— merge 的底子可信才允许写回。读库失败 / owner 未初始化被跳过时 + * 内存是空的, 此时任何 merge 结果都不代表账号真实状态, 写回会抹掉库里的有效数据。 + * 不能改用「payload 内容看起来是否有用」判断: 合法的空(限额解除、credits 清零)与 + * 事故的空形状完全一致, 按内容判会把前者一并拦下。 + * + * 它同时充当「无需再读库」的判据 —— 读失败时保持 false, 下一次 record / read 会重试。 + * 若另设一个「已加载」标志并在失败时也置位, 一次瞬时 db busy 就会让本进程之后 + * 所有落库被永久跳过。 + */ +let codexAccountUsageHydrated = false; +// 并发 record 必须等同一次 SQLite 读完成后再按到达顺序 merge(与 claude 侧同款) —— +// 否则第二笔会在 loaded 已被置位、内存却仍为空时 merge 出全 null payload。 +let codexAccountUsageLoadPromise: Promise | null = null; /** app-server 桶表: limitId → 该桶最近快照(同桶 merge, 跨桶隔离)。 */ let codexAppServerBuckets: Record = {}; /** 最近更新的 app-server 桶键 —— 顶层兼容位取它。 */ @@ -369,7 +384,7 @@ function resetCodexAccountUsageCacheIfOwnerChanged(): void { const owner = currentAccountUsageOwner(); if (owner === codexAccountUsageOwner) return; codexAccountUsageOwner = owner; - codexAccountUsageLoaded = false; + codexAccountUsageHydrated = false; codexAppServerBuckets = {}; codexAppServerLatestBucketKey = null; codexWebAccountUsageSnapshot = null; @@ -543,28 +558,59 @@ function isCodexWindowlessFallback(snapshot: RateLimitSnapshot): boolean { async function ensureCodexAccountUsageLoaded(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); - if (codexAccountUsageLoaded) return; - codexAccountUsageLoaded = true; - if (!codexAccountUsageOwner) return; - + if (codexAccountUsageHydrated) return; + if (!codexAccountUsageLoadPromise) { + // 句柄的清理必须放在 await 之后, 不能放进下面 IIFE 的 finally —— owner 缺失时 + // IIFE 会在首个 await 之前同步走完, 它 finally 里清掉的句柄随即被本行的赋值写回, + // 之后 ensure 永远复用这个已 resolve 的 Promise, 再也不查库(hydrated 也就永远 + // 是 false, 本进程之后所有落库都被守卫跳过)。 + codexAccountUsageLoadPromise = (async () => { + try { + if (!codexAccountUsageOwner) return; + const row = await getDbClient().queryOne<{ snapshot?: string | null }>( + 'SELECT snapshot FROM account_usage_snapshots WHERE agent_kind = ?', + ['codex'], + ); + // 读到库就算 hydrated(无行 = 确认库里本来就没有), 之后允许落库。置位放在 + // JSON.parse 之前: 损坏行解析失败仍应允许被新快照覆盖, 否则一条坏行会永久 + // 堵死写入。 + codexAccountUsageHydrated = true; + if (!row?.snapshot) return; + const parsed = JSON.parse(row.snapshot); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const slots = splitPersistedCodexAccountUsage(parsed as Record); + // hydration 不覆盖内存 —— 首次读失败后重试期间, 内存里可能已经装着那段时间 + // 收到的**更新**观测(它们因守卫未能落库)。库里的行比它们旧, 直接赋值会让 UI + // 回退到旧额度, 且那些观测永远等不到落库时机。 + // + // 但也不能按桶键整体覆盖: 那段时间收到的可能是 windowless 稀疏事件, 留下的是 + // 一个非空、却全 null 的同名桶 —— 整桶覆盖会抹掉持久化桶里的窗口, 正好复现 + // 本次要防的损坏。逐桶走常规 merge(持久化桶作 previous), 稀疏事件即按既有 + // 语义保住旧窗口。 + const persistedBuckets = slots.appServerBuckets; + const mergedBuckets: Record = { ...persistedBuckets }; + for (const [key, pending] of Object.entries(codexAppServerBuckets)) { + mergedBuckets[key] = mergeCodexAccountUsageSnapshot(persistedBuckets[key] ?? null, pending); + } + codexAppServerBuckets = mergedBuckets; + codexAppServerLatestBucketKey = codexAppServerLatestBucketKey ?? slots.latestBucketKey; + codexWebAccountUsageSnapshot = codexWebAccountUsageSnapshot + ? mergeCodexAccountUsageSnapshot(slots.web, codexWebAccountUsageSnapshot) + : slots.web; + } + } catch (err) { + log.warn( + 'readCodexAccountUsageSnapshot failed:', + err instanceof Error ? err.message : String(err), + ); + } + })(); + } try { - const row = await getDbClient().queryOne<{ snapshot?: string | null }>( - 'SELECT snapshot FROM account_usage_snapshots WHERE agent_kind = ?', - ['codex'], - ); - if (!row?.snapshot) return; - const parsed = JSON.parse(row.snapshot); - if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - const slots = splitPersistedCodexAccountUsage(parsed as Record); - codexAppServerBuckets = slots.appServerBuckets; - codexAppServerLatestBucketKey = slots.latestBucketKey; - codexWebAccountUsageSnapshot = slots.web; - } - } catch (err) { - log.warn( - 'readCodexAccountUsageSnapshot failed:', - err instanceof Error ? err.message : String(err), - ); + await codexAccountUsageLoadPromise; + } finally { + // hydrated 仅在读成功时置位 —— 读失败清掉句柄即留下重试机会。 + codexAccountUsageLoadPromise = null; } } @@ -607,6 +653,12 @@ export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promis const payload = buildCodexAccountUsagePayload(); broadcastCodexAccountUsage(payload); + // merge 的底子不可信时不写回, 见 codexAccountUsageHydrated。 + if (!codexAccountUsageHydrated) { + log.warn('skip persisting codex account usage snapshot: hydration unavailable'); + return; + } + try { await getDbClient().exec( `INSERT INTO account_usage_snapshots (agent_kind, snapshot, updated_at) @@ -626,7 +678,8 @@ export async function recordCodexAccountUsageSnapshot(snapshot: unknown): Promis export async function clearCodexAccountUsageSnapshot(): Promise { resetCodexAccountUsageCacheIfOwnerChanged(); - codexAccountUsageLoaded = true; + // clear 后库里的状态是已知的(行被删掉): 既不必再读库, 之后到达的快照也可正常落库。 + codexAccountUsageHydrated = true; codexAppServerBuckets = {}; codexAppServerLatestBucketKey = null; codexWebAccountUsageSnapshot = null; @@ -692,7 +745,8 @@ export function clearXaiRateLimitSnapshot(): void { // 误丢(headers 单笔 + 端点 180s 节流时, chip 要空到下一次刷新)。 let claudeSubscriptionUsageOwnerInitialized = false; let claudeSubscriptionUsageOwner: string | null = null; -let claudeSubscriptionUsageLoaded = false; +/** 与 codex 侧 codexAccountUsageHydrated 同义: 落库守卫 + 「无需再读库」的判据。 */ +let claudeSubscriptionUsageHydrated = false; let claudeSubscriptionUsageSnapshot: ClaudeSubscriptionUsageSnapshot | null = null; // 冷缓存 hydration 的 in-flight promise —— 并发 record 必须等同一次 SQLite 读完成后 // 再按到达顺序 merge, 否则后到的新快照会先写、再被读回的旧持久化行覆盖。 @@ -708,14 +762,14 @@ function resetClaudeSubscriptionUsageCacheIfOwnerChanged(): void { claudeSubscriptionUsageOwner = owner; // 首次初始化: loaded / snapshot 本就是初值, 世代不 bump(见上方注释)。 if (isFirstInit) return; - claudeSubscriptionUsageLoaded = false; + claudeSubscriptionUsageHydrated = false; claudeSubscriptionUsageSnapshot = null; claudeSubscriptionUsageGeneration += 1; } async function ensureClaudeSubscriptionUsageLoaded(): Promise { resetClaudeSubscriptionUsageCacheIfOwnerChanged(); - if (claudeSubscriptionUsageLoaded) return; + if (claudeSubscriptionUsageHydrated) return; if (!claudeSubscriptionUsageLoadPromise) { const generation = claudeSubscriptionUsageGeneration; claudeSubscriptionUsageLoadPromise = (async () => { @@ -727,25 +781,33 @@ async function ensureClaudeSubscriptionUsageLoaded(): Promise { ); // clear / owner 变化抢先发生 → 本次读结果作废, 不覆盖更新的内存状态。 if (generation !== claudeSubscriptionUsageGeneration) return; + // 读到库就算 hydrated(理由同 codex 侧, 含损坏行仍允许被覆盖)。 + claudeSubscriptionUsageHydrated = true; if (!row?.snapshot) return; const parsed = JSON.parse(row.snapshot); if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { - claudeSubscriptionUsageSnapshot = parsed as ClaudeSubscriptionUsageSnapshot; + // 理由同 codex 侧: 内存里可能是重试期间收到的更新快照, 库里的行是旧的 —— + // 以持久化行为底、内存增量在上做一次常规 merge, 而不是直接赋值。 + const persisted = parsed as ClaudeSubscriptionUsageSnapshot; + claudeSubscriptionUsageSnapshot = claudeSubscriptionUsageSnapshot + ? mergeClaudeSubscriptionUsageSnapshot(persisted, claudeSubscriptionUsageSnapshot) + : persisted; } } catch (err) { log.warn( 'readClaudeSubscriptionUsageSnapshot failed:', err instanceof Error ? err.message : String(err), ); - } finally { - if (generation === claudeSubscriptionUsageGeneration) { - claudeSubscriptionUsageLoaded = true; - } - claudeSubscriptionUsageLoadPromise = null; } })(); } - await claudeSubscriptionUsageLoadPromise; + try { + await claudeSubscriptionUsageLoadPromise; + } finally { + // 清理放在 await 之后, 理由同 codex 侧: owner 缺失时 IIFE 同步走完, 放进它的 + // finally 会被外层赋值写回, 之后永远复用这个已 resolve 的 Promise。 + claudeSubscriptionUsageLoadPromise = null; + } } export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): Promise { @@ -765,6 +827,12 @@ export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): claudeSubscriptionUsageSnapshot = next; broadcastClaudeSubscriptionUsage(next); + // 与 codex 侧同一条保护: merge 的底子不可信时不写回。 + if (!claudeSubscriptionUsageHydrated) { + log.warn('skip persisting claude subscription usage snapshot: hydration unavailable'); + return; + } + try { await getDbClient().exec( `INSERT INTO account_usage_snapshots (agent_kind, snapshot, updated_at) @@ -792,7 +860,8 @@ export async function recordClaudeSubscriptionUsageSnapshot(snapshot: unknown): export async function clearClaudeSubscriptionUsageSnapshot(): Promise { resetClaudeSubscriptionUsageCacheIfOwnerChanged(); - claudeSubscriptionUsageLoaded = true; + // clear 后库里的状态是已知的(行被删掉): 既不必再读库, 之后到达的快照也可正常落库。 + claudeSubscriptionUsageHydrated = true; claudeSubscriptionUsageSnapshot = null; // 仍在飞的冷缓存 hydration 必须作废 —— 否则它读回的旧持久化行会复活刚清掉的数据。 claudeSubscriptionUsageGeneration += 1;