From 7f40bda114a581490405ed282fc12e4a015767f8 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:36:52 +0300 Subject: [PATCH 1/2] fix(desktop): keep windows if update install fails Install stopped backends, destroyed every window, then called quitAndInstall. If that last step failed, quitting was cleared and an error was stored, but the windows were already gone. There was no path to open one again. Call quitAndInstall first. Destroy windows only after that starts. --- .../src/updates/DesktopUpdates.test.ts | 62 ++++++++++++++++++- apps/desktop/src/updates/DesktopUpdates.ts | 4 +- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index dd3cd1aaf5f5..6ed6519f5ac3 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,7 +114,9 @@ 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"]); @@ -219,6 +226,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { 0, ), sentStates, + installSteps, emit: (eventName: string, payload?: unknown) => { for (const listener of listeners.get(eventName) ?? []) { listener(payload); @@ -725,6 +733,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"]); + + 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..c8cbc8544bca 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -497,11 +497,13 @@ export const make = Effect.gen(function* () { (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({ From 4b33ef82b2aee24f203637d63bbb062d63121949 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:59:32 +0300 Subject: [PATCH 2/2] fix(desktop): restart backends if update install fails Install still stops backends before quitAndInstall. If that last step fails, the window stayed up but the pool was already down, so the UI was disconnected. On install failure, start the stopped backends again. --- apps/desktop/src/updates/DesktopUpdates.test.ts | 6 ++++-- apps/desktop/src/updates/DesktopUpdates.ts | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 6ed6519f5ac3..173c03cb0bdf 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -123,7 +123,9 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { 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({ @@ -774,7 +776,7 @@ describe("DesktopUpdates", () => { assert.isTrue(result.accepted); assert.isFalse(result.completed); assert.isFalse(yield* Ref.get(desktopState.quitting)); - assert.deepEqual(harness.installSteps, ["quitAndInstall"]); + assert.deepEqual(harness.installSteps, ["quitAndInstall", "startBackend"]); const failedState = yield* updates.getState; assert.equal(failedState.status, "downloaded"); diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index c8cbc8544bca..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,7 +496,6 @@ 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) }), @@ -510,6 +514,7 @@ export const make = Effect.gen(function* () { ElectronUpdaterQuitAndInstallError: Effect.fn("desktop.updates.handleInstallFailure")( function* (error) { yield* resetInstallAction; + yield* restartStoppedBackends; yield* updateState((current) => reduceDesktopUpdateStateOnInstallFailure(current, error.message), ); @@ -530,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),