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 @@
11import type { AdvertisedEndpoint, DesktopServerExposureState } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
23import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
34import { AtomRegistry } from "effect/unstable/reactivity";
45import { describe, expect, it, vi } from "vite-plus/test";
......@@ -14,6 +15,8 @@ const serverExposureState: DesktopServerExposureState = {
1415};
1516
1617const advertisedEndpoints: ReadonlyArray<AdvertisedEndpoint> = [];
18const serverExposureLoadCause = new Error("exposure failed");
19const advertisedEndpointsLoadCause = new Error("endpoints failed");
1720
1821describe("desktopNetworkAccessState", () => {
1922 it("retains the loaded snapshot when the settings screen remounts", async () => {
......@@ -47,4 +50,38 @@ describe("desktopNetworkAccessState", () => {
4750 remount();
4851 registry.dispose();
4952 });
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 });
5087});
apps/web/src/state/desktopNetworkAccess.ts+43-24
......@@ -21,13 +21,32 @@ export interface DesktopNetworkAccessSnapshot {
2121 readonly serverExposureState: DesktopServerExposureState;
2222}
2323
24class DesktopNetworkAccessError extends Schema.TaggedErrorClass<DesktopNetworkAccessError>()(
25 "DesktopNetworkAccessError",
26 {
27 message: Schema.String,
28 cause: Schema.optional(Schema.Defect()),
29 },
30) {}
24class DesktopNetworkAccessUnavailableError extends Schema.TaggedErrorClass<DesktopNetworkAccessUnavailableError>()(
25 "DesktopNetworkAccessUnavailableError",
26 {},
27) {
28 override get message(): string {
29 return "Desktop network access is unavailable.";
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
3251function getDesktopNetworkAccessBridge(): DesktopNetworkAccessBridge | undefined {
3352 return typeof window === "undefined" ? undefined : window.desktopBridge;
......@@ -39,25 +58,25 @@ export function createDesktopNetworkAccessStateAtom(
3958 const loadDesktopNetworkAccess = Effect.fn("loadDesktopNetworkAccess")(function* () {
4059 const bridge = getBridge();
4160 if (!bridge) {
42 return yield* new DesktopNetworkAccessError({
43 message: "Desktop network access is unavailable.",
44 });
61 return yield* new DesktopNetworkAccessUnavailableError();
4562 }
46 return yield* Effect.tryPromise({
47 try: async (): Promise<DesktopNetworkAccessSnapshot> => {
48 const [serverExposureState, advertisedEndpoints] = await Promise.all([
49 bridge.getServerExposureState(),
50 bridge.getAdvertisedEndpoints(),
51 ]);
52 return { advertisedEndpoints, serverExposureState };
53 },
54 catch: (cause) =>
55 new DesktopNetworkAccessError({
56 message:
57 cause instanceof Error ? cause.message : "Failed to load desktop network access.",
58 cause,
63 const [serverExposureState, advertisedEndpoints] = yield* Effect.all(
64 [
65 Effect.tryPromise({
66 try: () => bridge.getServerExposureState(),
67 catch: (cause) => new DesktopServerExposureStateLoadError({ cause }),
68 }),
69 Effect.tryPromise({
70 try: () => bridge.getAdvertisedEndpoints(),
71 catch: (cause) => new DesktopAdvertisedEndpointsLoadError({ cause }),
5972 }),
60 });
73 ],
74 { concurrency: "unbounded" },
75 );
76 return {
77 advertisedEndpoints,
78 serverExposureState,
79 } satisfies DesktopNetworkAccessSnapshot;
6180 });
6281
6382 return Atom.make(loadDesktopNetworkAccess()).pipe(
apps/web/src/state/desktopSshHosts.test.ts+22
......@@ -1,4 +1,5 @@
11import type { DesktopDiscoveredSshHost } from "@t3tools/contracts";
2import * as Cause from "effect/Cause";
23import { AtomRegistry } from "effect/unstable/reactivity";
34import * as AsyncResult from "effect/unstable/reactivity/AsyncResult";
45import { describe, expect, it, vi } from "vite-plus/test";
......@@ -38,4 +39,25 @@ describe("desktopSshHostsState", () => {
3839 remount();
3940 registry.dispose();
4041 });
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 });
4163});
apps/web/src/state/desktopSshHosts.ts+17-13
......@@ -5,13 +5,23 @@ import { Atom } from "effect/unstable/reactivity";
55
66type 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
817class DesktopSshDiscoveryError extends Schema.TaggedErrorClass<DesktopSshDiscoveryError>()(
918 "DesktopSshDiscoveryError",
10 {
11 message: Schema.String,
12 cause: Schema.optional(Schema.Defect()),
13 },
14) {}
19 { cause: Schema.Defect() },
20) {
21 override get message(): string {
22 return "Failed to discover SSH hosts.";
23 }
24}
1525
1626function getDesktopSshDiscoveryBridge(): DesktopSshDiscoveryBridge | undefined {
1727 return typeof window === "undefined" ? undefined : window.desktopBridge;
......@@ -23,17 +33,11 @@ export function createDesktopSshHostsStateAtom(
2333 const discoverDesktopSshHosts = Effect.fn("discoverDesktopSshHosts")(function* () {
2434 const bridge = getBridge();
2535 if (!bridge) {
26 return yield* new DesktopSshDiscoveryError({
27 message: "Desktop SSH host discovery is unavailable.",
28 });
36 return yield* new DesktopSshDiscoveryUnavailableError();
2937 }
3038 return yield* Effect.tryPromise({
3139 try: (): Promise<ReadonlyArray<DesktopDiscoveredSshHost>> => bridge.discoverSshHosts(),
32 catch: (cause) =>
33 new DesktopSshDiscoveryError({
34 message: cause instanceof Error ? cause.message : "Failed to discover SSH hosts.",
35 cause,
36 }),
40 catch: (cause) => new DesktopSshDiscoveryError({ cause }),
3741 });
3842 });
3943