authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 11:24:35-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 11:24:35-07:00
log2b8e012924063e185786c8ff120b6fd2886b3aa7
treef6dc9f4632ba1dea180406ee0468ca8486f6329d
parent49c23221d80c68114fe4d88e92b05fa1129de0d3
signature Signed by PGP key B5690EEEBB952194

[codex] Structure desktop server exposure errors (#3269)

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

2 files changed, 107 insertions(+), 19 deletions(-)

apps/desktop/src/backend/DesktopServerExposure.test.ts+65-1
......@@ -91,6 +91,7 @@ function makeLayer(input: {
9191 readonly networkInterfaces?: DesktopNetworkInterfaces.NetworkInterfaces;
9292 readonly env?: Record<string, string | undefined>;
9393 readonly spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>;
94 readonly desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>;
9495}) {
9596 const env = { T3CODE_HOME: input.baseDir, ...input.env };
9697 const environmentLayer = makeEnvironmentLayer(input.baseDir, env);
......@@ -99,7 +100,7 @@ function makeLayer(input: {
99100 });
100101
101102 return DesktopServerExposure.layer.pipe(
102 Layer.provideMerge(DesktopAppSettings.layer),
103 Layer.provideMerge(input.desktopSettingsLayer ?? DesktopAppSettings.layer),
103104 Layer.provideMerge(NodeFileSystem.layer),
104105 Layer.provideMerge(NodeHttpClient.layerUndici),
105106 Layer.provideMerge(input.spawnerLayer ?? mockSpawnerLayer()),
......@@ -122,6 +123,7 @@ const withHarness = <A, E, R>(
122123 >,
123124 env: Record<string, string | undefined> = {},
124125 spawnerLayer?: Layer.Layer<ChildProcessSpawner.ChildProcessSpawner>,
126 desktopSettingsLayer?: Layer.Layer<DesktopAppSettings.DesktopAppSettings>,
125127) =>
126128 Effect.gen(function* () {
127129 const fileSystem = yield* FileSystem.FileSystem;
......@@ -135,6 +137,7 @@ const withHarness = <A, E, R>(
135137 networkInterfaces,
136138 env,
137139 ...(spawnerLayer ? { spawnerLayer } : {}),
140 ...(desktopSettingsLayer ? { desktopSettingsLayer } : {}),
138141 }),
139142 ),
140143 );
......@@ -237,6 +240,67 @@ describe("DesktopServerExposure", () => {
237240 ),
238241 );
239242
243 it.effect("preserves persistence request context and the settings failure chain", () => {
244 const diskFailure = new Error("disk exploded");
245 const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({
246 operation: "replace-settings-file",
247 path: "/tmp/desktop-settings.json",
248 cause: diskFailure,
249 });
250 const settingsLayer = Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
251 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
252 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
253 setServerExposureMode: () => Effect.fail(settingsFailure),
254 setTailscaleServe: () => Effect.fail(settingsFailure),
255 setUpdateChannel: () => Effect.die("unexpected update channel change"),
256 } satisfies DesktopAppSettings.DesktopAppSettings["Service"]);
257
258 return withHarness(
259 lanNetworkInterfaces,
260 Effect.gen(function* () {
261 const serverExposure = yield* DesktopServerExposure.DesktopServerExposure;
262 yield* serverExposure.configureFromSettings({ port: 4173 });
263
264 const modeError = yield* serverExposure.setMode("network-accessible").pipe(Effect.flip);
265 assert.instanceOf(
266 modeError,
267 DesktopServerExposure.DesktopServerExposureModePersistenceError,
268 );
269 assert.isTrue(DesktopServerExposure.isDesktopServerExposureSetModeError(modeError));
270 assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(modeError));
271 assert.equal(modeError.mode, "network-accessible");
272 assert.strictEqual(modeError.cause, settingsFailure);
273 assert.strictEqual(modeError.cause.cause, diskFailure);
274 assert.equal(
275 modeError.message,
276 "Failed to persist desktop server exposure mode network-accessible.",
277 );
278 assert.notInclude(modeError.message, diskFailure.message);
279
280 const tailscaleError = yield* serverExposure
281 .setTailscaleServeEnabled({ enabled: true, port: 8443 })
282 .pipe(Effect.flip);
283 assert.instanceOf(
284 tailscaleError,
285 DesktopServerExposure.DesktopTailscaleServePersistenceError,
286 );
287 assert.isTrue(DesktopServerExposure.isDesktopServerExposureError(tailscaleError));
288 assert.equal(tailscaleError.enabled, true);
289 assert.equal(tailscaleError.port, 8443);
290 assert.strictEqual(tailscaleError.cause, settingsFailure);
291 assert.strictEqual(tailscaleError.cause.cause, diskFailure);
292 assert.equal(
293 tailscaleError.message,
294 "Failed to persist desktop Tailscale Serve settings (enabled: true, port: 8443).",
295 );
296 assert.notInclude(tailscaleError.message, diskFailure.message);
297 }),
298 {},
299 undefined,
300 settingsLayer,
301 );
302 });
303
240304 it.effect("resolves advertised endpoints from the scoped runtime state", () =>
241305 withHarness(
242306 { ...lanNetworkInterfaces, ...tailnetNetworkInterfaces },
apps/desktop/src/backend/DesktopServerExposure.ts+42-18
......@@ -2,11 +2,12 @@ import {
22 createAdvertisedEndpoint,
33 type CreateAdvertisedEndpointInput,
44} from "@t3tools/shared/advertisedEndpoint";
5import type {
6 AdvertisedEndpoint,
7 AdvertisedEndpointProvider,
8 DesktopServerExposureMode,
9 DesktopServerExposureState,
5import {
6 DesktopServerExposureModeSchema,
7 type AdvertisedEndpoint,
8 type AdvertisedEndpointProvider,
9 type DesktopServerExposureMode,
10 type DesktopServerExposureState,
1011} from "@t3tools/contracts";
1112import { readTailscaleStatus } from "@t3tools/tailscale";
1213import * as Context from "effect/Context";
......@@ -213,23 +214,45 @@ export class DesktopServerExposureNoNetworkAddressError extends Schema.TaggedErr
213214 }
214215}
215216
216export class DesktopServerExposurePersistenceError extends Schema.TaggedErrorClass<DesktopServerExposurePersistenceError>()(
217 "DesktopServerExposurePersistenceError",
217export class DesktopServerExposureModePersistenceError extends Schema.TaggedErrorClass<DesktopServerExposureModePersistenceError>()(
218 "DesktopServerExposureModePersistenceError",
218219 {
219 operation: Schema.Literals(["server-exposure-mode", "tailscale-serve"]),
220 mode: DesktopServerExposureModeSchema,
220221 cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),
221222 },
222223) {
223224 override get message(): string {
224 return `Failed to persist desktop ${this.operation} settings.`;
225 return `Failed to persist desktop server exposure mode ${this.mode}.`;
225226 }
226227}
227228
228export type DesktopServerExposureSetModeError =
229 | DesktopServerExposureNoNetworkAddressError
230 | DesktopServerExposurePersistenceError;
229export class DesktopTailscaleServePersistenceError extends Schema.TaggedErrorClass<DesktopTailscaleServePersistenceError>()(
230 "DesktopTailscaleServePersistenceError",
231 {
232 enabled: Schema.Boolean,
233 port: Schema.NullOr(Schema.Number),
234 cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),
235 },
236) {
237 override get message(): string {
238 return `Failed to persist desktop Tailscale Serve settings (enabled: ${this.enabled}, port: ${this.port ?? "unchanged"}).`;
239 }
240}
231241
232export type DesktopServerExposureError = DesktopServerExposureSetModeError;
242export const DesktopServerExposureSetModeError = Schema.Union([
243 DesktopServerExposureNoNetworkAddressError,
244 DesktopServerExposureModePersistenceError,
245]);
246export type DesktopServerExposureSetModeError = typeof DesktopServerExposureSetModeError.Type;
247export const isDesktopServerExposureSetModeError = Schema.is(DesktopServerExposureSetModeError);
248
249export const DesktopServerExposureError = Schema.Union([
250 DesktopServerExposureNoNetworkAddressError,
251 DesktopServerExposureModePersistenceError,
252 DesktopTailscaleServePersistenceError,
253]);
254export type DesktopServerExposureError = typeof DesktopServerExposureError.Type;
255export const isDesktopServerExposureError = Schema.is(DesktopServerExposureError);
233256
234257export interface DesktopServerExposureBackendConfig {
235258 readonly port: number;
......@@ -258,7 +281,7 @@ export class DesktopServerExposure extends Context.Service<
258281 readonly setTailscaleServeEnabled: (input: {
259282 readonly enabled: boolean;
260283 readonly port?: number;
261 }) => Effect.Effect<DesktopServerExposureChange, DesktopServerExposurePersistenceError>;
284 }) => Effect.Effect<DesktopServerExposureChange, DesktopTailscaleServePersistenceError>;
262285 readonly getAdvertisedEndpoints: Effect.Effect<readonly AdvertisedEndpoint[]>;
263286 }
264287>()("@t3tools/desktop/backend/DesktopServerExposure") {}
......@@ -449,8 +472,8 @@ export const make = Effect.gen(function* () {
449472 const change = yield* desktopSettings.setServerExposureMode(mode).pipe(
450473 Effect.mapError(
451474 (cause) =>
452 new DesktopServerExposurePersistenceError({
453 operation: "server-exposure-mode",
475 new DesktopServerExposureModePersistenceError({
476 mode,
454477 cause,
455478 }),
456479 ),
......@@ -477,8 +500,9 @@ export const make = Effect.gen(function* () {
477500 .pipe(
478501 Effect.mapError(
479502 (cause) =>
480 new DesktopServerExposurePersistenceError({
481 operation: "tailscale-serve",
503 new DesktopTailscaleServePersistenceError({
504 enabled: input.enabled,
505 port: input.port ?? null,
482506 cause,
483507 }),
484508 ),