6618207b5b
- 新增 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:记录该竞态的成因、触发面、处理取舍与变异验证结论。
240 lines
8.5 KiB
TypeScript
240 lines
8.5 KiB
TypeScript
/**
|
||
* 与 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;
|
||
}
|