修复运行模块切换版本报 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:
@@ -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 / #15800(tauri 2.12 起脚本自带条目判空)。
|
||||
* 本项目升级到 tauri >= 2.12 之后,这里可以退回直接用 `@tauri-apps/api/event` 的
|
||||
* `listen`,届时删掉 internals 分支即可。
|
||||
*
|
||||
* 真实 WebView 之外(浏览器预览、Vitest 的 jsdom 替身)没有
|
||||
* `window.__TAURI_INTERNALS__`,此时沿用注入的 `window.__TAURI__.event.listen`,
|
||||
* 保持各测试套件现有的替身注入点不变。
|
||||
*/
|
||||
|
||||
/** event 插件 `target`:与 `@tauri-apps/api/event` 的 `EventTarget` 一致。 */
|
||||
export type TauriEventTarget =
|
||||
| { kind: 'Any' }
|
||||
| { kind: 'AnyLabel'; label: string }
|
||||
| { kind: 'WebviewWindow'; label: string }
|
||||
| { kind: 'Webview'; label: string }
|
||||
| { kind: 'Window'; label: string };
|
||||
|
||||
/** 事件回调收到的数据,与 tauri `runCallback(listener.handlerId, eventData)` 的形状一致。 */
|
||||
export type TauriEventPayload<Payload> = {
|
||||
event: string;
|
||||
id: number;
|
||||
payload: Payload;
|
||||
};
|
||||
|
||||
/** 注销函数。重复调用安全(只发一次后端注销)。 */
|
||||
export type TauriEventUnsubscribe = () => void;
|
||||
|
||||
export type TauriEventHandler<Payload> = (
|
||||
event: TauriEventPayload<Payload>,
|
||||
) => void;
|
||||
|
||||
export type TauriEventSubscribeOptions = {
|
||||
/** 默认 `{ kind: 'Any' }`;窗口事件等需要显式限定目标时传字符串标签或 target 对象。 */
|
||||
target?: TauriEventTarget | string;
|
||||
};
|
||||
|
||||
type TauriEventInternals = {
|
||||
invoke?: (
|
||||
command: string,
|
||||
args?: Record<string, unknown>,
|
||||
) => Promise<unknown>;
|
||||
transformCallback?: (
|
||||
callback: (eventData: unknown) => void,
|
||||
once?: boolean,
|
||||
) => number;
|
||||
unregisterCallback?: (handlerId: number) => void;
|
||||
};
|
||||
|
||||
type TauriGlobalEventBridge = <Payload = unknown>(
|
||||
event: string,
|
||||
handler: (event: { payload: Payload }) => void,
|
||||
options?: { target?: TauriEventTarget | string },
|
||||
) => Promise<() => void>;
|
||||
|
||||
type InternalsWindow = Window & {
|
||||
__TAURI_INTERNALS__?: TauriEventInternals;
|
||||
};
|
||||
|
||||
const NOT_SUBSCRIBED: TauriEventUnsubscribe = () => {};
|
||||
|
||||
function resolveTauriEventInternals(): TauriEventInternals | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const internals = (window as InternalsWindow).__TAURI_INTERNALS__;
|
||||
if (
|
||||
!internals ||
|
||||
typeof internals.invoke !== 'function' ||
|
||||
typeof internals.transformCallback !== 'function' ||
|
||||
typeof internals.unregisterCallback !== 'function'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return internals;
|
||||
}
|
||||
|
||||
function resolveGlobalEventBridge(): TauriGlobalEventBridge | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
const bridge = window.__TAURI__?.event?.listen;
|
||||
return typeof bridge === 'function'
|
||||
? (bridge as TauriGlobalEventBridge)
|
||||
: null;
|
||||
}
|
||||
|
||||
function normalizeTarget(
|
||||
target: TauriEventTarget | string | undefined,
|
||||
): TauriEventTarget {
|
||||
if (!target) return { kind: 'Any' };
|
||||
return typeof target === 'string'
|
||||
? { kind: 'AnyLabel', label: target }
|
||||
: target;
|
||||
}
|
||||
|
||||
/**
|
||||
* 当前运行时是否能订阅 Tauri 事件。用于替换调用点原来的
|
||||
* `const listen = window.__TAURI__?.event?.listen; if (!listen) return;` 守卫。
|
||||
*/
|
||||
export function canSubscribeTauriEvents(): boolean {
|
||||
return Boolean(resolveTauriEventInternals() ?? resolveGlobalEventBridge());
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅一个 Tauri 事件,resolve 出幂等的注销函数;没有任何桥接时 resolve 空操作。
|
||||
*/
|
||||
export function subscribeTauriEvent<Payload>(
|
||||
event: string,
|
||||
handler: TauriEventHandler<Payload>,
|
||||
options: TauriEventSubscribeOptions = {},
|
||||
): Promise<TauriEventUnsubscribe> {
|
||||
const internals = resolveTauriEventInternals();
|
||||
if (internals) {
|
||||
return subscribeThroughTauriInternals(internals, event, handler, options);
|
||||
}
|
||||
const bridge = resolveGlobalEventBridge();
|
||||
if (bridge) {
|
||||
return subscribeThroughGlobalBridge(bridge, event, handler, options);
|
||||
}
|
||||
return Promise.resolve(NOT_SUBSCRIBED);
|
||||
}
|
||||
|
||||
async function subscribeThroughTauriInternals<Payload>(
|
||||
internals: TauriEventInternals,
|
||||
event: string,
|
||||
handler: TauriEventHandler<Payload>,
|
||||
options: TauriEventSubscribeOptions,
|
||||
): Promise<TauriEventUnsubscribe> {
|
||||
const invoke = internals.invoke as NonNullable<TauriEventInternals['invoke']>;
|
||||
const transformCallback = internals.transformCallback as NonNullable<
|
||||
TauriEventInternals['transformCallback']
|
||||
>;
|
||||
const unregisterCallback = internals.unregisterCallback as NonNullable<
|
||||
TauriEventInternals['unregisterCallback']
|
||||
>;
|
||||
|
||||
const handlerId = transformCallback((eventData) => {
|
||||
handler(eventData as TauriEventPayload<Payload>);
|
||||
});
|
||||
|
||||
let eventId: number;
|
||||
try {
|
||||
eventId = (await invoke('plugin:event|listen', {
|
||||
event,
|
||||
target: normalizeTarget(options.target),
|
||||
handler: handlerId,
|
||||
})) as number;
|
||||
} catch (error) {
|
||||
// 登记失败就别把刚建的 JS 回调留在 callbacks 里。
|
||||
unregisterCallback(handlerId);
|
||||
throw error;
|
||||
}
|
||||
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
// 先摘 JS 回调:`callbacks.delete` 幂等,即使注册 eval 还没落地也不会抛。
|
||||
unregisterCallback(handlerId);
|
||||
void invoke('plugin:event|unlisten', { event, eventId }).catch((error) => {
|
||||
// 显式记录:后端订阅没摘干净属于必须能看到的异常,不做静默吞掉。
|
||||
console.warn(`[AGC] 取消 Tauri 事件订阅失败:${event}`, error);
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function subscribeThroughGlobalBridge<Payload>(
|
||||
bridge: TauriGlobalEventBridge,
|
||||
event: string,
|
||||
handler: TauriEventHandler<Payload>,
|
||||
options: TauriEventSubscribeOptions,
|
||||
): Promise<TauriEventUnsubscribe> {
|
||||
const wrapped = (eventData: unknown) =>
|
||||
handler(eventData as TauriEventPayload<Payload>);
|
||||
// 没有显式 target 时只传两个参数:注入的事件替身与全局桥都按两参签名实现与断言。
|
||||
const unlisten =
|
||||
options.target === undefined
|
||||
? await bridge<Payload>(event, wrapped)
|
||||
: await bridge<Payload>(event, wrapped, {
|
||||
target: normalizeTarget(options.target),
|
||||
});
|
||||
let released = false;
|
||||
return () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
unlisten();
|
||||
};
|
||||
}
|
||||
@@ -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}`。
|
||||
|
||||
Reference in New Issue
Block a user