diff --git a/apps/desktop/src/ipc/DesktopIpc.test.ts b/apps/desktop/src/ipc/DesktopIpc.test.ts new file mode 100644 index 00000000000..fc311877f82 --- /dev/null +++ b/apps/desktop/src/ipc/DesktopIpc.test.ts @@ -0,0 +1,79 @@ +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import { vi } from "vite-plus/test"; + +import * as DesktopIpc from "./DesktopIpc.ts"; + +const invokeMethod: DesktopIpc.DesktopIpcMethod = { + channel: "desktop.test.invoke", + handler: () => Effect.void, +}; + +const syncMethod: DesktopIpc.DesktopSyncIpcMethod = { + channel: "desktop.test.sync", + handler: () => Effect.void, +}; + +function makeIpcMain( + overrides: Partial = {}, +): DesktopIpc.DesktopIpcMain { + return { + removeHandler: vi.fn(), + handle: vi.fn(), + removeAllListeners: vi.fn(), + on: vi.fn(), + ...overrides, + }; +} + +describe("DesktopIpc", () => { + it.effect("preserves invoke registration context and cause", () => + Effect.gen(function* () { + const cause = new Error("invoke registration failed"); + const ipcMain = makeIpcMain({ + handle: () => { + throw cause; + }, + }); + const ipc = DesktopIpc.make(ipcMain); + + const error = yield* Effect.flip(Effect.scoped(ipc.handle(invokeMethod))); + + assert.instanceOf(error, DesktopIpc.DesktopIpcRegistrationError); + assert.isTrue(DesktopIpc.isDesktopIpcError(error)); + assert.strictEqual(error.handlerKind, "invoke"); + assert.strictEqual(error.channel, invokeMethod.channel); + assert.strictEqual(error.cause, cause); + assert.include(error.message, "invoke"); + assert.include(error.message, invokeMethod.channel); + assert.notInclude(error.message, cause.message); + }), + ); + + it.effect("preserves sync unregistration context and cause in the finalizer defect", () => + Effect.gen(function* () { + const cause = new Error("sync unregistration failed"); + let removeCount = 0; + const ipcMain = makeIpcMain({ + removeAllListeners: () => { + removeCount += 1; + if (removeCount === 2) throw cause; + }, + }); + const ipc = DesktopIpc.make(ipcMain); + + const exit = yield* Effect.exit(Effect.scoped(ipc.handleSync(syncMethod))); + + assert.isTrue(exit._tag === "Failure"); + if (exit._tag === "Success") return; + const error = Cause.squash(exit.cause); + assert.instanceOf(error, DesktopIpc.DesktopIpcUnregistrationError); + assert.isTrue(DesktopIpc.isDesktopIpcError(error)); + assert.strictEqual(error.handlerKind, "sync"); + assert.strictEqual(error.channel, syncMethod.channel); + assert.strictEqual(error.cause, cause); + assert.notInclude(error.message, cause.message); + }), + ); +}); diff --git a/apps/desktop/src/ipc/DesktopIpc.ts b/apps/desktop/src/ipc/DesktopIpc.ts index 253bb2774e9..e948571cc62 100644 --- a/apps/desktop/src/ipc/DesktopIpc.ts +++ b/apps/desktop/src/ipc/DesktopIpc.ts @@ -24,6 +24,39 @@ export interface DesktopIpcMain { on(channel: string, listener: DesktopIpcSyncListener): void; } +export class DesktopIpcRegistrationError extends Schema.TaggedErrorClass()( + "DesktopIpcRegistrationError", + { + handlerKind: Schema.Literals(["invoke", "sync"]), + channel: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to register the ${this.handlerKind} IPC handler for ${this.channel}.`; + } +} + +export class DesktopIpcUnregistrationError extends Schema.TaggedErrorClass()( + "DesktopIpcUnregistrationError", + { + handlerKind: Schema.Literals(["invoke", "sync"]), + channel: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to unregister the ${this.handlerKind} IPC handler for ${this.channel}.`; + } +} + +export const DesktopIpcError = Schema.Union([ + DesktopIpcRegistrationError, + DesktopIpcUnregistrationError, +]); +export type DesktopIpcError = typeof DesktopIpcError.Type; +export const isDesktopIpcError = Schema.is(DesktopIpcError); + export interface DesktopIpcMethod { readonly channel: string; readonly handler: (raw: unknown) => Effect.Effect; @@ -39,10 +72,10 @@ export class DesktopIpc extends Context.Service< { readonly handle: ( input: DesktopIpcMethod, - ) => Effect.Effect; + ) => Effect.Effect; readonly handleSync: ( input: DesktopSyncIpcMethod, - ) => Effect.Effect; + ) => Effect.Effect; } >()("@t3tools/desktop/ipc/DesktopIpc") {} @@ -57,18 +90,27 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => const runPromise = Effect.runPromiseWith(context); yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeHandler(channel); - ipcMain.handle(channel, (_event, raw) => - runPromise( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ channel }); - return yield* handler(raw); - }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), - ), - ); + Effect.try({ + try: () => { + ipcMain.removeHandler(channel); + ipcMain.handle(channel, (_event, raw) => + runPromise( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(raw); + }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invoke")), + ), + ); + }, + catch: (cause) => + new DesktopIpcRegistrationError({ handlerKind: "invoke", channel, cause }), }), - () => Effect.sync(() => ipcMain.removeHandler(channel)), + () => + Effect.try({ + try: () => ipcMain.removeHandler(channel), + catch: (cause) => + new DesktopIpcUnregistrationError({ handlerKind: "invoke", channel, cause }), + }).pipe(Effect.orDie), ); }), @@ -81,18 +123,30 @@ export const make = (ipcMain: DesktopIpcMain): DesktopIpc["Service"] => const runSync = Effect.runSyncWith(context); yield* Effect.acquireRelease( - Effect.sync(() => { - ipcMain.removeAllListeners(channel); - ipcMain.on(channel, (event) => { - event.returnValue = runSync( - Effect.gen(function* () { - yield* Effect.annotateCurrentSpan({ channel }); - return yield* handler(); - }).pipe(Effect.annotateLogs({ channel }), Effect.withSpan("desktop.ipc.invokeSync")), - ); - }); + Effect.try({ + try: () => { + ipcMain.removeAllListeners(channel); + ipcMain.on(channel, (event) => { + event.returnValue = runSync( + Effect.gen(function* () { + yield* Effect.annotateCurrentSpan({ channel }); + return yield* handler(); + }).pipe( + Effect.annotateLogs({ channel }), + Effect.withSpan("desktop.ipc.invokeSync"), + ), + ); + }); + }, + catch: (cause) => + new DesktopIpcRegistrationError({ handlerKind: "sync", channel, cause }), }), - () => Effect.sync(() => ipcMain.removeAllListeners(channel)), + () => + Effect.try({ + try: () => ipcMain.removeAllListeners(channel), + catch: (cause) => + new DesktopIpcUnregistrationError({ handlerKind: "sync", channel, cause }), + }).pipe(Effect.orDie), ); }), });