diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index e072d80f03e..81aae92f0a3 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -83,12 +83,6 @@ export class DesktopSettingsWriteError extends Schema.TaggedErrorClass new DesktopSettingsWriteError({ operation, path, cause }); - export class DesktopAppSettings extends Context.Service< DesktopAppSettings, { @@ -244,18 +238,46 @@ const writeSettings = Effect.fn("desktop.settings.writeSettings")(function* (inp const tempPath = `${input.settingsPath}.${process.pid}.${input.suffix}.tmp`; const encoded = yield* encodeDesktopSettingsJson( toDesktopSettingsDocument(input.settings, input.defaultSettings), - ).pipe(Effect.mapError((cause) => writeError("encode-document", input.settingsPath, cause))); - yield* input.fileSystem - .makeDirectory(directory, { recursive: true }) - .pipe(Effect.mapError((cause) => writeError("create-directory", directory, cause))); - yield* input.fileSystem - .writeFileString(tempPath, `${encoded}\n`) - .pipe(Effect.mapError((cause) => writeError("write-temporary-file", tempPath, cause))); - yield* input.fileSystem - .rename(tempPath, input.settingsPath) - .pipe( - Effect.mapError((cause) => writeError("replace-settings-file", input.settingsPath, cause)), - ); + ).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "encode-document", + path: input.settingsPath, + cause, + }), + ), + ); + yield* input.fileSystem.makeDirectory(directory, { recursive: true }).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-directory", + path: directory, + cause, + }), + ), + ); + yield* input.fileSystem.writeFileString(tempPath, `${encoded}\n`).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "write-temporary-file", + path: tempPath, + cause, + }), + ), + ); + yield* input.fileSystem.rename(tempPath, input.settingsPath).pipe( + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: input.settingsPath, + cause, + }), + ), + ); }); export const make = Effect.gen(function* () { @@ -276,8 +298,13 @@ export const make = Effect.gen(function* () { return crypto.randomUUIDv4.pipe( Effect.map((uuid) => uuid.replace(/-/g, "")), - Effect.mapError((cause) => - writeError("create-temporary-file-name", environment.desktopSettingsPath, cause), + Effect.mapError( + (cause) => + new DesktopSettingsWriteError({ + operation: "create-temporary-file-name", + path: environment.desktopSettingsPath, + cause, + }), ), Effect.flatMap((suffix) => writeSettings({ diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index ad234df0bb5..4c90afb2a12 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -7,7 +7,10 @@ import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; import * as Layer from "effect/Layer"; +import * as Logger from "effect/Logger"; import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Ref from "effect/Ref"; import * as TestClock from "effect/testing/TestClock"; import * as DesktopBackendManager from "../backend/DesktopBackendManager.ts"; @@ -24,6 +27,9 @@ interface UpdatesHarnessOptions { void, ElectronUpdater.ElectronUpdaterCheckForUpdatesError >; + readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; + readonly setDisableDifferentialDownload?: Effect.Effect; + readonly stopBackend?: Effect.Effect; readonly env?: Record; } @@ -67,7 +73,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { allowDowngrade = value; }), - setDisableDifferentialDownload: () => Effect.void, + setDisableDifferentialDownload: () => options.setDisableDifferentialDownload ?? Effect.void, checkForUpdates: Effect.sync(() => { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), @@ -103,7 +109,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const backendLayer = Layer.succeed(DesktopBackendManager.DesktopBackendManager, { start: Effect.void, - stop: () => Effect.void, + stop: () => options.stopBackend ?? Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ desiredRunning: false, @@ -138,12 +144,23 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { ), ); + const setUpdateChannelError = options.setUpdateChannelError; + const settingsLayer = setUpdateChannelError + ? Layer.succeed(DesktopAppSettings.DesktopAppSettings, { + get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), + setServerExposureMode: () => Effect.die("unexpected server exposure update"), + setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), + setUpdateChannel: () => Effect.fail(setUpdateChannelError), + } satisfies DesktopAppSettings.DesktopAppSettings["Service"]) + : DesktopAppSettings.layer; + const layer = DesktopUpdates.layer.pipe( Layer.provideMerge(updaterLayer), Layer.provideMerge(windowLayer), Layer.provideMerge(backendLayer), Layer.provideMerge(DesktopState.layer), - Layer.provideMerge(DesktopAppSettings.layer), + Layer.provideMerge(settingsLayer), Layer.provideMerge( DesktopConfig.layerTest({ T3CODE_HOME: `/tmp/t3-desktop-updates-test-${process.pid}`, @@ -175,6 +192,45 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { } describe("DesktopUpdates", () => { + it("preserves complete causes for update poller and event failures", () => { + const cause = Cause.combine( + Cause.fail(new Error("updater failed")), + Cause.die(new Error("updater defect")), + ); + const pollerError = new DesktopUpdates.DesktopUpdatePollerError({ + poller: "startup", + cause, + }); + const eventError = new DesktopUpdates.DesktopUpdateEventHandlingError({ + event: "download-progress", + cause, + }); + const reportedError = new DesktopUpdates.DesktopUpdaterReportedError({ + operation: "download", + cause, + }); + const unexpectedActionError = new DesktopUpdates.DesktopUpdateUnexpectedActionError({ + action: "install", + cause, + }); + + assert.strictEqual(pollerError.cause, cause); + assert.equal(pollerError.poller, "startup"); + assert.equal(pollerError.message, "Desktop update startup poller failed."); + assert.strictEqual(eventError.cause, cause); + assert.equal(eventError.event, "download-progress"); + assert.equal(eventError.message, "Failed to handle desktop update download-progress event."); + assert.strictEqual(reportedError.cause, cause); + assert.equal(reportedError.operation, "download"); + assert.equal(reportedError.message, "Desktop updater download operation reported an error."); + assert.strictEqual(unexpectedActionError.cause, cause); + assert.equal(unexpectedActionError.action, "install"); + assert.equal( + unexpectedActionError.message, + "Desktop update install action failed unexpectedly.", + ); + }); + it.effect("configures the updater and runs startup checks on the test clock", () => { const harness = makeHarness(); @@ -222,6 +278,178 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("keeps raw updater event failures out of update state", () => { + const harness = makeHarness(); + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + harness.emit("error", cause); + yield* flushCallbacks; + + const state = yield* updates.getState; + assert.equal(state.status, "error"); + assert.equal(state.message, "Desktop updater background operation reported an error."); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("logs bounded updater failure context without exposing the cause", () => { + const cause = new Error( + "request failed for https://user:secret@example.com/update?token=secret", + ); + const updaterError = new ElectronUpdater.ElectronUpdaterCheckForUpdatesError({ + channel: null, + cause, + }); + const harness = makeHarness({ checkForUpdates: Effect.fail(updaterError) }); + const loggedAnnotations: Array> = []; + const logger = Logger.make(({ fiber }) => { + const annotations = fiber.getRef(References.CurrentLogAnnotations); + if (annotations.errorTag === "ElectronUpdaterCheckForUpdatesError") { + loggedAnnotations.push(annotations); + } + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + yield* updates.check("manual"); + + const state = yield* updates.getState; + const loggedAnnotation = loggedAnnotations.at(-1); + assert.isDefined(loggedAnnotation); + assert.equal(loggedAnnotation.errorTag, "ElectronUpdaterCheckForUpdatesError"); + assert.isNull(loggedAnnotation.channel); + assert.notProperty(loggedAnnotation, "error"); + assert.notInclude(Object.values(loggedAnnotation).map(String).join(" "), "secret"); + assert.equal( + state.message, + "Electron updater failed to check for updates on channel default.", + ); + assert.notInclude(state.message ?? "", "secret"); + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + TestClock.layer(), + harness.layer, + Logger.layer([logger], { mergeWithExisting: false }), + ), + ), + ); + }); + + it.effect("recovers download state after an unexpected setup failure", () => { + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + return disableDifferentialCalls === 1 + ? Effect.void + : Effect.die(new Error("download setup failed")); + }), + }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.download; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "available"); + assert.equal(failedState.errorContext, "download"); + assert.equal(failedState.message, "Desktop update download action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("restores download state and permits retry after interruption", () => + Effect.gen(function* () { + const actionStarted = yield* Deferred.make(); + let disableDifferentialCalls = 0; + const harness = makeHarness({ + setDisableDifferentialDownload: Effect.suspend(() => { + disableDifferentialCalls += 1; + if (disableDifferentialCalls === 1) { + return Effect.void; + } + if (disableDifferentialCalls === 2) { + return Deferred.succeed(actionStarted, undefined).pipe(Effect.andThen(Effect.never)); + } + return Effect.void; + }), + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-available", { version: "1.2.4" }); + yield* flushCallbacks; + + const downloadFiber = yield* updates.download.pipe(Effect.forkScoped); + yield* Deferred.await(actionStarted); + yield* Fiber.interrupt(downloadFiber); + + const interruptedState = yield* updates.getState; + assert.equal(interruptedState.status, "available"); + assert.isNull(interruptedState.message); + + const retry = yield* updates.download; + assert.isTrue(retry.accepted); + assert.isTrue(retry.completed); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }), + ); + + it.effect("clears quitting state after an unexpected install setup failure", () => { + const harness = makeHarness({ + stopBackend: Effect.die(new Error("backend stop failed")), + }); + + return Effect.scoped( + Effect.gen(function* () { + const desktopState = yield* DesktopState.DesktopState; + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + harness.emit("update-downloaded", { version: "1.2.4" }); + yield* flushCallbacks; + + const result = yield* updates.install; + assert.isTrue(result.accepted); + assert.isFalse(result.completed); + assert.isFalse(yield* Ref.get(desktopState.quitting)); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "downloaded"); + assert.equal(failedState.errorContext, "install"); + assert.equal(failedState.message, "Desktop update install action failed unexpectedly."); + + const changedState = yield* updates.setChannel("nightly"); + assert.equal(changedState.channel, "nightly"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); @@ -284,6 +512,7 @@ describe("DesktopUpdates", () => { const error = Cause.squash(exit.cause); assert.instanceOf(error, DesktopUpdates.DesktopUpdateActionInProgressError); assert.equal(error.action, "check"); + assert.equal(error.requestedChannel, "nightly"); } yield* Deferred.succeed(releaseCheck, undefined); @@ -292,4 +521,31 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }), ); + + it.effect("preserves settings failure context when an update channel cannot be persisted", () => { + const diskFailure = new Error("disk exploded"); + const settingsFailure = new DesktopAppSettings.DesktopSettingsWriteError({ + operation: "replace-settings-file", + path: "/tmp/settings.json", + cause: diskFailure, + }); + const harness = makeHarness({ setUpdateChannelError: settingsFailure }); + + return Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure; + + const error = yield* updates.setChannel("nightly").pipe(Effect.flip); + + assert.instanceOf(error, DesktopUpdates.DesktopUpdateChannelPersistenceError); + assert.isTrue(DesktopUpdates.isDesktopUpdateSetChannelError(error)); + assert.equal(error.channel, "nightly"); + assert.strictEqual(error.cause, settingsFailure); + assert.strictEqual(error.cause.cause, diskFailure); + assert.equal(error.message, "Failed to persist the nightly desktop update channel."); + assert.notInclude(error.message, diskFailure.message); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); }); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index e9142c369e5..aecbdcfc3e8 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1,9 +1,10 @@ -import type { - DesktopRuntimeInfo, - DesktopUpdateActionResult, - DesktopUpdateChannel, - DesktopUpdateCheckResult, - DesktopUpdateState, +import { + DesktopUpdateChannelSchema, + type DesktopRuntimeInfo, + type DesktopUpdateActionResult, + type DesktopUpdateChannel, + type DesktopUpdateCheckResult, + type DesktopUpdateState, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -63,30 +64,82 @@ export class DesktopUpdateActionInProgressError extends Schema.TaggedErrorClass< "DesktopUpdateActionInProgressError", { action: Schema.Literals(["check", "download", "install"]), + requestedChannel: DesktopUpdateChannelSchema, + }, +) { + override get message(): string { + return `Cannot change the desktop update channel to ${this.requestedChannel} while an update ${this.action} action is in progress.`; + } +} + +export class DesktopUpdateChannelPersistenceError extends Schema.TaggedErrorClass()( + "DesktopUpdateChannelPersistenceError", + { + channel: DesktopUpdateChannelSchema, + cause: Schema.instanceOf(DesktopAppSettings.DesktopSettingsWriteError), + }, +) { + override get message(): string { + return `Failed to persist the ${this.channel} desktop update channel.`; + } +} + +export class DesktopUpdatePollerError extends Schema.TaggedErrorClass()( + "DesktopUpdatePollerError", + { + poller: Schema.Literals(["startup", "poll"]), + cause: Schema.Defect(), }, ) { override get message(): string { - return `Cannot change update tracks while an update ${this.action} action is in progress.`; + return `Desktop update ${this.poller} poller failed.`; } } -export class DesktopUpdatePersistenceError extends Schema.TaggedErrorClass()( - "DesktopUpdatePersistenceError", +export class DesktopUpdateEventHandlingError extends Schema.TaggedErrorClass()( + "DesktopUpdateEventHandlingError", { + event: Schema.Literals(["update-available", "download-progress", "update-downloaded"]), cause: Schema.Defect(), }, ) { override get message(): string { - const detail = this.cause instanceof Error ? this.cause.message : String(this.cause); - return `Failed to persist desktop update settings: ${detail}`; + return `Failed to handle desktop update ${this.event} event.`; + } +} + +export class DesktopUpdaterReportedError extends Schema.TaggedErrorClass()( + "DesktopUpdaterReportedError", + { + operation: Schema.Literals(["check", "download", "install", "background"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop updater ${this.operation} operation reported an error.`; + } +} + +export class DesktopUpdateUnexpectedActionError extends Schema.TaggedErrorClass()( + "DesktopUpdateUnexpectedActionError", + { + action: Schema.Literals(["download", "install"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Desktop update ${this.action} action failed unexpectedly.`; } } export type DesktopUpdateConfigureError = never; -export type DesktopUpdateSetChannelError = - | DesktopUpdateActionInProgressError - | DesktopUpdatePersistenceError; +export const DesktopUpdateSetChannelError = Schema.Union([ + DesktopUpdateActionInProgressError, + DesktopUpdateChannelPersistenceError, +]); +export type DesktopUpdateSetChannelError = typeof DesktopUpdateSetChannelError.Type; +export const isDesktopUpdateSetChannelError = Schema.is(DesktopUpdateSetChannelError); export class DesktopUpdates extends Context.Service< DesktopUpdates, @@ -308,16 +361,21 @@ export const make = Effect.gen(function* () { return yield* electronUpdater.checkForUpdates.pipe( Effect.as(true), - Effect.catch( - Effect.fn("desktop.updates.handleCheckForUpdatesFailure")(function* (error) { + Effect.catchTags({ + ElectronUpdaterCheckForUpdatesError: Effect.fn( + "desktop.updates.handleCheckForUpdatesFailure", + )(function* (error) { const failedAt = yield* currentIsoTimestamp; yield* updateState((current) => reduceDesktopUpdateStateOnCheckFailure(current, error.message, failedAt), ); - yield* logUpdaterError("failed to check for updates", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); return true; }), - ), + }), Effect.ensuring(Ref.set(updateCheckInFlightRef, false)), ); }); @@ -342,19 +400,50 @@ export const make = Effect.gen(function* () { yield* electronUpdater.downloadUpdate; return { accepted: true, completed: true }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleDownloadFailure")(function* (error) { + Effect.catchTags({ + ElectronUpdaterDownloadUpdateError: Effect.fn("desktop.updates.handleDownloadFailure")( + function* (error) { + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => + updateState((current) => (current.status === "downloading" ? state : current)).pipe( + Effect.asVoid, + ), + ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.failCause(cause); + } + const error = new DesktopUpdateUnexpectedActionError({ action: "download", cause }); + return Effect.gen(function* () { yield* updateState((current) => reduceDesktopUpdateStateOnDownloadFailure(current, error.message), ); - yield* logUpdaterError("failed to download update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; - }), - ), + }); + }), Effect.ensuring(Ref.set(updateDownloadInFlightRef, false)), ); }).pipe(Effect.withSpan("desktop.updates.downloadAvailableUpdate")); + const resetInstallAction = Effect.all( + [Ref.set(updateInstallInFlightRef, false), Ref.set(desktopState.quitting, false)], + { discard: true }, + ); + const installDownloadedUpdate = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); if ( @@ -377,14 +466,38 @@ export const make = Effect.gen(function* () { }); return { accepted: true, completed: false }; }).pipe( - Effect.catch( - Effect.fn("desktop.updates.handleInstallFailure")(function* (error) { - yield* Ref.set(updateInstallInFlightRef, false); + Effect.catchTags({ + ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( + function* (error) { + yield* resetInstallAction; + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + channel: error.channel, + isSilent: error.isSilent, + isForceRunAfter: error.isForceRunAfter, + }); + return { accepted: true, completed: false }; + }, + ), + }), + Effect.onInterrupt(() => resetInstallAction), + Effect.catchCause((cause) => + Effect.gen(function* () { + if (Cause.hasInterruptsOnly(cause)) { + return yield* Effect.failCause(cause); + } + yield* resetInstallAction; + const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message), ); - yield* Ref.set(desktopState.quitting, false); - yield* logUpdaterError("failed to install update", { message: error.message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + action: error.action, + }); return { accepted: true, completed: false }; }), ), @@ -394,17 +507,31 @@ export const make = Effect.gen(function* () { const startUpdatePollers: Effect.Effect = Effect.gen(function* () { yield* Effect.sleep(AUTO_UPDATE_STARTUP_DELAY).pipe( Effect.andThen(checkForUpdates("startup")), - Effect.catchCause((cause) => - logUpdaterError("startup update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "startup", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); yield* Effect.sleep(AUTO_UPDATE_POLL_INTERVAL).pipe( Effect.andThen(checkForUpdates("poll")), Effect.forever, - Effect.catchCause((cause) => - logUpdaterError("poll update check failed", { cause: Cause.pretty(cause) }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdatePollerError({ poller: "poll", cause }); + return logUpdaterError(error.message, { + errorTag: error._tag, + poller: error.poller, + }); + }), Effect.forkScoped, ); }).pipe(Effect.withSpan("desktop.updates.startPollers")); @@ -435,11 +562,16 @@ export const make = Effect.gen(function* () { yield* logUpdaterInfo("update available", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-available event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-available", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -452,14 +584,23 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); const handleUpdaterError = Effect.fn("desktop.updates.handleUpdaterError")(function* ( - error: unknown, + cause: unknown, ) { - const message = error instanceof Error ? error.message : String(error); + const activeAction = yield* activeUpdateAction; + const error = new DesktopUpdaterReportedError({ + operation: Option.getOrElse(activeAction, () => "background" as const), + cause, + }); if (yield* Ref.get(updateInstallInFlightRef)) { yield* Ref.set(updateInstallInFlightRef, false); yield* Ref.set(desktopState.quitting, false); - yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, message)); - yield* logUpdaterError("updater error", { message }); + yield* updateState((current) => + reduceDesktopUpdateStateOnInstallFailure(current, error.message), + ); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); return; } @@ -469,7 +610,7 @@ export const make = Effect.gen(function* () { yield* updateState((current) => ({ ...current, status: "error", - message, + message: error.message, checkedAt, downloadPercent: null, errorContext, @@ -477,7 +618,10 @@ export const make = Effect.gen(function* () { })); } - yield* logUpdaterError("updater error", { message }); + yield* logUpdaterError(error.message, { + errorTag: error._tag, + operation: error.operation, + }); }); const handleDownloadProgress = Effect.fn("desktop.updates.handleDownloadProgress")(function* ( @@ -499,11 +643,16 @@ export const make = Effect.gen(function* () { } }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed download-progress event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "download-progress", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -518,11 +667,16 @@ export const make = Effect.gen(function* () { yield* logUpdaterInfo("update downloaded", { version: info.version }); }), ), - Effect.catchCause((cause) => - logUpdaterWarning("ignored malformed update-downloaded event", { - cause: Cause.pretty(cause), - }), - ), + Effect.catchCause((cause) => { + if (Cause.hasInterruptsOnly(cause)) { + return Effect.void; + } + const error = new DesktopUpdateEventHandlingError({ event: "update-downloaded", cause }); + return logUpdaterWarning(error.message, { + errorTag: error._tag, + event: error.event, + }); + }), ); }); @@ -598,7 +752,10 @@ export const make = Effect.gen(function* () { yield* Effect.annotateCurrentSpan({ channel: nextChannel }); const activeAction = yield* activeUpdateAction; if (Option.isSome(activeAction)) { - return yield* new DesktopUpdateActionInProgressError({ action: activeAction.value }); + return yield* new DesktopUpdateActionInProgressError({ + action: activeAction.value, + requestedChannel: nextChannel, + }); } const state = yield* Ref.get(updateStateRef); @@ -608,7 +765,11 @@ export const make = Effect.gen(function* () { yield* desktopSettings .setUpdateChannel(nextChannel) - .pipe(Effect.mapError((cause) => new DesktopUpdatePersistenceError({ cause }))); + .pipe( + Effect.mapError( + (cause) => new DesktopUpdateChannelPersistenceError({ channel: nextChannel, cause }), + ), + ); const enabled = yield* shouldEnableAutoUpdates; yield* setState(createBaseUpdateState(nextChannel, enabled, environment));