authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-20 17:09:17-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-20 17:09:17-07:00
log9f7861aadd257313efefdc690e440d23d771f315
tree790fd105686354e6e67ba7e9cedd54535716972a
parent9243ead1c3eebb6316321012cf42a21737965a56
signature Signed by PGP key B5690EEEBB952194

[codex] Structure desktop update persistence errors (#3261)

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

3 files changed, 524 insertions(+), 80 deletions(-)

apps/desktop/src/settings/DesktopAppSettings.ts+47-20
...@@ -83,12 +83,6 @@ export class DesktopSettingsWriteError extends Schema.TaggedErrorClass<DesktopSe...@@ -83,12 +83,6 @@ export class DesktopSettingsWriteError extends Schema.TaggedErrorClass<DesktopSe
83 }83 }
84}84}
8585
86const writeError = (
87 operation: DesktopSettingsWriteOperation,
88 path: string,
89 cause: unknown,
90): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause });
91
92export class DesktopAppSettings extends Context.Service<86export class DesktopAppSettings extends Context.Service<
93 DesktopAppSettings,87 DesktopAppSettings,
94 {88 {
...@@ -244,18 +238,46 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp...@@ -244,18 +238,46 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
244 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;238 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
245 const encoded = yield* encodeDesktopSettingsJson(239 const encoded = yield* encodeDesktopSettingsJson(
246 toDesktopSettingsDocument(input.settings, input.defaultSettings),240 toDesktopSettingsDocument(input.settings, input.defaultSettings),
247 ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)));241 ).pipe(
248 yield* input.fileSystem242 Effect.mapError(
249 .makeDirectory(directory, { recursive: true })243 (cause) =>
250 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));244 new DesktopSettingsWriteError({
251 yield* input.fileSystem245 operation: "encode-document",
252 .writeFileString(tempPath, `${encoded}\n`)246 path: input.settingsPath,
253 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));247 cause,
254 yield* input.fileSystem248 }),
255 .rename(tempPath, input.settingsPath)249 ),
256 .pipe(250 );
257 Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)),251 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
258 );252 Effect.mapError(
253 (cause) =>
254 new DesktopSettingsWriteError({
255 operation: "create-directory",
256 path: directory,
257 cause,
258 }),
259 ),
260 );
261 yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe(
262 Effect.mapError(
263 (cause) =>
264 new DesktopSettingsWriteError({
265 operation: "write-temporary-file",
266 path: tempPath,
267 cause,
268 }),
269 ),
270 );
271 yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe(
272 Effect.mapError(
273 (cause) =>
274 new DesktopSettingsWriteError({
275 operation: "replace-settings-file",
276 path: input.settingsPath,
277 cause,
278 }),
279 ),
280 );
259});281});
260282
261export const make = Effect.gen(function* () {283export const make = Effect.gen(function* () {
...@@ -276,8 +298,13 @@ export const make = Effect.gen(function* () {...@@ -276,8 +298,13 @@ export const make = Effect.gen(function* () {
276298
277 return crypto.randomUUIDv4.pipe(299 return crypto.randomUUIDv4.pipe(
278 Effect.map((uuid) => uuid.replace(/-/g, "")),300 Effect.map((uuid) => uuid.replace(/-/g, "")),
279 Effect.mapError((cause) =>301 Effect.mapError(
280 writeError("create-temporary-file-name", environment.desktopSettingsPath, cause),302 (cause) =>
303 new DesktopSettingsWriteError({
304 operation: "create-temporary-file-name",
305 path: environment.desktopSettingsPath,
306 cause,
307 }),
281 ),308 ),
282 Effect.flatMap((suffix) =>309 Effect.flatMap((suffix) =>
283 writeSettings({310 writeSettings({
apps/desktop/src/updates/DesktopUpdates.test.ts+259-3
...@@ -7,7 +7,10 @@ import * as Duration from "effect/Duration";...@@ -7,7 +7,10 @@ import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";7import * as Effect from "effect/Effect";
8import * as Fiber from "effect/Fiber";8import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";9import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
10import * as Option from "effect/Option";11import * as Option from "effect/Option";
12import * as References from "effect/References";
13import * as Ref from "effect/Ref";
11import * as TestClock from "effect/testing/TestClock";14import * as TestClock from "effect/testing/TestClock";
1215
13import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";16import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
...@@ -24,6 +27,9 @@ interface UpdatesHarnessOptions {...@@ -24,6 +27,9 @@ interface UpdatesHarnessOptions {
24 void,27 void,
25 ElectronUpdater.ElectronUpdaterCheckForUpdatesError28 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
26 >;29 >;
30 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
31 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
32 readonly stopBackend?: Effect.Effect<void>;
27 readonly env?: Record<string, string | undefined>;33 readonly env?: Record<string, string | undefined>;
28}34}
2935
...@@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
67 Effect.sync(() => {73 Effect.sync(() => {
68 allowDowngrade = value;74 allowDowngrade = value;
69 }),75 }),
70 setDisableDifferentialDownload: () => Effect.void,76 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
71 checkForUpdates: Effect.sync(() => {77 checkForUpdates: Effect.sync(() => {
72 checkCount += 1;78 checkCount += 1;
73 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),79 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
...@@ -103,7 +109,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -103,7 +109,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
103109
104 const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, {110 const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, {
105 start: Effect.void,111 start: Effect.void,
106 stop: () => Effect.void,112 stop: () => options.stopBackend ?? Effect.void,
107 currentConfig: Effect.succeed(Option.none()),113 currentConfig: Effect.succeed(Option.none()),
108 snapshot: Effect.succeed({114 snapshot: Effect.succeed({
109 desiredRunning: false,115 desiredRunning: false,
...@@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
138 ),144 ),
139 );145 );
140146
147 const setUpdateChannelError = options.setUpdateChannelError;
148 const settingsLayer = setUpdateChannelError
149 ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, {
150 get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
151 load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS),
152 setServerExposureMode: () => Effect.die("unexpected server exposure update"),
153 setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"),
154 setUpdateChannel: () => Effect.fail(setUpdateChannelError),
155 } satisfies DesktopAppSettings.DesktopAppSettings["Service"])
156 : DesktopAppSettings.layer;
157
141 const layer = DesktopUpdates.layer.pipe(158 const layer = DesktopUpdates.layer.pipe(
142 Layer.provideMerge(updaterLayer),159 Layer.provideMerge(updaterLayer),
143 Layer.provideMerge(windowLayer),160 Layer.provideMerge(windowLayer),
144 Layer.provideMerge(backendLayer),161 Layer.provideMerge(backendLayer),
145 Layer.provideMerge(DesktopState.layer),162 Layer.provideMerge(DesktopState.layer),
146 Layer.provideMerge(DesktopAppSettings.layer),163 Layer.provideMerge(settingsLayer),
147 Layer.provideMerge(164 Layer.provideMerge(
148 DesktopConfig.layerTest({165 DesktopConfig.layerTest({
149 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,166 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
...@@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {...@@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
175}192}
176193
177describe("DesktopUpdates", () => {194describe("DesktopUpdates", () => {
195 it("preserves complete causes for update poller and event failures", () => {
196 const cause = Cause.combine(
197 Cause.fail(new Error("updater failed")),
198 Cause.die(new Error("updater defect")),
199 );
200 const pollerError = new DesktopUpdates.DesktopUpdatePollerError({
201 poller: "startup",
202 cause,
203 });
204 const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({
205 event: "download-progress",
206 cause,
207 });
208 const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({
209 operation: "download",
210 cause,
211 });
212 const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({
213 action: "install",
214 cause,
215 });
216
217 assert.strictEqual(pollerError.cause, cause);
218 assert.equal(pollerError.poller, "startup");
219 assert.equal(pollerError.message, "Desktop update startup poller failed.");
220 assert.strictEqual(eventError.cause, cause);
221 assert.equal(eventError.event, "download-progress");
222 assert.equal(eventError.message, "Failed to handle desktop update download-progress event.");
223 assert.strictEqual(reportedError.cause, cause);
224 assert.equal(reportedError.operation, "download");
225 assert.equal(reportedError.message, "Desktop updater download operation reported an error.");
226 assert.strictEqual(unexpectedActionError.cause, cause);
227 assert.equal(unexpectedActionError.action, "install");
228 assert.equal(
229 unexpectedActionError.message,
230 "Desktop update install action failed unexpectedly.",
231 );
232 });
233
178 it.effect("configures the updater and runs startup checks on the test clock", () => {234 it.effect("configures the updater and runs startup checks on the test clock", () => {
179 const harness = makeHarness();235 const harness = makeHarness();
180236
...@@ -222,6 +278,178 @@ describe("DesktopUpdates", () => {...@@ -222,6 +278,178 @@ describe("DesktopUpdates", () => {
222 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));278 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
223 });279 });
224280
281 it.effect("keeps raw updater event failures out of update state", () => {
282 const harness = makeHarness();
283 const cause = new Error(
284 "request failed for https://user:secret@example.com/update?token=secret",
285 );
286
287 return Effect.scoped(
288 Effect.gen(function* () {
289 const updates = yield* DesktopUpdates.DesktopUpdates;
290 yield* updates.configure;
291
292 harness.emit("error", cause);
293 yield* flushCallbacks;
294
295 const state = yield* updates.getState;
296 assert.equal(state.status, "error");
297 assert.equal(state.message, "Desktop updater background operation reported an error.");
298 assert.notInclude(state.message ?? "", "secret");
299 }),
300 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
301 });
302
303 it.effect("logs bounded updater failure context without exposing the cause", () => {
304 const cause = new Error(
305 "request failed for https://user:secret@example.com/update?token=secret",
306 );
307 const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({
308 channel: null,
309 cause,
310 });
311 const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) });
312 const loggedAnnotations: Array<Record<string, unknown>> = [];
313 const logger = Logger.make(({ fiber }) => {
314 const annotations = fiber.getRef(References.CurrentLogAnnotations);
315 if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") {
316 loggedAnnotations.push(annotations);
317 }
318 });
319
320 return Effect.scoped(
321 Effect.gen(function* () {
322 const updates = yield* DesktopUpdates.DesktopUpdates;
323 yield* updates.configure;
324
325 yield* updates.check("manual");
326
327 const state = yield* updates.getState;
328 const loggedAnnotation = loggedAnnotations.at(-1);
329 assert.isDefined(loggedAnnotation);
330 assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError");
331 assert.isNull(loggedAnnotation.channel);
332 assert.notProperty(loggedAnnotation, "error");
333 assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret");
334 assert.equal(
335 state.message,
336 "Electron updater failed to check for updates on channel default.",
337 );
338 assert.notInclude(state.message ?? "", "secret");
339 }),
340 ).pipe(
341 Effect.provide(
342 Layer.mergeAll(
343 TestClock.layer(),
344 harness.layer,
345 Logger.layer([logger], { mergeWithExisting: false }),
346 ),
347 ),
348 );
349 });
350
351 it.effect("recovers download state after an unexpected setup failure", () => {
352 let disableDifferentialCalls = 0;
353 const harness = makeHarness({
354 setDisableDifferentialDownload: Effect.suspend(() => {
355 disableDifferentialCalls += 1;
356 return disableDifferentialCalls === 1
357 ? Effect.void
358 : Effect.die(new Error("download setup failed"));
359 }),
360 });
361
362 return Effect.scoped(
363 Effect.gen(function* () {
364 const updates = yield* DesktopUpdates.DesktopUpdates;
365 yield* updates.configure;
366 harness.emit("update-available", { version: "1.2.4" });
367 yield* flushCallbacks;
368
369 const result = yield* updates.download;
370 assert.isTrue(result.accepted);
371 assert.isFalse(result.completed);
372
373 const failedState = yield* updates.getState;
374 assert.equal(failedState.status, "available");
375 assert.equal(failedState.errorContext, "download");
376 assert.equal(failedState.message, "Desktop update download action failed unexpectedly.");
377
378 const changedState = yield* updates.setChannel("nightly");
379 assert.equal(changedState.channel, "nightly");
380 }),
381 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
382 });
383
384 it.effect("restores download state and permits retry after interruption", () =>
385 Effect.gen(function* () {
386 const actionStarted = yield* Deferred.make<void>();
387 let disableDifferentialCalls = 0;
388 const harness = makeHarness({
389 setDisableDifferentialDownload: Effect.suspend(() => {
390 disableDifferentialCalls += 1;
391 if (disableDifferentialCalls === 1) {
392 return Effect.void;
393 }
394 if (disableDifferentialCalls === 2) {
395 return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never));
396 }
397 return Effect.void;
398 }),
399 });
400
401 yield* Effect.scoped(
402 Effect.gen(function* () {
403 const updates = yield* DesktopUpdates.DesktopUpdates;
404 yield* updates.configure;
405 harness.emit("update-available", { version: "1.2.4" });
406 yield* flushCallbacks;
407
408 const downloadFiber = yield* updates.download.pipe(Effect.forkScoped);
409 yield* Deferred.await(actionStarted);
410 yield* Fiber.interrupt(downloadFiber);
411
412 const interruptedState = yield* updates.getState;
413 assert.equal(interruptedState.status, "available");
414 assert.isNull(interruptedState.message);
415
416 const retry = yield* updates.download;
417 assert.isTrue(retry.accepted);
418 assert.isTrue(retry.completed);
419 }),
420 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
421 }),
422 );
423
424 it.effect("clears quitting state after an unexpected install setup failure", () => {
425 const harness = makeHarness({
426 stopBackend: Effect.die(new Error("backend stop failed")),
427 });
428
429 return Effect.scoped(
430 Effect.gen(function* () {
431 const desktopState = yield* DesktopState.DesktopState;
432 const updates = yield* DesktopUpdates.DesktopUpdates;
433 yield* updates.configure;
434 harness.emit("update-downloaded", { version: "1.2.4" });
435 yield* flushCallbacks;
436
437 const result = yield* updates.install;
438 assert.isTrue(result.accepted);
439 assert.isFalse(result.completed);
440 assert.isFalse(yield* Ref.get(desktopState.quitting));
441
442 const failedState = yield* updates.getState;
443 assert.equal(failedState.status, "downloaded");
444 assert.equal(failedState.errorContext, "install");
445 assert.equal(failedState.message, "Desktop update install action failed unexpectedly.");
446
447 const changedState = yield* updates.setChannel("nightly");
448 assert.equal(changedState.channel, "nightly");
449 }),
450 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
451 });
452
225 it.effect("persists channel changes through the settings service", () => {453 it.effect("persists channel changes through the settings service", () => {
226 const harness = makeHarness();454 const harness = makeHarness();
227455
...@@ -284,6 +512,7 @@ describe("DesktopUpdates", () => {...@@ -284,6 +512,7 @@ describe("DesktopUpdates", () => {
284 const error = Cause.squash(exit.cause);512 const error = Cause.squash(exit.cause);
285 assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError);513 assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError);
286 assert.equal(error.action, "check");514 assert.equal(error.action, "check");
515 assert.equal(error.requestedChannel, "nightly");
287 }516 }
288517
289 yield* Deferred.succeed(releaseCheck, undefined);518 yield* Deferred.succeed(releaseCheck, undefined);
...@@ -292,4 +521,31 @@ describe("DesktopUpdates", () => {...@@ -292,4 +521,31 @@ describe("DesktopUpdates", () => {
292 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));521 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
293 }),522 }),
294 );523 );
524
525 it.effect("preserves settings failure context when an update channel cannot be persisted", () => {
526 const diskFailure = new Error("disk exploded");
527 const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({
528 operation: "replace-settings-file",
529 path: "/tmp/settings.json",
530 cause: diskFailure,
531 });
532 const harness = makeHarness({ setUpdateChannelError: settingsFailure });
533
534 return Effect.scoped(
535 Effect.gen(function* () {
536 const updates = yield* DesktopUpdates.DesktopUpdates;
537 yield* updates.configure;
538
539 const error = yield* updates.setChannel("nightly").pipe(Effect.flip);
540
541 assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError);
542 assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error));
543 assert.equal(error.channel, "nightly");
544 assert.strictEqual(error.cause, settingsFailure);
545 assert.strictEqual(error.cause.cause, diskFailure);
546 assert.equal(error.message, "Failed to persist the nightly desktop update channel.");
547 assert.notInclude(error.message, diskFailure.message);
548 }),
549 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
550 });
295});551});
apps/desktop/src/updates/DesktopUpdates.ts+218-57
...@@ -1,9 +1,10 @@...@@ -1,9 +1,10 @@
1import type {1import {
2 DesktopRuntimeInfo,2 DesktopUpdateChannelSchema,
3 DesktopUpdateActionResult,3 type DesktopRuntimeInfo,
4 DesktopUpdateChannel,4 type DesktopUpdateActionResult,
5 DesktopUpdateCheckResult,5 type DesktopUpdateChannel,
6 DesktopUpdateState,6 type DesktopUpdateCheckResult,
7 type DesktopUpdateState,
7} from "@t3tools/contracts";8} from "@t3tools/contracts";
8import * as Cause from "effect/Cause";9import * as Cause from "effect/Cause";
9import * as Context from "effect/Context";10import * as Context from "effect/Context";
...@@ -63,30 +64,82 @@ export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<...@@ -63,30 +64,82 @@ export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<
63 "DesktopUpdateActionInProgressError",64 "DesktopUpdateActionInProgressError",
64 {65 {
65 action: Schema.Literals(["check", "download", "install"]),66 action: Schema.Literals(["check", "download", "install"]),
67 requestedChannel: DesktopUpdateChannelSchema,
68 },
69) {
70 override get message(): string {
71 return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`;
72 }
73}
74
75export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass<DesktopUpdateChannelPersistenceError>()(
76 "DesktopUpdateChannelPersistenceError",
77 {
78 channel: DesktopUpdateChannelSchema,
79 cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError),
80 },
81) {
82 override get message(): string {
83 return `Failed to persist the ${this.channel} desktop update channel.`;
84 }
85}
86
87export class DesktopUpdatePollerError extends Schema.TaggedErrorClass<DesktopUpdatePollerError>()(
88 "DesktopUpdatePollerError",
89 {
90 poller: Schema.Literals(["startup", "poll"]),
91 cause: Schema.Defect(),
66 },92 },
67) {93) {
68 override get message(): string {94 override get message(): string {
69 return `Cannot change update tracks while an update ${this.action} action is in progress.`;95 return `Desktop update ${this.poller} poller failed.`;
70 }96 }
71}97}
7298
73export class DesktopUpdatePersistenceError extends Schema.TaggedErrorClass<DesktopUpdatePersistenceError>()(99export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass<DesktopUpdateEventHandlingError>()(
74 "DesktopUpdatePersistenceError",100 "DesktopUpdateEventHandlingError",
75 {101 {
102 event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]),
76 cause: Schema.Defect(),103 cause: Schema.Defect(),
77 },104 },
78) {105) {
79 override get message(): string {106 override get message(): string {
80 const detail = this.cause instanceof Error ? this.cause.message : String(this.cause);107 return `Failed to handle desktop update ${this.event} event.`;
81 return `Failed to persist desktop update settings: ${detail}`;108 }
109}
110
111export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass<DesktopUpdaterReportedError>()(
112 "DesktopUpdaterReportedError",
113 {
114 operation: Schema.Literals(["check", "download", "install", "background"]),
115 cause: Schema.Defect(),
116 },
117) {
118 override get message(): string {
119 return `Desktop updater ${this.operation} operation reported an error.`;
120 }
121}
122
123export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass<DesktopUpdateUnexpectedActionError>()(
124 "DesktopUpdateUnexpectedActionError",
125 {
126 action: Schema.Literals(["download", "install"]),
127 cause: Schema.Defect(),
128 },
129) {
130 override get message(): string {
131 return `Desktop update ${this.action} action failed unexpectedly.`;
82 }132 }
83}133}
84134
85export type DesktopUpdateConfigureError = never;135export type DesktopUpdateConfigureError = never;
86136
87export type DesktopUpdateSetChannelError =137export const DesktopUpdateSetChannelError = Schema.Union([
88 | DesktopUpdateActionInProgressError138 DesktopUpdateActionInProgressError,
89 | DesktopUpdatePersistenceError;139 DesktopUpdateChannelPersistenceError,
140]);
141export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type;
142export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError);
90143
91export class DesktopUpdates extends Context.Service<144export class DesktopUpdates extends Context.Service<
92 DesktopUpdates,145 DesktopUpdates,
...@@ -308,16 +361,21 @@ export const make = Effect.gen(function* () {...@@ -308,16 +361,21 @@ export const make = Effect.gen(function* () {
308361
309 return yield* electronUpdater.checkForUpdates.pipe(362 return yield* electronUpdater.checkForUpdates.pipe(
310 Effect.as(true),363 Effect.as(true),
311 Effect.catch(364 Effect.catchTags({
312 Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) {365 ElectronUpdaterCheckForUpdatesError: Effect.fn(
366 "desktop.updates.handleCheckForUpdatesFailure",
367 )(function* (error) {
313 const failedAt = yield* currentIsoTimestamp;368 const failedAt = yield* currentIsoTimestamp;
314 yield* updateState((current) =>369 yield* updateState((current) =>
315 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),370 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
316 );371 );
317 yield* logUpdaterError("failed to check for updates", { message: error.message });372 yield* logUpdaterError(error.message, {
373 errorTag: error._tag,
374 channel: error.channel,
375 });
318 return true;376 return true;
319 }),377 }),
320 ),378 }),
321 Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),379 Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),
322 );380 );
323 });381 });
...@@ -342,19 +400,50 @@ export const make = Effect.gen(function* () {...@@ -342,19 +400,50 @@ export const make = Effect.gen(function* () {
342 yield* electronUpdater.downloadUpdate;400 yield* electronUpdater.downloadUpdate;
343 return { accepted: true, completed: true };401 return { accepted: true, completed: true };
344 }).pipe(402 }).pipe(
345 Effect.catch(403 Effect.catchTags({
346 Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) {404 ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")(
405 function* (error) {
406 yield* updateState((current) =>
407 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
408 );
409 yield* logUpdaterError(error.message, {
410 errorTag: error._tag,
411 channel: error.channel,
412 });
413 return { accepted: true, completed: false };
414 },
415 ),
416 }),
417 Effect.onInterrupt(() =>
418 updateState((current) => (current.status === "downloading" ? state : current)).pipe(
419 Effect.asVoid,
420 ),
421 ),
422 Effect.catchCause((cause) => {
423 if (Cause.hasInterruptsOnly(cause)) {
424 return Effect.failCause(cause);
425 }
426 const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause });
427 return Effect.gen(function* () {
347 yield* updateState((current) =>428 yield* updateState((current) =>
348 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),429 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
349 );430 );
350 yield* logUpdaterError("failed to download update", { message: error.message });431 yield* logUpdaterError(error.message, {
432 errorTag: error._tag,
433 action: error.action,
434 });
351 return { accepted: true, completed: false };435 return { accepted: true, completed: false };
352 }),436 });
353 ),437 }),
354 Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),438 Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),
355 );439 );
356 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));440 }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate"));
357441
442 const resetInstallAction = Effect.all(
443 [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)],
444 { discard: true },
445 );
446
358 const installDownloadedUpdate = Effect.gen(function* () {447 const installDownloadedUpdate = Effect.gen(function* () {
359 const state = yield* Ref.get(updateStateRef);448 const state = yield* Ref.get(updateStateRef);
360 if (449 if (
...@@ -377,14 +466,38 @@ export const make = Effect.gen(function* () {...@@ -377,14 +466,38 @@ export const make = Effect.gen(function* () {
377 });466 });
378 return { accepted: true, completed: false };467 return { accepted: true, completed: false };
379 }).pipe(468 }).pipe(
380 Effect.catch(469 Effect.catchTags({
381 Effect.fn("desktop.updates.handleInstallFailure")(function* (error) {470 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
382 yield* Ref.set(updateInstallInFlightRef, false);471 function* (error) {
472 yield* resetInstallAction;
473 yield* updateState((current) =>
474 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
475 );
476 yield* logUpdaterError(error.message, {
477 errorTag: error._tag,
478 channel: error.channel,
479 isSilent: error.isSilent,
480 isForceRunAfter: error.isForceRunAfter,
481 });
482 return { accepted: true, completed: false };
483 },
484 ),
485 }),
486 Effect.onInterrupt(() => resetInstallAction),
487 Effect.catchCause((cause) =>
488 Effect.gen(function* () {
489 if (Cause.hasInterruptsOnly(cause)) {
490 return yield* Effect.failCause(cause);
491 }
492 yield* resetInstallAction;
493 const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause });
383 yield* updateState((current) =>494 yield* updateState((current) =>
384 reduceDesktopUpdateStateOnInstallFailure(current, error.message),495 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
385 );496 );
386 yield* Ref.set(desktopState.quitting, false);497 yield* logUpdaterError(error.message, {
387 yield* logUpdaterError("failed to install update", { message: error.message });498 errorTag: error._tag,
499 action: error.action,
500 });
388 return { accepted: true, completed: false };501 return { accepted: true, completed: false };
389 }),502 }),
390 ),503 ),
...@@ -394,17 +507,31 @@ export const make = Effect.gen(function* () {...@@ -394,17 +507,31 @@ export const make = Effect.gen(function* () {
394 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {507 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
395 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(508 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
396 Effect.andThen(checkForUpdates("startup")),509 Effect.andThen(checkForUpdates("startup")),
397 Effect.catchCause((cause) =>510 Effect.catchCause((cause) => {
398 logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }),511 if (Cause.hasInterruptsOnly(cause)) {
399 ),512 return Effect.void;
513 }
514 const error = new DesktopUpdatePollerError({ poller: "startup", cause });
515 return logUpdaterError(error.message, {
516 errorTag: error._tag,
517 poller: error.poller,
518 });
519 }),
400 Effect.forkScoped,520 Effect.forkScoped,
401 );521 );
402 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(522 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(
403 Effect.andThen(checkForUpdates("poll")),523 Effect.andThen(checkForUpdates("poll")),
404 Effect.forever,524 Effect.forever,
405 Effect.catchCause((cause) =>525 Effect.catchCause((cause) => {
406 logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }),526 if (Cause.hasInterruptsOnly(cause)) {
407 ),527 return Effect.void;
528 }
529 const error = new DesktopUpdatePollerError({ poller: "poll", cause });
530 return logUpdaterError(error.message, {
531 errorTag: error._tag,
532 poller: error.poller,
533 });
534 }),
408 Effect.forkScoped,535 Effect.forkScoped,
409 );536 );
410 }).pipe(Effect.withSpan("desktop.updates.startPollers"));537 }).pipe(Effect.withSpan("desktop.updates.startPollers"));
...@@ -435,11 +562,16 @@ export const make = Effect.gen(function* () {...@@ -435,11 +562,16 @@ export const make = Effect.gen(function* () {
435 yield* logUpdaterInfo("update available", { version: info.version });562 yield* logUpdaterInfo("update available", { version: info.version });
436 }),563 }),
437 ),564 ),
438 Effect.catchCause((cause) =>565 Effect.catchCause((cause) => {
439 logUpdaterWarning("ignored malformed update-available event", {566 if (Cause.hasInterruptsOnly(cause)) {
440 cause: Cause.pretty(cause),567 return Effect.void;
441 }),568 }
442 ),569 const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause });
570 return logUpdaterWarning(error.message, {
571 errorTag: error._tag,
572 event: error.event,
573 });
574 }),
443 );575 );
444 });576 });
445577
...@@ -452,14 +584,23 @@ export const make = Effect.gen(function* () {...@@ -452,14 +584,23 @@ export const make = Effect.gen(function* () {
452 }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));584 }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));
453585
454 const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* (586 const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* (
455 error: unknown,587 cause: unknown,
456 ) {588 ) {
457 const message = error instanceof Error ? error.message : String(error);589 const activeAction = yield* activeUpdateAction;
590 const error = new DesktopUpdaterReportedError({
591 operation: Option.getOrElse(activeAction, () => "background" as const),
592 cause,
593 });
458 if (yield* Ref.get(updateInstallInFlightRef)) {594 if (yield* Ref.get(updateInstallInFlightRef)) {
459 yield* Ref.set(updateInstallInFlightRef, false);595 yield* Ref.set(updateInstallInFlightRef, false);
460 yield* Ref.set(desktopState.quitting, false);596 yield* Ref.set(desktopState.quitting, false);
461 yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));597 yield* updateState((current) =>
462 yield* logUpdaterError("updater error", { message });598 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
599 );
600 yield* logUpdaterError(error.message, {
601 errorTag: error._tag,
602 operation: error.operation,
603 });
463 return;604 return;
464 }605 }
465606
...@@ -469,7 +610,7 @@ export const make = Effect.gen(function* () {...@@ -469,7 +610,7 @@ export const make = Effect.gen(function* () {
469 yield* updateState((current) => ({610 yield* updateState((current) => ({
470 ...current,611 ...current,
471 status: "error",612 status: "error",
472 message,613 message: error.message,
473 checkedAt,614 checkedAt,
474 downloadPercent: null,615 downloadPercent: null,
475 errorContext,616 errorContext,
...@@ -477,7 +618,10 @@ export const make = Effect.gen(function* () {...@@ -477,7 +618,10 @@ export const make = Effect.gen(function* () {
477 }));618 }));
478 }619 }
479620
480 yield* logUpdaterError("updater error", { message });621 yield* logUpdaterError(error.message, {
622 errorTag: error._tag,
623 operation: error.operation,
624 });
481 });625 });
482626
483 const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* (627 const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* (
...@@ -499,11 +643,16 @@ export const make = Effect.gen(function* () {...@@ -499,11 +643,16 @@ export const make = Effect.gen(function* () {
499 }643 }
500 }),644 }),
501 ),645 ),
502 Effect.catchCause((cause) =>646 Effect.catchCause((cause) => {
503 logUpdaterWarning("ignored malformed download-progress event", {647 if (Cause.hasInterruptsOnly(cause)) {
504 cause: Cause.pretty(cause),648 return Effect.void;
505 }),649 }
506 ),650 const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause });
651 return logUpdaterWarning(error.message, {
652 errorTag: error._tag,
653 event: error.event,
654 });
655 }),
507 );656 );
508 });657 });
509658
...@@ -518,11 +667,16 @@ export const make = Effect.gen(function* () {...@@ -518,11 +667,16 @@ export const make = Effect.gen(function* () {
518 yield* logUpdaterInfo("update downloaded", { version: info.version });667 yield* logUpdaterInfo("update downloaded", { version: info.version });
519 }),668 }),
520 ),669 ),
521 Effect.catchCause((cause) =>670 Effect.catchCause((cause) => {
522 logUpdaterWarning("ignored malformed update-downloaded event", {671 if (Cause.hasInterruptsOnly(cause)) {
523 cause: Cause.pretty(cause),672 return Effect.void;
524 }),673 }
525 ),674 const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause });
675 return logUpdaterWarning(error.message, {
676 errorTag: error._tag,
677 event: error.event,
678 });
679 }),
526 );680 );
527 });681 });
528682
...@@ -598,7 +752,10 @@ export const make = Effect.gen(function* () {...@@ -598,7 +752,10 @@ export const make = Effect.gen(function* () {
598 yield* Effect.annotateCurrentSpan({ channel: nextChannel });752 yield* Effect.annotateCurrentSpan({ channel: nextChannel });
599 const activeAction = yield* activeUpdateAction;753 const activeAction = yield* activeUpdateAction;
600 if (Option.isSome(activeAction)) {754 if (Option.isSome(activeAction)) {
601 return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value });755 return yield* new DesktopUpdateActionInProgressError({
756 action: activeAction.value,
757 requestedChannel: nextChannel,
758 });
602 }759 }
603760
604 const state = yield* Ref.get(updateStateRef);761 const state = yield* Ref.get(updateStateRef);
...@@ -608,7 +765,11 @@ export const make = Effect.gen(function* () {...@@ -608,7 +765,11 @@ export const make = Effect.gen(function* () {
608765
609 yield* desktopSettings766 yield* desktopSettings
610 .setUpdateChannel(nextChannel)767 .setUpdateChannel(nextChannel)
611 .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause })));768 .pipe(
769 Effect.mapError(
770 (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }),
771 ),
772 );
612773
613 const enabled = yield* shouldEnableAutoUpdates;774 const enabled = yield* shouldEnableAutoUpdates;
614 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));775 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));