修复运行模块切换版本报 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:记录该竞态的成因、触发面、处理取舍与变异验证结论。
This commit is contained in:
2026-09-12 17:23:22 +08:00
parent 46788edbb8
commit 6618207b5b
10 changed files with 1047 additions and 206 deletions
+104 -94
View File
@@ -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<GameCreatorDirectTurnUpdateEvent>(
void subscribeTauriEvent<GameCreatorDirectTurnUpdateEvent>(
'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<AgentProgressEvent>('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<AgentProgressEvent>(
'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<GameCreatorAgentRuntimeUpdateEvent>(
void subscribeTauriEvent<GameCreatorAgentRuntimeUpdateEvent>(
'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<PlanningSessionStreamEventV2>(
void subscribeTauriEvent<PlanningSessionStreamEventV2>(
'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<GameCreatorManifestInvalidatedEvent>(
void subscribeTauriEvent<GameCreatorManifestInvalidatedEvent>(
'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<GameCreatorRoleAgentChatStreamEvent>(
'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<GameCreatorRoleAgentChatStreamEvent>(
'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();
@@ -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<DownloadProgress>(
void subscribeTauriEvent<DownloadProgress>(
AGC_UPDATE_DOWNLOAD_PROGRESS_EVENT,
(event) => {
if (!disposed) setDownloadProgress(event.payload);
@@ -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();
@@ -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<GameCreatorAgentRuntimeUpdateEvent>(
void subscribeTauriEvent<GameCreatorAgentRuntimeUpdateEvent>(
'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<GameCreatorRoleAgentChatStreamEvent>(
'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<GameCreatorRoleAgentChatStreamEvent>(
'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();
@@ -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<ErrorReportUpdate>('error-report-updated', handleUpdate).catch(
() => () => {},
);
return subscribeTauriEvent<ErrorReportUpdate>(
'error-report-updated',
handleUpdate,
).catch(() => () => {});
}
@@ -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 / #15800tauri 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();
};
}
@@ -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<string, number>();
const invoke = vi.fn(
async (command: string, args?: Record<string, unknown>) => {
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,
),
);
});
});
@@ -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<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;
}
@@ -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<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();
});
});
@@ -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}`