From 6618207b5b1eaaacb65bfe76c676fb728ed9e588 Mon Sep 17 00:00:00 2001 From: Suzumiya Date: Sat, 12 Sep 2026 17:23:22 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E8=BF=90=E8=A1=8C=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=E5=88=87=E6=8D=A2=E7=89=88=E6=9C=AC=E6=8A=A5=20listen?= =?UTF-8?q?ers[eventId].handlerId=EF=BC=9A=E4=BA=8B=E4=BB=B6=E8=AE=A2?= =?UTF-8?q?=E9=98=85=E6=94=B9=E4=B8=BA=E8=87=AA=E5=BB=BA=E7=99=BB=E8=AE=B0?= =?UTF-8?q?=20+=20=E5=B9=82=E7=AD=89=E6=B3=A8=E9=94=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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:记录该竞态的成因、触发面、处理取舍与变异验证结论。 --- apps/ai-game-creator-shell/src/App.tsx | 198 ++++++++------- .../src/components/AppUpdateNotice.tsx | 9 +- .../src/components/WindowChrome.tsx | 9 +- .../app-shell/useDeveloperAgentPanel.ts | 179 ++++++------- .../src/services/errorReportingBridge.ts | 32 +-- .../src/services/tauriEventSubscription.ts | 208 +++++++++++++++ ...runVersionSwitchEventSubscription.test.tsx | 191 ++++++++++++++ .../tests/tauriEventFake.ts | 239 ++++++++++++++++++ .../tests/tauriEventSubscription.test.ts | 178 +++++++++++++ docs/project-memory/shared-memory/pitfalls.md | 10 + 10 files changed, 1047 insertions(+), 206 deletions(-) create mode 100644 apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts create mode 100644 apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx create mode 100644 apps/ai-game-creator-shell/tests/tauriEventFake.ts create mode 100644 apps/ai-game-creator-shell/tests/tauriEventSubscription.test.ts diff --git a/apps/ai-game-creator-shell/src/App.tsx b/apps/ai-game-creator-shell/src/App.tsx index 0c1c63ff5..97b9e4bc7 100644 --- a/apps/ai-game-creator-shell/src/App.tsx +++ b/apps/ai-game-creator-shell/src/App.tsx @@ -264,6 +264,10 @@ import { currentPlatformSessionGeneration, requestPlatformSessionRefresh, } from './services/platformSession'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, +} from './services/tauriEventSubscription'; import type { HomeCreationType } from './view/home'; import { type ProjectAgentResultSummary, @@ -1462,13 +1466,12 @@ export function App({ if (!directCodexProductRuntime) { return; } - const listen = window.__TAURI__?.event?.listen; - if (!listen) { + if (!canSubscribeTauriEvents()) { return; } let cleanup: (() => void) | null = null; let disposed = false; - void listen( + void subscribeTauriEvent( 'game-creator-direct-turn-update', (event) => { const payload = event.payload; @@ -1547,36 +1550,40 @@ export function App({ if (projectSupervisorOnly && !directCodexProductRuntime) { return; } - const listen = window.__TAURI__?.event?.listen; - if (!listen) { + if (!canSubscribeTauriEvents()) { return; } let cleanup: (() => void) | null = null; let disposed = false; - void listen('game-creator-agent-progress', (event) => { - if (event.payload.projectPath !== localProjectPathRef.current) { - return; - } - if (directCodexProductRuntime) { - const activeTurn = activeDirectCodexTurnRef.current; - if ( - !activeTurn || - activeTurn.projectPath !== event.payload.projectPath || - activeTurn.receivedDirectUpdate - ) { + void subscribeTauriEvent( + 'game-creator-agent-progress', + (event) => { + if (event.payload.projectPath !== localProjectPathRef.current) { return; } - const progressDetail = ensureDirectProcessPrefix(event.payload.message); - setDirectCodexStatus('running'); - setDirectCodexProgress(progressDetail); - setDirectCodexProgressUpdatedAt(Date.now()); - return; - } - setMessages((current) => [ - ...current, - { role: 'assistant', text: event.payload.message }, - ]); - }) + if (directCodexProductRuntime) { + const activeTurn = activeDirectCodexTurnRef.current; + if ( + !activeTurn || + activeTurn.projectPath !== event.payload.projectPath || + activeTurn.receivedDirectUpdate + ) { + return; + } + const progressDetail = ensureDirectProcessPrefix( + event.payload.message, + ); + setDirectCodexStatus('running'); + setDirectCodexProgress(progressDetail); + setDirectCodexProgressUpdatedAt(Date.now()); + return; + } + setMessages((current) => [ + ...current, + { role: 'assistant', text: event.payload.message }, + ]); + }, + ) .then((unlisten) => { if (disposed) { unlisten(); @@ -1601,14 +1608,17 @@ export function App({ }, [directCodexProductRuntime, projectSupervisorOnly]); useEffect(() => { - const listen = window.__TAURI__?.event?.listen; const invoke = resolveTauriInvoke(); - if (!listen || directCodexProductRuntime || planningV2Active) { + if ( + !canSubscribeTauriEvents() || + directCodexProductRuntime || + planningV2Active + ) { return; } let cleanup: (() => void) | null = null; let disposed = false; - void listen( + void subscribeTauriEvent( 'game-creator-agent-runtime-update', (event) => { const payload = event.payload; @@ -1712,13 +1722,12 @@ export function App({ ]); useEffect(() => { - const listen = window.__TAURI__?.event?.listen; - if (!listen || !planningV2Active) { + if (!canSubscribeTauriEvents() || !planningV2Active) { return; } let cleanup: (() => void) | null = null; let disposed = false; - void listen( + void subscribeTauriEvent( 'planning-session-v2-stream', (event) => { const payload = event.payload; @@ -1769,13 +1778,12 @@ export function App({ }, [planningV2Active]); useEffect(() => { - const listen = window.__TAURI__?.event?.listen; - if (!listen) { + if (!canSubscribeTauriEvents()) { return; } let cleanup: (() => void) | null = null; let disposed = false; - void listen( + void subscribeTauriEvent( 'game-creator-manifest-invalidated', (event) => { if (event.payload.projectPath !== localProjectPathRef.current) { @@ -3639,66 +3647,68 @@ export function App({ setAgentConversationMessages(savedUserMessages); setAgentConversationStatus('正在连接 Agent LLM'); const streamRunId = createAgentChatRunId('agent-conversation'); - const listen = window.__TAURI__?.event?.listen; - if (listen) { + if (canSubscribeTauriEvents()) { try { - stopStreamListen = await listen( - 'game-creator-role-agent-chat-stream', - (event) => { - const payload = event.payload; - if ( - payload.projectPath !== nextProjectPath || - payload.agentId !== agent.id || - payload.runId !== streamRunId || - agentConversationLoadVersionRef.current !== saveVersion - ) { - return; - } - if (payload.runtimeState) { - setAgentConversationRuntime((current) => { - const nextRuntime = normalizeAgentRuntimeState( - payload.runtimeState!, - current, - ); - rememberAgentRuntimeState(nextRuntime); - return nextRuntime; - }); - setAgentConversationRuntimeError(''); - } - if (payload.status === 'started') { - setAgentConversationStatus( - payload.runtimeSummary ?? 'Agent 已连接,正在等待回复', - ); - return; - } - if (payload.status === 'delta') { - const draftText = payload.accumulatedText || payload.deltaText; - if (draftText) { - setAgentConversationMessages([ - ...savedUserMessages, - createLocalConversationDraftMessage(draftText), - ]); + stopStreamListen = + await subscribeTauriEvent( + 'game-creator-role-agent-chat-stream', + (event) => { + const payload = event.payload; + if ( + payload.projectPath !== nextProjectPath || + payload.agentId !== agent.id || + payload.runId !== streamRunId || + agentConversationLoadVersionRef.current !== saveVersion + ) { + return; } - setAgentConversationStatus( - payload.finishReason - ? `Agent 回复结束:${payload.finishReason}` - : '正在接收 Agent 回复', - ); - return; - } - if (payload.status === 'completed') { - setAgentConversationStatus( - payload.runtimeSummary ?? 'Agent 回复完成,正在保存', - ); - return; - } - if (payload.status === 'failed') { - setAgentConversationStatus( - payload.runtimeSummary ?? 'Agent 流式回复失败,正在记录错误', - ); - } - }, - ); + if (payload.runtimeState) { + setAgentConversationRuntime((current) => { + const nextRuntime = normalizeAgentRuntimeState( + payload.runtimeState!, + current, + ); + rememberAgentRuntimeState(nextRuntime); + return nextRuntime; + }); + setAgentConversationRuntimeError(''); + } + if (payload.status === 'started') { + setAgentConversationStatus( + payload.runtimeSummary ?? 'Agent 已连接,正在等待回复', + ); + return; + } + if (payload.status === 'delta') { + const draftText = + payload.accumulatedText || payload.deltaText; + if (draftText) { + setAgentConversationMessages([ + ...savedUserMessages, + createLocalConversationDraftMessage(draftText), + ]); + } + setAgentConversationStatus( + payload.finishReason + ? `Agent 回复结束:${payload.finishReason}` + : '正在接收 Agent 回复', + ); + return; + } + if (payload.status === 'completed') { + setAgentConversationStatus( + payload.runtimeSummary ?? 'Agent 回复完成,正在保存', + ); + return; + } + if (payload.status === 'failed') { + setAgentConversationStatus( + payload.runtimeSummary ?? + 'Agent 流式回复失败,正在记录错误', + ); + } + }, + ); streamListenReady = true; if (streamListenDisposed) { stopStreamListen(); diff --git a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx index fa9630719..858442126 100644 --- a/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx +++ b/apps/ai-game-creator-shell/src/components/AppUpdateNotice.tsx @@ -1,4 +1,3 @@ -import { listen } from '@tauri-apps/api/event'; import { Download, LoaderCircle, X } from 'lucide-react'; import { useEffect, useState } from 'react'; @@ -10,6 +9,10 @@ import { downloadAppUpdate, subscribeToAppUpdate, } from '../services/appUpdate'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, +} from '../services/tauriEventSubscription'; type DownloadProgress = { downloadedBytes: number; @@ -46,10 +49,10 @@ export function AppUpdateNotice() { }, []); useEffect(() => { - if (typeof window === 'undefined' || !window.__TAURI__ || !update) return; + if (!canSubscribeTauriEvents() || !update) return; let disposed = false; let unlisten: (() => void) | undefined; - void listen( + void subscribeTauriEvent( AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT, (event) => { if (!disposed) setDownloadProgress(event.payload); diff --git a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx index 265de4f43..a9c5e2f29 100644 --- a/apps/ai-game-creator-shell/src/components/WindowChrome.tsx +++ b/apps/ai-game-creator-shell/src/components/WindowChrome.tsx @@ -3,6 +3,7 @@ import { Copy, Minus, Square, X } from 'lucide-react'; import { type ReactNode, useCallback, useEffect, useState } from 'react'; import brandIcon from '../../../../packages/shared/src/icons/taonier-product-ip.png'; +import { subscribeTauriEvent } from '../services/tauriEventSubscription'; import { AppUpdateNotice } from './AppUpdateNotice'; import { WINDOW_CHROME_DEFAULT_TITLE, @@ -74,8 +75,12 @@ export function WindowChrome({ children }: WindowChromeProps) { }; syncMaximizedState(); - void nativeWindow - .onResized(syncMaximizedState) + // 直接用窗口订阅入口而不是 `nativeWindow.onResized`:后者在 tauri 2.11 上会 + // 走库内那条「读监听注册表条目再注销」的竞态路径(挂载即注销时抛 + // `listeners[eventId].handlerId`),窗口标题栏又是每次启动必挂载的组件。 + void subscribeTauriEvent('tauri://resize', syncMaximizedState, { + target: { kind: 'Window', label: nativeWindow.label }, + }) .then((unlisten) => { if (disposed) { unlisten(); diff --git a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts index 6a4cbc30a..e10054496 100644 --- a/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts +++ b/apps/ai-game-creator-shell/src/features/app-shell/useDeveloperAgentPanel.ts @@ -23,6 +23,10 @@ import type { LocalConversationResult, TauriInvoke, } from '../../app/types'; +import { + canSubscribeTauriEvents, + subscribeTauriEvent, +} from '../../services/tauriEventSubscription'; import { type LauncherView } from '../../view/layout'; import { agentGoalStatusIsTerminal, @@ -269,18 +273,21 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { } useEffect(() => { - const listen = window.__TAURI__?.event?.listen; const invoke = resolveTauriInvoke(); // WorkspaceLauncher stays mounted while users work in a project. The // legacy developer Agent Runtime listener must therefore exist only on // the explicit developer Agent chat surface, never behind direct Codex // project workbenches. - if (launcherView !== 'agent-chat' || !listen || !invoke) { + if ( + launcherView !== 'agent-chat' || + !canSubscribeTauriEvents() || + !invoke + ) { return; } let cleanup: (() => void) | null = null; let disposed = false; - void listen( + void subscribeTauriEvent( 'game-creator-agent-runtime-update', (event) => { const payload = event.payload; @@ -1273,93 +1280,95 @@ export function useDeveloperAgentPanel(launcherView: LauncherView) { setAgentChatReplyPhase('connecting'); setAgentChatStatus('正在连接 Agent LLM'); const streamRunId = createAgentChatRunId('launcher-agent-chat'); - const listen = window.__TAURI__?.event?.listen; - if (listen) { + if (canSubscribeTauriEvents()) { try { - stopStreamListen = await listen( - 'game-creator-role-agent-chat-stream', - (event) => { - const payload = event.payload; - if ( - payload.projectPath !== projectPathForChat || - payload.agentId !== agent.id || - payload.runId !== streamRunId || - agentChatLoadVersionRef.current !== saveVersion - ) { - return; - } - if (payload.runtimeState && payload.status !== 'delta') { - setAgentChatRuntime((current) => - normalizeAgentRuntimeState(payload.runtimeState!, current), - ); - setAgentChatActiveRuntime((current) => - normalizeAgentRuntimeState(payload.runtimeState!, current), - ); - setAgentChatRuntimeError(''); - } - if (payload.status === 'started') { - setAgentChatReplyPhase('waiting-first-content'); - setAgentChatStatus( - payload.runtimeSummary - ? `已连接 Agent LLM,${payload.runtimeSummary}` - : '已连接 Agent LLM,正在等待首个回复片段', - ); - return; - } - if (payload.status === 'delta') { - setAgentChatReplyPhase('streaming'); - const draftText = payload.accumulatedText || payload.deltaText; - if (draftText) { - pendingStreamDraftText = draftText; + stopStreamListen = + await subscribeTauriEvent( + 'game-creator-role-agent-chat-stream', + (event) => { + const payload = event.payload; + if ( + payload.projectPath !== projectPathForChat || + payload.agentId !== agent.id || + payload.runId !== streamRunId || + agentChatLoadVersionRef.current !== saveVersion + ) { + return; } - pendingStreamFinishReason = payload.finishReason ?? null; - if (streamFrameId === null) { - streamFrameId = window.requestAnimationFrame(() => { + if (payload.runtimeState && payload.status !== 'delta') { + setAgentChatRuntime((current) => + normalizeAgentRuntimeState(payload.runtimeState!, current), + ); + setAgentChatActiveRuntime((current) => + normalizeAgentRuntimeState(payload.runtimeState!, current), + ); + setAgentChatRuntimeError(''); + } + if (payload.status === 'started') { + setAgentChatReplyPhase('waiting-first-content'); + setAgentChatStatus( + payload.runtimeSummary + ? `已连接 Agent LLM,${payload.runtimeSummary}` + : '已连接 Agent LLM,正在等待首个回复片段', + ); + return; + } + if (payload.status === 'delta') { + setAgentChatReplyPhase('streaming'); + const draftText = + payload.accumulatedText || payload.deltaText; + if (draftText) { + pendingStreamDraftText = draftText; + } + pendingStreamFinishReason = payload.finishReason ?? null; + if (streamFrameId === null) { + streamFrameId = window.requestAnimationFrame(() => { + streamFrameId = null; + if (agentChatLoadVersionRef.current !== saveVersion) { + return; + } + if (pendingStreamDraftText) { + setAgentChatMessages([ + ...savedUserMessages, + createLocalConversationDraftMessage( + pendingStreamDraftText, + streamDraftUpdatedAt, + ), + ]); + } + setAgentChatStatus( + pendingStreamFinishReason + ? `Agent 回复结束:${pendingStreamFinishReason}` + : '正在接收 Agent 回复', + ); + }); + } + return; + } + if (payload.status === 'completed') { + if (streamFrameId !== null) { + window.cancelAnimationFrame(streamFrameId); streamFrameId = null; - if (agentChatLoadVersionRef.current !== saveVersion) { - return; - } - if (pendingStreamDraftText) { - setAgentChatMessages([ - ...savedUserMessages, - createLocalConversationDraftMessage( - pendingStreamDraftText, - streamDraftUpdatedAt, - ), - ]); - } - setAgentChatStatus( - pendingStreamFinishReason - ? `Agent 回复结束:${pendingStreamFinishReason}` - : '正在接收 Agent 回复', - ); - }); + } + setAgentChatReplyPhase('saving-reply'); + setAgentChatStatus( + payload.runtimeSummary ?? 'Agent 回复完成,正在保存', + ); + return; } - return; - } - if (payload.status === 'completed') { - if (streamFrameId !== null) { - window.cancelAnimationFrame(streamFrameId); - streamFrameId = null; + if (payload.status === 'failed') { + if (streamFrameId !== null) { + window.cancelAnimationFrame(streamFrameId); + streamFrameId = null; + } + setAgentChatReplyPhase('saving-reply'); + setAgentChatStatus( + payload.runtimeSummary ?? + 'Agent 流式回复失败,正在记录错误', + ); } - setAgentChatReplyPhase('saving-reply'); - setAgentChatStatus( - payload.runtimeSummary ?? 'Agent 回复完成,正在保存', - ); - return; - } - if (payload.status === 'failed') { - if (streamFrameId !== null) { - window.cancelAnimationFrame(streamFrameId); - streamFrameId = null; - } - setAgentChatReplyPhase('saving-reply'); - setAgentChatStatus( - payload.runtimeSummary ?? 'Agent 流式回复失败,正在记录错误', - ); - } - }, - ); + }, + ); streamListenReady = true; if (streamListenDisposed) { stopStreamListen(); diff --git a/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts b/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts index 1e36fb9ee..4f4130095 100644 --- a/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts +++ b/apps/ai-game-creator-shell/src/services/errorReportingBridge.ts @@ -1,7 +1,8 @@ import { invoke } from '@tauri-apps/api/core'; -import { listen, type UnlistenFn } from '@tauri-apps/api/event'; +import type { UnlistenFn } from '@tauri-apps/api/event'; import type { ClientErrorEvent } from './errorReporting'; +import { subscribeTauriEvent } from './tauriEventSubscription'; type RustErrorReportEvent = ClientErrorEvent & { lastOccurredAt?: string; @@ -36,28 +37,15 @@ export function subscribeErrorReportUpdates( listener(); }; - // 客户端也会运行在浏览器预览和 Vitest 的 jsdom 中。Tauri API 模块默认 - // `__TAURI_INTERNALS__` 已存在,缺失时会拒绝,因此原生 WebView 之外保持 - // 桥接为空操作。启用 `withGlobalTauri` 时优先使用全局桥接,让测试替身与 - // 客户端其它事件订阅复用同一入口。 - if (typeof window !== 'undefined') { - const globalListen = window.__TAURI__?.event?.listen; - if (globalListen) { - return globalListen('error-report-updated', handleUpdate).catch( - () => () => {}, - ); - } - - const internals = (window as Window & { __TAURI_INTERNALS__?: unknown }) - .__TAURI_INTERNALS__; - if (!internals) { - return Promise.resolve(() => {}); - } - } else { + // 客户端也会运行在浏览器预览和 Vitest 的 jsdom 中:`subscribeTauriEvent` + // 在原生 WebView 内走自建登记(避开 tauri 2.11 的注销竞态), + // 其它环境沿用注入的事件桥接(测试替身)或返回空操作。 + if (typeof window === 'undefined') { return Promise.resolve(() => {}); } - return listen('error-report-updated', handleUpdate).catch( - () => () => {}, - ); + return subscribeTauriEvent( + 'error-report-updated', + handleUpdate, + ).catch(() => () => {}); } diff --git a/apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts b/apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts new file mode 100644 index 000000000..2a544081b --- /dev/null +++ b/apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts @@ -0,0 +1,208 @@ +/** + * 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 = { + event: string; + id: number; + payload: Payload; +}; + +/** 注销函数。重复调用安全(只发一次后端注销)。 */ +export type TauriEventUnsubscribe = () => void; + +export type TauriEventHandler = ( + event: TauriEventPayload, +) => void; + +export type TauriEventSubscribeOptions = { + /** 默认 `{ kind: 'Any' }`;窗口事件等需要显式限定目标时传字符串标签或 target 对象。 */ + target?: TauriEventTarget | string; +}; + +type TauriEventInternals = { + invoke?: ( + command: string, + args?: Record, + ) => Promise; + transformCallback?: ( + callback: (eventData: unknown) => void, + once?: boolean, + ) => number; + unregisterCallback?: (handlerId: number) => void; +}; + +type TauriGlobalEventBridge = ( + 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( + event: string, + handler: TauriEventHandler, + options: TauriEventSubscribeOptions = {}, +): Promise { + 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( + internals: TauriEventInternals, + event: string, + handler: TauriEventHandler, + options: TauriEventSubscribeOptions, +): Promise { + const invoke = internals.invoke as NonNullable; + const transformCallback = internals.transformCallback as NonNullable< + TauriEventInternals['transformCallback'] + >; + const unregisterCallback = internals.unregisterCallback as NonNullable< + TauriEventInternals['unregisterCallback'] + >; + + const handlerId = transformCallback((eventData) => { + handler(eventData as TauriEventPayload); + }); + + 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( + bridge: TauriGlobalEventBridge, + event: string, + handler: TauriEventHandler, + options: TauriEventSubscribeOptions, +): Promise { + const wrapped = (eventData: unknown) => + handler(eventData as TauriEventPayload); + // 没有显式 target 时只传两个参数:注入的事件替身与全局桥都按两参签名实现与断言。 + const unlisten = + options.target === undefined + ? await bridge(event, wrapped) + : await bridge(event, wrapped, { + target: normalizeTarget(options.target), + }); + let released = false; + return () => { + if (released) return; + released = true; + unlisten(); + }; +} diff --git a/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx b/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx new file mode 100644 index 000000000..073cdf6f4 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/runVersionSwitchEventSubscription.test.tsx @@ -0,0 +1,191 @@ +// @vitest-environment jsdom + +/** + * 运行模块「切换游戏版本」的事件订阅回归用例。 + * + * 背景:tauri 2.11 的 lib 注销脚本会先读 webview 侧监听注册表条目 + * (`listeners[eventId].handlerId`),而条目由注册 eval 异步写入;AGC 在 + * `React.StrictMode` 下挂载即注销,运行模块切换版本又会让工作区重渲染、 + * 重载预览并触发运行时事件,因此这条路径最容易撞上该竞态并抛出 + * `undefined is not an object (evaluating 'listeners[eventId].handlerId')`。 + * + * 这里用与 tauri 2.11 等价的替身(见 `tauriEventFake.ts`)驱动真实的 + * `WorkspaceLauncher` + 运行模块 + 版本切换,断言: + * 1. 切换版本(含连续切换两次)期间不会走库内那条会读注册表条目的注销路径; + * 2. 组件卸载后后端订阅不泄漏(旧写法会因抛在 invoke 之前而泄漏); + * 3. 新登记路径仍然真的能把事件送到 App(清单失效 → 重读清单)。 + */ + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { GameCreationAppManifest } from '../../../packages/shared/src/contracts/gameCreationApp'; +import { + cleanup, + createGameCreationAppManifest, + createGameCreationAppSeedTasks, + createProjectSupervisorRuntimeHarness, + fireEvent, + pickProjectFromLauncher, + renderLauncherProjectsAt, + screen, + waitFor, + within, +} from './appSurface/harness'; +import { createTauriEventFake, type TauriEventFake } from './tauriEventFake'; + +const PROJECT_PATH = '/tmp/run-module-version-switch'; + +let fake: TauriEventFake | null = null; + +afterEach(() => { + cleanup(); + fake?.restore(); + fake = null; + vi.restoreAllMocks(); +}); + +function createVersionedManifest(): GameCreationAppManifest { + const manifest = createGameCreationAppManifest( + 'run-module-version-switch', + '运行模块版本切换', + ); + // 运行模块的可用性与版本入口无关,这里用一条已完成原型任务把它打开。 + manifest.tasks = createGameCreationAppSeedTasks().map((task) => + task.id === 'code-prototype' + ? { ...task, status: 'completed' as const } + : task, + ); + // createdAt 是 Unix 秒(写入侧 manifest.rs 用 unix_timestamp())。 + manifest.versions = [ + { + versionId: 'version-root', + parentVersionId: null, + projectRevision: 3, + resourceBindings: [], + createdReason: 'initial', + createdAt: 1_788_075_047, + }, + { + versionId: 'version-child', + parentVersionId: 'version-root', + projectRevision: 4, + resourceBindings: [], + createdReason: 'agent-revision', + createdAt: 1_788_075_104, + }, + ]; + return manifest; +} + +function installProjectWithRunModule(manifest: GameCreationAppManifest) { + const supervisorHarness = createProjectSupervisorRuntimeHarness({ + projectPath: PROJECT_PATH, + initialSessionExists: false, + }); + const invokeCounts = new Map(); + const invoke = vi.fn( + async (command: string, args?: Record) => { + invokeCounts.set(command, (invokeCounts.get(command) ?? 0) + 1); + if (command === 'inspect_local_project_directory') { + return { + projectPath: PROJECT_PATH, + exists: true, + isDirectory: true, + isGameCreatorProject: true, + projectName: '运行模块版本切换', + recentRunStatus: null, + recentRunStopReason: null, + }; + } + if (command === 'get_local_game_manifest') { + return manifest; + } + if (command === 'get_local_game_preview_status') { + return { status: 'stopped', url: null, port: null, root: null }; + } + if (command === 'start_local_game_preview') { + return { + url: 'http://127.0.0.1:43210/game/index.html', + port: 43210, + root: PROJECT_PATH, + }; + } + return supervisorHarness.invoke(command, args); + }, + ); + + fake = createTauriEventFake({ invoke }); + fake.install(); + + renderLauncherProjectsAt('/?launcher'); + pickProjectFromLauncher(PROJECT_PATH); + + return { invoke, invokeCounts }; +} +async function openRunModuleAndPickVersion(label: RegExp) { + fireEvent.click(screen.getByRole('tab', { name: '运行' })); + fireEvent.click(await screen.findByLabelText(/^当前版本:/)); + const menu = await screen.findByRole('listbox', { name: '切换游戏版本' }); + fireEvent.click(within(menu).getByRole('option', { name: label })); +} + +describe('运行模块切换版本时的事件订阅', () => { + it('切换版本期间不读监听注册表条目,卸载后也不泄漏后端订阅', async () => { + const { invoke } = installProjectWithRunModule(createVersionedManifest()); + + await screen.findByLabelText('陶泥儿项目对话'); + const activeFake = fake as TauriEventFake; + expect(activeFake.listeners.length).toBeGreaterThan(0); + + // 连续切换两次(用户口径:反复切换两次以上不再报错)。 + await openRunModuleAndPickVersion(/初始版本/); + await waitFor(() => + expect(invoke).toHaveBeenCalledWith('start_local_game_preview', { + projectPath: PROJECT_PATH, + }), + ); + await openRunModuleAndPickVersion(/智能体修订/); + + // 切换与重渲染全程都不该碰库内那条「先读注册表条目」的注销脚本。 + expect(activeFake.racyUnregisterCalls).toBe(0); + expect(activeFake.racyUnregisterViolations).toEqual([]); + + // 卸载:此时注册 eval 还没落地(故意不 flush),旧写法会在这里抛错并泄漏。 + cleanup(); + + expect(activeFake.racyUnregisterViolations).toEqual([]); + expect(activeFake.listeners).toEqual([]); + expect(activeFake.jsCallbackCount()).toBe(0); + }); + + it('切换版本后仍能收到清单失效事件并重读清单', async () => { + const { invokeCounts } = installProjectWithRunModule( + createVersionedManifest(), + ); + + await screen.findByLabelText('陶泥儿项目对话'); + const activeFake = fake as TauriEventFake; + // 让注册 eval 落地:之后 `emit` 才会真正投递到 AGC 的 handler。 + activeFake.flushRegistrationEvals(); + + await openRunModuleAndPickVersion(/初始版本/); + await waitFor(() => + expect(invokeCounts.get('start_local_game_preview') ?? 0).toBeGreaterThan( + 0, + ), + ); + + const manifestReadsBefore = + invokeCounts.get('get_local_game_manifest') ?? 0; + activeFake.emit('game-creator-manifest-invalidated', { + projectPath: PROJECT_PATH, + agentId: 'project-supervisor', + }); + + await waitFor(() => + expect(invokeCounts.get('get_local_game_manifest') ?? 0).toBeGreaterThan( + manifestReadsBefore, + ), + ); + }); +}); diff --git a/apps/ai-game-creator-shell/tests/tauriEventFake.ts b/apps/ai-game-creator-shell/tests/tauriEventFake.ts new file mode 100644 index 000000000..bcc8b4d7d --- /dev/null +++ b/apps/ai-game-creator-shell/tests/tauriEventFake.ts @@ -0,0 +1,239 @@ +/** + * 与 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; + /** 每个事件的 `plugin:event|listen` 调用次数。 */ + listenInvokeCounts: Map; + /** 还没落地的注册 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, + ) => Promise; +}; + +const INTERNALS_KEY = '__TAURI_INTERNALS__'; +const EVENT_INTERNALS_KEY = '__TAURI_EVENT_PLUGIN_INTERNALS__'; +const GLOBAL_KEY = '__TAURI__'; + +type MutableWindow = Window & Record; + +export function createTauriEventFake( + options: FakeOptions = {}, +): TauriEventFake { + const listeners: FakeListener[] = []; + const racyUnregisterViolations: string[] = []; + const unlistenInvokeCounts = new Map(); + const listenInvokeCounts = new Map(); + const jsCallbacks = new Map 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> = {}; + + 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) => { + 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; +} diff --git a/apps/ai-game-creator-shell/tests/tauriEventSubscription.test.ts b/apps/ai-game-creator-shell/tests/tauriEventSubscription.test.ts new file mode 100644 index 000000000..43a9aa8e3 --- /dev/null +++ b/apps/ai-game-creator-shell/tests/tauriEventSubscription.test.ts @@ -0,0 +1,178 @@ +// @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).__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).__TAURI__; + delete (window as unknown as Record).__TAURI_INTERNALS__; + + expect(canSubscribeTauriEvents()).toBe(false); + const unsubscribe = await subscribeTauriEvent( + 'error-report-updated', + vi.fn(), + ); + expect(typeof unsubscribe).toBe('function'); + expect(() => unsubscribe()).not.toThrow(); + }); +}); diff --git a/docs/project-memory/shared-memory/pitfalls.md b/docs/project-memory/shared-memory/pitfalls.md index d6f01d3ab..3b3461f6e 100644 --- a/docs/project-memory/shared-memory/pitfalls.md +++ b/docs/project-memory/shared-memory/pitfalls.md @@ -5380,3 +5380,13 @@ - **处理**:把 CAS 判据收窄到契约面——新增 `manifestCasFingerprint`(`{ assets, versions }`),同 revision 下三态:判据面不同 → 仍 `revision-conflict`(真实的漏推 revision 必须继续可见,不许被吞掉);判据面相同且整份清单相同 → `duplicate`;判据面相同、只有非 CAS 账目状态(`preview` / 任务进度 / 项目名)不同 → `accepted`,revision **不推也不退**,让内容真正落地。同类簿记写点(`record_command_run`、`update_manifest_task_status_at`、`rename_local_game_project`、Godot 根校准)同样不推 revision,收窄判据面后不再产生用户可见拒收。 - **验证**:新增用例 `adopts same-revision bookkeeping changes instead of rejecting them`(同 revision 只有 `preview` 从 stopped 变 running ⇒ 必须 `accepted`、`projectManifestMergeRejectionDecision` 为 null、revision 仍为 4)与对照钉子 `still fails closed when the protected assets change under the same revision`(同 revision 多出一条资产 ⇒ 必须仍 `revision-conflict`)。变异验证:把判据改回整份 JSON 指纹 → 新用例以 `expected 'revision-conflict' to be 'accepted'` 失败(正是用户看到的那条决策),恢复后转绿。定向:`projectResourceLiveUpdateModel` 17 passed、`workspaceLauncherManifestMerge` 5 passed、appSurface 413 passed、`npm run typecheck` exit 0、`npm run check:encoding` 4399 files passed、`git diff --check` 干净。 - **关联**:`apps/ai-game-creator-shell/src/view/project-development/projectResourceLiveUpdateModel.ts`、`apps/ai-game-creator-shell/src/features/app-shell/WorkspaceLauncher.tsx`、`apps/ai-game-creator-shell/src/App.tsx`(supervisor 配对读)、`apps/ai-game-creator-shell/src-tauri/src/project/manifest.rs`、`apps/ai-game-creator-shell/tests/projectResourceLiveUpdateModel.test.ts`。 + +## 2026-09-12 运行模块切换版本报 `listeners[eventId].handlerId`:tauri 2.11 的注销竞态,修在订阅入口而不是吞异常 + +- **现象**:运行模块切换游戏版本时(含连续切两次)控制台出现 `undefined is not an object (evaluating 'listeners[eventId].handlerId')`,并经 `unhandledrejection` 上报后在界面右上角弹出「发现问题」通知。 +- **根因(上游同因已确认)**:这条报错来自 tauri 2.11.x 注入的库内注销脚本(`crates/tauri/src/event/mod.rs::unlisten_js_script`):它先读 webview 侧监听注册表条目(`window.__internal_unstable_listeners_object_id__[event][eventId].handlerId`)再摘 JS 回调,而该条目由 `plugin:event|listen` 之后**另一次** eval(`listen_js_script`)写入,与 `plugin:event|listen` 的 IPC 返回没有顺序保证(macOS/WKWebView 的 IPC 响应走自定义协议,可早于那次 eval 落地)。于是「订阅后立刻注销」读到缺失条目并抛错,且抛在 `_unlisten` 第一行 ⇒ `plugin:event|unlisten` 发不出去,后端订阅与 JS 回调一起泄漏,注销过的 handler 之后还会被触发一次。上游 issue tauri-apps/tauri#15799、修复 PR #15800(tauri 2.12 起脚本自带条目判空)。 +- **本仓库为什么必踩**:`apps/ai-game-creator-shell/src/main.tsx` 开着 `React.StrictMode`(挂载 → cleanup → 再挂载),而 App / useDeveloperAgentPanel / ErrorReportNotice / AppUpdateNotice / WindowChrome 里 9 处订阅全是 `void listen(...).then(unlisten => { if (disposed) unlisten(); ... })` 的「cleanup 抢在 listen 前就立刻注销」写法;触发点不止切换版本(实测切换版本本身不新登记监听:探针显示 `plugin:event|listen` 调用数 3 → 3,报错窗口来自每一次挂载/重订阅),运行模块的重渲染 + 预览重载 + 运行时事件只是把它放大到用户可见。 +- **处理**:新增唯一订阅入口 `src/services/tauriEventSubscription.ts`,**不再依赖那条注册表条目**——真实 WebView 内直接用 event 插件 invoke 登记(`plugin:event|listen` + `transformCallback`,拿回 `eventId` 与 `handlerId`),注销时先 `unregisterCallback(handlerId)`(即 `callbacks.delete`,幂等、缺条目也不抛),再调 `plugin:event|unlisten`(Rust 侧自身幂等),并对重复注销去重;注销失败只在 `console.warn` 显式记录,不做空 `try/catch` 吞掉。没有 `window.__TAURI_INTERNALS__` 的环境(浏览器预览、Vitest 里 `window.__TAURI__ = { core, event }` 的替身)仍走注入的 `event.listen`,**各套件的替身注入点与调用形状(两参)保持不变**;`canSubscribeTauriEvents()` 顶替原来的 `?? .event?.listen` 守卫,未接桥接的场景行为不变。 +- **验证**:新增 `tests/tauriEventFake.ts`(与 tauri 2.11.3 等价的替身:注册表条目由注册 eval 异步写入、库内注销脚本读缺失条目即抛并留痕)与两个回归用例——`tauriEventSubscription.test.ts` 7 passed(含「同一事件已有条目落地、注销还没落地的订阅」这一报错现场)、`runVersionSwitchEventSubscription.test.tsx` 2 passed(真实 launcher + 运行模块连续切换两次版本 + 卸载不漏订阅 + 清单失效事件仍能送达)。变异验证:把订阅入口改回「直接用库内 listen」→ 两个文件 3 条红(`racyUnregisterCalls` 1≠0、`jsCallbackCount` 3≠0、`bridgeListenCalls` 1≠0),还原后全绿。定向:`appSurface.test.ts` 413 passed、`resourceVersionSwitch.test.tsx` 4 passed、`errorReporting` / `ErrorReportNotice` / `appUpdate` / `WindowChrome` / `ErrorReportDialog` 全绿、`npm run typecheck` exit 0。 +- **后续(未在本批做)**:tauri 升级到 >= 2.12 后,本入口的 internals 分支可以删掉,退回 `@tauri-apps/api/event` 的 `listen`。 +- **关联**:`apps/ai-game-creator-shell/src/services/tauriEventSubscription.ts`、`src/services/errorReportingBridge.ts`、`src/App.tsx`、`src/features/app-shell/useDeveloperAgentPanel.ts`、`src/components/{AppUpdateNotice.tsx,WindowChrome.tsx}`、`tests/{tauriEventFake.ts,tauriEventSubscription.test.ts,runVersionSwitchEventSubscription.test.tsx}`。