authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:32:47-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:32:47-07:00
log2c16edd0cb1169ce916e3da6a736d371954532a1
treed67c128b671fa1815478b0a71ef36a8c600d62da
parent708bc7091dcdf5073a374464ffcbe45ba520b8fb
signature Signed by PGP key B5690EEEBB952194

[codex] Structure desktop bridge state errors (#3381)

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

4 files changed, 119 insertions(+), 37 deletions(-)

apps/web/src/state/desktopNetworkAccess.test.ts+37
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts";1import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
2import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
3import { AtomRegistry } from "effect/unstable/reactivity";4import { AtomRegistry } from "effect/unstable/reactivity";
4import { describe, expect, it, vi } from "vite-plus/test";5import { describe, expect, it, vi } from "vite-plus/test";
...@@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = {...@@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = {
14};15};
1516
16const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = [];17const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = [];
18const serverExposureLoadCause = new Error("exposure failed");
19const advertisedEndpointsLoadCause = new Error("endpoints failed");
1720
18describe("desktopNetworkAccessState", () => {21describe("desktopNetworkAccessState", () => {
19 it("retains the loaded snapshot when the settings screen remounts", async () => {22 it("retains the loaded snapshot when the settings screen remounts", async () => {
...@@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => {...@@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => {
47 remount();50 remount();
48 registry.dispose();51 registry.dispose();
49 });52 });
53
54 it.each([
55 {
56 cause: serverExposureLoadCause,
57 expectedTag: "DesktopServerExposureStateLoadError",
58 getAdvertisedEndpoints: async () => advertisedEndpoints,
59 getServerExposureState: async () => Promise.reject(serverExposureLoadCause),
60 },
61 {
62 cause: advertisedEndpointsLoadCause,
63 expectedTag: "DesktopAdvertisedEndpointsLoadError",
64 getAdvertisedEndpoints: async () => Promise.reject(advertisedEndpointsLoadCause),
65 getServerExposureState: async () => serverExposureState,
66 },
67 ])("retains the $expectedTag cause", async (testCase) => {
68 const atom = createDesktopNetworkAccessStateAtom(() => ({
69 getAdvertisedEndpoints: testCase.getAdvertisedEndpoints,
70 getServerExposureState: testCase.getServerExposureState,
71 }));
72 const registry = AtomRegistry.make();
73 registry.mount(atom);
74
75 await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true));
76 const result = registry.get(atom);
77 if (!AsyncResult.isFailure(result)) throw new Error("Expected network access load to fail.");
78
79 expect(Cause.squash(result.cause)).toEqual(
80 expect.objectContaining({
81 _tag: testCase.expectedTag,
82 cause: testCase.cause,
83 }),
84 );
85 registry.dispose();
86 });
50});87});
apps/web/src/state/desktopNetworkAccess.ts+43-24
...@@ -21,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot {...@@ -21,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot {
21 readonly serverExposureState: DesktopServerExposureState;21 readonly serverExposureState: DesktopServerExposureState;
22}22}
2323
24class DesktopNetworkAccessError extends Schema.TaggedErrorClass<DesktopNetworkAccessError>()(24class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass<DesktopNetworkAccessUnavailableError>()(
25 "DesktopNetworkAccessError",25 "DesktopNetworkAccessUnavailableError",
26 {26 {},
27 message: Schema.String,27) {
28 cause: Schema.optional(Schema.Defect()),28 override get message(): string {
29 },29 return "Desktop network access is unavailable.";
30) {}30 }
31}
32
33class DesktopServerExposureStateLoadError extends Schema.TaggedErrorClass<DesktopServerExposureStateLoadError>()(
34 "DesktopServerExposureStateLoadError",
35 { cause: Schema.Defect() },
36) {
37 override get message(): string {
38 return "Failed to load desktop server exposure state.";
39 }
40}
41
42class DesktopAdvertisedEndpointsLoadError extends Schema.TaggedErrorClass<DesktopAdvertisedEndpointsLoadError>()(
43 "DesktopAdvertisedEndpointsLoadError",
44 { cause: Schema.Defect() },
45) {
46 override get message(): string {
47 return "Failed to load advertised desktop endpoints.";
48 }
49}
3150
32function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined {51function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined {
33 return typeof window === "undefined" ? undefined : window.desktopBridge;52 return typeof window === "undefined" ? undefined : window.desktopBridge;
...@@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom(...@@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom(
39 const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () {58 const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () {
40 const bridge = getBridge();59 const bridge = getBridge();
41 if (!bridge) {60 if (!bridge) {
42 return yield* new DesktopNetworkAccessError({61 return yield* new DesktopNetworkAccessUnavailableError();
43 message: "Desktop network access is unavailable.",
44 });
45 }62 }
46 return yield* Effect.tryPromise({63 const [serverExposureState, advertisedEndpoints] = yield* Effect.all(
47 try: async (): Promise<DesktopNetworkAccessSnapshot> => {64 [
48 const [serverExposureState, advertisedEndpoints] = await Promise.all([65 Effect.tryPromise({
49 bridge.getServerExposureState(),66 try: () => bridge.getServerExposureState(),
50 bridge.getAdvertisedEndpoints(),67 catch: (cause) => new DesktopServerExposureStateLoadError({ cause }),
51 ]);68 }),
52 return { advertisedEndpoints, serverExposureState };69 Effect.tryPromise({
53 },70 try: () => bridge.getAdvertisedEndpoints(),
54 catch: (cause) =>71 catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }),
55 new DesktopNetworkAccessError({
56 message:
57 cause instanceof Error ? cause.message : "Failed to load desktop network access.",
58 cause,
59 }),72 }),
60 });73 ],
74 { concurrency: "unbounded" },
75 );
76 return {
77 advertisedEndpoints,
78 serverExposureState,
79 } satisfies DesktopNetworkAccessSnapshot;
61 });80 });
6281
63 return Atom.make(loadDesktopNetworkAccess()).pipe(82 return Atom.make(loadDesktopNetworkAccess()).pipe(
apps/web/src/state/desktopSshHosts.test.ts+22
...@@ -1,4 +1,5 @@...@@ -1,4 +1,5 @@
1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";1import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
2import { AtomRegistry } from "effect/unstable/reactivity";3import { AtomRegistry } from "effect/unstable/reactivity";
3import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";4import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
4import { describe, expect, it, vi } from "vite-plus/test";5import { describe, expect, it, vi } from "vite-plus/test";
...@@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => {...@@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => {
38 remount();39 remount();
39 registry.dispose();40 registry.dispose();
40 });41 });
42
43 it("retains the desktop bridge failure as the discovery error cause", async () => {
44 const cause = new Error("ssh config unavailable");
45 const atom = createDesktopSshHostsStateAtom(() => ({
46 discoverSshHosts: async () => Promise.reject(cause),
47 }));
48 const registry = AtomRegistry.make();
49 registry.mount(atom);
50
51 await vi.waitFor(() => expect(AsyncResult.isFailure(registry.get(atom))).toBe(true));
52 const result = registry.get(atom);
53 if (!AsyncResult.isFailure(result)) throw new Error("Expected SSH host discovery to fail.");
54
55 expect(Cause.squash(result.cause)).toEqual(
56 expect.objectContaining({
57 _tag: "DesktopSshDiscoveryError",
58 cause,
59 }),
60 );
61 registry.dispose();
62 });
41});63});
apps/web/src/state/desktopSshHosts.ts+17-13
...@@ -5,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity";...@@ -5,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity";
55
6type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">;6type DesktopSshDiscoveryBridge = Pick<DesktopBridge, "discoverSshHosts">;
77
8class DesktopSshDiscoveryUnavailableError extends Schema.TaggedErrorClass<DesktopSshDiscoveryUnavailableError>()(
9 "DesktopSshDiscoveryUnavailableError",
10 {},
11) {
12 override get message(): string {
13 return "Desktop SSH host discovery is unavailable.";
14 }
15}
16
8class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()(17class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()(
9 "DesktopSshDiscoveryError",18 "DesktopSshDiscoveryError",
10 {19 { cause: Schema.Defect() },
11 message: Schema.String,20) {
12 cause: Schema.optional(Schema.Defect()),21 override get message(): string {
13 },22 return "Failed to discover SSH hosts.";
14) {}23 }
24}
1525
16function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined {26function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined {
17 return typeof window === "undefined" ? undefined : window.desktopBridge;27 return typeof window === "undefined" ? undefined : window.desktopBridge;
...@@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom(...@@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom(
23 const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () {33 const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () {
24 const bridge = getBridge();34 const bridge = getBridge();
25 if (!bridge) {35 if (!bridge) {
26 return yield* new DesktopSshDiscoveryError({36 return yield* new DesktopSshDiscoveryUnavailableError();
27 message: "Desktop SSH host discovery is unavailable.",
28 });
29 }37 }
30 return yield* Effect.tryPromise({38 return yield* Effect.tryPromise({
31 try: (): Promise<ReadonlyArray<DesktopDiscoveredSshHost>> => bridge.discoverSshHosts(),39 try: (): Promise<ReadonlyArray<DesktopDiscoveredSshHost>> => bridge.discoverSshHosts(),
32 catch: (cause) =>40 catch: (cause) => new DesktopSshDiscoveryError({ cause }),
33 new DesktopSshDiscoveryError({
34 message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.",
35 cause,
36 }),
37 });41 });
38 });42 });
3943