diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index dd3cd1aaf5f5..173c03cb0bdf 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -31,6 +31,7 @@ interface UpdatesHarnessOptions { readonly setUpdateChannelError?: DesktopAppSettings.DesktopSettingsWriteError; readonly setDisableDifferentialDownload?: Effect.Effect; readonly stopBackend?: Effect.Effect; + readonly quitAndInstall?: Effect.Effect; readonly env?: Record; } @@ -43,6 +44,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { const feedUrls: ElectronUpdater.ElectronUpdaterFeedUrl[] = []; const listeners = new Map void>>(); const sentStates: DesktopUpdateState[] = []; + const installSteps: string[] = []; const addListener = (eventName: string, listener: (...args: readonly unknown[]) => void) => { const eventListeners = listeners.get(eventName) ?? new Set(); @@ -84,7 +86,10 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { checkCount += 1; }).pipe(Effect.andThen(options.checkForUpdates ?? Effect.void)), downloadUpdate: Effect.void, - quitAndInstall: () => Effect.void, + quitAndInstall: () => + Effect.sync(() => { + installSteps.push("quitAndInstall"); + }).pipe(Effect.andThen(options.quitAndInstall ?? Effect.void)), on: (eventName, listener) => Effect.acquireRelease( Effect.sync(() => { @@ -109,14 +114,18 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { Effect.sync(() => { sentStates.push(state as DesktopUpdateState); }), - destroyAll: Effect.void, + destroyAll: Effect.sync(() => { + installSteps.push("destroyAll"); + }), syncAllAppearance: () => Effect.void, } satisfies ElectronWindow.ElectronWindow["Service"]); const stubBackendInstance: DesktopBackendPool.DesktopBackendInstance = { id: DesktopBackendPool.PRIMARY_INSTANCE_ID, label: Effect.succeed("Windows"), - start: Effect.void, + start: Effect.sync(() => { + installSteps.push("startBackend"); + }), stop: () => options.stopBackend ?? Effect.void, currentConfig: Effect.succeed(Option.none()), snapshot: Effect.succeed({ @@ -219,6 +228,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { 0, ), sentStates, + installSteps, emit: (eventName: string, payload?: unknown) => { for (const listener of listeners.get(eventName) ?? []) { listener(payload); @@ -725,6 +735,56 @@ describe("DesktopUpdates", () => { ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); }); + it.effect("destroys windows only after quitAndInstall starts", () => { + const harness = makeHarness(); + + return Effect.scoped( + Effect.gen(function* () { + 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.deepEqual(harness.installSteps, ["quitAndInstall", "destroyAll"]); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + + it.effect("keeps windows when quitAndInstall fails", () => { + const harness = makeHarness({ + quitAndInstall: Effect.fail( + new ElectronUpdater.ElectronUpdaterQuitAndInstallError({ + channel: null, + isSilent: true, + isForceRunAfter: true, + cause: new Error("installer refused"), + }), + ), + }); + + 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)); + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); + + const failedState = yield* updates.getState; + assert.equal(failedState.status, "downloaded"); + assert.equal(failedState.errorContext, "install"); + }), + ).pipe(Effect.provide(Layer.merge(TestClock.layer(), harness.layer))); + }); + it.effect("persists channel changes through the settings service", () => { const harness = makeHarness(); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 483ace0ff439..570e08458653 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -483,6 +483,11 @@ export const make = Effect.gen(function* () { yield* Ref.set(desktopState.quitting, true); + const instances = yield* pool.list; + const restartStoppedBackends = Effect.forEach(instances, (instance) => instance.start, { + concurrency: "unbounded", + }).pipe(Effect.asVoid); + return yield* Effect.gen(function* () { // Stop every backend in the pool, not just the primary. With // parallel WSL + Windows backends, leaving the WSL instance up @@ -491,23 +496,25 @@ export const make = Effect.gen(function* () { // WSL child gets hard-killed by the OS instead of receiving // SIGTERM + grace. Stops run concurrently with the same 5s // budget the primary had on its own. - const instances = yield* pool.list; yield* Effect.forEach( instances, (instance) => instance.stop({ timeout: Duration.seconds(5) }), { concurrency: "unbounded" }, ); - yield* electronWindow.destroyAll; yield* electronUpdater.quitAndInstall({ isSilent: true, isForceRunAfter: true, }); + // Close windows only after quitAndInstall has started. If install + // fails, the user still has a window. + yield* electronWindow.destroyAll; return { accepted: true, completed: false }; }).pipe( Effect.catchTags({ ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( function* (error) { yield* resetInstallAction; + yield* restartStoppedBackends; yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message), ); @@ -528,6 +535,7 @@ export const make = Effect.gen(function* () { return yield* Effect.failCause(cause); } yield* resetInstallAction; + yield* restartStoppedBackends; const error = new DesktopUpdateUnexpectedActionError({ action: "install", cause }); yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message),