6618207b5b
- 新增 apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts:AGC 唯一的事件订阅入口。真实 WebView 内用 plugin:event|listen + transformCallback 自建登记,注销时先 unregisterCallback(handlerId)(callbacks.delete,幂等、缺条目也不抛)再发 plugin:event|unlisten,并对重复注销去重,注销失败只在 console.warn 显式记录;非原生环境沿用注入的 event.listen(两参调用形状不变),未接桥接时返回空操作。tauri 2.11 的注销脚本会先读注册表条目再摘回调,而条目由注册 eval 异步写入,与 IPC 返回无序,这一层自建登记正好绕开该竞态(上游 tauri-apps/tauri#15799 / #15800,2.12 起脚本自带判空,升级后可删掉 internals 分支)。 - App.tsx:5 处事件订阅(game-creator-direct-turn-update / agent-progress / agent-runtime-update / planning-session-v2-stream / manifest-invalidated)与角色 Agent 流式回复监听改用订阅入口,守卫由 window.__TAURI__?.event?.listen 换成 canSubscribeTauriEvents()。 - features/app-shell/useDeveloperAgentPanel.ts:Agent Runtime 与角色 Agent 流式回复两处订阅改用订阅入口。 - services/errorReportingBridge.ts:error-report-updated 订阅改用订阅入口,不再直接依赖库内 listen。 - components/AppUpdateNotice.tsx:更新下载进度订阅改用订阅入口。 - components/WindowChrome.tsx:窗口尺寸监听不再走 nativeWindow.onResized,改为订阅 tauri://resize(限定当前窗口),避开库内注销竞态。 - 新增 tests/tauriEventFake.ts:与 tauri 2.11.3 等价的 Tauri 事件替身(注册表条目由注册 eval 异步写入、库内注销脚本读缺失条目即抛并留痕、全局桥 event.listen 按库内实现返回会读条目的注销函数)。 - 新增 tests/tauriEventSubscription.test.ts(7 例):注册 eval 未落地就注销、重复注销只摘一次、注册落地后能投递且注销后不再投递、真实 WebView 不走库内注销脚本、无 internals 时回落注入桥接、无桥接时空操作。 - 新增 tests/runVersionSwitchEventSubscription.test.tsx(2 例):真实 launcher + 运行模块连续切换两次版本(断言 start_local_game_preview 被调用)、卸载后后端订阅与 JS 回调都不泄漏、切换版本后清单失效事件仍能送达并重读清单。 - docs/project-memory/shared-memory/pitfalls.md:记录该竞态的成因、触发面、处理取舍与变异验证结论。
179 lines
5.8 KiB
TypeScript
179 lines
5.8 KiB
TypeScript
// @vitest-environment jsdom
|
||
|
||
/**
|
||
* 「注销竞态」回归用例(tauri-apps/tauri#15799):
|
||
* 运行模块切换版本时会重渲染并成对触发 AGC 的事件订阅/注销,`React.StrictMode`
|
||
* 更是挂载即注销。tauri 2.11 的库内注销脚本会先读监听注册表条目,而条目由注册
|
||
* eval 异步写入,于是「订阅后立刻注销」会抛
|
||
* `undefined is not an object (evaluating 'listeners[eventId].handlerId')`,
|
||
* 且抛在 invoke 之前 → 后端订阅泄漏。这里用与 tauri 2.11 等价的替身锁死行为。
|
||
*/
|
||
|
||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||
|
||
import {
|
||
canSubscribeTauriEvents,
|
||
subscribeTauriEvent,
|
||
} from '../src/services/tauriEventSubscription';
|
||
import { createTauriEventFake, type TauriEventFake } from './tauriEventFake';
|
||
|
||
let fake: TauriEventFake | null = null;
|
||
|
||
afterEach(() => {
|
||
fake?.restore();
|
||
fake = null;
|
||
vi.restoreAllMocks();
|
||
});
|
||
|
||
describe('subscribeTauriEvent', () => {
|
||
it('注册 eval 还没落地就注销:不抛错、不读注册表条目、不漏后端订阅', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
const handler = vi.fn();
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-progress',
|
||
handler,
|
||
);
|
||
expect(fake.pendingRegistrationEvals()).toBe(1);
|
||
|
||
// 注册 eval 还没落地(tauri 2.11 的竞态窗口):注销必须安全。
|
||
unsubscribe();
|
||
|
||
expect(fake.racyUnregisterViolations).toEqual([]);
|
||
expect(fake.racyUnregisterCalls).toBe(0);
|
||
expect(fake.listeners).toEqual([]);
|
||
expect(fake.jsCallbackCount()).toBe(0);
|
||
expect(fake.unlistenInvokeCounts.get('game-creator-agent-progress')).toBe(
|
||
1,
|
||
);
|
||
});
|
||
|
||
it('同一事件已有注册条目落地时,注销还没落地的订阅也不该抛', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
// 先让第一个订阅的注册 eval 落地:该事件的监听对象因此存在。
|
||
const firstUnsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-runtime-update',
|
||
vi.fn(),
|
||
);
|
||
fake.flushRegistrationEvals();
|
||
|
||
// 重订阅(StrictMode 二次挂载 / 依赖变化都会重订阅),它的注册 eval 还没落地。
|
||
// 这就是报错现场:事件对象存在、新 eventId 的条目不存在。
|
||
const secondUnsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-runtime-update',
|
||
vi.fn(),
|
||
);
|
||
secondUnsubscribe();
|
||
|
||
expect(fake.racyUnregisterViolations).toEqual([]);
|
||
expect(
|
||
fake.unlistenInvokeCounts.get('game-creator-agent-runtime-update'),
|
||
).toBe(1);
|
||
expect(fake.listeners).toHaveLength(1);
|
||
expect(fake.jsCallbackCount()).toBe(1);
|
||
|
||
firstUnsubscribe();
|
||
expect(fake.listeners).toEqual([]);
|
||
expect(fake.jsCallbackCount()).toBe(0);
|
||
});
|
||
|
||
it('重复注销只摘一次后端订阅', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'game-creator-manifest-invalidated',
|
||
vi.fn(),
|
||
);
|
||
unsubscribe();
|
||
unsubscribe();
|
||
unsubscribe();
|
||
|
||
expect(
|
||
fake.unlistenInvokeCounts.get('game-creator-manifest-invalidated'),
|
||
).toBe(1);
|
||
expect(fake.racyUnregisterViolations).toEqual([]);
|
||
});
|
||
|
||
it('注册 eval 落地后事件能投递,注销后不再投递', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
const handler = vi.fn();
|
||
const unsubscribe = await subscribeTauriEvent<{ message: string }>(
|
||
'game-creator-agent-progress',
|
||
handler,
|
||
);
|
||
fake.flushRegistrationEvals();
|
||
|
||
fake.emit('game-creator-agent-progress', { message: '正在生成' });
|
||
expect(handler).toHaveBeenCalledTimes(1);
|
||
expect(handler.mock.calls[0][0]).toMatchObject({
|
||
event: 'game-creator-agent-progress',
|
||
payload: { message: '正在生成' },
|
||
});
|
||
|
||
unsubscribe();
|
||
fake.emit('game-creator-agent-progress', { message: '不该再收到' });
|
||
expect(handler).toHaveBeenCalledTimes(1);
|
||
});
|
||
|
||
it('真实 WebView 内不经过会读注册表的库内注销脚本', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'planning-session-v2-stream',
|
||
vi.fn(),
|
||
);
|
||
unsubscribe();
|
||
|
||
// 库内 listen 一次都没用到:AGC 自己走 event 插件 invoke。
|
||
expect(fake.bridgeListenCalls).toBe(0);
|
||
expect(fake.racyUnregisterCalls).toBe(0);
|
||
expect(fake.racyUnregisterViolations).toEqual([]);
|
||
});
|
||
|
||
it('没有原生 internals 时回落到注入的事件桥接(jsdom 替身 / 浏览器预览)', async () => {
|
||
fake = createTauriEventFake();
|
||
fake.install();
|
||
// 只留全局桥,模拟各套件里 `event: { listen }` 的替身环境。
|
||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||
|
||
expect(canSubscribeTauriEvents()).toBe(true);
|
||
const handler = vi.fn();
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'game-creator-agent-progress',
|
||
handler,
|
||
);
|
||
expect(fake.bridgeListenCalls).toBe(1);
|
||
|
||
fake.emit('game-creator-agent-progress', { message: 'hi' });
|
||
fake.flushRegistrationEvals();
|
||
fake.emit('game-creator-agent-progress', { message: 'hi' });
|
||
expect(handler).toHaveBeenCalledTimes(1);
|
||
|
||
unsubscribe();
|
||
unsubscribe();
|
||
expect(fake.unlistenInvokeCounts.get('game-creator-agent-progress')).toBe(
|
||
1,
|
||
);
|
||
});
|
||
|
||
it('没有任何桥接时返回空操作', async () => {
|
||
delete (window as unknown as Record<string, unknown>).__TAURI__;
|
||
delete (window as unknown as Record<string, unknown>).__TAURI_INTERNALS__;
|
||
|
||
expect(canSubscribeTauriEvents()).toBe(false);
|
||
const unsubscribe = await subscribeTauriEvent(
|
||
'error-report-updated',
|
||
vi.fn(),
|
||
);
|
||
expect(typeof unsubscribe).toBe('function');
|
||
expect(() => unsubscribe()).not.toThrow();
|
||
});
|
||
});
|