fix(desktop): skip differential updates when the cache is stale (#47925)

This commit is contained in:
Luke Parker
2026-09-08 19:03:14 +10:00
committed by GitHub
Unverified
parent c3f1bdaf97
commit 22a534a0bb
3 changed files with 63 additions and 17 deletions
@@ -9,7 +9,8 @@ afterEach(async () => {
})
// Drives the updater the way the app does: start or check, then install like a button click. `calls` records the platform
// operations in order; installs record the staged version they would apply.
// operations in order; downloads record whether a differential download was allowed and installs record the staged
// version they would apply.
function setup(input?: {
currentVersion?: string
ready?: { version: string }
@@ -29,10 +30,11 @@ function setup(input?: {
},
catch: (error) => error,
}),
stageUpdate: Effect.tryPromise(async () => {
calls.push("download")
await input?.stage?.()
}),
stageUpdate: (options) =>
Effect.tryPromise(async () => {
calls.push(options.differential ? "download" : "download:full")
await input?.stage?.()
}),
installAndRestart: Effect.suspend(() => {
calls.push(`install:${ready?.version}`)
return Effect.tryPromise({
@@ -76,6 +78,7 @@ describe("updater", () => {
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
expect(app.getReady()).toEqual({ version: "2.0.0" })
})
@@ -90,15 +93,37 @@ describe("updater", () => {
expect(app.getReady()).toBeUndefined()
})
test("revalidates a persisted target through the updater cache on launch", async () => {
test("revalidates a persisted target through the updater cache on launch without a differential download", async () => {
const app = setup({ ready: { version: "2.0.0" } })
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(app.calls).toEqual(["check", "download:full"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "2.0.0" })
})
test("keeps differential downloads after the persisted target was installed", async () => {
const app = setup({ currentVersion: "2.0.0", ready: { version: "2.0.0" }, latest: () => "3.0.0" })
await app.updater.start()
expect(app.calls).toEqual(["check", "download"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" })
expect(app.getReady()).toEqual({ version: "3.0.0" })
})
test("downloads newer releases in full once one is staged", async () => {
let latest = "2.0.0"
const app = setup({ latest: () => latest })
await app.updater.start()
latest = "3.0.0"
await app.updater.check()
expect(app.calls).toEqual(["check", "download", "check", "download:full"])
expect(await app.updater.getState()).toEqual({ status: "ready", version: "3.0.0" })
})
test("concurrent checks share one platform check", async () => {
const app = setup()
@@ -140,7 +165,7 @@ describe("updater", () => {
expect(await app.updater.getState()).toEqual({ status: "installing", version: "2.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"])
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
})
@@ -220,7 +245,7 @@ describe("updater", () => {
await refresh
expect(await app.updater.getState()).toEqual({ status: "installing", version: "3.0.0" })
await new Promise((resolve) => setTimeout(resolve, 0))
expect(app.calls).toEqual(["check", "download", "check", "download", "prepare", "install:3.0.0"])
expect(app.calls).toEqual(["check", "download", "check", "download:full", "prepare", "install:3.0.0"])
})
test("returns to ready after a failed installation and allows a retry", async () => {
+21 -5
View File
@@ -8,7 +8,7 @@ import { emitIpcEvent } from "../ipc-events"
export type Platform = {
readonly checkForUpdate: Effect.Effect<string | undefined, unknown>
readonly stageUpdate: Effect.Effect<unknown, unknown>
readonly stageUpdate: (options: { readonly differential: boolean }) => Effect.Effect<unknown, unknown>
readonly installAndRestart: Effect.Effect<never, unknown>
readonly dispose: () => void
}
@@ -55,6 +55,22 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
listeners.forEach((listener) => listener(state))
return state
}
// electron-updater builds NSIS deltas against the installer of the running version but reads the "old" blockmap from
// the last download. Once a release is staged without installing, the two no longer match and every later delta fails
// its checksum before falling back to a full download, so remember which release the cache holds and skip the attempt.
let downloaded: string | undefined
const stage = (platform: Platform, version: string) =>
Effect.gen(function* () {
if (downloaded)
yield* Effect.logInfo("skipping differential download, updater cache is stale", {
current: dependencies.currentVersion,
staged: downloaded,
version,
})
yield* platform.stageUpdate({ differential: !downloaded })
downloaded = version
yield* dependencies.persistence.set({ version })
})
const findAndStage = (platform: Platform) =>
Effect.gen(function* () {
yield* Effect.sync(() => transition({ status: "checking" }))
@@ -64,8 +80,7 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
return transition({ status: "up-to-date" })
}
transition({ status: "downloading", version })
yield* platform.stageUpdate
yield* dependencies.persistence.set({ version })
yield* stage(platform, version)
return transition({ status: "ready", version })
}).pipe(
Effect.catch((error) =>
@@ -78,8 +93,7 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
Effect.gen(function* () {
const version = yield* platform.checkForUpdate
if (!version || version === staged || version === dependencies.currentVersion) return state
yield* platform.stageUpdate
yield* dependencies.persistence.set({ version })
yield* stage(platform, version)
return transition({ status: installing ? "installing" : "ready", version })
}).pipe(
Effect.catch((error) =>
@@ -137,6 +151,8 @@ export const make = Effect.fn("Updater.make")(function* (dependencies: Dependenc
const start = Effect.gen(function* () {
const ready = yield* dependencies.persistence.get
if (ready?.version === dependencies.currentVersion) yield* dependencies.persistence.clear
// Any other persisted target was downloaded by an earlier launch and never installed, so its blockmap is cached.
if (ready && ready.version !== dependencies.currentVersion) downloaded = ready.version
yield* check
})
const unsubscribe = (id: number) => {
@@ -37,16 +37,21 @@ export const make = Effect.gen(function* () {
},
catch: (error) => error,
}),
stageUpdate: stageUpdate(),
stageUpdate,
installAndRestart,
dispose: () => autoUpdater.off("before-quit-for-update", beforeQuit),
} satisfies Platform
})
function stageUpdate() {
function stageUpdate(options: { readonly differential: boolean }) {
if (process.platform !== "darwin")
return Effect.tryPromise({
try: () => updateClient.downloadUpdate(),
try: () => {
// Only the NSIS cache goes stale: macOS refreshes its cached zip with every download and AppImage reads the
// blockmap embedded in the running file.
updateClient.disableDifferentialDownload = process.platform === "win32" && !options.differential
return updateClient.downloadUpdate()
},
catch: (error) => error,
}).pipe(Effect.asVoid)