authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:43:21-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:43:21-07:00
logf4ef356215271b47dd2acdd93590f919f548ffd6
treea9c2cad886976eb602d384917d08311153927dbe
parentd60f5c61013dfda68cbbf79bdac2425b73ddaa05
signature Signed by PGP key B5690EEEBB952194

[codex] structure desktop IPC registration errors (#3291)

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

2 files changed, 157 insertions(+), 24 deletions(-)

apps/desktop/src/ipc/DesktopIpc.test.ts created+79
......@@ -0,0 +1,79 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import { vi } from "vite-plus/test";
5
6import * as DesktopIpc from "./DesktopIpc.ts";
7
8const invokeMethod: DesktopIpc.DesktopIpcMethod<never, never> = {
9 channel: "desktop.test.invoke",
10 handler: () => Effect.void,
11};
12
13const syncMethod: DesktopIpc.DesktopSyncIpcMethod<never, never> = {
14 channel: "desktop.test.sync",
15 handler: () => Effect.void,
16};
17
18function makeIpcMain(
19 overrides: Partial<DesktopIpc.DesktopIpcMain> = {},
20): DesktopIpc.DesktopIpcMain {
21 return {
22 removeHandler: vi.fn(),
23 handle: vi.fn(),
24 removeAllListeners: vi.fn(),
25 on: vi.fn(),
26 ...overrides,
27 };
28}
29
30describe("DesktopIpc", () => {
31 it.effect("preserves invoke registration context and cause", () =>
32 Effect.gen(function* () {
33 const cause = new Error("invoke registration failed");
34 const ipcMain = makeIpcMain({
35 handle: () => {
36 throw cause;
37 },
38 });
39 const ipc = DesktopIpc.make(ipcMain);
40
41 const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod)));
42
43 assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError);
44 assert.isTrue(DesktopIpc.isDesktopIpcError(error));
45 assert.strictEqual(error.handlerKind, "invoke");
46 assert.strictEqual(error.channel, invokeMethod.channel);
47 assert.strictEqual(error.cause, cause);
48 assert.include(error.message, "invoke");
49 assert.include(error.message, invokeMethod.channel);
50 assert.notInclude(error.message, cause.message);
51 }),
52 );
53
54 it.effect("preserves sync unregistration context and cause in the finalizer defect", () =>
55 Effect.gen(function* () {
56 const cause = new Error("sync unregistration failed");
57 let removeCount = 0;
58 const ipcMain = makeIpcMain({
59 removeAllListeners: () => {
60 removeCount += 1;
61 if (removeCount === 2) throw cause;
62 },
63 });
64 const ipc = DesktopIpc.make(ipcMain);
65
66 const exit = yield* Effect.exit(Effect.scoped(ipc.handleSync(syncMethod)));
67
68 assert.isTrue(exit._tag === "Failure");
69 if (exit._tag === "Success") return;
70 const error = Cause.squash(exit.cause);
71 assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError);
72 assert.isTrue(DesktopIpc.isDesktopIpcError(error));
73 assert.strictEqual(error.handlerKind, "sync");
74 assert.strictEqual(error.channel, syncMethod.channel);
75 assert.strictEqual(error.cause, cause);
76 assert.notInclude(error.message, cause.message);
77 }),
78 );
79});
apps/desktop/src/ipc/DesktopIpc.ts+78-24
......@@ -24,6 +24,39 @@ export interface DesktopIpcMain {
2424 on(channel: string, listener: DesktopIpcSyncListener): void;
2525}
2626
27export class DesktopIpcRegistrationError extends Schema.TaggedErrorClass<DesktopIpcRegistrationError>()(
28 "DesktopIpcRegistrationError",
29 {
30 handlerKind: Schema.Literals(["invoke", "sync"]),
31 channel: Schema.String,
32 cause: Schema.Defect(),
33 },
34) {
35 override get message(): string {
36 return `Failed to register the ${this.handlerKind} IPC handler for ${this.channel}.`;
37 }
38}
39
40export class DesktopIpcUnregistrationError extends Schema.TaggedErrorClass<DesktopIpcUnregistrationError>()(
41 "DesktopIpcUnregistrationError",
42 {
43 handlerKind: Schema.Literals(["invoke", "sync"]),
44 channel: Schema.String,
45 cause: Schema.Defect(),
46 },
47) {
48 override get message(): string {
49 return `Failed to unregister the ${this.handlerKind} IPC handler for ${this.channel}.`;
50 }
51}
52
53export const DesktopIpcError = Schema.Union([
54 DesktopIpcRegistrationError,
55 DesktopIpcUnregistrationError,
56]);
57export type DesktopIpcError = typeof DesktopIpcError.Type;
58export const isDesktopIpcError = Schema.is(DesktopIpcError);
59
2760export interface DesktopIpcMethod<E, R> {
2861 readonly channel: string;
2962 readonly handler: (raw: unknown) => Effect.Effect<unknown, E, R>;
......@@ -39,10 +72,10 @@ export class DesktopIpc extends Context.Service<
3972 {
4073 readonly handle: <E, R>(
4174 input: DesktopIpcMethod<E, R>,
42 ) => Effect.Effect<void, never, R | Scope.Scope>;
75 ) => Effect.Effect<void, DesktopIpcRegistrationError, R | Scope.Scope>;
4376 readonly handleSync: <E, R>(
4477 input: DesktopSyncIpcMethod<E, R>,
45 ) => Effect.Effect<void, never, R | Scope.Scope>;
78 ) => Effect.Effect<void, DesktopIpcRegistrationError, R | Scope.Scope>;
4679 }
4780>()("@t3tools/desktop/ipc/DesktopIpc") {}
4881
......@@ -57,18 +90,27 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
5790 const runPromise = Effect.runPromiseWith(context);
5891
5992 yield* Effect.acquireRelease(
60 Effect.sync(() => {
61 ipcMain.removeHandler(channel);
62 ipcMain.handle(channel, (_event, raw) =>
63 runPromise(
64 Effect.gen(function* () {
65 yield* Effect.annotateCurrentSpan({ channel });
66 return yield* handler(raw);
67 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
68 ),
69 );
93 Effect.try({
94 try: () => {
95 ipcMain.removeHandler(channel);
96 ipcMain.handle(channel, (_event, raw) =>
97 runPromise(
98 Effect.gen(function* () {
99 yield* Effect.annotateCurrentSpan({ channel });
100 return yield* handler(raw);
101 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
102 ),
103 );
104 },
105 catch: (cause) =>
106 new DesktopIpcRegistrationError({ handlerKind: "invoke", channel, cause }),
70107 }),
71 () => Effect.sync(() => ipcMain.removeHandler(channel)),
108 () =>
109 Effect.try({
110 try: () => ipcMain.removeHandler(channel),
111 catch: (cause) =>
112 new DesktopIpcUnregistrationError({ handlerKind: "invoke", channel, cause }),
113 }).pipe(Effect.orDie),
72114 );
73115 }),
74116
......@@ -81,18 +123,30 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
81123 const runSync = Effect.runSyncWith(context);
82124
83125 yield* Effect.acquireRelease(
84 Effect.sync(() => {
85 ipcMain.removeAllListeners(channel);
86 ipcMain.on(channel, (event) => {
87 event.returnValue = runSync(
88 Effect.gen(function* () {
89 yield* Effect.annotateCurrentSpan({ channel });
90 return yield* handler();
91 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")),
92 );
93 });
126 Effect.try({
127 try: () => {
128 ipcMain.removeAllListeners(channel);
129 ipcMain.on(channel, (event) => {
130 event.returnValue = runSync(
131 Effect.gen(function* () {
132 yield* Effect.annotateCurrentSpan({ channel });
133 return yield* handler();
134 }).pipe(
135 Effect.annotateLogs({ channel }),
136 Effect.withSpan("desktop.ipc.invokeSync"),
137 ),
138 );
139 });
140 },
141 catch: (cause) =>
142 new DesktopIpcRegistrationError({ handlerKind: "sync", channel, cause }),
94143 }),
95 () => Effect.sync(() => ipcMain.removeAllListeners(channel)),
144 () =>
145 Effect.try({
146 try: () => ipcMain.removeAllListeners(channel),
147 catch: (cause) =>
148 new DesktopIpcUnregistrationError({ handlerKind: "sync", channel, cause }),
149 }).pipe(Effect.orDie),
96150 );
97151 }),
98152 });