authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 15:12:27-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 15:12:27-07:00
logb6c590af389d09400fa4f99a56ffc33e9ed4ebf1
tree3c3adf424d641cfbbfb55fd31ad96e13aa5f3301
parentb51aef10c4f21c35f195b81e548c052041a15051
signature Signed by PGP key B5690EEEBB952194

[codex] Fix desktop preview event delivery errors (#3435)

Co-authored-by: codex <codex@users.noreply.github.com>

3 files changed, 108 insertions(+), 25 deletions(-)

apps/desktop/src/ipc/methods/preview.ts+11-18
...@@ -19,37 +19,30 @@ import {...@@ -19,37 +19,30 @@ import {
19 PreviewAutomationSnapshot,19 PreviewAutomationSnapshot,
20 PreviewAutomationStatus,20 PreviewAutomationStatus,
21} from "@t3tools/contracts";21} from "@t3tools/contracts";
22import { BrowserWindow } from "electron";
23import * as Effect from "effect/Effect";22import * as Effect from "effect/Effect";
24import * as Schema from "effect/Schema";23import * as Schema from "effect/Schema";
25import * as NodeURL from "node:url";24import * as NodeURL from "node:url";
2625
26import * as ElectronWindow from "../../electron/ElectronWindow.ts";
27import * as PreviewManager from "../../preview/Manager.ts";27import * as PreviewManager from "../../preview/Manager.ts";
28import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";28import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
29import * as IpcChannels from "../channels.ts";29import * as IpcChannels from "../channels.ts";
30import * as DesktopIpc from "../DesktopIpc.ts";30import * as DesktopIpc from "../DesktopIpc.ts";
3131
32const broadcast = (channel: string, ...args: ReadonlyArray<unknown>): void => {
33 for (const window of BrowserWindow.getAllWindows()) {
34 if (!window.isDestroyed()) {
35 window.webContents.send(channel, ...args);
36 }
37 }
38};
39
40export const installPreviewEventForwarding = Effect.fn(32export const installPreviewEventForwarding = Effect.fn(
41 "desktop.ipc.preview.installEventForwarding",33 "desktop.ipc.preview.installEventForwarding",
42)(function* () {34)(function* () {
35 const electronWindow = yield* ElectronWindow.ElectronWindow;
43 const manager = yield* PreviewManager.PreviewManager;36 const manager = yield* PreviewManager.PreviewManager;
44 yield* manager.subscribeStateChanges((tabId, state) => {37 yield* manager.subscribeStateChanges((tabId, state) =>
45 broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state);38 electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state),
46 });39 );
47 yield* manager.subscribeRecordingFrames((frame) => {40 yield* manager.subscribeRecordingFrames((frame) =>
48 broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame);41 electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame),
49 });42 );
50 yield* manager.subscribePointerEvents((event) => {43 yield* manager.subscribePointerEvents((event) =>
51 broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event);44 electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
52 });45 );
53});46});
5447
55export const createTab = DesktopIpc.makeIpcMethod({48export const createTab = DesktopIpc.makeIpcMethod({
apps/desktop/src/preview/Manager.test.ts+73-1
...@@ -5,6 +5,7 @@ import * as Exit from "effect/Exit";...@@ -5,6 +5,7 @@ import * as Exit from "effect/Exit";
5import * as FileSystem from "effect/FileSystem";5import * as FileSystem from "effect/FileSystem";
6import * as Fiber from "effect/Fiber";6import * as Fiber from "effect/Fiber";
7import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
8import * as Option from "effect/Option";9import * as Option from "effect/Option";
9import * as Path from "effect/Path";10import * as Path from "effect/Path";
10import * as Schema from "effect/Schema";11import * as Schema from "effect/Schema";
...@@ -13,6 +14,7 @@ import { TestClock } from "effect/testing";...@@ -13,6 +14,7 @@ import { TestClock } from "effect/testing";
13import { beforeEach, describe, expect, it, vi } from "vite-plus/test";14import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1415
15import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";16import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17import * as ElectronWindow from "../electron/ElectronWindow.ts";
16import * as BrowserSession from "./BrowserSession.ts";18import * as BrowserSession from "./BrowserSession.ts";
17import * as PreviewManager from "./Manager.ts";19import * as PreviewManager from "./Manager.ts";
1820
...@@ -130,6 +132,72 @@ describe("PreviewManager", () => {...@@ -130,6 +132,72 @@ describe("PreviewManager", () => {
130 ),132 ),
131 );133 );
132134
135 effectIt.effect("isolates failed state listeners and continues delivery", () => {
136 const loggedErrors: Array<unknown> = [];
137 const logger = Logger.make(({ message }) => {
138 for (const value of Array.isArray(message) ? message : [message]) {
139 if (typeof value === "object" && value !== null && "cause" in value) {
140 loggedErrors.push(Cause.squash(value.cause as Cause.Cause<never>));
141 }
142 }
143 });
144 const deliveryError = new ElectronWindow.ElectronWindowOperationError({
145 operation: "send-window-message",
146 platform: "darwin",
147 windowId: 42,
148 channel: "preview:state-change",
149 cause: new Error("renderer unavailable"),
150 });
151 const delivered = vi.fn();
152
153 return withManager((manager) =>
154 Effect.gen(function* () {
155 yield* manager.subscribeStateChanges(() => Effect.die(deliveryError));
156 yield* manager.subscribeStateChanges((tabId, state) =>
157 Effect.sync(() => {
158 delivered(tabId, state);
159 }),
160 );
161
162 const state = yield* manager.createTab("tab_listener_failure");
163
164 expect(delivered).toHaveBeenCalledOnce();
165 expect(delivered).toHaveBeenCalledWith("tab_listener_failure", state);
166 expect(loggedErrors).toHaveLength(1);
167 expect(loggedErrors[0]).toBeInstanceOf(ElectronWindow.ElectronWindowOperationError);
168 expect(loggedErrors[0]).toMatchObject({
169 operation: "send-window-message",
170 windowId: 42,
171 channel: "preview:state-change",
172 });
173 }),
174 ).pipe(
175 Effect.provide(
176 Logger.layer([logger], {
177 mergeWithExisting: false,
178 }),
179 ),
180 );
181 });
182
183 effectIt.effect("does not swallow state listener interruption", () =>
184 withManager((manager) =>
185 Effect.gen(function* () {
186 const exit = yield* Effect.scoped(
187 Effect.gen(function* () {
188 yield* manager.subscribeStateChanges(() => Effect.interrupt);
189 return yield* Effect.exit(manager.createTab("tab_interrupted_listener"));
190 }),
191 );
192
193 expect(Exit.isFailure(exit)).toBe(true);
194 if (Exit.isFailure(exit)) {
195 expect(Cause.hasInterrupts(exit.cause)).toBe(true);
196 }
197 }),
198 ),
199 );
200
133 effectIt.effect("queues navigation until the webview registers", () =>201 effectIt.effect("queues navigation until the webview registers", () =>
134 withManager((manager) =>202 withManager((manager) =>
135 Effect.gen(function* () {203 Effect.gen(function* () {
...@@ -411,7 +479,11 @@ describe("PreviewManager", () => {...@@ -411,7 +479,11 @@ describe("PreviewManager", () => {
411 },479 },
412 } as never);480 } as never);
413481
414 yield* manager.subscribePointerEvents((event) => activity.push(event.phase));482 yield* manager.subscribePointerEvents((event) =>
483 Effect.sync(() => {
484 activity.push(event.phase);
485 }),
486 );
415 yield* manager.createTab("tab_1");487 yield* manager.createTab("tab_1");
416 yield* manager.registerWebview("tab_1", 42);488 yield* manager.registerWebview("tab_1", 42);
417 const click = yield* manager489 const click = yield* manager
apps/desktop/src/preview/Manager.ts+24-6
...@@ -281,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => {...@@ -281,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
281 return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;281 return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;
282};282};
283283
284type Listener = (tabId: string, state: PreviewTabState) => void;284type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>;
285type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;285type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>;
286286
287type PreviewInputSignal =287type PreviewInputSignal =
288 | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }288 | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
...@@ -313,7 +313,7 @@ interface BrowserDiagnostics {...@@ -313,7 +313,7 @@ interface BrowserDiagnostics {
313 readonly requests: ReadonlyMap<string, { url: string; method: string }>;313 readonly requests: ReadonlyMap<string, { url: string; method: string }>;
314}314}
315315
316type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;316type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;
317317
318interface ExpectedAgentInput {318interface ExpectedAgentInput {
319 readonly signal: PreviewInputSignal;319 readonly signal: PreviewInputSignal;
...@@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
442 return copy;442 return copy;
443 };443 };
444444
445 const deliverEvent = (
446 eventKind: "state-change" | "recording-frame" | "pointer-event",
447 tabId: string,
448 delivery: () => Effect.Effect<void>,
449 ) =>
450 Effect.suspend(delivery).pipe(
451 Effect.catchCause((cause) =>
452 Cause.hasInterrupts(cause)
453 ? Effect.failCause(cause)
454 : Effect.logWarning("Desktop preview event listener failed.", {
455 eventKind,
456 tabId,
457 cause,
458 }),
459 ),
460 );
461
445 const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) {462 const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) {
446 const listeners = yield* Ref.get(listenersRef);463 const listeners = yield* Ref.get(listenersRef);
447 yield* Effect.forEach(464 yield* Effect.forEach(
448 listeners,465 listeners,
449 (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore),466 (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)),
450 { discard: true },467 { discard: true },
451 );468 );
452 });469 });
...@@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
739 };756 };
740 yield* Effect.forEach(757 yield* Effect.forEach(
741 listeners,758 listeners,
742 (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore),759 (listener) =>
760 deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
743 { discard: true },761 { discard: true },
744 );762 );
745 }763 }
...@@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function...@@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
1918 const listeners = yield* Ref.get(pointerEventListenersRef);1936 const listeners = yield* Ref.get(pointerEventListenersRef);
1919 yield* Effect.forEach(1937 yield* Effect.forEach(
1920 listeners,1938 listeners,
1921 (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore),1939 (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)),
1922 { discard: true },1940 { discard: true },
1923 );1941 );
1924 });1942 });