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 {
1919 PreviewAutomationSnapshot,
2020 PreviewAutomationStatus,
2121} from "@t3tools/contracts";
22import { BrowserWindow } from "electron";
2322import * as Effect from "effect/Effect";
2423import * as Schema from "effect/Schema";
2524import * as NodeURL from "node:url";
2625
26import * as ElectronWindow from "../../electron/ElectronWindow.ts";
2727import * as PreviewManager from "../../preview/Manager.ts";
2828import { PREVIEW_WEBVIEW_PREFERENCES } from "../../preview/WebviewPreferences.ts";
2929import * as IpcChannels from "../channels.ts";
3030import * 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
4032export const installPreviewEventForwarding = Effect.fn(
4133 "desktop.ipc.preview.installEventForwarding",
4234)(function* () {
35 const electronWindow = yield* ElectronWindow.ElectronWindow;
4336 const manager = yield* PreviewManager.PreviewManager;
44 yield* manager.subscribeStateChanges((tabId, state) => {
45 broadcast(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state);
46 });
47 yield* manager.subscribeRecordingFrames((frame) => {
48 broadcast(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame);
49 });
50 yield* manager.subscribePointerEvents((event) => {
51 broadcast(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event);
52 });
37 yield* manager.subscribeStateChanges((tabId, state) =>
38 electronWindow.sendAll(IpcChannels.PREVIEW_STATE_CHANGE_CHANNEL, tabId, state),
39 );
40 yield* manager.subscribeRecordingFrames((frame) =>
41 electronWindow.sendAll(IpcChannels.PREVIEW_RECORDING_FRAME_CHANNEL, frame),
42 );
43 yield* manager.subscribePointerEvents((event) =>
44 electronWindow.sendAll(IpcChannels.PREVIEW_POINTER_EVENT_CHANNEL, event),
45 );
5346});
5447
5548export const createTab = DesktopIpc.makeIpcMethod({
apps/desktop/src/preview/Manager.test.ts+73-1
......@@ -5,6 +5,7 @@ import * as Exit from "effect/Exit";
55import * as FileSystem from "effect/FileSystem";
66import * as Fiber from "effect/Fiber";
77import * as Layer from "effect/Layer";
8import * as Logger from "effect/Logger";
89import * as Option from "effect/Option";
910import * as Path from "effect/Path";
1011import * as Schema from "effect/Schema";
......@@ -13,6 +14,7 @@ import { TestClock } from "effect/testing";
1314import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
1415
1516import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
17import * as ElectronWindow from "../electron/ElectronWindow.ts";
1618import * as BrowserSession from "./BrowserSession.ts";
1719import * as PreviewManager from "./Manager.ts";
1820
......@@ -130,6 +132,72 @@ describe("PreviewManager", () => {
130132 ),
131133 );
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
133201 effectIt.effect("queues navigation until the webview registers", () =>
134202 withManager((manager) =>
135203 Effect.gen(function* () {
......@@ -411,7 +479,11 @@ describe("PreviewManager", () => {
411479 },
412480 } 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 );
415487 yield* manager.createTab("tab_1");
416488 yield* manager.registerWebview("tab_1", 42);
417489 const click = yield* manager
apps/desktop/src/preview/Manager.ts+24-6
......@@ -281,8 +281,8 @@ const nextZoomLevel = (current: number, direction: "in" | "out"): number => {
281281 return ZOOM_LEVELS[Math.max(step - 1, 0)] ?? current;
282282};
283283
284type Listener = (tabId: string, state: PreviewTabState) => void;
285type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => void;
284type Listener = (tabId: string, state: PreviewTabState) => Effect.Effect<void>;
285type RecordingFrameListener = (frame: DesktopPreviewRecordingFrame) => Effect.Effect<void>;
286286
287287type PreviewInputSignal =
288288 | { readonly kind: "pointer"; readonly x: number; readonly y: number; readonly button: number }
......@@ -313,7 +313,7 @@ interface BrowserDiagnostics {
313313 readonly requests: ReadonlyMap<string, { url: string; method: string }>;
314314}
315315
316type PointerEventListener = (event: DesktopPreviewPointerEvent) => void;
316type PointerEventListener = (event: DesktopPreviewPointerEvent) => Effect.Effect<void>;
317317
318318interface ExpectedAgentInput {
319319 readonly signal: PreviewInputSignal;
......@@ -442,11 +442,28 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
442442 return copy;
443443 };
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
445462 const emit = Effect.fn("PreviewManager.emit")(function* (tabId: string, state: PreviewTabState) {
446463 const listeners = yield* Ref.get(listenersRef);
447464 yield* Effect.forEach(
448465 listeners,
449 (listener) => Effect.sync(() => listener(tabId, state)).pipe(Effect.ignore),
466 (listener) => deliverEvent("state-change", tabId, () => listener(tabId, state)),
450467 { discard: true },
451468 );
452469 });
......@@ -739,7 +756,8 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
739756 };
740757 yield* Effect.forEach(
741758 listeners,
742 (listener) => Effect.sync(() => listener(frame)).pipe(Effect.ignore),
759 (listener) =>
760 deliverEvent("recording-frame", frame.tabId, () => listener(frame)),
743761 { discard: true },
744762 );
745763 }
......@@ -1918,7 +1936,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function
19181936 const listeners = yield* Ref.get(pointerEventListenersRef);
19191937 yield* Effect.forEach(
19201938 listeners,
1921 (listener) => Effect.sync(() => listener(event)).pipe(Effect.ignore),
1939 (listener) => deliverEvent("pointer-event", event.tabId, () => listener(event)),
19221940 { discard: true },
19231941 );
19241942 });