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
8383 }
8484}
8585
86const writeError = (
87 operation: DesktopSettingsWriteOperation,
88 path: string,
89 cause: unknown,
90): DesktopSettingsWriteError => new DesktopSettingsWriteError({ operation, path, cause });
91
9286export class DesktopAppSettings extends Context.Service<
9387 DesktopAppSettings,
9488 {
......@@ -244,18 +238,46 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp
244238 const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`;
245239 const encoded = yield* encodeDesktopSettingsJson(
246240 toDesktopSettingsDocument(input.settings, input.defaultSettings),
247 ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause)));
248 yield* input.fileSystem
249 .makeDirectory(directory, { recursive: true })
250 .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause)));
251 yield* input.fileSystem
252 .writeFileString(tempPath, `${encoded}\n`)
253 .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause)));
254 yield* input.fileSystem
255 .rename(tempPath, input.settingsPath)
256 .pipe(
257 Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)),
258 );
241 ).pipe(
242 Effect.mapError(
243 (cause) =>
244 new DesktopSettingsWriteError({
245 operation: "encode-document",
246 path: input.settingsPath,
247 cause,
248 }),
249 ),
250 );
251 yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe(
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 );
259281});
260282
261283export const make = Effect.gen(function* () {
......@@ -276,8 +298,13 @@ export const make = Effect.gen(function* () {
276298
277299 return crypto.randomUUIDv4.pipe(
278300 Effect.map((uuid) => uuid.replace(/-/g, "")),
279 Effect.mapError((cause) =>
280 writeError("create-temporary-file-name", environment.desktopSettingsPath, cause),
301 Effect.mapError(
302 (cause) =>
303 new DesktopSettingsWriteError({
304 operation: "create-temporary-file-name",
305 path: environment.desktopSettingsPath,
306 cause,
307 }),
281308 ),
282309 Effect.flatMap((suffix) =>
283310 writeSettings({
apps/desktop/src/updates/DesktopUpdates.test.ts+259-3
......@@ -7,7 +7,10 @@ import * as Duration from "effect/Duration";
77import * as Effect from "effect/Effect";
88import * as Fiber from "effect/Fiber";
99import * as Layer from "effect/Layer";
10import * as Logger from "effect/Logger";
1011import * as Option from "effect/Option";
12import * as References from "effect/References";
13import * as Ref from "effect/Ref";
1114import * as TestClock from "effect/testing/TestClock";
1215
1316import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts";
......@@ -24,6 +27,9 @@ interface UpdatesHarnessOptions {
2427 void,
2528 ElectronUpdater.ElectronUpdaterCheckForUpdatesError
2629 >;
30 readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError;
31 readonly setDisableDifferentialDownload?: Effect.Effect<void>;
32 readonly stopBackend?: Effect.Effect<void>;
2733 readonly env?: Record<string, string | undefined>;
2834}
2935
......@@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
6773 Effect.sync(() => {
6874 allowDowngrade = value;
6975 }),
70 setDisableDifferentialDownload: () => Effect.void,
76 setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void,
7177 checkForUpdates: Effect.sync(() => {
7278 checkCount += 1;
7379 }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)),
......@@ -103,7 +109,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
103109
104110 const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, {
105111 start: Effect.void,
106 stop: () => Effect.void,
112 stop: () => options.stopBackend ?? Effect.void,
107113 currentConfig: Effect.succeed(Option.none()),
108114 snapshot: Effect.succeed({
109115 desiredRunning: false,
......@@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
138144 ),
139145 );
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
141158 const layer = DesktopUpdates.layer.pipe(
142159 Layer.provideMerge(updaterLayer),
143160 Layer.provideMerge(windowLayer),
144161 Layer.provideMerge(backendLayer),
145162 Layer.provideMerge(DesktopState.layer),
146 Layer.provideMerge(DesktopAppSettings.layer),
163 Layer.provideMerge(settingsLayer),
147164 Layer.provideMerge(
148165 DesktopConfig.layerTest({
149166 T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`,
......@@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) {
175192}
176193
177194describe("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
178234 it.effect("configures the updater and runs startup checks on the test clock", () => {
179235 const harness = makeHarness();
180236
......@@ -222,6 +278,178 @@ describe("DesktopUpdates", () => {
222278 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
223279 });
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
225453 it.effect("persists channel changes through the settings service", () => {
226454 const harness = makeHarness();
227455
......@@ -284,6 +512,7 @@ describe("DesktopUpdates", () => {
284512 const error = Cause.squash(exit.cause);
285513 assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError);
286514 assert.equal(error.action, "check");
515 assert.equal(error.requestedChannel, "nightly");
287516 }
288517
289518 yield* Deferred.succeed(releaseCheck, undefined);
......@@ -292,4 +521,31 @@ describe("DesktopUpdates", () => {
292521 ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer)));
293522 }),
294523 );
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 });
295551});
apps/desktop/src/updates/DesktopUpdates.ts+218-57
......@@ -1,9 +1,10 @@
1import type {
2 DesktopRuntimeInfo,
3 DesktopUpdateActionResult,
4 DesktopUpdateChannel,
5 DesktopUpdateCheckResult,
6 DesktopUpdateState,
1import {
2 DesktopUpdateChannelSchema,
3 type DesktopRuntimeInfo,
4 type DesktopUpdateActionResult,
5 type DesktopUpdateChannel,
6 type DesktopUpdateCheckResult,
7 type DesktopUpdateState,
78} from "@t3tools/contracts";
89import * as Cause from "effect/Cause";
910import * as Context from "effect/Context";
......@@ -63,30 +64,82 @@ export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass<
6364 "DesktopUpdateActionInProgressError",
6465 {
6566 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(),
6692 },
6793) {
6894 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.`;
7096 }
7197}
7298
73export class DesktopUpdatePersistenceError extends Schema.TaggedErrorClass<DesktopUpdatePersistenceError>()(
74 "DesktopUpdatePersistenceError",
99export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass<DesktopUpdateEventHandlingError>()(
100 "DesktopUpdateEventHandlingError",
75101 {
102 event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]),
76103 cause: Schema.Defect(),
77104 },
78105) {
79106 override get message(): string {
80 const detail = this.cause instanceof Error ? this.cause.message : String(this.cause);
81 return `Failed to persist desktop update settings: ${detail}`;
107 return `Failed to handle desktop update ${this.event} event.`;
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.`;
82132 }
83133}
84134
85135export type DesktopUpdateConfigureError = never;
86136
87export type DesktopUpdateSetChannelError =
88 | DesktopUpdateActionInProgressError
89 | DesktopUpdatePersistenceError;
137export const DesktopUpdateSetChannelError = Schema.Union([
138 DesktopUpdateActionInProgressError,
139 DesktopUpdateChannelPersistenceError,
140]);
141export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type;
142export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError);
90143
91144export class DesktopUpdates extends Context.Service<
92145 DesktopUpdates,
......@@ -308,16 +361,21 @@ export const make = Effect.gen(function* () {
308361
309362 return yield* electronUpdater.checkForUpdates.pipe(
310363 Effect.as(true),
311 Effect.catch(
312 Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) {
364 Effect.catchTags({
365 ElectronUpdaterCheckForUpdatesError: Effect.fn(
366 "desktop.updates.handleCheckForUpdatesFailure",
367 )(function* (error) {
313368 const failedAt = yield* currentIsoTimestamp;
314369 yield* updateState((current) =>
315370 reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt),
316371 );
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 });
318376 return true;
319377 }),
320 ),
378 }),
321379 Effect.ensuring(Ref.set(updateCheckInFlightRef, false)),
322380 );
323381 });
......@@ -342,19 +400,50 @@ export const make = Effect.gen(function* () {
342400 yield* electronUpdater.downloadUpdate;
343401 return { accepted: true, completed: true };
344402 }).pipe(
345 Effect.catch(
346 Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) {
403 Effect.catchTags({
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* () {
347428 yield* updateState((current) =>
348429 reduceDesktopUpdateStateOnDownloadFailure(current, error.message),
349430 );
350 yield* logUpdaterError("failed to download update", { message: error.message });
431 yield* logUpdaterError(error.message, {
432 errorTag: error._tag,
433 action: error.action,
434 });
351435 return { accepted: true, completed: false };
352 }),
353 ),
436 });
437 }),
354438 Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)),
355439 );
356440 }).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
358447 const installDownloadedUpdate = Effect.gen(function* () {
359448 const state = yield* Ref.get(updateStateRef);
360449 if (
......@@ -377,14 +466,38 @@ export const make = Effect.gen(function* () {
377466 });
378467 return { accepted: true, completed: false };
379468 }).pipe(
380 Effect.catch(
381 Effect.fn("desktop.updates.handleInstallFailure")(function* (error) {
382 yield* Ref.set(updateInstallInFlightRef, false);
469 Effect.catchTags({
470 ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")(
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 });
383494 yield* updateState((current) =>
384495 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
385496 );
386 yield* Ref.set(desktopState.quitting, false);
387 yield* logUpdaterError("failed to install update", { message: error.message });
497 yield* logUpdaterError(error.message, {
498 errorTag: error._tag,
499 action: error.action,
500 });
388501 return { accepted: true, completed: false };
389502 }),
390503 ),
......@@ -394,17 +507,31 @@ export const make = Effect.gen(function* () {
394507 const startUpdatePollers: Effect.Effect<void, never, Scope.Scope> = Effect.gen(function* () {
395508 yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe(
396509 Effect.andThen(checkForUpdates("startup")),
397 Effect.catchCause((cause) =>
398 logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }),
399 ),
510 Effect.catchCause((cause) => {
511 if (Cause.hasInterruptsOnly(cause)) {
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 }),
400520 Effect.forkScoped,
401521 );
402522 yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe(
403523 Effect.andThen(checkForUpdates("poll")),
404524 Effect.forever,
405 Effect.catchCause((cause) =>
406 logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }),
407 ),
525 Effect.catchCause((cause) => {
526 if (Cause.hasInterruptsOnly(cause)) {
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 }),
408535 Effect.forkScoped,
409536 );
410537 }).pipe(Effect.withSpan("desktop.updates.startPollers"));
......@@ -435,11 +562,16 @@ export const make = Effect.gen(function* () {
435562 yield* logUpdaterInfo("update available", { version: info.version });
436563 }),
437564 ),
438 Effect.catchCause((cause) =>
439 logUpdaterWarning("ignored malformed update-available event", {
440 cause: Cause.pretty(cause),
441 }),
442 ),
565 Effect.catchCause((cause) => {
566 if (Cause.hasInterruptsOnly(cause)) {
567 return Effect.void;
568 }
569 const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause });
570 return logUpdaterWarning(error.message, {
571 errorTag: error._tag,
572 event: error.event,
573 });
574 }),
443575 );
444576 });
445577
......@@ -452,14 +584,23 @@ export const make = Effect.gen(function* () {
452584 }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable"));
453585
454586 const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* (
455 error: unknown,
587 cause: unknown,
456588 ) {
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 });
458594 if (yield* Ref.get(updateInstallInFlightRef)) {
459595 yield* Ref.set(updateInstallInFlightRef, false);
460596 yield* Ref.set(desktopState.quitting, false);
461 yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message));
462 yield* logUpdaterError("updater error", { message });
597 yield* updateState((current) =>
598 reduceDesktopUpdateStateOnInstallFailure(current, error.message),
599 );
600 yield* logUpdaterError(error.message, {
601 errorTag: error._tag,
602 operation: error.operation,
603 });
463604 return;
464605 }
465606
......@@ -469,7 +610,7 @@ export const make = Effect.gen(function* () {
469610 yield* updateState((current) => ({
470611 ...current,
471612 status: "error",
472 message,
613 message: error.message,
473614 checkedAt,
474615 downloadPercent: null,
475616 errorContext,
......@@ -477,7 +618,10 @@ export const make = Effect.gen(function* () {
477618 }));
478619 }
479620
480 yield* logUpdaterError("updater error", { message });
621 yield* logUpdaterError(error.message, {
622 errorTag: error._tag,
623 operation: error.operation,
624 });
481625 });
482626
483627 const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* (
......@@ -499,11 +643,16 @@ export const make = Effect.gen(function* () {
499643 }
500644 }),
501645 ),
502 Effect.catchCause((cause) =>
503 logUpdaterWarning("ignored malformed download-progress event", {
504 cause: Cause.pretty(cause),
505 }),
506 ),
646 Effect.catchCause((cause) => {
647 if (Cause.hasInterruptsOnly(cause)) {
648 return Effect.void;
649 }
650 const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause });
651 return logUpdaterWarning(error.message, {
652 errorTag: error._tag,
653 event: error.event,
654 });
655 }),
507656 );
508657 });
509658
......@@ -518,11 +667,16 @@ export const make = Effect.gen(function* () {
518667 yield* logUpdaterInfo("update downloaded", { version: info.version });
519668 }),
520669 ),
521 Effect.catchCause((cause) =>
522 logUpdaterWarning("ignored malformed update-downloaded event", {
523 cause: Cause.pretty(cause),
524 }),
525 ),
670 Effect.catchCause((cause) => {
671 if (Cause.hasInterruptsOnly(cause)) {
672 return Effect.void;
673 }
674 const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause });
675 return logUpdaterWarning(error.message, {
676 errorTag: error._tag,
677 event: error.event,
678 });
679 }),
526680 );
527681 });
528682
......@@ -598,7 +752,10 @@ export const make = Effect.gen(function* () {
598752 yield* Effect.annotateCurrentSpan({ channel: nextChannel });
599753 const activeAction = yield* activeUpdateAction;
600754 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 });
602759 }
603760
604761 const state = yield* Ref.get(updateStateRef);
......@@ -608,7 +765,11 @@ export const make = Effect.gen(function* () {
608765
609766 yield* desktopSettings
610767 .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
613774 const enabled = yield* shouldEnableAutoUpdates;
614775 yield* setState(createBaseUpdateState(nextChannel, enabled, environment));