From d347a16d5a8805055c44cbc936b68ad6e8531f6b Mon Sep 17 00:00:00 2001 From: kogekiplay <46434871+kogekiplay@users.noreply.github.com> Date: Tue, 10 Nov 2026 10:19:56 +0800 Subject: [PATCH 1/2] fix(memory): release Tauri event callbacks across registration races --- patches/@tauri-apps__api@2.12.4.patch | 164 ++++++++++++++++ patches/README.md | 25 +++ pnpm-lock.yaml | 17 +- pnpm-workspace.yaml | 3 + src/lib/tauri-native-event-memory.test.ts | 222 ++++++++++++++++++++++ src/lib/transport/tauri-transport.test.ts | 131 +++++++++++++ src/lib/transport/tauri-transport.ts | 42 ++-- 7 files changed, 582 insertions(+), 22 deletions(-) create mode 100644 patches/@tauri-apps__api@2.12.4.patch create mode 100644 patches/README.md create mode 100644 src/lib/tauri-native-event-memory.test.ts create mode 100644 src/lib/transport/tauri-transport.test.ts diff --git a/patches/@tauri-apps__api@2.12.4.patch b/patches/@tauri-apps__api@2.12.4.patch new file mode 100644 index 00000000000..c17bcb366bb --- /dev/null +++ b/patches/@tauri-apps__api@2.12.4.patch @@ -0,0 +1,164 @@ +diff --git a/event.cjs b/event.cjs +index b53f3497d826d26e6182e28781b519e6e14f39c8..c926acf081427b7c79bcf170c0028a18fceda786 100644 +--- a/event.cjs ++++ b/event.cjs +@@ -99,7 +99,13 @@ exports.TauriEvent = void 0; + * @returns + */ + async function _unlisten(event, eventId) { +- window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(event, eventId); ++ try { ++ window.__TAURI_EVENT_PLUGIN_INTERNALS__?.unregisterListener(event, eventId); ++ } ++ catch { ++ // Native registration can complete before its JavaScript registry eval. ++ // The captured callback has already been released by _listen. ++ } + await core.invoke('plugin:event|unlisten', { + event, + eventId +@@ -134,17 +140,44 @@ async function _unlisten(event, eventId) { + * @since 1.0.0 + */ + async function listen(event, handler, options) { ++ return _listen(event, handler, options, false); ++} ++async function _listen(event, handler, options, onlyOnce) { + var _a; + const target = typeof (options === null || options === void 0 ? void 0 : options.target) === 'string' + ? { kind: 'AnyLabel', label: options.target } + : ((_a = options === null || options === void 0 ? void 0 : options.target) !== null && _a !== void 0 ? _a : { kind: 'Any' }); +- return core.invoke('plugin:event|listen', { +- event, +- target, +- handler: core.transformCallback(handler) +- }).then((eventId) => { +- return async () => _unlisten(event, eventId); ++ let disposed = false; ++ const releaseCallback = () => { ++ window.__TAURI_INTERNALS__.unregisterCallback?.(handlerId); ++ }; ++ const cleanup = async (eventId) => { ++ if (disposed) ++ return; ++ disposed = true; ++ releaseCallback(); ++ await _unlisten(event, eventId); ++ }; ++ const handlerId = core.transformCallback((eventData) => { ++ if (disposed) ++ return; ++ if (onlyOnce) ++ void cleanup(eventData.id).catch(() => { }); ++ handler(eventData); + }); ++ try { ++ const eventId = await core.invoke('plugin:event|listen', { ++ event, ++ target, ++ handler: handlerId ++ }); ++ return async () => cleanup(eventId); ++ } ++ catch (error) { ++ disposed = true; ++ releaseCallback(); ++ throw error; ++ } + } + /** + * Listens once to an emitted event to any {@link EventTarget|target}. +@@ -177,10 +210,7 @@ async function listen(event, handler, options) { + * @since 1.0.0 + */ + async function once(event, handler, options) { +- return listen(event, (eventData) => { +- void _unlisten(event, eventData.id); +- handler(eventData); +- }, options); ++ return _listen(event, handler, options, true); + } + /** + * Emits an event to all {@link EventTarget|targets}. +diff --git a/event.js b/event.js +index 1e03752962d8cb5edad90d59732e486e187bee5b..3a3ebc94e4d5a58a95f1529a031615d162946f03 100644 +--- a/event.js ++++ b/event.js +@@ -97,7 +97,13 @@ var TauriEvent; + * @returns + */ + async function _unlisten(event, eventId) { +- window.__TAURI_EVENT_PLUGIN_INTERNALS__.unregisterListener(event, eventId); ++ try { ++ window.__TAURI_EVENT_PLUGIN_INTERNALS__?.unregisterListener(event, eventId); ++ } ++ catch { ++ // Native registration can complete before its JavaScript registry eval. ++ // The captured callback has already been released by _listen. ++ } + await invoke('plugin:event|unlisten', { + event, + eventId +@@ -132,17 +138,44 @@ async function _unlisten(event, eventId) { + * @since 1.0.0 + */ + async function listen(event, handler, options) { ++ return _listen(event, handler, options, false); ++} ++async function _listen(event, handler, options, onlyOnce) { + var _a; + const target = typeof (options === null || options === void 0 ? void 0 : options.target) === 'string' + ? { kind: 'AnyLabel', label: options.target } + : ((_a = options === null || options === void 0 ? void 0 : options.target) !== null && _a !== void 0 ? _a : { kind: 'Any' }); +- return invoke('plugin:event|listen', { +- event, +- target, +- handler: transformCallback(handler) +- }).then((eventId) => { +- return async () => _unlisten(event, eventId); ++ let disposed = false; ++ const releaseCallback = () => { ++ window.__TAURI_INTERNALS__.unregisterCallback?.(handlerId); ++ }; ++ const cleanup = async (eventId) => { ++ if (disposed) ++ return; ++ disposed = true; ++ releaseCallback(); ++ await _unlisten(event, eventId); ++ }; ++ const handlerId = transformCallback((eventData) => { ++ if (disposed) ++ return; ++ if (onlyOnce) ++ void cleanup(eventData.id).catch(() => { }); ++ handler(eventData); + }); ++ try { ++ const eventId = await invoke('plugin:event|listen', { ++ event, ++ target, ++ handler: handlerId ++ }); ++ return async () => cleanup(eventId); ++ } ++ catch (error) { ++ disposed = true; ++ releaseCallback(); ++ throw error; ++ } + } + /** + * Listens once to an emitted event to any {@link EventTarget|target}. +@@ -175,10 +208,7 @@ async function listen(event, handler, options) { + * @since 1.0.0 + */ + async function once(event, handler, options) { +- return listen(event, (eventData) => { +- void _unlisten(event, eventData.id); +- handler(eventData); +- }, options); ++ return _listen(event, handler, options, true); + } + /** + * Emits an event to all {@link EventTarget|targets}. diff --git a/patches/README.md b/patches/README.md new file mode 100644 index 00000000000..5c365be446a --- /dev/null +++ b/patches/README.md @@ -0,0 +1,25 @@ +# Tauri API 2.12.4 event callback cleanup + +Tauri creates a JavaScript callback before awaiting native listener registration. +Its native registration eval and invoke response can reach WKWebView in either +order. If cleanup runs before that eval, the event registry does not yet contain +the callback ID. Registration failures never populate it at all. Both cases can +retain the subscriber closure after its view has gone away. + +The patch captures the callback ID at registration, releases it explicitly on +failure or cleanup, and still calls native unlisten when registry cleanup is +unavailable. Cleanup is idempotent. `once` releases before invoking user code, +including when the first event arrives before the registration response. +Both `event.js` and `event.cjs` are patched; global, Webview, and Window listeners +all reach these functions. The transport, which registers through the core API, +owns the same cleanup directly. + +The lockfile records the package patch hash. Run a frozen install and +`src/lib/tauri-native-event-memory.test.ts` against the installed ESM/CommonJS +package, plus `src/lib/transport/tauri-transport.test.ts`. They cover registration +failure, cleanup racing the registration eval, repeated mounts, normal independent +subscriptions, registry exceptions, and early `once` events. Run full TypeScript +checking as well, including tests rather than just Next's production build. + +Revisit this patch on upgrades to `@tauri-apps/api` or after upstream provides an +equivalent callback lifetime fix. It does not change the Rust Tauri event plugin. diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bced3b46bb3..a4975ad8b85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,9 @@ overrides: katex: ^0.19.0 ansi-to-react>linkify-it: ^5.0.2 +patchedDependencies: + '@tauri-apps/api@2.12.4': cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce + importers: .: @@ -62,7 +65,7 @@ importers: version: 1.0.3(react@19.2.4) '@tauri-apps/api': specifier: ^2.12.4 - version: 2.12.4 + version: 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-dialog': specifier: ^2.8.3 version: 2.8.3 @@ -9410,7 +9413,7 @@ snapshots: postcss: 8.5.30 tailwindcss: 4.1.18 - '@tauri-apps/api@2.12.4': {} + '@tauri-apps/api@2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce)': {} '@tauri-apps/cli-darwin-arm64@2.12.3': optional: true @@ -9461,23 +9464,23 @@ snapshots: '@tauri-apps/plugin-dialog@2.8.3': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-opener@2.7.2': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-process@2.4.0': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-updater@2.14.0': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@tauri-apps/plugin-window-state@2.5.0': dependencies: - '@tauri-apps/api': 2.12.4 + '@tauri-apps/api': 2.12.4(patch_hash=cb5d7a9395e8e570985347e12bb9ec386e8ac4e2981f4cc5d9d3351452b5e2ce) '@testing-library/dom@10.4.1': dependencies: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f5f7f886596..9697f379db5 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -33,3 +33,6 @@ minimumReleaseAgeExclude: - '@tauri-apps/plugin-dialog@2.8.3' - '@tauri-apps/plugin-opener@2.7.2' - '@tauri-apps/plugin-updater@2.14.0' + +patchedDependencies: + '@tauri-apps/api@2.12.4': patches/@tauri-apps__api@2.12.4.patch diff --git a/src/lib/tauri-native-event-memory.test.ts b/src/lib/tauri-native-event-memory.test.ts new file mode 100644 index 00000000000..94e62afdf83 --- /dev/null +++ b/src/lib/tauri-native-event-memory.test.ts @@ -0,0 +1,222 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { + listen, + once, + type Event, + type EventTarget, +} from "@tauri-apps/api/event" +import { getCurrentWebview } from "@tauri-apps/api/webview" +import { getCurrentWindow } from "@tauri-apps/api/window" +import { createRequire } from "node:module" + +const require = createRequire(import.meta.url) + +describe("native Tauri event callback lifetime", () => { + let callbacks: Map) => void> + let registered: Map + let invoke: ReturnType< + typeof vi.fn< + (command: string, args?: Record) => Promise + > + > + let nextId: number + + beforeEach(() => { + callbacks = new Map() + registered = new Map() + nextId = 0 + invoke = vi.fn(async (command: string, args?: Record) => { + if (command === "plugin:event|listen") { + const handler = args?.handler + if (typeof handler !== "number") throw new Error("missing handler") + registered.set(1000 + handler, handler) + return 1000 + handler + } + }) + vi.stubGlobal("__TAURI_INTERNALS__", { + metadata: { + currentWindow: { label: "main" }, + currentWebview: { label: "main" }, + }, + invoke, + transformCallback: (callback: (event: Event) => void) => { + callbacks.set(++nextId, callback) + return nextId + }, + unregisterCallback: (id: number) => callbacks.delete(id), + }) + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + unregisterListener: (_event: string, eventId: number) => { + const id = registered.get(eventId) + if (id !== undefined) callbacks.delete(id) + }, + }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + const scopes: { scope: string; target: EventTarget }[] = [ + { scope: "global", target: { kind: "Any" } }, + { scope: "webview", target: { kind: "Webview", label: "main" } }, + { scope: "window", target: { kind: "Window", label: "main" } }, + ] + + function subscribe(scope: string, handler: (event: Event) => void) { + if (scope === "webview") { + return getCurrentWebview().listen("tauri://drag-over", handler) + } + if (scope === "window") { + return getCurrentWindow().listen("tauri://drag-over", handler) + } + return listen("tauri://drag-over", handler) + } + + it.each(scopes)( + "releases $scope callback when cleanup beats native registration eval", + async ({ scope, target }) => { + invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 1001 : undefined + ) + const handler = vi.fn() + const stop = await subscribe(scope, handler) + expect(callbacks.size).toBe(1) + await stop() + expect(callbacks.size).toBe(0) + registered.set(1001, 1) + callbacks.get(1)?.({ event: "tauri://drag-over", id: 1001, payload: 1 }) + expect(handler).not.toHaveBeenCalled() + expect(invoke).toHaveBeenCalledWith( + "plugin:event|listen", + { event: "tauri://drag-over", target, handler: 1 }, + undefined + ) + expect(invoke).toHaveBeenCalledWith( + "plugin:event|unlisten", + { event: "tauri://drag-over", eventId: 1001 }, + undefined + ) + } + ) + + it.each(scopes)( + "releases $scope callback when native registration fails", + async ({ scope }) => { + const error = new Error("synthetic closed webview") + invoke.mockRejectedValueOnce(error) + await expect(subscribe(scope, vi.fn())).rejects.toBe(error) + expect(callbacks.size).toBe(0) + } + ) + + it("does not retain scoped closures after repeated registration races", async () => { + invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 1001 : undefined + ) + for (let i = 0; i < 100; i++) { + const stop = await getCurrentWebview().listen( + "tauri://drag-over", + vi.fn() + ) + await stop() + } + expect(callbacks.size).toBe(0) + }) + + it("still cleans native registration when the JS event registry throws", async () => { + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + unregisterListener: () => { + throw new Error("registration eval unavailable") + }, + }) + const stop = await listen("tauri://drag-over", vi.fn()) + await stop() + expect(callbacks.size).toBe(0) + expect(invoke).toHaveBeenLastCalledWith( + "plugin:event|unlisten", + { event: "tauri://drag-over", eventId: 1001 }, + undefined + ) + }) + + it("keeps independent scopes live and cleanup idempotent", async () => { + const first = vi.fn() + const second = vi.fn() + const stopFirst = await getCurrentWebview().listen( + "tauri://drag-over", + first + ) + const stopSecond = await getCurrentWindow().listen( + "tauri://drag-over", + second + ) + const event = { event: "tauri://drag-over", id: 1002, payload: { x: 1 } } + await stopFirst() + await stopFirst() + callbacks.get(2)?.(event) + expect(first).not.toHaveBeenCalled() + expect(second).toHaveBeenCalledWith(event) + await stopSecond() + expect(callbacks.size).toBe(0) + expect( + invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(2) + }) + + it("releases a once callback when its event beats registration eval", async () => { + invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 1001 : undefined + ) + const handler = vi.fn() + const stop = await once("tauri://drag-over", handler) + callbacks.get(1)?.({ event: "tauri://drag-over", id: 1001, payload: 1 }) + await Promise.resolve() + expect(handler).toHaveBeenCalledTimes(1) + expect(callbacks.size).toBe(0) + await stop() + expect( + invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(1) + }) + + it("releases once before the listen response and ignores a second event", async () => { + let finishListen!: (eventId: number) => void + invoke.mockImplementation((command) => + command === "plugin:event|listen" + ? new Promise((resolve) => { + finishListen = resolve + }) + : Promise.resolve() + ) + const handler = vi.fn() + const pending = once("tauri://drag-over", handler) + const callback = callbacks.get(1)! + const event = { event: "tauri://drag-over", id: 1001, payload: 1 } + callback(event) + callback(event) + expect(callbacks.size).toBe(0) + expect(handler).toHaveBeenCalledTimes(1) + finishListen(1001) + const stop = await pending + await stop() + expect( + invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(1) + }) + + it("also protects CommonJS consumers from failed registrations", async () => { + const cjsEvent = + require("@tauri-apps/api/event") as typeof import("@tauri-apps/api/event") + const error = new Error("synthetic registration rejection") + invoke.mockRejectedValueOnce(error) + await expect(cjsEvent.listen("tauri://drag-over", vi.fn())).rejects.toBe( + error + ) + expect(callbacks.size).toBe(0) + }) +}) diff --git a/src/lib/transport/tauri-transport.test.ts b/src/lib/transport/tauri-transport.test.ts new file mode 100644 index 00000000000..bec11b22bff --- /dev/null +++ b/src/lib/transport/tauri-transport.test.ts @@ -0,0 +1,131 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { TauriTransport } from "./tauri-transport" + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(), + transformCallback: vi.fn(), +})) + +vi.mock("@tauri-apps/api/core", () => mocks) + +describe("Tauri event subscription lifetime", () => { + let callbacks: Map void> + let registered: Map + let unregisterCallback: ReturnType boolean>> + let nextCallback: number + + beforeEach(() => { + callbacks = new Map() + registered = new Map() + nextCallback = 0 + unregisterCallback = vi.fn((id: number) => callbacks.delete(id)) + vi.stubGlobal("__TAURI_INTERNALS__", { unregisterCallback }) + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + // Tauri 2.12.4 can unregister by eventId only after its registration + // eval has supplied handlerId. The invoke response can arrive first. + unregisterListener: (_event: string, eventId: number) => { + const handlerId = registered.get(eventId) + if (handlerId !== undefined) unregisterCallback(handlerId) + }, + }) + mocks.transformCallback.mockReset().mockImplementation((callback) => { + const id = ++nextCallback + callbacks.set(id, callback) + return id + }) + mocks.invoke.mockReset().mockImplementation(async (command, args) => { + if (command === "plugin:event|listen") { + registered.set(100 + args.handler, args.handler) + return 100 + args.handler + } + }) + }) + + afterEach(() => vi.unstubAllGlobals()) + + it("releases a callback when cleanup beats the listener registration eval", async () => { + mocks.invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 101 : undefined + ) + const listener = vi.fn() + const unsubscribe = await new TauriTransport().subscribe( + "acp://event", + listener + ) + expect(callbacks.size).toBe(1) + unsubscribe() + expect(callbacks.size).toBe(0) + // A late native registration cannot revive the disposed callback. + registered.set(101, 1) + callbacks.get(1)?.({ payload: "late event" }) + expect(listener).not.toHaveBeenCalled() + expect(mocks.invoke).toHaveBeenCalledWith("plugin:event|unlisten", { + event: "acp://event", + eventId: 101, + }) + }) + + it("releases a callback if native subscription creation rejects", async () => { + const error = new Error("webview closed") + mocks.invoke.mockRejectedValueOnce(error) + await expect( + new TauriTransport().subscribe("acp://event", vi.fn()) + ).rejects.toBe(error) + expect(callbacks.size).toBe(0) + expect(mocks.invoke).toHaveBeenCalledTimes(1) + }) + + it("does not accumulate callbacks across repeated mount and cleanup races", async () => { + mocks.invoke.mockImplementation(async (command) => + command === "plugin:event|listen" ? 101 : undefined + ) + const transport = new TauriTransport() + for (let index = 0; index < 100; index++) { + const unsubscribe = await transport.subscribe("acp://event", vi.fn()) + unsubscribe() + } + expect(callbacks.size).toBe(0) + }) + + it("keeps independent subscriptions live and cleanup idempotent", async () => { + const transport = new TauriTransport() + const first = vi.fn() + const second = vi.fn() + const stopFirst = await transport.subscribe("acp://event", first) + const stopSecond = await transport.subscribe("acp://event", second) + callbacks.get(1)?.({ payload: "first" }) + callbacks.get(2)?.({ payload: "second" }) + expect(first).toHaveBeenCalledWith("first") + expect(second).toHaveBeenCalledWith("second") + stopFirst() + stopFirst() + expect(callbacks.size).toBe(1) + callbacks.get(2)?.({ payload: "still live" }) + expect(second).toHaveBeenLastCalledWith("still live") + stopSecond() + expect(callbacks.size).toBe(0) + expect( + mocks.invoke.mock.calls.filter( + ([command]) => command === "plugin:event|unlisten" + ) + ).toHaveLength(2) + }) + + it("cleans up both callback and native listener if the event registry throws", async () => { + vi.stubGlobal("__TAURI_EVENT_PLUGIN_INTERNALS__", { + unregisterListener: () => { + throw new Error("registration eval not ready") + }, + }) + const unsubscribe = await new TauriTransport().subscribe( + "acp://event", + vi.fn() + ) + unsubscribe() + expect(callbacks.size).toBe(0) + expect(mocks.invoke).toHaveBeenLastCalledWith("plugin:event|unlisten", { + event: "acp://event", + eventId: 101, + }) + }) +}) diff --git a/src/lib/transport/tauri-transport.ts b/src/lib/transport/tauri-transport.ts index 153dae021c1..ed3cba274e5 100644 --- a/src/lib/transport/tauri-transport.ts +++ b/src/lib/transport/tauri-transport.ts @@ -1,6 +1,9 @@ import type { CallOptions, Transport, UnsubscribeFn } from "./types" type TauriEventListenersWindow = { + __TAURI_INTERNALS__?: { + unregisterCallback?: (callbackId: number) => void + } __TAURI_EVENT_PLUGIN_INTERNALS__?: { unregisterListener?: (event: string, eventId: number) => void } @@ -20,17 +23,14 @@ export class TauriTransport implements Transport { return invoke(command, args) } - // Bypasses `@tauri-apps/api/event#listen` to sidestep an intermittent race: - // Tauri's Rust `listen_js` schedules a fire-and-forget eval that populates - // `window.__TAURI_EVENT_LISTENERS__[event][eventId]` and, separately, returns - // `eventId` via the invoke response. On WKWebView the two can arrive out of - // order. When cleanup fires before the eval lands, the built-in `_unlisten` - // throws synchronously on `listeners[eventId].handlerId` — and because that - // throw happens BEFORE `await invoke('plugin:event|unlisten')`, the backend - // listener is never removed (handler + payload buffering leak). + // Tauri registers a callback before awaiting the native listener. The + // listener's registration eval can arrive after its invoke response; cleanup + // then cannot find handlerId in the event registry. A rejected listen never + // populates that registry either. Retain our callback ID so both paths can + // release the callback and everything captured by the subscriber. // - // We own the eventId here so we can always issue the backend unlisten, even - // when the client-side registry entry hasn't appeared yet. + // Native unlisten must still run if the event registry is absent or its + // cleanup throws, as happened in older Tauri releases. async subscribe( event: string, handler: (payload: T) => void @@ -39,16 +39,28 @@ export class TauriTransport implements Transport { const handlerId = transformCallback((e: { payload: T }) => { handler(e.payload) }) - const eventId = await invoke("plugin:event|listen", { - event, - target: { kind: "Any" }, - handler: handlerId, - }) + const releaseCallback = () => { + const internals = (window as unknown as TauriEventListenersWindow) + .__TAURI_INTERNALS__ + internals?.unregisterCallback?.(handlerId) + } + let eventId: number + try { + eventId = await invoke("plugin:event|listen", { + event, + target: { kind: "Any" }, + handler: handlerId, + }) + } catch (error) { + releaseCallback() + throw error + } let unlistened = false return () => { if (unlistened) return unlistened = true + releaseCallback() try { const internals = (window as unknown as TauriEventListenersWindow) .__TAURI_EVENT_PLUGIN_INTERNALS__ From 481bc7698e09a519fbf0a94295c1e1371e099010 Mon Sep 17 00:00:00 2001 From: xintaofei Date: Tue, 10 Nov 2026 16:31:11 +0800 Subject: [PATCH 2/2] build(docker): copy dependency patches before frozen install pnpm-workspace.yaml now lists patchedDependencies, which pnpm applies during install. The frontend stage copied only the manifests first, so `pnpm install --frozen-lockfile` failed with ENOENT on the patch file. --- Dockerfile | 2 ++ 1 file changed, 2 insertions(+) diff --git a/Dockerfile b/Dockerfile index a010feff896..f4df4301e81 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,6 +3,8 @@ FROM node:24-alpine AS frontend RUN corepack enable WORKDIR /app COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# patchedDependencies in pnpm-workspace.yaml are applied during install. +COPY patches/ ./patches/ RUN pnpm install --frozen-lockfile COPY src/ ./src/ COPY public/ ./public/