Files
Genarrative/apps/ai-game-creator-shell/tests/tauriEventFake.ts
T
suzmii 6618207b5b 修复运行模块切换版本报 listeners[eventId].handlerId:事件订阅改为自建登记 + 幂等注销
- 新增 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:记录该竞态的成因、触发面、处理取舍与变异验证结论。
2026-09-12 17:23:22 +08:00

240 lines
8.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 与 tauri 2.11.x 等价的 Tauri 事件替身,用于回归「注销竞态」这一类 bug。
*
* 现有各套件的替身(`window.__TAURI__ = { core: { invoke }, event: { listen } }`
* 里的 `listen` 返回的注销函数不读任何注册表,所以**结构上抓不到**
* `undefined is not an object (evaluating 'listeners[eventId].handlerId')`
* tauri-apps/tauri#15799)。这里按 tauri 2.11.3 的真实实现建三层:
*
* - `window.__TAURI_INTERNALS__`core 注入的底层桥(`invoke` / `transformCallback`
* / `unregisterCallback`),AGC 的 `subscribeTauriEvent` 走这一层。
* - `window.__TAURI__``withGlobalTauri` 的全局桥,`event.listen` 按库内实现返回
* 「先读注册表条目再摘回调」的注销函数(就是会抛的那条路径)。
* - `window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener`:库内注销脚本本体,
* 条目缺失时抛 `TypeError`,并在 `racyUnregisterViolations` 里留痕,便于断言。
*
* 注册表条目由 `plugin:event|listen` 之后**另一次** eval 写入:替身把它排进
* `pendingRegistrationEvals`,只有在 `flushRegistrationEvals()` 之后才可见。
*/
export type TauriEventFakeEvent = {
event: string;
id: number;
payload: unknown;
};
type FakeListener = {
event: string;
eventId: number;
handlerId: number;
target: unknown;
};
export type TauriEventFake = {
/** 已登记在「后端」的事件订阅(模拟 `Listeners::js_event_listeners`)。 */
listeners: FakeListener[];
/** 库内注销脚本读不到注册表条目时的记录(每次都是一次竞态命中)。 */
racyUnregisterViolations: string[];
/** 库内注销脚本被调用的次数。 */
racyUnregisterCalls: number;
/** 全局桥 `event.listen` 被调用的次数。 */
bridgeListenCalls: number;
/** 每个事件的 `plugin:event|unlisten` 调用次数。 */
unlistenInvokeCounts: Map<string, number>;
/** 每个事件的 `plugin:event|listen` 调用次数。 */
listenInvokeCounts: Map<string, number>;
/** 还没落地的注册 eval 数量(模拟 listen 之后那次独立 eval)。 */
pendingRegistrationEvals(): number;
/** 让注册 eval 落地:之后 `emit` 才能命中新登记的事件 ID。 */
flushRegistrationEvals(): void;
/** 仍在 `__TAURI_INTERNALS__.callbacks` 里的 handler 数量。 */
jsCallbackCount(): number;
/** 直接投递事件,语义与 `event_initialization_script` 一致(条目缺失则跳过)。 */
emit(event: string, payload: unknown): void;
install(): void;
restore(): void;
};
type FakeOptions = {
/** 非 event 插件的命令交给它(通常是套件自己的 invoke 替身)。 */
invoke?: (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
};
const INTERNALS_KEY = '__TAURI_INTERNALS__';
const EVENT_INTERNALS_KEY = '__TAURI_EVENT_PLUGIN_INTERNALS__';
const GLOBAL_KEY = '__TAURI__';
type MutableWindow = Window & Record<string, unknown>;
export function createTauriEventFake(
options: FakeOptions = {},
): TauriEventFake {
const listeners: FakeListener[] = [];
const racyUnregisterViolations: string[] = [];
const unlistenInvokeCounts = new Map<string, number>();
const listenInvokeCounts = new Map<string, number>();
const jsCallbacks = new Map<number, (eventData: unknown) => void>();
const registrations: FakeListener[] = [];
let nextEventId = 1;
let nextHandlerId = 1;
let racyUnregisterCalls = 0;
let bridgeListenCalls = 0;
let installedGlobals: Array<{ key: string; had: boolean; value: unknown }> =
[];
const registry: Record<string, Record<number, { handlerId: number }>> = {};
const fake: TauriEventFake = {
listeners,
racyUnregisterViolations,
get racyUnregisterCalls() {
return racyUnregisterCalls;
},
get bridgeListenCalls() {
return bridgeListenCalls;
},
unlistenInvokeCounts,
listenInvokeCounts,
pendingRegistrationEvals: () => registrations.length,
flushRegistrationEvals() {
const pending = registrations.splice(0, registrations.length);
for (const listener of pending) {
registry[listener.event] ??= {};
registry[listener.event][listener.eventId] = {
handlerId: listener.handlerId,
};
}
},
jsCallbackCount: () => jsCallbacks.size,
emit(event, payload) {
for (const listener of listeners) {
if (listener.event !== event) continue;
const entry = registry[event]?.[listener.eventId];
if (!entry) continue;
jsCallbacks.get(entry.handlerId)?.({
event,
id: listener.eventId,
payload,
});
}
},
install() {
const target = window as unknown as MutableWindow;
const internals = {
invoke: async (command: string, args?: Record<string, unknown>) => {
if (command === 'plugin:event|listen') {
const event = String(args?.event ?? '');
const eventId = nextEventId++;
listenInvokeCounts.set(
event,
(listenInvokeCounts.get(event) ?? 0) + 1,
);
const listener: FakeListener = {
event,
eventId,
handlerId: Number(args?.handler ?? 0),
target: args?.target,
};
listeners.push(listener);
// 注册表条目由 listen 之后一次独立 eval 写入,这里只排队。
registrations.push(listener);
return eventId;
}
if (command === 'plugin:event|unlisten') {
const event = String(args?.event ?? '');
const eventId = Number(args?.eventId ?? 0);
unlistenInvokeCounts.set(
event,
(unlistenInvokeCounts.get(event) ?? 0) + 1,
);
const index = listeners.findIndex(
(item) => item.event === event && item.eventId === eventId,
);
if (index >= 0) listeners.splice(index, 1);
return null;
}
if (options.invoke) return options.invoke(command, args);
return null;
},
transformCallback: (callback: (eventData: unknown) => void) => {
const handlerId = nextHandlerId++;
jsCallbacks.set(handlerId, callback);
return handlerId;
},
unregisterCallback: (handlerId: number) => {
jsCallbacks.delete(handlerId);
},
};
const racyUnregisterListener = (event: string, eventId: number) => {
racyUnregisterCalls += 1;
const eventListeners = registry[event];
if (!eventListeners) return;
const entry = eventListeners[eventId];
if (!entry) {
racyUnregisterViolations.push(`${event}#${eventId}`);
// tauri 2.11 unlisten_js_script 的真实抛错点。
throw new TypeError(
"undefined is not an object (evaluating 'listeners[eventId].handlerId')",
);
}
jsCallbacks.delete(entry.handlerId);
};
// 全局桥(withGlobalTauri):按库内实现返回会读注册表的注销函数。
const bridgeListen = async (
event: string,
handler: (eventData: unknown) => void,
) => {
bridgeListenCalls += 1;
const handlerId = internals.transformCallback(handler);
const eventId = Number(
(await internals.invoke('plugin:event|listen', {
event,
target: { kind: 'Any' },
handler: handlerId,
})) as number,
);
return () => {
racyUnregisterListener(event, eventId);
void internals.invoke('plugin:event|unlisten', { event, eventId });
};
};
const globals: Array<[string, unknown]> = [
[INTERNALS_KEY, internals],
[EVENT_INTERNALS_KEY, { unregisterListener: racyUnregisterListener }],
[
GLOBAL_KEY,
{
core: { invoke: internals.invoke },
event: {
listen: (event: string, handler: (eventData: unknown) => void) =>
bridgeListen(event, handler),
},
},
],
];
installedGlobals = globals.map(([key]) => ({
key,
had: key in target,
value: target[key],
}));
for (const [key, value] of globals) target[key] = value;
},
restore() {
const target = window as unknown as MutableWindow;
for (const entry of installedGlobals) {
if (entry.had) target[entry.key] = entry.value;
else delete target[entry.key];
}
installedGlobals = [];
},
};
return fake;
}