Files
Genarrative/apps/ai-game-creator-shell/src/services/tauriEventSubscription.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

209 lines
7.8 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.
/**
* AGC 的 Tauri 事件订阅入口:登记与注销收敛到这一处,避免 tauri 2.11 的注销竞态。
*
* 背景(`undefined is not an object (evaluating 'listeners[eventId].handlerId')`):
* tauri 2.11.x 的 JS 侧注销脚本(`crates/tauri/src/event/mod.rs` 的 `unlisten_js_script`)
* 会先读 webview 内的监听注册表条目
* (`window.__internal_unstable_listeners_object_id__[event][eventId].handlerId`),再调
* `unregisterCallback`。这条注册表条目由 `plugin:event|listen` 之后**另一次** eval
* (`listen_js_script`)写入,和 `plugin:event|listen` 的 IPC 返回之间没有顺序保证
* (macOS/WKWebView 的 IPC 响应经由自定义协议返回,可以早于那次 eval 落地)。
* 于是「订阅后立刻注销」会读到一个还不存在的条目并抛错;更糟的是这一抛发生在
* `_unlisten` 的第一行,`plugin:event|unlisten` 根本发不出去,后端订阅泄漏、
* 注销过的 handler 之后还会被触发一次。
*
* 本仓库 `src/main.tsx` 开着 `React.StrictMode`,挂载即注销(mount → cleanup → mount),
* 所以任何一处 `listen(...).then(unlisten => disposed && unlisten())` 都会踩这个窗口;
* 运行模块切换版本时的重渲染/IPC 洪峰会把这个窗口放大成用户可见的报错
* (经 `unhandledrejection` 上报后还会弹出「发现问题」通知)。
*
* 这里的做法是**不依赖那条注册表条目**:直接用 event 插件的 invoke 登记
* (`plugin:event|listen`),拿回 `eventId` 和 `handlerId`;注销时先摘 JS 回调
* (`unregisterCallback` 就是 `callbacks.delete`,幂等、缺条目也不抛),再摘后端订阅
* (Rust 侧 `unlisten` 自身幂等)。竞态因此被消除,而不是把异常吞掉。
*
* 上游同因修复:tauri-apps/tauri#15799 / #15800(tauri 2.12 起脚本自带条目判空)。
* 本项目升级到 tauri >= 2.12 之后,这里可以退回直接用 `@tauri-apps/api/event` 的
* `listen`,届时删掉 internals 分支即可。
*
* 真实 WebView 之外(浏览器预览、Vitest 的 jsdom 替身)没有
* `window.__TAURI_INTERNALS__`,此时沿用注入的 `window.__TAURI__.event.listen`,
* 保持各测试套件现有的替身注入点不变。
*/
/** event 插件 `target`:与 `@tauri-apps/api/event` 的 `EventTarget` 一致。 */
export type TauriEventTarget =
| { kind: 'Any' }
| { kind: 'AnyLabel'; label: string }
| { kind: 'WebviewWindow'; label: string }
| { kind: 'Webview'; label: string }
| { kind: 'Window'; label: string };
/** 事件回调收到的数据,与 tauri `runCallback(listener.handlerId, eventData)` 的形状一致。 */
export type TauriEventPayload<Payload> = {
event: string;
id: number;
payload: Payload;
};
/** 注销函数。重复调用安全(只发一次后端注销)。 */
export type TauriEventUnsubscribe = () => void;
export type TauriEventHandler<Payload> = (
event: TauriEventPayload<Payload>,
) => void;
export type TauriEventSubscribeOptions = {
/** 默认 `{ kind: 'Any' }`;窗口事件等需要显式限定目标时传字符串标签或 target 对象。 */
target?: TauriEventTarget | string;
};
type TauriEventInternals = {
invoke?: (
command: string,
args?: Record<string, unknown>,
) => Promise<unknown>;
transformCallback?: (
callback: (eventData: unknown) => void,
once?: boolean,
) => number;
unregisterCallback?: (handlerId: number) => void;
};
type TauriGlobalEventBridge = <Payload = unknown>(
event: string,
handler: (event: { payload: Payload }) => void,
options?: { target?: TauriEventTarget | string },
) => Promise<() => void>;
type InternalsWindow = Window & {
__TAURI_INTERNALS__?: TauriEventInternals;
};
const NOT_SUBSCRIBED: TauriEventUnsubscribe = () => {};
function resolveTauriEventInternals(): TauriEventInternals | null {
if (typeof window === 'undefined') return null;
const internals = (window as InternalsWindow).__TAURI_INTERNALS__;
if (
!internals ||
typeof internals.invoke !== 'function' ||
typeof internals.transformCallback !== 'function' ||
typeof internals.unregisterCallback !== 'function'
) {
return null;
}
return internals;
}
function resolveGlobalEventBridge(): TauriGlobalEventBridge | null {
if (typeof window === 'undefined') return null;
const bridge = window.__TAURI__?.event?.listen;
return typeof bridge === 'function'
? (bridge as TauriGlobalEventBridge)
: null;
}
function normalizeTarget(
target: TauriEventTarget | string | undefined,
): TauriEventTarget {
if (!target) return { kind: 'Any' };
return typeof target === 'string'
? { kind: 'AnyLabel', label: target }
: target;
}
/**
* 当前运行时是否能订阅 Tauri 事件。用于替换调用点原来的
* `const listen = window.__TAURI__?.event?.listen; if (!listen) return;` 守卫。
*/
export function canSubscribeTauriEvents(): boolean {
return Boolean(resolveTauriEventInternals() ?? resolveGlobalEventBridge());
}
/**
* 订阅一个 Tauri 事件,resolve 出幂等的注销函数;没有任何桥接时 resolve 空操作。
*/
export function subscribeTauriEvent<Payload>(
event: string,
handler: TauriEventHandler<Payload>,
options: TauriEventSubscribeOptions = {},
): Promise<TauriEventUnsubscribe> {
const internals = resolveTauriEventInternals();
if (internals) {
return subscribeThroughTauriInternals(internals, event, handler, options);
}
const bridge = resolveGlobalEventBridge();
if (bridge) {
return subscribeThroughGlobalBridge(bridge, event, handler, options);
}
return Promise.resolve(NOT_SUBSCRIBED);
}
async function subscribeThroughTauriInternals<Payload>(
internals: TauriEventInternals,
event: string,
handler: TauriEventHandler<Payload>,
options: TauriEventSubscribeOptions,
): Promise<TauriEventUnsubscribe> {
const invoke = internals.invoke as NonNullable<TauriEventInternals['invoke']>;
const transformCallback = internals.transformCallback as NonNullable<
TauriEventInternals['transformCallback']
>;
const unregisterCallback = internals.unregisterCallback as NonNullable<
TauriEventInternals['unregisterCallback']
>;
const handlerId = transformCallback((eventData) => {
handler(eventData as TauriEventPayload<Payload>);
});
let eventId: number;
try {
eventId = (await invoke('plugin:event|listen', {
event,
target: normalizeTarget(options.target),
handler: handlerId,
})) as number;
} catch (error) {
// 登记失败就别把刚建的 JS 回调留在 callbacks 里。
unregisterCallback(handlerId);
throw error;
}
let released = false;
return () => {
if (released) return;
released = true;
// 先摘 JS 回调:`callbacks.delete` 幂等,即使注册 eval 还没落地也不会抛。
unregisterCallback(handlerId);
void invoke('plugin:event|unlisten', { event, eventId }).catch((error) => {
// 显式记录:后端订阅没摘干净属于必须能看到的异常,不做静默吞掉。
console.warn(`[AGC] 取消 Tauri 事件订阅失败:${event}`, error);
});
};
}
async function subscribeThroughGlobalBridge<Payload>(
bridge: TauriGlobalEventBridge,
event: string,
handler: TauriEventHandler<Payload>,
options: TauriEventSubscribeOptions,
): Promise<TauriEventUnsubscribe> {
const wrapped = (eventData: unknown) =>
handler(eventData as TauriEventPayload<Payload>);
// 没有显式 target 时只传两个参数:注入的事件替身与全局桥都按两参签名实现与断言。
const unlisten =
options.target === undefined
? await bridge<Payload>(event, wrapped)
: await bridge<Payload>(event, wrapped, {
target: normalizeTarget(options.target),
});
let released = false;
return () => {
if (released) return;
released = true;
unlisten();
};
}