From aeae9480c407cd4050a170900c1d57a13ed319eb Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 5 Nov 2026 13:41:58 +0530 Subject: [PATCH 1/2] fix(desktop): preserve downloads across overlapping updater callbacks --- .../src/updates/DesktopUpdates.test.ts | 44 ++++++ apps/desktop/src/updates/DesktopUpdates.ts | 138 ++++++++++-------- 2 files changed, 118 insertions(+), 64 deletions(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 3b45d80bbc6e2..70e8aca0015d6 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -1,6 +1,7 @@ import { assert, describe, it } from "@effect/vitest"; import { DESKTOP_UPDATE_RESTART_MARKER_FILE } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; +import * as Clock from "effect/Clock"; import * as Deferred from "effect/Deferred"; import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; @@ -23,6 +24,49 @@ import * as DesktopUpdates from "./DesktopUpdates.ts"; import { flushCallbacks, makeHarness } from "./updatesTestHarness.ts"; describe("DesktopUpdates", () => { + it.effect("retains a completed download when an overlapping availability callback resumes", () => + Effect.gen(function* () { + const timestampRequested = yield* Deferred.make(); + const releaseTimestamp = yield* Deferred.make(); + const clock = yield* Clock.Clock; + let pauseNextTimestamp = false; + const callbackClock: Clock.Clock = { + ...clock, + currentTimeMillis: Effect.suspend(() => { + if (!pauseNextTimestamp) return clock.currentTimeMillis; + pauseNextTimestamp = false; + return Deferred.succeed(timestampRequested, undefined).pipe( + Effect.andThen(Deferred.await(releaseTimestamp)), + Effect.andThen(clock.currentTimeMillis), + ); + }), + }; + const harness = makeHarness(); + + yield* Effect.scoped( + Effect.gen(function* () { + const updates = yield* DesktopUpdates.DesktopUpdates; + yield* updates.configure.pipe(Effect.provideService(Clock.Clock, callbackClock)); + const { changes } = yield* updates.subscribe; + + pauseNextTimestamp = true; + harness.emit("update-available", { version: "1.2.4" }); + yield* Deferred.await(timestampRequested); + harness.emit("update-downloaded", { version: "1.2.4" }); + const downloaded = Option.getOrThrow(yield* Stream.runHead(changes)); + assert.equal(downloaded.status, "downloaded"); + + const { changes: resumedChanges } = yield* updates.subscribe; + yield* Deferred.succeed(releaseTimestamp, undefined); + const resumed = Option.getOrThrow(yield* Stream.runHead(resumedChanges)); + assert.equal(resumed.status, "downloaded"); + assert.equal(resumed.downloadedVersion, "1.2.4"); + assert.equal(resumed.downloadPercent, 100); + }), + ).pipe(Effect.provide(harness.layer)); + }), + ); + it.effect("updates Linux .deb installs and leaves other non-AppImage installs off", () => Effect.gen(function* () { const linuxState = (packageType: string | undefined) => diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index 14688e5258067..f053163519c4e 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -414,22 +414,26 @@ export const make = Effect.gen(function* () { Effect.flatMap((state) => electronWindow.sendAll(IpcChannels.UPDATE_STATE_CHANNEL, state)), ); - const setState = (state: DesktopUpdateState): Effect.Effect => - stateMutex - .withPermits(1)( - Ref.set(updateStateRef, state).pipe(Effect.andThen(PubSub.publish(stateChanges, state))), - ) - .pipe(Effect.andThen(emitState)); - - const updateState = ( - f: (state: DesktopUpdateState) => DesktopUpdateState, - ): Effect.Effect => - Ref.get(updateStateRef).pipe( - Effect.flatMap((state) => { - const nextState = f(state); - return setState(nextState).pipe(Effect.as(nextState)); + const updateStateEffect = Effect.fn("desktop.updates.updateState")(function* ( + f: (state: DesktopUpdateState) => Effect.Effect, + ) { + const state = yield* stateMutex.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(updateStateRef); + const next = yield* f(current); + yield* Ref.set(updateStateRef, next); + yield* PubSub.publish(stateChanges, next); + return next; }), ); + yield* emitState; + return state; + }); + + const updateState = (f: (state: DesktopUpdateState) => DesktopUpdateState) => + updateStateEffect((state) => Effect.sync(() => f(state))); + + const setState = (state: DesktopUpdateState) => updateState(() => state).pipe(Effect.asVoid); const readAppUpdateYml = fileSystem.readFileString(environment.appUpdateYmlPath, "utf-8").pipe( Effect.option, @@ -925,40 +929,39 @@ export const make = Effect.gen(function* () { yield* decodeUpdateInfo(raw).pipe( Effect.flatMap( Effect.fn("desktop.updates.applyUpdateAvailable")(function* (info) { - const state = yield* Ref.get(updateStateRef); - if (!(yield* offerable(info.version, state))) { - yield* logUpdaterInfo("ignoring update outside the selected channel or its floor", { - version: info.version, - channel: state.channel, - }); - const checkedAt = yield* currentIsoTimestamp; - yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); - yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - return; - } - const checkedAt = yield* currentIsoTimestamp; - const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( - info.releaseNotes, - info.version, - state.channel, - info.releaseDate, - ); - yield* setState( - reduceDesktopUpdateStateOnUpdateAvailable( - state, - info.version, - checkedAt, - releaseNotes, - omittedReleaseCount, - ), + const state = yield* updateStateEffect((current) => + Effect.gen(function* () { + if (!(yield* offerable(info.version, current))) { + yield* logUpdaterInfo("ignoring update outside the selected channel or its floor", { + version: info.version, + channel: current.channel, + }); + return reduceDesktopUpdateStateOnNoUpdate(current, checkedAt); + } + const { releaseNotes, omittedReleaseCount } = normalizeDesktopUpdateReleaseNotes( + info.releaseNotes, + info.version, + current.channel, + info.releaseDate, + ); + return reduceDesktopUpdateStateOnUpdateAvailable( + current, + info.version, + checkedAt, + releaseNotes, + omittedReleaseCount, + ); + }), ); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); - yield* logUpdaterInfo("update available", { - version: info.version, - releaseNoteGroups: releaseNotes.length, - omittedReleaseCount, - }); + if (state.availableVersion === info.version) { + yield* logUpdaterInfo("update available", { + version: info.version, + releaseNoteGroups: state.releaseNotes.length, + omittedReleaseCount: state.omittedReleaseCount, + }); + } }), ), Effect.catchCause((cause) => { @@ -977,8 +980,7 @@ export const make = Effect.gen(function* () { const handleUpdateNotAvailable = Effect.gen(function* () { const checkedAt = yield* currentIsoTimestamp; - const state = yield* Ref.get(updateStateRef); - yield* setState(reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); + yield* updateState((state) => reduceDesktopUpdateStateOnNoUpdate(state, checkedAt)); yield* Ref.set(lastLoggedDownloadMilestoneRef, -1); yield* logUpdaterInfo("no updates available"); }).pipe(Effect.withSpan("desktop.updates.handleUpdateNotAvailable")); @@ -1033,7 +1035,9 @@ export const make = Effect.gen(function* () { const state = yield* Ref.get(updateStateRef); const percent = Math.floor(progress.percent); if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) { - yield* setState(reduceDesktopUpdateStateOnDownloadProgress(state, progress.percent)); + yield* updateState((current) => + reduceDesktopUpdateStateOnDownloadProgress(current, progress.percent), + ); } const milestone = percent - (percent % 10); const lastLoggedMilestone = yield* Ref.get(lastLoggedDownloadMilestoneRef); @@ -1063,23 +1067,29 @@ export const make = Effect.gen(function* () { yield* decodeUpdateInfo(raw).pipe( Effect.flatMap( Effect.fn("desktop.updates.applyUpdateDownloaded")(function* (info) { - const state = yield* Ref.get(updateStateRef); - // A download can finish after its track switch moved on; never offer that build. - if (!(yield* offerable(info.version, state))) { - yield* logUpdaterInfo("ignoring download outside the selected channel or its floor", { - version: info.version, - channel: state.channel, - }); - // Its progress events left the updater "downloading", which would block every check. - if (state.status === "downloading") { - yield* setState( - reduceDesktopUpdateStateOnNoUpdate(state, yield* currentIsoTimestamp), - ); - } - return; + const checkedAt = yield* currentIsoTimestamp; + const state = yield* updateStateEffect((current) => + Effect.gen(function* () { + // A download can finish after its track switch moved on; never offer that build. + if (!(yield* offerable(info.version, current))) { + yield* logUpdaterInfo( + "ignoring download outside the selected channel or its floor", + { + version: info.version, + channel: current.channel, + }, + ); + // Its progress events must not leave the updater stuck downloading. + return current.status === "downloading" + ? reduceDesktopUpdateStateOnNoUpdate(current, checkedAt) + : current; + } + return reduceDesktopUpdateStateOnDownloadComplete(current, info.version); + }), + ); + if (state.downloadedVersion === info.version) { + yield* logUpdaterInfo("update downloaded", { version: info.version }); } - yield* setState(reduceDesktopUpdateStateOnDownloadComplete(state, info.version)); - yield* logUpdaterInfo("update downloaded", { version: info.version }); }), ), Effect.catchCause((cause) => { From 6a81d804dc84fdfb09a05a87fdf3b0f3870d3bce Mon Sep 17 00:00:00 2001 From: Lakshmi Tanmay Date: Thu, 5 Nov 2026 14:01:06 +0530 Subject: [PATCH 2/2] fix(desktop): keep late progress from undoing completed downloads --- apps/desktop/src/updates/DesktopUpdates.test.ts | 7 +++++++ apps/desktop/src/updates/DesktopUpdates.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 70e8aca0015d6..ddc9463b314c6 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -62,6 +62,13 @@ describe("DesktopUpdates", () => { assert.equal(resumed.status, "downloaded"); assert.equal(resumed.downloadedVersion, "1.2.4"); assert.equal(resumed.downloadPercent, 100); + + const { changes: progressChanges } = yield* updates.subscribe; + harness.emit("download-progress", { percent: 80 }); + const afterProgress = Option.getOrThrow(yield* Stream.runHead(progressChanges)); + assert.equal(afterProgress.status, "downloaded"); + assert.equal(afterProgress.downloadedVersion, "1.2.4"); + assert.equal(afterProgress.downloadPercent, 100); }), ).pipe(Effect.provide(harness.layer)); }), diff --git a/apps/desktop/src/updates/DesktopUpdates.ts b/apps/desktop/src/updates/DesktopUpdates.ts index f053163519c4e..2b18c2f4426a7 100644 --- a/apps/desktop/src/updates/DesktopUpdates.ts +++ b/apps/desktop/src/updates/DesktopUpdates.ts @@ -1036,7 +1036,9 @@ export const make = Effect.gen(function* () { const percent = Math.floor(progress.percent); if (shouldBroadcastDownloadProgress(state, progress.percent) || state.message !== null) { yield* updateState((current) => - reduceDesktopUpdateStateOnDownloadProgress(current, progress.percent), + current.status === "downloaded" + ? current + : reduceDesktopUpdateStateOnDownloadProgress(current, progress.percent), ); } const milestone = percent - (percent % 10);