From 2ffe8f338754f5bcac92e94cf20e0202e9637592 Mon Sep 17 00:00:00 2001 From: Hampus Date: Mon, 12 Oct 2026 21:39:10 +0200 Subject: [PATCH] fix(desktop): fresh manual update check, 90 second polling (#3434) --- .../src/common/ModulePollPlan.test.mjs | 11 +- fluxer_desktop/src/common/ModulePollPlan.ts | 4 +- fluxer_desktop/src/main/Bootstrap.ts | 62 ++----- .../src/main/BootstrapEntry.test.mjs | 18 +- .../src/main/DesktopUpdateGate.test.mjs | 44 +++++ fluxer_desktop/src/main/DesktopUpdateGate.ts | 35 +++- .../src/main/DesktopUpdatePoll.test.mjs | 156 ++++++++++++++++++ fluxer_desktop/src/main/DesktopUpdatePoll.ts | 132 +++++++++++++++ .../src/main/DesktopUpdatePrompt.ts | 4 +- .../src/main/DesktopUpdateRun.test.mjs | 32 ++++ fluxer_desktop/src/main/DesktopUpdateRun.ts | 18 +- .../src/main/ModuleManifestRepository.ts | 60 ++++++- .../src/main/ModuleUpdater.test.mjs | 132 ++++++++++++++- fluxer_desktop/src/main/ModuleUpdater.ts | 50 +++++- fluxer_desktop/src/main/Updater.ts | 4 +- fluxer_desktop/src/main/Window.ts | 2 + 16 files changed, 663 insertions(+), 101 deletions(-) create mode 100644 fluxer_desktop/src/main/DesktopUpdatePoll.test.mjs create mode 100644 fluxer_desktop/src/main/DesktopUpdatePoll.ts diff --git a/fluxer_desktop/src/common/ModulePollPlan.test.mjs b/fluxer_desktop/src/common/ModulePollPlan.test.mjs index db253f768..4b38c3545 100644 --- a/fluxer_desktop/src/common/ModulePollPlan.test.mjs +++ b/fluxer_desktop/src/common/ModulePollPlan.test.mjs @@ -19,9 +19,12 @@ describe('how often a client re-checks the module manifest', () => { ); }); - test('a shipped build polls every ten minutes, not every two seconds', () => { + test('a shipped build polls every 75 to 105 seconds', () => { assert.equal(resolveModulePollPlan(null).intervalMs, MODULE_POLL_INTERVAL_MS); - assert.equal(MODULE_POLL_INTERVAL_MS, 10 * 60 * 1000); + assert.equal(MODULE_POLL_INTERVAL_MS, 90 * 1000); + const plan = resolveModulePollPlan(null); + assert.ok(nextModulePollDelay(plan, () => 0) >= 75 * 1000); + assert.ok(nextModulePollDelay(plan, () => 1) <= 105 * 1000); }); test('a shipped build spreads the poll so clients do not stampede the manifest', () => { @@ -34,8 +37,8 @@ describe('how often a client re-checks the module manifest', () => { const mid = nextModulePollDelay(plan, () => 0.5); const high = nextModulePollDelay(plan, () => 1); assert.equal(mid, plan.intervalMs); - assert.equal(low, plan.intervalMs * (1 - plan.jitterRatio)); - assert.equal(high, plan.intervalMs * (1 + plan.jitterRatio)); + assert.equal(low, Math.round(plan.intervalMs * (1 - plan.jitterRatio))); + assert.equal(high, Math.round(plan.intervalMs * (1 + plan.jitterRatio))); assert.ok(low < mid && mid < high); }); diff --git a/fluxer_desktop/src/common/ModulePollPlan.ts b/fluxer_desktop/src/common/ModulePollPlan.ts index 46a717749..ea76d15cf 100644 --- a/fluxer_desktop/src/common/ModulePollPlan.ts +++ b/fluxer_desktop/src/common/ModulePollPlan.ts @@ -1,7 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -export const MODULE_POLL_INTERVAL_MS = 10 * 60 * 1000; -const MODULE_POLL_JITTER_RATIO = 0.2; +export const MODULE_POLL_INTERVAL_MS = 90 * 1000; +const MODULE_POLL_JITTER_RATIO = 0.15; export interface ModulePollPlan { readonly intervalMs: number; diff --git a/fluxer_desktop/src/main/Bootstrap.ts b/fluxer_desktop/src/main/Bootstrap.ts index b73db639e..a95b32f1c 100644 --- a/fluxer_desktop/src/main/Bootstrap.ts +++ b/fluxer_desktop/src/main/Bootstrap.ts @@ -5,7 +5,7 @@ import {createRequire} from 'node:module'; import path from 'node:path'; import {BUILD_CHANNEL} from '@electron/common/BuildChannel'; import {WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID} from '@electron/common/DesktopIdentity'; -import {type ModulePollPlan, nextModulePollDelay, resolveModulePollPlan} from '@electron/common/ModulePollPlan'; +import {resolveModulePollPlan} from '@electron/common/ModulePollPlan'; import { hasOfflineRenderer, ModuleSystemLaunchDecision, @@ -13,6 +13,7 @@ import { resolveModuleSystemLaunch, } from '@electron/common/ModuleSystem'; import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate'; +import {armDesktopUpdatePoll, wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll'; import {DesktopUpdateRun, type DesktopUpdateTakeover} from '@electron/main/DesktopUpdateRun'; import {armOpenUrlForwarding, observeRendererLaunchConfirmed} from '@electron/main/ModuleBootHandoff'; import type {ModuleLaunchAttempt, ModuleStore as ModuleStoreInstance} from '@electron/main/ModuleStore'; @@ -134,51 +135,6 @@ async function launchMainApp(permit: ModuleLaunchPermit, logger: BootstrapLogger await import(MAIN_PROCESS_ENTRY_URL); } -function armModulePoll(plan: ModulePollPlan, logger: BootstrapLogger): void { - let inFlight = false; - let failing = false; - logger.info('Polling the module manifest', { - intervalMs: plan.intervalMs, - jitterRatio: plan.jitterRatio, - }); - const schedule = (): void => { - const timer = setTimeout(tick, nextModulePollDelay(plan)); - timer.unref(); - }; - const tick = (): void => { - if (inFlight) { - schedule(); - return; - } - inFlight = true; - void checkDesktopUpdateNow() - .then((state) => { - if (failing) { - failing = false; - logger.info('The module poll recovered'); - } - if (state.available) { - logger.info('A desktop update is available, waiting for the user to start it'); - } - }) - .catch((error: unknown) => { - if (!failing) { - failing = true; - logger.warn('The module poll failed, staying quiet until it recovers', error); - } - }) - .finally(() => { - inFlight = false; - schedule(); - }); - }; - if (process.platform === 'linux') { - tick(); - } else { - schedule(); - } -} - async function refuseUnsupportedBuild(reason: string): Promise { const {app} = await import('electron'); const {createChildLogger} = await import('@electron/common/Logger'); @@ -811,8 +767,8 @@ async function runModuleBootstrap(): Promise { ); markSplashLaunching(); armDesktopUpdate({ - check: async () => { - const check = await desktopUpdate.check(); + check: async (manual) => { + const check = await desktopUpdate.check({manual}); recordPendingUpdate(check); return check; }, @@ -821,7 +777,15 @@ async function runModuleBootstrap(): Promise { armMainWindowHandoff(updater, permit.launchAttempt, () => { desktopUpdate.markLaunchSettled(); preloadUpdateSplash(); - armModulePoll(resolveModulePollPlan(readDevModulePollInterval()), logger); + armDesktopUpdatePoll( + { + plan: resolveModulePollPlan(readDevModulePollInterval()), + check: () => checkDesktopUpdateNow(), + logger, + }, + process.platform === 'linux', + ); + observeRendererLaunchConfirmed(wakeDesktopUpdatePoll); }); app.once('before-quit', () => { closeSplashWindow(); diff --git a/fluxer_desktop/src/main/BootstrapEntry.test.mjs b/fluxer_desktop/src/main/BootstrapEntry.test.mjs index 6e6d1aa3c..0a53777ca 100644 --- a/fluxer_desktop/src/main/BootstrapEntry.test.mjs +++ b/fluxer_desktop/src/main/BootstrapEntry.test.mjs @@ -195,12 +195,9 @@ describe('Bootstrap entry point', () => { ); }); - test('the module poll only checks, it never downloads or reloads underneath the user', () => { - const poll = source.slice( - source.indexOf('function armModulePoll('), - source.indexOf('async function refuseUnsupportedBuild('), - ); - assert.match(poll, /void checkDesktopUpdateNow\(\)/); + test('the update poll only checks, it never downloads or reloads underneath the user', () => { + const poll = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'DesktopUpdatePoll.ts'), 'utf8'); + assert.match(source, /check: \(\) => checkDesktopUpdateNow\(\),/); assert.doesNotMatch( poll, /installPending|reloadIgnoringCache|refreshModuleRoots|activateMergedForRendererReload/, @@ -208,12 +205,9 @@ describe('Bootstrap entry point', () => { ); assert.doesNotMatch(source, /reloadIgnoringCache/); }); - test('a module poll that went quiet says so when it comes back', () => { - assert.match( - source, - /if \(failing\) \{\n\t+failing = false;\n\t+logger\.info\('The module poll recovered'\);\n\t+\}/, - 'The failure is logged once and then suppressed until recovery. Without a line on the way back, a reader of the log cannot tell a poll that recovered from one that is still failing, which is the whole question that warning raises.', - ); + + test('a reload of the renderer checks for an update right away', () => { + assert.match(source, /observeRendererLaunchConfirmed\(wakeDesktopUpdatePoll\);/); }); test('a minimized login launch converges the modules without flashing the splash', () => { diff --git a/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs b/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs index 527f5a67f..554e146e2 100644 --- a/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs +++ b/fluxer_desktop/src/main/DesktopUpdateGate.test.mjs @@ -71,6 +71,50 @@ describe('DesktopUpdateGate', () => { stop(); }); + test('a background poll joins a check already running instead of starting another', async () => { + const pending = deferred(); + const calls = []; + armDesktopUpdate({ + check: async (manual) => { + calls.push(manual); + await pending.promise; + return {shellNewer: false, modulesChanged: false}; + }, + start: async () => {}, + }); + + const first = checkDesktopUpdateNow(); + const second = checkDesktopUpdateNow(); + pending.resolve(); + await Promise.all([first, second]); + + assert.deepEqual(calls, [false]); + }); + + test('a manual check never settles for the answer of a background poll that was already running', async () => { + const pending = deferred(); + const calls = []; + armDesktopUpdate({ + check: async (manual) => { + calls.push(manual); + if (!manual) { + await pending.promise; + return {shellNewer: false, modulesChanged: false}; + } + return {shellNewer: true, modulesChanged: false}; + }, + start: async () => {}, + }); + + const background = checkDesktopUpdateNow(); + const manual = checkDesktopUpdateNow({manual: true}); + pending.resolve(); + await background; + + assert.deepEqual(await manual, {available: true, updating: false}); + assert.deepEqual(calls, [false, true]); + }); + test('a shell update that could not finish stays available for the next click', async () => { armDesktopUpdate({ check: async () => ({shellNewer: true, modulesChanged: false}), diff --git a/fluxer_desktop/src/main/DesktopUpdateGate.ts b/fluxer_desktop/src/main/DesktopUpdateGate.ts index 0f7c3ab79..716c8dcb9 100644 --- a/fluxer_desktop/src/main/DesktopUpdateGate.ts +++ b/fluxer_desktop/src/main/DesktopUpdateGate.ts @@ -11,7 +11,7 @@ export interface DesktopUpdateCheck { } interface DesktopUpdateController { - readonly check: () => Promise; + readonly check: (manual: boolean) => Promise; readonly start: () => Promise; } @@ -21,6 +21,7 @@ interface DesktopUpdateGateState { controller: DesktopUpdateController | null; lastCheck: DesktopUpdateCheck | null; running: Promise | null; + checking: {readonly manual: boolean; readonly settled: Promise} | null; notifiedAvailable: boolean; notifiedUpdating: boolean; readonly listeners: Set; @@ -30,6 +31,7 @@ const gateState: DesktopUpdateGateState = { controller: null, lastCheck: null, running: null, + checking: null, notifiedAvailable: false, notifiedUpdating: false, listeners: new Set(), @@ -76,14 +78,37 @@ export function publishDesktopUpdateCheck(check: DesktopUpdateCheck): void { notify(); } -export async function checkDesktopUpdateNow(): Promise { +export async function checkDesktopUpdateNow({ + manual = false, +}: { + readonly manual?: boolean; +} = {}): Promise { + const pending = gateState.checking; + if (pending != null) { + if (pending.manual || !manual) { + await pending.settled; + return getDesktopUpdateState(); + } + await pending.settled.catch(() => {}); + return await checkDesktopUpdateNow({manual}); + } const controller = gateState.controller; if (controller == null || gateState.running != null) { return getDesktopUpdateState(); } - const check = await controller.check(); - if (gateState.running == null) { - publishDesktopUpdateCheck(check); + const settled = controller.check(manual).then((check) => { + if (gateState.running == null) { + publishDesktopUpdateCheck(check); + } + }); + const checking = {manual, settled}; + gateState.checking = checking; + try { + await settled; + } finally { + if (gateState.checking === checking) { + gateState.checking = null; + } } return getDesktopUpdateState(); } diff --git a/fluxer_desktop/src/main/DesktopUpdatePoll.test.mjs b/fluxer_desktop/src/main/DesktopUpdatePoll.test.mjs new file mode 100644 index 000000000..9baa1d36a --- /dev/null +++ b/fluxer_desktop/src/main/DesktopUpdatePoll.test.mjs @@ -0,0 +1,156 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import assert from 'node:assert/strict'; +import {describe, test} from 'node:test'; +import './LocalAppTestSupport.test.mjs'; + +const {DesktopUpdatePoll} = await import('@electron/main/DesktopUpdatePoll'); + +const PLAN = Object.freeze({intervalMs: 90_000, jitterRatio: 0.15}); + +function settle() { + return new Promise((resolve) => setImmediate(resolve)); +} + +function harness(results) { + const timers = []; + const logs = []; + let checks = 0; + let release = null; + const poll = new DesktopUpdatePoll({ + plan: PLAN, + random: () => 0.5, + check: () => { + const result = results[Math.min(checks, results.length - 1)]; + checks += 1; + if (result === 'hold') { + return new Promise((resolve) => { + release = () => resolve({available: false, updating: false}); + }); + } + return result instanceof Error ? Promise.reject(result) : Promise.resolve(result); + }, + logger: { + info: (message) => logs.push(['info', message]), + warn: (message) => logs.push(['warn', message]), + }, + setTimer: (callback, delayMs) => { + const timer = {callback, delayMs, cleared: false}; + timers.push(timer); + return timer; + }, + clearTimer: (timer) => { + timer.cleared = true; + }, + }); + const fire = async () => { + const timer = timers.findLast((candidate) => !candidate.cleared); + timer.cleared = true; + timer.callback(); + await settle(); + }; + return { + poll, + timers, + logs, + fire, + checks: () => checks, + release: async () => { + release(); + await settle(); + }, + }; +} + +const NOTHING = Object.freeze({available: false, updating: false}); +const AVAILABLE = Object.freeze({available: true, updating: false}); + +describe('DesktopUpdatePoll', () => { + test('a healthy poll checks about every ninety seconds', async () => { + const {poll, timers, fire, checks} = harness([NOTHING]); + + poll.start(false); + assert.equal(checks(), 0); + assert.equal(timers.at(-1).delayMs, 90_000); + + await fire(); + await fire(); + + assert.equal(checks(), 2); + assert.equal(timers.at(-1).delayMs, 90_000); + }); + + test('a failing poll backs off, warns once, and says so when it recovers', async () => { + const offline = new Error('offline'); + const {poll, timers, logs, fire} = harness([offline, offline, offline, NOTHING, NOTHING]); + + poll.start(true); + await settle(); + const delays = [timers.at(-1).delayMs]; + await fire(); + delays.push(timers.at(-1).delayMs); + await fire(); + delays.push(timers.at(-1).delayMs); + await fire(); + delays.push(timers.at(-1).delayMs); + await fire(); + + assert.deepEqual(delays, [135_000, 270_000, 540_000, 90_000]); + assert.deepEqual( + logs.filter(([level]) => level === 'warn').map(([, message]) => message), + ['The desktop update poll failed, backing off until it recovers'], + ); + assert.equal(logs.filter(([, message]) => message === 'The desktop update poll recovered').length, 1); + }); + + test('the back off never waits longer than fifteen minutes', async () => { + const {poll, timers, fire} = harness([new Error('offline')]); + + poll.start(true); + await settle(); + for (let attempt = 0; attempt < 12; attempt += 1) { + await fire(); + } + + assert.ok(timers.at(-1).delayMs <= 15 * 60 * 1000); + }); + + test('a reopened window or a reload checks at once and restarts the interval', async () => { + const {poll, timers, checks} = harness([NOTHING]); + poll.start(false); + const pending = timers.at(-1); + + poll.wake(); + await settle(); + + assert.equal(checks(), 1); + assert.equal(pending.cleared, true); + assert.equal(timers.filter((timer) => !timer.cleared).length, 1); + }); + + test('a wake while a check runs never starts a second one', async () => { + const {poll, checks, release, timers} = harness(['hold', NOTHING]); + poll.start(true); + + poll.wake(); + poll.wake(); + assert.equal(checks(), 1); + + await release(); + assert.equal(timers.filter((timer) => !timer.cleared).length, 1); + }); + + test('an update that stays available is logged once, not on every tick', async () => { + const {poll, logs, fire} = harness([AVAILABLE]); + poll.start(true); + await settle(); + await fire(); + await fire(); + + assert.equal( + logs.filter(([, message]) => message === 'A desktop update is available, waiting for the user to start it') + .length, + 1, + ); + }); +}); diff --git a/fluxer_desktop/src/main/DesktopUpdatePoll.ts b/fluxer_desktop/src/main/DesktopUpdatePoll.ts new file mode 100644 index 000000000..c120056c9 --- /dev/null +++ b/fluxer_desktop/src/main/DesktopUpdatePoll.ts @@ -0,0 +1,132 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import {jitteredExponentialDelay} from '@electron/common/ExponentialBackoff'; +import {type ModulePollPlan, nextModulePollDelay} from '@electron/common/ModulePollPlan'; +import type {DesktopUpdateState} from '@fluxer/desktop_ipc/src/ModuleContract'; + +const DESKTOP_UPDATE_POLL_BACKOFF_CAP_MS = 15 * 60 * 1000; + +interface DesktopUpdatePollLogger { + readonly info: (message: string, details?: unknown) => void; + readonly warn: (message: string, details?: unknown) => void; +} + +interface DesktopUpdatePollOptions { + readonly plan: ModulePollPlan; + readonly check: () => Promise; + readonly logger: DesktopUpdatePollLogger; + readonly random?: () => number; + readonly setTimer?: (callback: () => void, delayMs: number) => unknown; + readonly clearTimer?: (timer: unknown) => void; +} + +function setUnrefTimer(callback: () => void, delayMs: number): NodeJS.Timeout { + const timer = setTimeout(callback, delayMs); + timer.unref(); + return timer; +} + +export class DesktopUpdatePoll { + private readonly options: DesktopUpdatePollOptions; + private readonly random: () => number; + private readonly setTimer: (callback: () => void, delayMs: number) => unknown; + private readonly clearTimer: (timer: unknown) => void; + private timer: unknown = null; + private inFlight = false; + private failures = 0; + private available = false; + + public constructor(options: DesktopUpdatePollOptions) { + this.options = options; + this.random = options.random ?? Math.random; + this.setTimer = options.setTimer ?? setUnrefTimer; + this.clearTimer = options.clearTimer ?? ((timer) => clearTimeout(timer as NodeJS.Timeout)); + } + + public start(immediately: boolean): void { + this.options.logger.info('Polling the desktop update feeds', { + intervalMs: this.options.plan.intervalMs, + jitterRatio: this.options.plan.jitterRatio, + }); + if (immediately) { + this.tick(); + } else { + this.schedule(); + } + } + + public wake(): void { + this.tick(); + } + + private nextDelay(): number { + const delay = nextModulePollDelay(this.options.plan, this.random); + if (this.failures === 0) { + return delay; + } + return Math.max( + delay, + jitteredExponentialDelay( + this.options.plan.intervalMs, + DESKTOP_UPDATE_POLL_BACKOFF_CAP_MS, + this.failures, + this.random, + ), + ); + } + + private schedule(): void { + this.timer = this.setTimer(() => { + this.timer = null; + this.tick(); + }, this.nextDelay()); + } + + private tick(): void { + if (this.inFlight) { + return; + } + if (this.timer != null) { + this.clearTimer(this.timer); + this.timer = null; + } + this.inFlight = true; + const {logger} = this.options; + void this.options + .check() + .then((state) => { + if (this.failures > 0) { + logger.info('The desktop update poll recovered', {failures: this.failures}); + this.failures = 0; + } + if (state.available && !this.available) { + logger.info('A desktop update is available, waiting for the user to start it'); + } + this.available = state.available; + }) + .catch((error: unknown) => { + this.failures += 1; + if (this.failures === 1) { + logger.warn('The desktop update poll failed, backing off until it recovers', error); + } + }) + .finally(() => { + this.inFlight = false; + this.schedule(); + }); + } +} + +let armedPoll: DesktopUpdatePoll | null = null; + +export function armDesktopUpdatePoll(options: DesktopUpdatePollOptions, immediately: boolean): void { + if (armedPoll != null) { + return; + } + armedPoll = new DesktopUpdatePoll(options); + armedPoll.start(immediately); +} + +export function wakeDesktopUpdatePoll(): void { + armedPoll?.wake(); +} diff --git a/fluxer_desktop/src/main/DesktopUpdatePrompt.ts b/fluxer_desktop/src/main/DesktopUpdatePrompt.ts index 499d6349e..ff936ffdc 100644 --- a/fluxer_desktop/src/main/DesktopUpdatePrompt.ts +++ b/fluxer_desktop/src/main/DesktopUpdatePrompt.ts @@ -41,7 +41,7 @@ async function runUpdateCheck(): Promise { for (;;) { let manualVersion: string | null = null; try { - const state = await checkDesktopUpdateNow(); + const state = await checkDesktopUpdateNow({manual: true}); if (state.updating === true) { logger.info('A desktop update is already running, leaving it to finish'); return; @@ -129,7 +129,7 @@ export function startDesktopUpdateFromShell(): void { export async function updateFromCommandLine(): Promise { try { - const state = await checkDesktopUpdateNow(); + const state = await checkDesktopUpdateNow({manual: true}); if (!state.available) { logger.info('The command line asked for an update and none is waiting'); return; diff --git a/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs b/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs index 367369a1d..defa3d52a 100644 --- a/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs +++ b/fluxer_desktop/src/main/DesktopUpdateRun.test.mjs @@ -16,6 +16,7 @@ function harness({ shellResults = [], installFails = false, settled = true, + now = () => 0, } = {}) { const steps = []; const published = []; @@ -70,6 +71,7 @@ function harness({ failures.push(failure); }, logger: {info: quiet, warn: quiet, error: quiet}, + now, }); if (settled) run.markLaunchSettled(); return {run, steps, published, failures}; @@ -144,6 +146,36 @@ describe('DesktopUpdateRun', () => { assert.deepEqual(steps, ['probe', 'takeover', 'close', 'shell-update', 'reopen', 'probe', 'probe']); }); + test('a manual check offers the shell again even after the platform feed said it had nothing', async () => { + const lagging = {shellLatestVersion: '2026.1012.154032', shellNewer: true, modulesChanged: false}; + const {run} = harness({probes: [lagging], shellResults: [{reason: 'no-update', detail: null}]}); + await run.start(); + assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: false}); + + assert.deepEqual( + await run.check({manual: true}), + {shellNewer: true, modulesChanged: false}, + 'one stale answer from the platform feed must not make Check for updates say up to date for the rest of the session', + ); + assert.deepEqual(await run.check(), {shellNewer: true, modulesChanged: false}); + }); + + test('the background check offers a lagging shell again once the platform feed had time to catch up', async () => { + let clock = 0; + const lagging = {shellLatestVersion: '2026.1012.154032', shellNewer: true, modulesChanged: false}; + const {run} = harness({ + probes: [lagging], + shellResults: [{reason: 'no-update', detail: null}], + now: () => clock, + }); + await run.start(); + + clock = 9 * 60_000; + assert.deepEqual(await run.check(), {shellNewer: false, modulesChanged: false}); + clock = 10 * 60_000; + assert.deepEqual(await run.check(), {shellNewer: true, modulesChanged: false}); + }); + test('a shell update that failed on the network is retried by the next click', async () => { const shell = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false}; const {run, steps, published} = harness({ diff --git a/fluxer_desktop/src/main/DesktopUpdateRun.ts b/fluxer_desktop/src/main/DesktopUpdateRun.ts index 4b1b3109a..6bb36aa16 100644 --- a/fluxer_desktop/src/main/DesktopUpdateRun.ts +++ b/fluxer_desktop/src/main/DesktopUpdateRun.ts @@ -43,6 +43,8 @@ interface DesktopUpdateRunOptions { readonly now?: () => number; } +const SHELL_FEED_BEHIND_RETRY_MS = 10 * 60_000; + interface FilteredProbe { readonly check: DesktopUpdateCheck; readonly shellLatestVersion: string; @@ -52,7 +54,7 @@ export class DesktopUpdateRun { private readonly options: DesktopUpdateRunOptions; private readonly now: () => number; private launchSettled = false; - private shellFeedBehindVersion: string | null = null; + private shellFeedBehind: {readonly version: string; readonly at: number} | null = null; private shellInstallFailed = false; private lastShellFailure: ShellSelfUpdateResult | null = null; private lastCheck: DesktopUpdateCheck | null = null; @@ -66,7 +68,10 @@ export class DesktopUpdateRun { this.launchSettled = true; } - public async check(): Promise { + public async check({manual = false}: {readonly manual?: boolean} = {}): Promise { + if (manual) { + this.shellFeedBehind = null; + } return (await this.probe()).check; } @@ -188,7 +193,7 @@ export class DesktopUpdateRun { logger.warn('The shell self update did not restart the app', result); this.lastShellFailure = result.reason === 'no-update' ? null : result; if (result.reason === 'no-update') { - this.shellFeedBehindVersion = probe.shellLatestVersion; + this.shellFeedBehind = {version: probe.shellLatestVersion, at: this.now()}; return {shellNewer: false, modulesChanged: probe.check.modulesChanged}; } if (result.reason === 'install-failed') { @@ -201,13 +206,18 @@ export class DesktopUpdateRun { const probe = await this.options.probe(); const check = { shellNewer: - this.options.canSelfUpdateShell && probe.shellNewer && probe.shellLatestVersion !== this.shellFeedBehindVersion, + this.options.canSelfUpdateShell && probe.shellNewer && !this.isShellFeedBehind(probe.shellLatestVersion), modulesChanged: probe.modulesChanged, }; this.lastCheck = check; return {check, shellLatestVersion: probe.shellLatestVersion}; } + private isShellFeedBehind(version: string): boolean { + const behind = this.shellFeedBehind; + return behind != null && behind.version === version && this.now() - behind.at < SHELL_FEED_BEHIND_RETRY_MS; + } + private publish(check: DesktopUpdateCheck): void { this.lastCheck = check; this.options.publish(check); diff --git a/fluxer_desktop/src/main/ModuleManifestRepository.ts b/fluxer_desktop/src/main/ModuleManifestRepository.ts index 6c42df1e1..3f3f5226a 100644 --- a/fluxer_desktop/src/main/ModuleManifestRepository.ts +++ b/fluxer_desktop/src/main/ModuleManifestRepository.ts @@ -10,11 +10,13 @@ import { parseModuleUpdateManifest, } from '@electron/main/ModuleManifest'; import type {ModuleStore} from '@electron/main/ModuleStore'; +import {type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion'; const CHANNEL_MANIFEST_CACHE_FILE_NAME = 'manifest.json'; const MODULE_MANIFEST_TIMEOUT_MS = 15000; const MODULE_MANIFEST_MEMO_TTL_MS = 60000; const MODULE_MANIFEST_MAX_BYTES = 1024 * 1024; +const SHELL_FEED_MAX_BYTES = 64 * 1024; const MODULE_CACHE_FILE_MODE = 0o644; export interface ModuleManifestDocument { @@ -93,6 +95,8 @@ export class ModuleManifestRepository { private memo: {readonly expiresAt: number; readonly document: ModuleManifestDocument} | null = null; private inFlight: Promise | null = null; private memoEpoch = 0; + private validated: ModuleManifestDocument | null = null; + private shellFeed: {readonly etag: string; readonly version: ModuleVersion} | null = null; public constructor(options: ModuleManifestRepositoryOptions) { this.store = options.store; @@ -158,9 +162,45 @@ export class ModuleManifestRepository { await writeFileAtomically(this.manifestCachePath, document.bytes, MODULE_CACHE_FILE_MODE); } + public async fetchShellLatestVersion(): Promise { + const url = this.shellFeedUrl(); + const known = this.shellFeed; + const headers: Record = {accept: 'application/json'}; + if (known != null) { + headers['if-none-match'] = known.etag; + } + let response: Response; + try { + response = await this.fetchImplementation(url, { + headers, + signal: AbortSignal.timeout(MODULE_MANIFEST_TIMEOUT_MS), + }); + } catch (error) { + throw new ModuleManifestFetchError(`failed to reach ${url}`, null, {cause: error}); + } + if (response.status === 304 && known != null) { + return known.version; + } + if (!response.ok) { + throw new ModuleManifestFetchError(`shell feed request returned ${response.status}`, response.status); + } + const bytes = await this.readBoundedBody(response, SHELL_FEED_MAX_BYTES, 'shell feed'); + const payload = JSON.parse(bytes.toString('utf8')) as {version?: unknown}; + if (typeof payload.version !== 'string') { + throw new ModuleManifestFetchError('shell feed is missing its version', response.status); + } + const version = parseModuleVersion(payload.version, 'shell feed version'); + const etag = response.headers.get('etag'); + this.shellFeed = etag == null ? null : {etag, version}; + return version; + } + private async readLatestDocument(): Promise { - const etag = this.store.getState().last_manifest_etag; - const first = await this.requestManifest(etag); + const validated = this.validated; + if (validated != null) { + return (await this.requestManifest(validated.etag)) ?? validated; + } + const first = await this.requestManifest(this.store.getState().last_manifest_etag); if (first != null) { return first; } @@ -175,12 +215,20 @@ export class ModuleManifestRepository { return refetched; } - private manifestUrl(): string { + private feedUrl(fileName: string): string { return new URL( - `${this.packageOrigin}/desktop/${this.target.releaseChannel}/${this.target.platform}/${this.target.arch}/modules.json`, + `${this.packageOrigin}/desktop/${this.target.releaseChannel}/${this.target.platform}/${this.target.arch}/${fileName}`, ).toString(); } + private manifestUrl(): string { + return this.feedUrl('modules.json'); + } + + private shellFeedUrl(): string { + return this.feedUrl('latest.json'); + } + private async requestManifest(ifNoneMatch: string | null): Promise { const url = this.manifestUrl(); const headers: Record = {accept: 'application/json'}; @@ -204,7 +252,9 @@ export class ModuleManifestRepository { } const bytes = await this.readBoundedBody(response, MODULE_MANIFEST_MAX_BYTES, 'manifest'); parseModuleUpdateManifest(bytes, this.target); - return {bytes, etag: response.headers.get('etag'), fromCache: false}; + const document = {bytes, etag: response.headers.get('etag'), fromCache: false}; + this.validated = document.etag == null ? null : document; + return document; } private async readBoundedBody(response: Response, limit: number, description: string): Promise { diff --git a/fluxer_desktop/src/main/ModuleUpdater.test.mjs b/fluxer_desktop/src/main/ModuleUpdater.test.mjs index e2690fe0a..d87fbfdc3 100644 --- a/fluxer_desktop/src/main/ModuleUpdater.test.mjs +++ b/fluxer_desktop/src/main/ModuleUpdater.test.mjs @@ -3,7 +3,7 @@ import assert from 'node:assert/strict'; import {Buffer} from 'node:buffer'; import {createHash} from 'node:crypto'; -import {mkdirSync, mkdtempSync, writeFileSync} from 'node:fs'; +import {mkdirSync, mkdtempSync, rmSync, writeFileSync} from 'node:fs'; import {rm} from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; @@ -129,7 +129,14 @@ function createUpdater(store, modules, options = {}) { fetch: async (url, init) => { requests.push(url); const response = options.respond == null ? null : await options.respond(url, init); - return response ?? new Response(bytes, {status: 200, headers: {etag: '"manifest-1"'}}); + if (response != null) { + return response; + } + if (String(url).endsWith('/latest.json')) { + const version = options.shellFeed ?? options.shellLatest ?? SHELL_VERSION; + return new Response(JSON.stringify({version}), {status: 200, headers: {etag: `"feed-${version}"`}}); + } + return new Response(bytes, {status: 200, headers: {etag: '"manifest-1"'}}); }, sleep: async (ms) => { sleeps.push(ms); @@ -412,7 +419,7 @@ describe('ModuleUpdater.run', () => { assert.equal(store.getState().boot_attempt, 2); }); - test('a manifest older than the one already seen launches the verified set without retrying', async () => { + test('a manifest older than the one already seen launches on the last accepted manifest without retrying', async () => { const userDataPath = createUserData(); const store = await openStore(userDataPath); installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]); @@ -423,13 +430,30 @@ describe('ModuleUpdater.run', () => { const outcome = await updater.run(); - assert.equal(outcome.status, 'unreachable-launch'); + assert.equal(outcome.status, 'launching'); assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA}); assert.deepEqual(sleeps, []); - assert.deepEqual( - reports.map((report) => report.type), - ['manifest-rollback'], - ); + assert.deepEqual(reports, []); + assert.equal(store.getState().manifest_high_water[0].metadataVersion, 5); + }); + + test('an older manifest with no accepted copy to fall back on retries like an outage instead of blocking', async () => { + const userDataPath = createUserData(); + const storeRoot = getModuleStoreRoot(userDataPath); + const store = await openStore(userDataPath); + installModuleOnDisk(storeRoot, 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]); + await store.commit({fluxer_renderer: RENDERER_SHA}); + const seen = createUpdater(store, {fluxer_renderer: RENDERER_SHA}, {metadataVersion: 5}); + assert.equal((await seen.updater.run()).status, 'launching'); + rmSync(path.join(storeRoot, 'manifest.json')); + const {updater, sleeps, reports} = createUpdater(store, {fluxer_renderer: NEXT_RENDERER_SHA}, {metadataVersion: 4}); + + const outcome = await updater.run(); + + assert.equal(outcome.status, 'unreachable-launch'); + assert.deepEqual(outcome.committed, {fluxer_renderer: RENDERER_SHA}); + assert.ok(sleeps.length > 0, 'a stale edge copy clears within a minute, so the launch retries before giving up'); + assert.ok(reports.every((report) => report.type === 'manifest-rollback')); }); test('a package the server no longer has demotes an optional module to its committed digest', async () => { @@ -501,7 +525,7 @@ describe('ModuleUpdater checks without downloading and installs only when asked' assert.deepEqual(check, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true}); assert.deepEqual( requests.map((url) => String(url).split('/').pop()), - ['modules.json'], + ['modules.json', 'latest.json'], ); assert.deepEqual(installs, []); assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA}); @@ -521,6 +545,96 @@ describe('ModuleUpdater checks without downloading and installs only when asked' }); }); + test('a shell feed ahead of the manifest offers the newer shell', async () => { + const {store} = await storeOnRenderer(); + const {updater} = createUpdater(store, {fluxer_renderer: RENDERER_SHA}, {shellFeed: '2026.1012.154032'}); + + assert.deepEqual(await updater.checkForUpdate(), { + shellLatestVersion: '2026.1012.154032', + shellNewer: true, + modulesChanged: false, + }); + }); + + test('a shell feed behind the manifest never hides the shell the manifest names', async () => { + const {store} = await storeOnRenderer(); + const {updater} = createUpdater( + store, + {fluxer_renderer: RENDERER_SHA}, + {shellLatest: '2026.824.1', shellFeed: SHELL_VERSION}, + ); + + assert.deepEqual(await updater.checkForUpdate(), { + shellLatestVersion: '2026.824.1', + shellNewer: true, + modulesChanged: false, + }); + }); + + test('an unreachable shell feed still answers from the manifest', async () => { + const {store} = await storeOnRenderer(); + const {updater} = createUpdater( + store, + {fluxer_renderer: NEXT_RENDERER_SHA}, + {respond: async (url) => (String(url).endsWith('/latest.json') ? new Response('', {status: 503}) : null)}, + ); + + assert.deepEqual(await updater.checkForUpdate(), { + shellLatestVersion: SHELL_VERSION, + shellNewer: false, + modulesChanged: true, + }); + }); + + test('repeat checks revalidate both feeds and reuse them when nothing changed', async () => { + const {store} = await storeOnRenderer(); + const conditions = []; + const {updater} = createUpdater( + store, + {fluxer_renderer: NEXT_RENDERER_SHA}, + { + shellFeed: '2026.824.1', + respond: async (url, init) => { + const etag = init?.headers?.['if-none-match'] ?? null; + conditions.push([String(url).split('/').pop(), etag]); + return etag == null ? null : new Response(null, {status: 304}); + }, + }, + ); + const first = await updater.checkForUpdate(); + + const second = await updater.checkForUpdate(); + + assert.deepEqual(second, first); + assert.deepEqual(second, {shellLatestVersion: '2026.824.1', shellNewer: true, modulesChanged: true}); + assert.deepEqual(conditions.slice(-2), [ + ['modules.json', '"manifest-1"'], + ['latest.json', '"feed-2026.824.1"'], + ]); + }); + + test('an older manifest during a background check reads as nothing new instead of failing', async () => { + const {store} = await storeOnRenderer(); + let metadataVersion = 6; + const {updater} = createUpdater( + store, + {fluxer_renderer: NEXT_RENDERER_SHA}, + { + respond: async (url) => + String(url).endsWith('/modules.json') + ? new Response(manifestBytes({fluxer_renderer: NEXT_RENDERER_SHA}, {metadataVersion}), {status: 200}) + : null, + }, + ); + const fresh = await updater.checkForUpdate(); + metadataVersion = 5; + + const stale = await updater.checkForUpdate(); + + assert.deepEqual(stale, fresh); + assert.deepEqual(stale, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true}); + }); + test('a module that needs the next shell reads as a shell update, not as a module update', async () => { const {store} = await storeOnRenderer(); const {updater} = createUpdater( diff --git a/fluxer_desktop/src/main/ModuleUpdater.ts b/fluxer_desktop/src/main/ModuleUpdater.ts index c12e76d00..5cedc516a 100644 --- a/fluxer_desktop/src/main/ModuleUpdater.ts +++ b/fluxer_desktop/src/main/ModuleUpdater.ts @@ -324,6 +324,7 @@ export class ModuleUpdater { private startupUpdatePolicy: ModuleStartupUpdatePolicy; private manifestObservationTail: Promise = Promise.resolve(); private recordedManifestSha256: string | null = null; + private lastAccepted: {readonly document: ModuleManifestDocument; readonly metadataVersion: number} | null = null; public constructor(options: ModuleUpdaterOptions) { this.store = options.store; @@ -448,7 +449,7 @@ export class ModuleUpdater { try { const document = await this.manifestRepository.fetchLatest(); fetched = true; - const manifest = await this.observeManifest(document); + const manifest = await this.acceptManifest(document); const startupUpdateRequired = await this.startupUpdateRequired(); if (!startupUpdateRequired) { return await this.launchIfAtFloor(); @@ -502,7 +503,9 @@ export class ModuleUpdater { } const delayMs = backoff.fail(); const exhausted = - backoff.reachedCeiling || isRejectedManifestError(error) || (await this.onlyForcedByShellChange()); + backoff.reachedCeiling || + error instanceof ModuleManifestEquivocationError || + (await this.onlyForcedByShellChange()); if (exhausted || !fetched) { const decision = await this.planner.evaluateUnreachableLaunch( this.isStartupUpdateEnforced(), @@ -623,7 +626,7 @@ export class ModuleUpdater { } private async resolveManifestForEnsure(): Promise { - return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument()); + return this.acceptManifest(await this.manifestRepository.resolveMemoizedDocument()); } private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise { @@ -642,9 +645,19 @@ export class ModuleUpdater { } public async checkForUpdate(): Promise { - const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY); - const shellLatestVersion = manifest.shell.latestVersion.source; - const shellNewer = compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) > 0; + const [manifest, feedLatest] = await Promise.all([ + this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY), + this.manifestRepository.fetchShellLatestVersion().catch((error: unknown) => { + this.reportFailure(error); + return null; + }), + ]); + const shellLatest = + feedLatest != null && compareModuleVersions(feedLatest, manifest.shell.latestVersion) > 0 + ? feedLatest + : manifest.shell.latestVersion; + const shellLatestVersion = shellLatest.source; + const shellNewer = compareModuleVersions(shellLatest, this.shellVersion) > 0; if (this.planner.shellUpdateRequired(manifest)) { return {shellLatestVersion, shellNewer, modulesChanged: false}; } @@ -672,7 +685,25 @@ export class ModuleUpdater { private async fetchFreshManifest(mode: ModuleManifestObservationMode): Promise { this.manifestRepository.clearMemo(); - return await this.observeManifest(await this.manifestRepository.fetchLatest(), mode); + return await this.acceptManifest(await this.manifestRepository.fetchLatest(), mode); + } + + private async acceptManifest( + document: ModuleManifestDocument, + mode: ModuleManifestObservationMode = ModuleManifestObservationMode.RECORD_FETCH, + ): Promise { + try { + return await this.observeManifest(document, mode); + } catch (error) { + if (!(error instanceof ModuleManifestRollbackError)) { + throw error; + } + const fallback = this.lastAccepted?.document ?? (await this.manifestRepository.readCached()); + if (fallback == null || fallback === document) { + throw error; + } + return await this.observeManifest(fallback, mode); + } } private async collectPendingItems( @@ -923,6 +954,10 @@ export class ModuleUpdater { ): Promise { const observe = async (): Promise => { const manifest = this.manifestRepository.parse(document); + const accepted = this.lastAccepted; + if (accepted != null && manifest.metadataVersion < accepted.metadataVersion) { + throw new ModuleManifestRollbackError(this.manifestFeed, manifest.metadataVersion, accepted.metadataVersion); + } const observation: ModuleManifestFeedObservation = { feed: this.manifestFeed, metadataVersion: manifest.metadataVersion, @@ -937,6 +972,7 @@ export class ModuleUpdater { await this.persistManifest(document, manifest, observation); this.recordedManifestSha256 = observation.manifestSha256; } + this.lastAccepted = {document, metadataVersion: manifest.metadataVersion}; return manifest; }; const observed = this.manifestObservationTail.then(observe, observe); diff --git a/fluxer_desktop/src/main/Updater.ts b/fluxer_desktop/src/main/Updater.ts index ae6927569..4d3ab11de 100644 --- a/fluxer_desktop/src/main/Updater.ts +++ b/fluxer_desktop/src/main/Updater.ts @@ -148,7 +148,7 @@ function registerSelfUpdater(getMainWindow: () => BrowserWindow | null): void { return; } try { - await checkDesktopUpdateNow(); + await checkDesktopUpdateNow({manual: true}); send(getMainWindow(), {type: 'not-available', context}); } catch (error) { log.warn('Desktop update check failed', error); @@ -164,7 +164,7 @@ function registerManualUpdater( ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => { if (context === 'user') { try { - await checkDesktopUpdateNow(); + await checkDesktopUpdateNow({manual: true}); } catch (error) { log.warn('Desktop module update check failed', error); } diff --git a/fluxer_desktop/src/main/Window.ts b/fluxer_desktop/src/main/Window.ts index 928e0664e..a689e16b6 100644 --- a/fluxer_desktop/src/main/Window.ts +++ b/fluxer_desktop/src/main/Window.ts @@ -22,6 +22,7 @@ import {getDesktopDistributionPath} from '@electron/main/DesktopDistributionPath import {resolveDesktopLandingUrl} from '@electron/main/DesktopLastRoute'; import {DesktopRuntimeSecurity} from '@electron/main/DesktopRuntimeSecurity'; import {hasActiveDesktopTray, refreshDesktopTrayMenu} from '@electron/main/DesktopTray'; +import {wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll'; import {drainPendingDisplayMediaRequests, registerDisplayMediaRequestHandler} from '@electron/main/DisplayMedia'; import {isNativeGatewayAvailable} from '@electron/main/GatewaySocketNativeBoundary'; import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions'; @@ -856,6 +857,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow { }); mainWindow.on('restore', refreshDesktopTrayMenu); mainWindow.on('show', refreshDesktopTrayMenu); + mainWindow.on('show', wakeDesktopUpdatePoll); mainWindow.on('hide', refreshDesktopTrayMenu); mainWindow.on('close', (event) => { if (saveTimeout) clearTimeout(saveTimeout);