authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:04:53-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:04:53-07:00
logb9e22de6a354a97c2ecc772ad00754bfbd7333e6
treef83e2f30a8d78cdaa86fd274cef81fcdd18bd874
parentf3b43a148b9e39192be5d43415289ac06eca1443
signature Signed by PGP key B5690EEEBB952194

[codex] Preserve desktop update state read failures (#3370)

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

2 files changed, 52 insertions(+), 5 deletions(-)

apps/web/src/state/desktopUpdate.test.ts+20-2
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { DesktopUpdateState } from "@t3tools/contracts";1import type { DesktopUpdateState } from "@t3tools/contracts";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { AtomRegistry } from "effect/unstable/reactivity";3import { AtomRegistry } from "effect/unstable/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";4import { afterEach, describe, expect, it, vi } from "vite-plus/test";
55
6import { createDesktopUpdateStateAtom } from "./desktopUpdate";6import { createDesktopUpdateStateAtom } from "./desktopUpdate";
77
...@@ -22,6 +22,10 @@ const baseState: DesktopUpdateState = {...@@ -22,6 +22,10 @@ const baseState: DesktopUpdateState = {
22 canRetry: false,22 canRetry: false,
23};23};
2424
25afterEach(() => {
26 vi.restoreAllMocks();
27});
28
25describe("desktopUpdateStateAtom", () => {29describe("desktopUpdateStateAtom", () => {
26 it("loads once, retains state, and follows desktop update events", async () => {30 it("loads once, retains state, and follows desktop update events", async () => {
27 let listener: ((state: DesktopUpdateState) => void) | undefined;31 let listener: ((state: DesktopUpdateState) => void) | undefined;
...@@ -91,8 +95,11 @@ describe("desktopUpdateStateAtom", () => {...@@ -91,8 +95,11 @@ describe("desktopUpdateStateAtom", () => {
9195
92 it("keeps listening when the initial desktop state read fails", async () => {96 it("keeps listening when the initial desktop state read fails", async () => {
93 let listener: ((state: DesktopUpdateState) => void) | undefined;97 let listener: ((state: DesktopUpdateState) => void) | undefined;
98 const cause = new Error("IPC unavailable");
99 const reportError = vi.spyOn(console, "log").mockImplementation(() => undefined);
100 const getUpdateState = vi.fn(async () => Promise.reject(cause));
94 const atom = createDesktopUpdateStateAtom(() => ({101 const atom = createDesktopUpdateStateAtom(() => ({
95 getUpdateState: async () => Promise.reject(new Error("IPC unavailable")),102 getUpdateState,
96 onUpdateState: (nextListener) => {103 onUpdateState: (nextListener) => {
97 listener = nextListener;104 listener = nextListener;
98 return () => undefined;105 return () => undefined;
...@@ -102,6 +109,17 @@ describe("desktopUpdateStateAtom", () => {...@@ -102,6 +109,17 @@ describe("desktopUpdateStateAtom", () => {
102 registry.mount(atom);109 registry.mount(atom);
103110
104 await vi.waitFor(() => expect(listener).toBeDefined());111 await vi.waitFor(() => expect(listener).toBeDefined());
112 await vi.waitFor(() => expect(reportError).toHaveBeenCalledOnce());
113 expect(getUpdateState).toHaveBeenCalledTimes(3);
114 const [, errorMessage, errorContext] = reportError.mock.calls[0] ?? [];
115 expect(errorMessage).toBe("Failed to read the initial desktop update state after 3 attempts.");
116 expect(errorContext).toMatchObject({
117 errorTag: "DesktopUpdateStateReadError",
118 attemptCount: 3,
119 });
120 expect(errorContext).not.toHaveProperty("error");
121 expect(errorContext).not.toHaveProperty("cause");
122
105 listener?.(baseState);123 listener?.(baseState);
106 await vi.waitFor(() => {124 await vi.waitFor(() => {
107 expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState);125 expect(AsyncResult.getOrElse(registry.get(atom), () => null)).toEqual(baseState);
apps/web/src/state/desktopUpdate.ts+32-3
...@@ -2,12 +2,27 @@ import { useAtomValue } from "@effect/atom-react";...@@ -2,12 +2,27 @@ import { useAtomValue } from "@effect/atom-react";
2import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";2import type { DesktopBridge, DesktopUpdateState } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
4import * as Queue from "effect/Queue";4import * as Queue from "effect/Queue";
5import * as Schema from "effect/Schema";
5import * as Stream from "effect/Stream";6import * as Stream from "effect/Stream";
6import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";7import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
7import { Atom } from "effect/unstable/reactivity";8import { Atom } from "effect/unstable/reactivity";
89
9type DesktopUpdateBridge = Pick<DesktopBridge, "getUpdateState" | "onUpdateState">;10type DesktopUpdateBridge = Pick<DesktopBridge, "getUpdateState" | "onUpdateState">;
1011
12const INITIAL_STATE_READ_ATTEMPT_COUNT = 3;
13
14export class DesktopUpdateStateReadError extends Schema.TaggedErrorClass<DesktopUpdateStateReadError>()(
15 "DesktopUpdateStateReadError",
16 {
17 attemptCount: Schema.Number,
18 cause: Schema.Defect(),
19 },
20) {
21 override get message(): string {
22 return `Failed to read the initial desktop update state after ${this.attemptCount} attempts.`;
23 }
24}
25
11function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined {26function getDesktopUpdateBridge(): DesktopUpdateBridge | undefined {
12 return typeof window === "undefined" ? undefined : window.desktopBridge;27 return typeof window === "undefined" ? undefined : window.desktopBridge;
13}28}
...@@ -32,9 +47,23 @@ export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridg...@@ -32,9 +47,23 @@ export function createDesktopUpdateStateAtom(getBridge: () => DesktopUpdateBridg
32 (unsubscribe) => Effect.sync(unsubscribe),47 (unsubscribe) => Effect.sync(unsubscribe),
33 );48 );
3449
35 const initialState = yield* Effect.tryPromise(() => bridge.getUpdateState()).pipe(50 const initialState = yield* Effect.tryPromise({
36 Effect.retry({ times: 2 }),51 try: () => bridge.getUpdateState(),
37 Effect.orElseSucceed(() => null),52 catch: (cause) =>
53 new DesktopUpdateStateReadError({
54 attemptCount: INITIAL_STATE_READ_ATTEMPT_COUNT,
55 cause,
56 }),
57 }).pipe(
58 Effect.retry({ times: INITIAL_STATE_READ_ATTEMPT_COUNT - 1 }),
59 Effect.catchTags({
60 DesktopUpdateStateReadError: (error) =>
61 Effect.logError(error.message, {
62 errorTag: error._tag,
63 attemptCount: error.attemptCount,
64 stack: error.stack,
65 }).pipe(Effect.as(null)),
66 }),
38 );67 );
39 if (!receivedUpdate && initialState !== null) {68 if (!receivedUpdate && initialState !== null) {
40 Queue.offerUnsafe(queue, initialState);69 Queue.offerUnsafe(queue, initialState);