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 {...@@ -24,6 +24,39 @@ export interface DesktopIpcMain {
24 on(channel: string, listener: DesktopIpcSyncListener): void;24 on(channel: string, listener: DesktopIpcSyncListener): void;
25}25}
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
27export interface DesktopIpcMethod<E, R> {60export interface DesktopIpcMethod<E, R> {
28 readonly channel: string;61 readonly channel: string;
29 readonly handler: (raw: unknown) => Effect.Effect<unknown, E, R>;62 readonly handler: (raw: unknown) => Effect.Effect<unknown, E, R>;
...@@ -39,10 +72,10 @@ export class DesktopIpc extends Context.Service<...@@ -39,10 +72,10 @@ export class DesktopIpc extends Context.Service<
39 {72 {
40 readonly handle: <E, R>(73 readonly handle: <E, R>(
41 input: DesktopIpcMethod<E, R>,74 input: DesktopIpcMethod<E, R>,
42 ) => Effect.Effect<void, never, R | Scope.Scope>;75 ) => Effect.Effect<void, DesktopIpcRegistrationError, R | Scope.Scope>;
43 readonly handleSync: <E, R>(76 readonly handleSync: <E, R>(
44 input: DesktopSyncIpcMethod<E, R>,77 input: DesktopSyncIpcMethod<E, R>,
45 ) => Effect.Effect<void, never, R | Scope.Scope>;78 ) => Effect.Effect<void, DesktopIpcRegistrationError, R | Scope.Scope>;
46 }79 }
47>()("@t3tools/desktop/ipc/DesktopIpc") {}80>()("@t3tools/desktop/ipc/DesktopIpc") {}
4881
...@@ -57,18 +90,27 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>...@@ -57,18 +90,27 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
57 const runPromise = Effect.runPromiseWith(context);90 const runPromise = Effect.runPromiseWith(context);
5891
59 yield* Effect.acquireRelease(92 yield* Effect.acquireRelease(
60 Effect.sync(() => {93 Effect.try({
61 ipcMain.removeHandler(channel);94 try: () => {
62 ipcMain.handle(channel, (_event, raw) =>95 ipcMain.removeHandler(channel);
63 runPromise(96 ipcMain.handle(channel, (_event, raw) =>
64 Effect.gen(function* () {97 runPromise(
65 yield* Effect.annotateCurrentSpan({ channel });98 Effect.gen(function* () {
66 return yield* handler(raw);99 yield* Effect.annotateCurrentSpan({ channel });
67 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),100 return yield* handler(raw);
68 ),101 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")),
69 );102 ),
103 );
104 },
105 catch: (cause) =>
106 new DesktopIpcRegistrationError({ handlerKind: "invoke", channel, cause }),
70 }),107 }),
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),
72 );114 );
73 }),115 }),
74116
...@@ -81,18 +123,30 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>...@@ -81,18 +123,30 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] =>
81 const runSync = Effect.runSyncWith(context);123 const runSync = Effect.runSyncWith(context);
82124
83 yield* Effect.acquireRelease(125 yield* Effect.acquireRelease(
84 Effect.sync(() => {126 Effect.try({
85 ipcMain.removeAllListeners(channel);127 try: () => {
86 ipcMain.on(channel, (event) => {128 ipcMain.removeAllListeners(channel);
87 event.returnValue = runSync(129 ipcMain.on(channel, (event) => {
88 Effect.gen(function* () {130 event.returnValue = runSync(
89 yield* Effect.annotateCurrentSpan({ channel });131 Effect.gen(function* () {
90 return yield* handler();132 yield* Effect.annotateCurrentSpan({ channel });
91 }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")),133 return yield* handler();
92 );134 }).pipe(
93 });135 Effect.annotateLogs({ channel }),
136 Effect.withSpan("desktop.ipc.invokeSync"),
137 ),
138 );
139 });
140 },
141 catch: (cause) =>
142 new DesktopIpcRegistrationError({ handlerKind: "sync", channel, cause }),
94 }),143 }),
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),
96 );150 );
97 }),151 }),
98 });152 });