authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 12:10:51-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 12:10:51-07:00
logce11e6facd6e557c382b3995242e2b501317fecd
treee83b3e6d678c3e336aee651cd6df6c0056190472
parentf1052dd8b1040b22ad5a2e21282ee9980f98dbc5
signature Signed by PGP key B5690EEEBB952194

[codex] Structure desktop browser session errors (#3273)

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

2 files changed, 199 insertions(+), 18 deletions(-)

apps/desktop/src/preview/BrowserSession.test.ts+108
......@@ -1,7 +1,9 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as NodeServices from "@effect/platform-node/NodeServices";
3import * as Crypto from "effect/Crypto";
34import * as Effect from "effect/Effect";
45import * as Layer from "effect/Layer";
6import * as PlatformError from "effect/PlatformError";
57import { beforeEach, vi } from "vite-plus/test";
68
79const { fromPartition, sessions } = vi.hoisted(() => ({
......@@ -59,6 +61,64 @@ describe("BrowserSession", () => {
5961 }).pipe(Effect.provide(layer)),
6062 );
6163
64 it.effect("preserves partition scope and the platform failure chain", () => {
65 const nativeCause = new Error("native digest failed");
66 const platformCause = PlatformError.systemError({
67 _tag: "Unknown",
68 module: "Crypto",
69 method: "digest",
70 cause: nativeCause,
71 });
72 const failingCryptoLayer = Layer.succeed(
73 Crypto.Crypto,
74 Crypto.make({
75 randomBytes: (size) => new Uint8Array(size),
76 digest: () => Effect.fail(platformCause),
77 }),
78 );
79
80 return Effect.gen(function* () {
81 const browserSessions = yield* BrowserSession.BrowserSession;
82 const error = yield* browserSessions.getPartition("environment-a").pipe(Effect.flip);
83
84 assert.instanceOf(error, BrowserSession.BrowserSessionPartitionDerivationError);
85 assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error));
86 assert.isTrue(BrowserSession.isBrowserSessionError(error));
87 assert.equal(error.scope, "environment-a");
88 assert.strictEqual(error.cause, platformCause);
89 assert.strictEqual(error.cause.reason.cause, nativeCause);
90 assert.equal(
91 error.message,
92 "Failed to derive a desktop preview browser partition for scope environment-a.",
93 );
94 assert.notInclude(error.message, nativeCause.message);
95 }).pipe(Effect.provide(BrowserSession.layer.pipe(Layer.provide(failingCryptoLayer))));
96 });
97
98 it.effect("preserves session scope, partition, and the Electron failure", () =>
99 Effect.gen(function* () {
100 const cause = new Error("Electron session failed");
101 fromPartition.mockImplementationOnce(() => {
102 throw cause;
103 });
104 const browserSessions = yield* BrowserSession.BrowserSession;
105 const partition = yield* browserSessions.getPartition("environment-b");
106 const error = yield* browserSessions.getSession("environment-b").pipe(Effect.flip);
107
108 assert.instanceOf(error, BrowserSession.BrowserSessionCreationError);
109 assert.isTrue(BrowserSession.isBrowserSessionGetSessionError(error));
110 assert.isTrue(BrowserSession.isBrowserSessionError(error));
111 assert.equal(error.scope, "environment-b");
112 assert.equal(error.partition, partition);
113 assert.strictEqual(error.cause, cause);
114 assert.equal(
115 error.message,
116 `Failed to create a desktop preview browser session for scope environment-b (partition ${partition}).`,
117 );
118 assert.notInclude(error.message, cause.message);
119 }).pipe(Effect.provide(layer)),
120 );
121
62122 it.effect("clears storage and cache for every created session", () =>
63123 Effect.gen(function* () {
64124 const browserSessions = yield* BrowserSession.BrowserSession;
......@@ -80,4 +140,52 @@ describe("BrowserSession", () => {
80140 }
81141 }).pipe(Effect.provide(layer)),
82142 );
143
144 it.effect("correlates clear failures while still attempting every session", () =>
145 Effect.gen(function* () {
146 const browserSessions = yield* BrowserSession.BrowserSession;
147 yield* browserSessions.getSession("scope-a");
148 yield* browserSessions.getSession("scope-b");
149 const firstPartition = yield* browserSessions.getPartition("scope-a");
150 const secondPartition = yield* browserSessions.getPartition("scope-b");
151 const firstSession = sessions.get(firstPartition);
152 const secondSession = sessions.get(secondPartition);
153 assert.isDefined(firstSession);
154 assert.isDefined(secondSession);
155
156 const storageCause = new Error("storage clear failed");
157 secondSession.clearStorageData.mockImplementationOnce(() => Promise.reject(storageCause));
158 const storageError = yield* browserSessions.clearCookies().pipe(Effect.flip);
159
160 assert.instanceOf(storageError, BrowserSession.BrowserSessionStorageClearError);
161 assert.isTrue(BrowserSession.isBrowserSessionError(storageError));
162 assert.equal(storageError.partition, secondPartition);
163 assert.strictEqual(storageError.cause, storageCause);
164 assert.equal(
165 storageError.message,
166 `Failed to clear desktop preview browser storage for partition ${secondPartition}.`,
167 );
168 assert.notInclude(storageError.message, storageCause.message);
169 for (const browserSession of sessions.values()) {
170 assert.strictEqual(browserSession.clearStorageData.mock.calls.length, 1);
171 }
172
173 const cacheCause = new Error("cache clear failed");
174 firstSession.clearCache.mockImplementationOnce(() => Promise.reject(cacheCause));
175 const cacheError = yield* browserSessions.clearCache().pipe(Effect.flip);
176
177 assert.instanceOf(cacheError, BrowserSession.BrowserSessionCacheClearError);
178 assert.isTrue(BrowserSession.isBrowserSessionError(cacheError));
179 assert.equal(cacheError.partition, firstPartition);
180 assert.strictEqual(cacheError.cause, cacheCause);
181 assert.equal(
182 cacheError.message,
183 `Failed to clear the desktop preview browser cache for partition ${firstPartition}.`,
184 );
185 assert.notInclude(cacheError.message, cacheCause.message);
186 for (const browserSession of sessions.values()) {
187 assert.strictEqual(browserSession.clearCache.mock.calls.length, 1);
188 }
189 }).pipe(Effect.provide(layer)),
190 );
83191});
apps/desktop/src/preview/BrowserSession.ts+91-18
......@@ -5,31 +5,87 @@ import * as Crypto from "effect/Crypto";
55import * as Effect from "effect/Effect";
66import * as Encoding from "effect/Encoding";
77import * as Layer from "effect/Layer";
8import * as PlatformError from "effect/PlatformError";
89import * as Schema from "effect/Schema";
910import * as SynchronizedRef from "effect/SynchronizedRef";
1011
1112const PREVIEW_PARTITION_PREFIX = "persist:t3code-preview-";
1213
13export class BrowserSessionError extends Schema.TaggedErrorClass<BrowserSessionError>()(
14 "BrowserSessionError",
14export class BrowserSessionPartitionDerivationError extends Schema.TaggedErrorClass<BrowserSessionPartitionDerivationError>()(
15 "BrowserSessionPartitionDerivationError",
1516 {
16 operation: Schema.Literals(["getPartition", "getSession", "clearCookies", "clearCache"]),
17 scope: Schema.String,
18 cause: Schema.instanceOf(PlatformError.PlatformError),
19 },
20) {
21 override get message(): string {
22 return `Failed to derive a desktop preview browser partition for scope ${this.scope}.`;
23 }
24}
25
26export class BrowserSessionCreationError extends Schema.TaggedErrorClass<BrowserSessionCreationError>()(
27 "BrowserSessionCreationError",
28 {
29 scope: Schema.String,
30 partition: Schema.String,
31 cause: Schema.Defect(),
32 },
33) {
34 override get message(): string {
35 return `Failed to create a desktop preview browser session for scope ${this.scope} (partition ${this.partition}).`;
36 }
37}
38
39export class BrowserSessionStorageClearError extends Schema.TaggedErrorClass<BrowserSessionStorageClearError>()(
40 "BrowserSessionStorageClearError",
41 {
42 partition: Schema.String,
1743 cause: Schema.Defect(),
1844 },
1945) {
2046 override get message(): string {
21 return `Desktop preview browser session operation failed: ${this.operation}`;
47 return `Failed to clear desktop preview browser storage for partition ${this.partition}.`;
2248 }
2349}
2450
51export class BrowserSessionCacheClearError extends Schema.TaggedErrorClass<BrowserSessionCacheClearError>()(
52 "BrowserSessionCacheClearError",
53 {
54 partition: Schema.String,
55 cause: Schema.Defect(),
56 },
57) {
58 override get message(): string {
59 return `Failed to clear the desktop preview browser cache for partition ${this.partition}.`;
60 }
61}
62
63export const BrowserSessionGetSessionError = Schema.Union([
64 BrowserSessionPartitionDerivationError,
65 BrowserSessionCreationError,
66]);
67export type BrowserSessionGetSessionError = typeof BrowserSessionGetSessionError.Type;
68export const isBrowserSessionGetSessionError = Schema.is(BrowserSessionGetSessionError);
69
70export const BrowserSessionError = Schema.Union([
71 BrowserSessionPartitionDerivationError,
72 BrowserSessionCreationError,
73 BrowserSessionStorageClearError,
74 BrowserSessionCacheClearError,
75]);
76export type BrowserSessionError = typeof BrowserSessionError.Type;
77export const isBrowserSessionError = Schema.is(BrowserSessionError);
78
2579export class BrowserSession extends Context.Service<
2680 BrowserSession,
2781 {
28 readonly getPartition: (scope?: string) => Effect.Effect<string, BrowserSessionError>;
82 readonly getPartition: (
83 scope?: string,
84 ) => Effect.Effect<string, BrowserSessionPartitionDerivationError>;
2985 readonly isPartition: (partition: string) => boolean;
30 readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionError>;
31 readonly clearCookies: () => Effect.Effect<void, BrowserSessionError>;
32 readonly clearCache: () => Effect.Effect<void, BrowserSessionError>;
86 readonly getSession: (scope?: string) => Effect.Effect<Session, BrowserSessionGetSessionError>;
87 readonly clearCookies: () => Effect.Effect<void, BrowserSessionStorageClearError>;
88 readonly clearCache: () => Effect.Effect<void, BrowserSessionCacheClearError>;
3389 }
3490>()("@t3tools/desktop/preview/BrowserSession") {}
3591
......@@ -38,11 +94,15 @@ export const make = Effect.gen(function* BrowserSessionMake() {
3894 const sessionsRef = yield* SynchronizedRef.make<ReadonlyMap<string, Session>>(new Map());
3995
4096 const getPartition = Effect.fn("BrowserSession.getPartition")(function* (scope = "shared") {
41 const digest = yield* crypto
42 .digest("SHA-256", new TextEncoder().encode(scope))
43 .pipe(
44 Effect.mapError((cause) => new BrowserSessionError({ operation: "getPartition", cause })),
45 );
97 const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(scope)).pipe(
98 Effect.mapError(
99 (cause) =>
100 new BrowserSessionPartitionDerivationError({
101 scope,
102 cause,
103 }),
104 ),
105 );
46106 return `${PREVIEW_PARTITION_PREFIX}${Encoding.encodeHex(digest).slice(0, 20)}`;
47107 });
48108
......@@ -67,7 +127,12 @@ export const make = Effect.gen(function* BrowserSessionMake() {
67127 next.set(partition, browserSession);
68128 return [browserSession, next] as const;
69129 },
70 catch: (cause) => new BrowserSessionError({ operation: "getSession", cause }),
130 catch: (cause) =>
131 new BrowserSessionCreationError({
132 scope,
133 partition,
134 cause,
135 }),
71136 });
72137 });
73138 });
......@@ -79,13 +144,17 @@ export const make = Effect.gen(function* BrowserSessionMake() {
79144 clearCookies: Effect.fn("BrowserSession.clearCookies")(function* () {
80145 const sessions = yield* SynchronizedRef.get(sessionsRef);
81146 yield* Effect.all(
82 [...sessions.values()].map((browserSession) =>
147 [...sessions.entries()].map(([partition, browserSession]) =>
83148 Effect.tryPromise({
84149 try: () =>
85150 browserSession.clearStorageData({
86151 storages: ["cookies", "localstorage", "indexdb", "websql", "serviceworkers"],
87152 }),
88 catch: (cause) => new BrowserSessionError({ operation: "clearCookies", cause }),
153 catch: (cause) =>
154 new BrowserSessionStorageClearError({
155 partition,
156 cause,
157 }),
89158 }),
90159 ),
91160 { concurrency: "unbounded", discard: true },
......@@ -94,10 +163,14 @@ export const make = Effect.gen(function* BrowserSessionMake() {
94163 clearCache: Effect.fn("BrowserSession.clearCache")(function* () {
95164 const sessions = yield* SynchronizedRef.get(sessionsRef);
96165 yield* Effect.all(
97 [...sessions.values()].map((browserSession) =>
166 [...sessions.entries()].map(([partition, browserSession]) =>
98167 Effect.tryPromise({
99168 try: () => browserSession.clearCache(),
100 catch: (cause) => new BrowserSessionError({ operation: "clearCache", cause }),
169 catch: (cause) =>
170 new BrowserSessionCacheClearError({
171 partition,
172 cause,
173 }),
101174 }),
102175 ),
103176 { concurrency: "unbounded", discard: true },