diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index 59fc222..2678539 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -2042,11 +2042,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { }) .then((fn) => { if (cancelled) { - try { - fn() - } catch { - // Tauri listener may not be fully registered yet - } + fn() } else { unlisten = fn listenerReadyRef.current = true @@ -2066,11 +2062,7 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { clearTimeout(flushTimerRef.current) flushTimerRef.current = null } - try { - unlisten?.() - } catch { - // Tauri listener may not be fully registered yet - } + unlisten?.() } }, [bufferUnmappedEvent, handleMappedEvent, resolveListenerReadyWaiters]) diff --git a/src/lib/transport/tauri-transport.ts b/src/lib/transport/tauri-transport.ts index 8430402..2a80db1 100644 --- a/src/lib/transport/tauri-transport.ts +++ b/src/lib/transport/tauri-transport.ts @@ -1,17 +1,58 @@ import type { Transport, UnsubscribeFn } from "./types" +type TauriEventListenersWindow = { + __TAURI_EVENT_PLUGIN_INTERNALS__?: { + unregisterListener?: (event: string, eventId: number) => void + } +} + export class TauriTransport implements Transport { async call(command: string, args?: Record): Promise { const { invoke } = await import("@tauri-apps/api/core") 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). + // + // We own the eventId here so we can always issue the backend unlisten, even + // when the client-side registry entry hasn't appeared yet. async subscribe( event: string, handler: (payload: T) => void ): Promise { - const { listen } = await import("@tauri-apps/api/event") - return listen(event, (e) => handler(e.payload)) + const { invoke, transformCallback } = await import("@tauri-apps/api/core") + const handlerId = transformCallback((e: { payload: T }) => { + handler(e.payload) + }) + const eventId = await invoke("plugin:event|listen", { + event, + target: { kind: "Any" }, + handler: handlerId, + }) + + let unlistened = false + return () => { + if (unlistened) return + unlistened = true + try { + const internals = (window as unknown as TauriEventListenersWindow) + .__TAURI_EVENT_PLUGIN_INTERNALS__ + internals?.unregisterListener?.(event, eventId) + } catch { + // Registration eval has not landed yet; server-side unlisten below + // still clears the listener so events stop flowing. + } + // Fire-and-forget: callers expect a sync unsubscribe, and a failure here + // only means the backend already forgot this listener. + invoke("plugin:event|unlisten", { event, eventId }).catch(() => {}) + } } isDesktop(): boolean {