From ce11e6facd6e557c382b3995242e2b501317fecd Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sat, 20 Jun 2026 12:10:51 -0700 Subject: [PATCH] [codex] Structure desktop browser session errors (#3273) Co-authored-by: codex --- .../src/preview/BrowserSession.test.ts | 108 +++++++++++++++++ apps/desktop/src/preview/BrowserSession.ts | 109 +++++++++++++++--- 2 files changed, 199 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/preview/BrowserSession.test.ts b/apps/desktop/src/preview/BrowserSession.test.ts index 5526e5e0e543e455446a5f36b9af8c016c6335f2..e258bb2dfc5a38ee549ee4c68b7b695f86316d1e 100644 --- a/apps/desktop/src/preview/BrowserSession.test.ts +++ b/apps/desktop/src/preview/BrowserSession.test.ts @@ -1,7 +1,9 @@ import { assert, describe, it } from "@effect/vitest"; import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import { beforeEach, vi } from "vite-plus/test"; const { fromPartition, sessions } = vi.hoisted(() => ({ @@ -59,6 +61,64 @@ describe("BrowserSession", () => { }).pipe(Effect.provide(layer)), ); + it.effect("preserves partition scope and the platform failure chain", () => { + const nativeCause = new Error("native digest failed"); + const platformCause = PlatformError.systemError({ + _tag: "Unknown", + module: "Crypto", + method: "digest", + cause: nativeCause, + }); + const failingCryptoLayer = Layer.succeed( + Crypto.Crypto, + Crypto.make({ + randomBytes: (size) => new Uint8Array(size), + digest: () => Effect.fail(platformCause), + }), + ); + + return Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip); + + assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError); + assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); + assert.isTrue(BrowserSession.isBrowserSessionError(error)); + assert.equal(error.scope, "environment-a"); + assert.strictEqual(error.cause, platformCause); + assert.strictEqual(error.cause.reason.cause, nativeCause); + assert.equal( + error.message, + "Failed to derive a desktop preview browser partition for scope environment-a.", + ); + assert.notInclude(error.message, nativeCause.message); + }).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(failingCryptoLayer)))); + }); + + it.effect("preserves session scope, partition, and the Electron failure", () => + Effect.gen(function* () { + const cause = new Error("Electron session failed"); + fromPartition.mockImplementationOnce(() => { + throw cause; + }); + const browserSessions = yield* BrowserSession.BrowserSession; + const partition = yield* browserSessions.getPartition("environment-b"); + const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip); + + assert.instanceOf(error, BrowserSession.BrowserSessionCreationError); + assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error)); + assert.isTrue(BrowserSession.isBrowserSessionError(error)); + assert.equal(error.scope, "environment-b"); + assert.equal(error.partition, partition); + assert.strictEqual(error.cause, cause); + assert.equal( + error.message, + `Failed to create a desktop preview browser session for scope environment-b (partition ${partition}).`, + ); + assert.notInclude(error.message, cause.message); + }).pipe(Effect.provide(layer)), + ); + it.effect("clears storage and cache for every created session", () => Effect.gen(function* () { const browserSessions = yield* BrowserSession.BrowserSession; @@ -80,4 +140,52 @@ describe("BrowserSession", () => { } }).pipe(Effect.provide(layer)), ); + + it.effect("correlates clear failures while still attempting every session", () => + Effect.gen(function* () { + const browserSessions = yield* BrowserSession.BrowserSession; + yield* browserSessions.getSession("scope-a"); + yield* browserSessions.getSession("scope-b"); + const firstPartition = yield* browserSessions.getPartition("scope-a"); + const secondPartition = yield* browserSessions.getPartition("scope-b"); + const firstSession = sessions.get(firstPartition); + const secondSession = sessions.get(secondPartition); + assert.isDefined(firstSession); + assert.isDefined(secondSession); + + const storageCause = new Error("storage clear failed"); + secondSession.clearStorageData.mockImplementationOnce(() => Promise.reject(storageCause)); + const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip); + + assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError); + assert.isTrue(BrowserSession.isBrowserSessionError(storageError)); + assert.equal(storageError.partition, secondPartition); + assert.strictEqual(storageError.cause, storageCause); + assert.equal( + storageError.message, + `Failed to clear desktop preview browser storage for partition ${secondPartition}.`, + ); + assert.notInclude(storageError.message, storageCause.message); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1); + } + + const cacheCause = new Error("cache clear failed"); + firstSession.clearCache.mockImplementationOnce(() => Promise.reject(cacheCause)); + const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip); + + assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError); + assert.isTrue(BrowserSession.isBrowserSessionError(cacheError)); + assert.equal(cacheError.partition, firstPartition); + assert.strictEqual(cacheError.cause, cacheCause); + assert.equal( + cacheError.message, + `Failed to clear the desktop preview browser cache for partition ${firstPartition}.`, + ); + assert.notInclude(cacheError.message, cacheCause.message); + for (const browserSession of sessions.values()) { + assert.strictEqual(browserSession.clearCache.mock.calls.length, 1); + } + }).pipe(Effect.provide(layer)), + ); }); diff --git a/apps/desktop/src/preview/BrowserSession.ts b/apps/desktop/src/preview/BrowserSession.ts index 7155b975f78ff0a57e8e3a349a0624f379c20e0e..afa8dafe976ffac3e98ce99889030679cb2ed155 100644 --- a/apps/desktop/src/preview/BrowserSession.ts +++ b/apps/desktop/src/preview/BrowserSession.ts @@ -5,31 +5,87 @@ import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Layer from "effect/Layer"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as SynchronizedRef from "effect/SynchronizedRef"; const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-"; -export class BrowserSessionError extends Schema.TaggedErrorClass()( - "BrowserSessionError", +export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass()( + "BrowserSessionPartitionDerivationError", { - operation: Schema.Literals(["getPartition", "getSession", "clearCookies", "clearCache"]), + scope: Schema.String, + cause: Schema.instanceOf(PlatformError.PlatformError), + }, +) { + override get message(): string { + return `Failed to derive a desktop preview browser partition for scope ${this.scope}.`; + } +} + +export class BrowserSessionCreationError extends Schema.TaggedErrorClass()( + "BrowserSessionCreationError", + { + scope: Schema.String, + partition: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to create a desktop preview browser session for scope ${this.scope} (partition ${this.partition}).`; + } +} + +export class BrowserSessionStorageClearError extends Schema.TaggedErrorClass()( + "BrowserSessionStorageClearError", + { + partition: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to clear desktop preview browser storage for partition ${this.partition}.`; + } +} + +export class BrowserSessionCacheClearError extends Schema.TaggedErrorClass()( + "BrowserSessionCacheClearError", + { + partition: Schema.String, cause: Schema.Defect(), }, ) { override get message(): string { - return `Desktop preview browser session operation failed: ${this.operation}`; + return `Failed to clear the desktop preview browser cache for partition ${this.partition}.`; } } +export const BrowserSessionGetSessionError = Schema.Union([ + BrowserSessionPartitionDerivationError, + BrowserSessionCreationError, +]); +export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type; +export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError); + +export const BrowserSessionError = Schema.Union([ + BrowserSessionPartitionDerivationError, + BrowserSessionCreationError, + BrowserSessionStorageClearError, + BrowserSessionCacheClearError, +]); +export type BrowserSessionError = typeof BrowserSessionError.Type; +export const isBrowserSessionError = Schema.is(BrowserSessionError); + export class BrowserSession extends Context.Service< BrowserSession, { - readonly getPartition: (scope?: string) => Effect.Effect; + readonly getPartition: ( + scope?: string, + ) => Effect.Effect; readonly isPartition: (partition: string) => boolean; - readonly getSession: (scope?: string) => Effect.Effect; - readonly clearCookies: () => Effect.Effect; - readonly clearCache: () => Effect.Effect; + readonly getSession: (scope?: string) => Effect.Effect; + readonly clearCookies: () => Effect.Effect; + readonly clearCache: () => Effect.Effect; } >()("@t3tools/desktop/preview/BrowserSession") {} @@ -38,11 +94,15 @@ export const make = Effect.gen(function* BrowserSessionMake() { const sessionsRef = yield* SynchronizedRef.make>(new Map()); const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") { - const digest = yield* crypto - .digest("SHA-256", new TextEncoder().encode(scope)) - .pipe( - Effect.mapError((cause) => new BrowserSessionError({ operation: "getPartition", cause })), - ); + const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe( + Effect.mapError( + (cause) => + new BrowserSessionPartitionDerivationError({ + scope, + cause, + }), + ), + ); return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`; }); @@ -67,7 +127,12 @@ export const make = Effect.gen(function* BrowserSessionMake() { next.set(partition, browserSession); return [browserSession, next] as const; }, - catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }), + catch: (cause) => + new BrowserSessionCreationError({ + scope, + partition, + cause, + }), }); }); }); @@ -79,13 +144,17 @@ export const make = Effect.gen(function* BrowserSessionMake() { clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.values()].map((browserSession) => + [...sessions.entries()].map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearStorageData({ storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"], }), - catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }), + catch: (cause) => + new BrowserSessionStorageClearError({ + partition, + cause, + }), }), ), { concurrency: "unbounded", discard: true }, @@ -94,10 +163,14 @@ export const make = Effect.gen(function* BrowserSessionMake() { clearCache: Effect.fn("BrowserSession.clearCache")(function* () { const sessions = yield* SynchronizedRef.get(sessionsRef); yield* Effect.all( - [...sessions.values()].map((browserSession) => + [...sessions.entries()].map(([partition, browserSession]) => Effect.tryPromise({ try: () => browserSession.clearCache(), - catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }), + catch: (cause) => + new BrowserSessionCacheClearError({ + partition, + cause, + }), }), ), { concurrency: "unbounded", discard: true }, -- 2.54.0