mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 04:55:42 +09:00
fix(desktop): fresh manual update check, 90 second polling (#3434)
This commit is contained in:
1 parent
4194525933
commit
2ffe8f3387
16 files changed
+663
-101
No files matched your search
@@ -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(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', () => {
|
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 mid = nextModulePollDelay(plan, () => 0.5);
|
||||||
const high = nextModulePollDelay(plan, () => 1);
|
const high = nextModulePollDelay(plan, () => 1);
|
||||||
assert.equal(mid, plan.intervalMs);
|
assert.equal(mid, plan.intervalMs);
|
||||||
assert.equal(low, plan.intervalMs * (1 - plan.jitterRatio));
|
assert.equal(low, Math.round(plan.intervalMs * (1 - plan.jitterRatio)));
|
||||||
assert.equal(high, plan.intervalMs * (1 + plan.jitterRatio));
|
assert.equal(high, Math.round(plan.intervalMs * (1 + plan.jitterRatio)));
|
||||||
assert.ok(low < mid && mid < high);
|
assert.ok(low < mid && mid < high);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// SPDX-License-Identifier: AGPL-3.0-or-later
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
||||||
|
|
||||||
export const MODULE_POLL_INTERVAL_MS = 10 * 60 * 1000;
|
export const MODULE_POLL_INTERVAL_MS = 90 * 1000;
|
||||||
const MODULE_POLL_JITTER_RATIO = 0.2;
|
const MODULE_POLL_JITTER_RATIO = 0.15;
|
||||||
|
|
||||||
export interface ModulePollPlan {
|
export interface ModulePollPlan {
|
||||||
readonly intervalMs: number;
|
readonly intervalMs: number;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {createRequire} from 'node:module';
|
|||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
import {BUILD_CHANNEL} from '@electron/common/BuildChannel';
|
||||||
import {WINDOWS_APP_USER_MODEL_ID, WINDOWS_TOAST_ACTIVATOR_CLSID} from '@electron/common/DesktopIdentity';
|
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 {
|
import {
|
||||||
hasOfflineRenderer,
|
hasOfflineRenderer,
|
||||||
ModuleSystemLaunchDecision,
|
ModuleSystemLaunchDecision,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
resolveModuleSystemLaunch,
|
resolveModuleSystemLaunch,
|
||||||
} from '@electron/common/ModuleSystem';
|
} from '@electron/common/ModuleSystem';
|
||||||
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
|
import {checkDesktopUpdateNow} from '@electron/main/DesktopUpdateGate';
|
||||||
|
import {armDesktopUpdatePoll, wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll';
|
||||||
import {DesktopUpdateRun, type DesktopUpdateTakeover} from '@electron/main/DesktopUpdateRun';
|
import {DesktopUpdateRun, type DesktopUpdateTakeover} from '@electron/main/DesktopUpdateRun';
|
||||||
import {armOpenUrlForwarding, observeRendererLaunchConfirmed} from '@electron/main/ModuleBootHandoff';
|
import {armOpenUrlForwarding, observeRendererLaunchConfirmed} from '@electron/main/ModuleBootHandoff';
|
||||||
import type {ModuleLaunchAttempt, ModuleStore as ModuleStoreInstance} from '@electron/main/ModuleStore';
|
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);
|
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<never> {
|
async function refuseUnsupportedBuild(reason: string): Promise<never> {
|
||||||
const {app} = await import('electron');
|
const {app} = await import('electron');
|
||||||
const {createChildLogger} = await import('@electron/common/Logger');
|
const {createChildLogger} = await import('@electron/common/Logger');
|
||||||
@@ -811,8 +767,8 @@ async function runModuleBootstrap(): Promise<void> {
|
|||||||
);
|
);
|
||||||
markSplashLaunching();
|
markSplashLaunching();
|
||||||
armDesktopUpdate({
|
armDesktopUpdate({
|
||||||
check: async () => {
|
check: async (manual) => {
|
||||||
const check = await desktopUpdate.check();
|
const check = await desktopUpdate.check({manual});
|
||||||
recordPendingUpdate(check);
|
recordPendingUpdate(check);
|
||||||
return check;
|
return check;
|
||||||
},
|
},
|
||||||
@@ -821,7 +777,15 @@ async function runModuleBootstrap(): Promise<void> {
|
|||||||
armMainWindowHandoff(updater, permit.launchAttempt, () => {
|
armMainWindowHandoff(updater, permit.launchAttempt, () => {
|
||||||
desktopUpdate.markLaunchSettled();
|
desktopUpdate.markLaunchSettled();
|
||||||
preloadUpdateSplash();
|
preloadUpdateSplash();
|
||||||
armModulePoll(resolveModulePollPlan(readDevModulePollInterval()), logger);
|
armDesktopUpdatePoll(
|
||||||
|
{
|
||||||
|
plan: resolveModulePollPlan(readDevModulePollInterval()),
|
||||||
|
check: () => checkDesktopUpdateNow(),
|
||||||
|
logger,
|
||||||
|
},
|
||||||
|
process.platform === 'linux',
|
||||||
|
);
|
||||||
|
observeRendererLaunchConfirmed(wakeDesktopUpdatePoll);
|
||||||
});
|
});
|
||||||
app.once('before-quit', () => {
|
app.once('before-quit', () => {
|
||||||
closeSplashWindow();
|
closeSplashWindow();
|
||||||
|
|||||||
@@ -195,12 +195,9 @@ describe('Bootstrap entry point', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('the module poll only checks, it never downloads or reloads underneath the user', () => {
|
test('the update poll only checks, it never downloads or reloads underneath the user', () => {
|
||||||
const poll = source.slice(
|
const poll = readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), 'DesktopUpdatePoll.ts'), 'utf8');
|
||||||
source.indexOf('function armModulePoll('),
|
assert.match(source, /check: \(\) => checkDesktopUpdateNow\(\),/);
|
||||||
source.indexOf('async function refuseUnsupportedBuild('),
|
|
||||||
);
|
|
||||||
assert.match(poll, /void checkDesktopUpdateNow\(\)/);
|
|
||||||
assert.doesNotMatch(
|
assert.doesNotMatch(
|
||||||
poll,
|
poll,
|
||||||
/installPending|reloadIgnoringCache|refreshModuleRoots|activateMergedForRendererReload/,
|
/installPending|reloadIgnoringCache|refreshModuleRoots|activateMergedForRendererReload/,
|
||||||
@@ -208,12 +205,9 @@ describe('Bootstrap entry point', () => {
|
|||||||
);
|
);
|
||||||
assert.doesNotMatch(source, /reloadIgnoringCache/);
|
assert.doesNotMatch(source, /reloadIgnoringCache/);
|
||||||
});
|
});
|
||||||
test('a module poll that went quiet says so when it comes back', () => {
|
|
||||||
assert.match(
|
test('a reload of the renderer checks for an update right away', () => {
|
||||||
source,
|
assert.match(source, /observeRendererLaunchConfirmed\(wakeDesktopUpdatePoll\);/);
|
||||||
/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 minimized login launch converges the modules without flashing the splash', () => {
|
test('a minimized login launch converges the modules without flashing the splash', () => {
|
||||||
|
|||||||
@@ -71,6 +71,50 @@ describe('DesktopUpdateGate', () => {
|
|||||||
stop();
|
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 () => {
|
test('a shell update that could not finish stays available for the next click', async () => {
|
||||||
armDesktopUpdate({
|
armDesktopUpdate({
|
||||||
check: async () => ({shellNewer: true, modulesChanged: false}),
|
check: async () => ({shellNewer: true, modulesChanged: false}),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export interface DesktopUpdateCheck {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface DesktopUpdateController {
|
interface DesktopUpdateController {
|
||||||
readonly check: () => Promise<DesktopUpdateCheck>;
|
readonly check: (manual: boolean) => Promise<DesktopUpdateCheck>;
|
||||||
readonly start: () => Promise<void>;
|
readonly start: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -21,6 +21,7 @@ interface DesktopUpdateGateState {
|
|||||||
controller: DesktopUpdateController | null;
|
controller: DesktopUpdateController | null;
|
||||||
lastCheck: DesktopUpdateCheck | null;
|
lastCheck: DesktopUpdateCheck | null;
|
||||||
running: Promise<void> | null;
|
running: Promise<void> | null;
|
||||||
|
checking: {readonly manual: boolean; readonly settled: Promise<void>} | null;
|
||||||
notifiedAvailable: boolean;
|
notifiedAvailable: boolean;
|
||||||
notifiedUpdating: boolean;
|
notifiedUpdating: boolean;
|
||||||
readonly listeners: Set<DesktopUpdateStateListener>;
|
readonly listeners: Set<DesktopUpdateStateListener>;
|
||||||
@@ -30,6 +31,7 @@ const gateState: DesktopUpdateGateState = {
|
|||||||
controller: null,
|
controller: null,
|
||||||
lastCheck: null,
|
lastCheck: null,
|
||||||
running: null,
|
running: null,
|
||||||
|
checking: null,
|
||||||
notifiedAvailable: false,
|
notifiedAvailable: false,
|
||||||
notifiedUpdating: false,
|
notifiedUpdating: false,
|
||||||
listeners: new Set(),
|
listeners: new Set(),
|
||||||
@@ -76,14 +78,37 @@ export function publishDesktopUpdateCheck(check: DesktopUpdateCheck): void {
|
|||||||
notify();
|
notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkDesktopUpdateNow(): Promise<DesktopUpdateState> {
|
export async function checkDesktopUpdateNow({
|
||||||
|
manual = false,
|
||||||
|
}: {
|
||||||
|
readonly manual?: boolean;
|
||||||
|
} = {}): Promise<DesktopUpdateState> {
|
||||||
|
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;
|
const controller = gateState.controller;
|
||||||
if (controller == null || gateState.running != null) {
|
if (controller == null || gateState.running != null) {
|
||||||
return getDesktopUpdateState();
|
return getDesktopUpdateState();
|
||||||
}
|
}
|
||||||
const check = await controller.check();
|
const settled = controller.check(manual).then((check) => {
|
||||||
if (gateState.running == null) {
|
if (gateState.running == null) {
|
||||||
publishDesktopUpdateCheck(check);
|
publishDesktopUpdateCheck(check);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const checking = {manual, settled};
|
||||||
|
gateState.checking = checking;
|
||||||
|
try {
|
||||||
|
await settled;
|
||||||
|
} finally {
|
||||||
|
if (gateState.checking === checking) {
|
||||||
|
gateState.checking = null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return getDesktopUpdateState();
|
return getDesktopUpdateState();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<DesktopUpdateState>;
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -41,7 +41,7 @@ async function runUpdateCheck(): Promise<void> {
|
|||||||
for (;;) {
|
for (;;) {
|
||||||
let manualVersion: string | null = null;
|
let manualVersion: string | null = null;
|
||||||
try {
|
try {
|
||||||
const state = await checkDesktopUpdateNow();
|
const state = await checkDesktopUpdateNow({manual: true});
|
||||||
if (state.updating === true) {
|
if (state.updating === true) {
|
||||||
logger.info('A desktop update is already running, leaving it to finish');
|
logger.info('A desktop update is already running, leaving it to finish');
|
||||||
return;
|
return;
|
||||||
@@ -129,7 +129,7 @@ export function startDesktopUpdateFromShell(): void {
|
|||||||
|
|
||||||
export async function updateFromCommandLine(): Promise<void> {
|
export async function updateFromCommandLine(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const state = await checkDesktopUpdateNow();
|
const state = await checkDesktopUpdateNow({manual: true});
|
||||||
if (!state.available) {
|
if (!state.available) {
|
||||||
logger.info('The command line asked for an update and none is waiting');
|
logger.info('The command line asked for an update and none is waiting');
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function harness({
|
|||||||
shellResults = [],
|
shellResults = [],
|
||||||
installFails = false,
|
installFails = false,
|
||||||
settled = true,
|
settled = true,
|
||||||
|
now = () => 0,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
const steps = [];
|
const steps = [];
|
||||||
const published = [];
|
const published = [];
|
||||||
@@ -70,6 +71,7 @@ function harness({
|
|||||||
failures.push(failure);
|
failures.push(failure);
|
||||||
},
|
},
|
||||||
logger: {info: quiet, warn: quiet, error: quiet},
|
logger: {info: quiet, warn: quiet, error: quiet},
|
||||||
|
now,
|
||||||
});
|
});
|
||||||
if (settled) run.markLaunchSettled();
|
if (settled) run.markLaunchSettled();
|
||||||
return {run, steps, published, failures};
|
return {run, steps, published, failures};
|
||||||
@@ -144,6 +146,36 @@ describe('DesktopUpdateRun', () => {
|
|||||||
assert.deepEqual(steps, ['probe', 'takeover', 'close', 'shell-update', 'reopen', 'probe', 'probe']);
|
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 () => {
|
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 shell = {shellLatestVersion: '2026.1008.2', shellNewer: true, modulesChanged: false};
|
||||||
const {run, steps, published} = harness({
|
const {run, steps, published} = harness({
|
||||||
|
|||||||
@@ -43,6 +43,8 @@ interface DesktopUpdateRunOptions {
|
|||||||
readonly now?: () => number;
|
readonly now?: () => number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const SHELL_FEED_BEHIND_RETRY_MS = 10 * 60_000;
|
||||||
|
|
||||||
interface FilteredProbe {
|
interface FilteredProbe {
|
||||||
readonly check: DesktopUpdateCheck;
|
readonly check: DesktopUpdateCheck;
|
||||||
readonly shellLatestVersion: string;
|
readonly shellLatestVersion: string;
|
||||||
@@ -52,7 +54,7 @@ export class DesktopUpdateRun {
|
|||||||
private readonly options: DesktopUpdateRunOptions;
|
private readonly options: DesktopUpdateRunOptions;
|
||||||
private readonly now: () => number;
|
private readonly now: () => number;
|
||||||
private launchSettled = false;
|
private launchSettled = false;
|
||||||
private shellFeedBehindVersion: string | null = null;
|
private shellFeedBehind: {readonly version: string; readonly at: number} | null = null;
|
||||||
private shellInstallFailed = false;
|
private shellInstallFailed = false;
|
||||||
private lastShellFailure: ShellSelfUpdateResult | null = null;
|
private lastShellFailure: ShellSelfUpdateResult | null = null;
|
||||||
private lastCheck: DesktopUpdateCheck | null = null;
|
private lastCheck: DesktopUpdateCheck | null = null;
|
||||||
@@ -66,7 +68,10 @@ export class DesktopUpdateRun {
|
|||||||
this.launchSettled = true;
|
this.launchSettled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async check(): Promise<DesktopUpdateCheck> {
|
public async check({manual = false}: {readonly manual?: boolean} = {}): Promise<DesktopUpdateCheck> {
|
||||||
|
if (manual) {
|
||||||
|
this.shellFeedBehind = null;
|
||||||
|
}
|
||||||
return (await this.probe()).check;
|
return (await this.probe()).check;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,7 +193,7 @@ export class DesktopUpdateRun {
|
|||||||
logger.warn('The shell self update did not restart the app', result);
|
logger.warn('The shell self update did not restart the app', result);
|
||||||
this.lastShellFailure = result.reason === 'no-update' ? null : result;
|
this.lastShellFailure = result.reason === 'no-update' ? null : result;
|
||||||
if (result.reason === 'no-update') {
|
if (result.reason === 'no-update') {
|
||||||
this.shellFeedBehindVersion = probe.shellLatestVersion;
|
this.shellFeedBehind = {version: probe.shellLatestVersion, at: this.now()};
|
||||||
return {shellNewer: false, modulesChanged: probe.check.modulesChanged};
|
return {shellNewer: false, modulesChanged: probe.check.modulesChanged};
|
||||||
}
|
}
|
||||||
if (result.reason === 'install-failed') {
|
if (result.reason === 'install-failed') {
|
||||||
@@ -201,13 +206,18 @@ export class DesktopUpdateRun {
|
|||||||
const probe = await this.options.probe();
|
const probe = await this.options.probe();
|
||||||
const check = {
|
const check = {
|
||||||
shellNewer:
|
shellNewer:
|
||||||
this.options.canSelfUpdateShell && probe.shellNewer && probe.shellLatestVersion !== this.shellFeedBehindVersion,
|
this.options.canSelfUpdateShell && probe.shellNewer && !this.isShellFeedBehind(probe.shellLatestVersion),
|
||||||
modulesChanged: probe.modulesChanged,
|
modulesChanged: probe.modulesChanged,
|
||||||
};
|
};
|
||||||
this.lastCheck = check;
|
this.lastCheck = check;
|
||||||
return {check, shellLatestVersion: probe.shellLatestVersion};
|
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 {
|
private publish(check: DesktopUpdateCheck): void {
|
||||||
this.lastCheck = check;
|
this.lastCheck = check;
|
||||||
this.options.publish(check);
|
this.options.publish(check);
|
||||||
|
|||||||
@@ -10,11 +10,13 @@ import {
|
|||||||
parseModuleUpdateManifest,
|
parseModuleUpdateManifest,
|
||||||
} from '@electron/main/ModuleManifest';
|
} from '@electron/main/ModuleManifest';
|
||||||
import type {ModuleStore} from '@electron/main/ModuleStore';
|
import type {ModuleStore} from '@electron/main/ModuleStore';
|
||||||
|
import {type ModuleVersion, parseModuleVersion} from '@electron/main/ModuleVersion';
|
||||||
|
|
||||||
const CHANNEL_MANIFEST_CACHE_FILE_NAME = 'manifest.json';
|
const CHANNEL_MANIFEST_CACHE_FILE_NAME = 'manifest.json';
|
||||||
const MODULE_MANIFEST_TIMEOUT_MS = 15000;
|
const MODULE_MANIFEST_TIMEOUT_MS = 15000;
|
||||||
const MODULE_MANIFEST_MEMO_TTL_MS = 60000;
|
const MODULE_MANIFEST_MEMO_TTL_MS = 60000;
|
||||||
const MODULE_MANIFEST_MAX_BYTES = 1024 * 1024;
|
const MODULE_MANIFEST_MAX_BYTES = 1024 * 1024;
|
||||||
|
const SHELL_FEED_MAX_BYTES = 64 * 1024;
|
||||||
const MODULE_CACHE_FILE_MODE = 0o644;
|
const MODULE_CACHE_FILE_MODE = 0o644;
|
||||||
|
|
||||||
export interface ModuleManifestDocument {
|
export interface ModuleManifestDocument {
|
||||||
@@ -93,6 +95,8 @@ export class ModuleManifestRepository {
|
|||||||
private memo: {readonly expiresAt: number; readonly document: ModuleManifestDocument} | null = null;
|
private memo: {readonly expiresAt: number; readonly document: ModuleManifestDocument} | null = null;
|
||||||
private inFlight: Promise<ModuleManifestDocument> | null = null;
|
private inFlight: Promise<ModuleManifestDocument> | null = null;
|
||||||
private memoEpoch = 0;
|
private memoEpoch = 0;
|
||||||
|
private validated: ModuleManifestDocument | null = null;
|
||||||
|
private shellFeed: {readonly etag: string; readonly version: ModuleVersion} | null = null;
|
||||||
|
|
||||||
public constructor(options: ModuleManifestRepositoryOptions) {
|
public constructor(options: ModuleManifestRepositoryOptions) {
|
||||||
this.store = options.store;
|
this.store = options.store;
|
||||||
@@ -158,9 +162,45 @@ export class ModuleManifestRepository {
|
|||||||
await writeFileAtomically(this.manifestCachePath, document.bytes, MODULE_CACHE_FILE_MODE);
|
await writeFileAtomically(this.manifestCachePath, document.bytes, MODULE_CACHE_FILE_MODE);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async fetchShellLatestVersion(): Promise<ModuleVersion> {
|
||||||
|
const url = this.shellFeedUrl();
|
||||||
|
const known = this.shellFeed;
|
||||||
|
const headers: Record<string, string> = {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<ModuleManifestDocument> {
|
private async readLatestDocument(): Promise<ModuleManifestDocument> {
|
||||||
const etag = this.store.getState().last_manifest_etag;
|
const validated = this.validated;
|
||||||
const first = await this.requestManifest(etag);
|
if (validated != null) {
|
||||||
|
return (await this.requestManifest(validated.etag)) ?? validated;
|
||||||
|
}
|
||||||
|
const first = await this.requestManifest(this.store.getState().last_manifest_etag);
|
||||||
if (first != null) {
|
if (first != null) {
|
||||||
return first;
|
return first;
|
||||||
}
|
}
|
||||||
@@ -175,12 +215,20 @@ export class ModuleManifestRepository {
|
|||||||
return refetched;
|
return refetched;
|
||||||
}
|
}
|
||||||
|
|
||||||
private manifestUrl(): string {
|
private feedUrl(fileName: string): string {
|
||||||
return new URL(
|
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();
|
).toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private manifestUrl(): string {
|
||||||
|
return this.feedUrl('modules.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
private shellFeedUrl(): string {
|
||||||
|
return this.feedUrl('latest.json');
|
||||||
|
}
|
||||||
|
|
||||||
private async requestManifest(ifNoneMatch: string | null): Promise<ModuleManifestDocument | null> {
|
private async requestManifest(ifNoneMatch: string | null): Promise<ModuleManifestDocument | null> {
|
||||||
const url = this.manifestUrl();
|
const url = this.manifestUrl();
|
||||||
const headers: Record<string, string> = {accept: 'application/json'};
|
const headers: Record<string, string> = {accept: 'application/json'};
|
||||||
@@ -204,7 +252,9 @@ export class ModuleManifestRepository {
|
|||||||
}
|
}
|
||||||
const bytes = await this.readBoundedBody(response, MODULE_MANIFEST_MAX_BYTES, 'manifest');
|
const bytes = await this.readBoundedBody(response, MODULE_MANIFEST_MAX_BYTES, 'manifest');
|
||||||
parseModuleUpdateManifest(bytes, this.target);
|
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<Buffer> {
|
private async readBoundedBody(response: Response, limit: number, description: string): Promise<Buffer> {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import {Buffer} from 'node:buffer';
|
import {Buffer} from 'node:buffer';
|
||||||
import {createHash} from 'node:crypto';
|
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 {rm} from 'node:fs/promises';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
@@ -129,7 +129,14 @@ function createUpdater(store, modules, options = {}) {
|
|||||||
fetch: async (url, init) => {
|
fetch: async (url, init) => {
|
||||||
requests.push(url);
|
requests.push(url);
|
||||||
const response = options.respond == null ? null : await options.respond(url, init);
|
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) => {
|
sleep: async (ms) => {
|
||||||
sleeps.push(ms);
|
sleeps.push(ms);
|
||||||
@@ -412,7 +419,7 @@ describe('ModuleUpdater.run', () => {
|
|||||||
assert.equal(store.getState().boot_attempt, 2);
|
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 userDataPath = createUserData();
|
||||||
const store = await openStore(userDataPath);
|
const store = await openStore(userDataPath);
|
||||||
installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
|
installModuleOnDisk(getModuleStoreRoot(userDataPath), 'fluxer_renderer', RENDERER_SHA, [['index.js', 'renderer']]);
|
||||||
@@ -423,13 +430,30 @@ describe('ModuleUpdater.run', () => {
|
|||||||
|
|
||||||
const outcome = await updater.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(outcome.committed, {fluxer_renderer: RENDERER_SHA});
|
||||||
assert.deepEqual(sleeps, []);
|
assert.deepEqual(sleeps, []);
|
||||||
assert.deepEqual(
|
assert.deepEqual(reports, []);
|
||||||
reports.map((report) => report.type),
|
assert.equal(store.getState().manifest_high_water[0].metadataVersion, 5);
|
||||||
['manifest-rollback'],
|
});
|
||||||
);
|
|
||||||
|
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 () => {
|
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(check, {shellLatestVersion: SHELL_VERSION, shellNewer: false, modulesChanged: true});
|
||||||
assert.deepEqual(
|
assert.deepEqual(
|
||||||
requests.map((url) => String(url).split('/').pop()),
|
requests.map((url) => String(url).split('/').pop()),
|
||||||
['modules.json'],
|
['modules.json', 'latest.json'],
|
||||||
);
|
);
|
||||||
assert.deepEqual(installs, []);
|
assert.deepEqual(installs, []);
|
||||||
assert.deepEqual(store.getCommitted(), {fluxer_renderer: RENDERER_SHA});
|
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 () => {
|
test('a module that needs the next shell reads as a shell update, not as a module update', async () => {
|
||||||
const {store} = await storeOnRenderer();
|
const {store} = await storeOnRenderer();
|
||||||
const {updater} = createUpdater(
|
const {updater} = createUpdater(
|
||||||
|
|||||||
@@ -324,6 +324,7 @@ export class ModuleUpdater {
|
|||||||
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
|
||||||
private manifestObservationTail: Promise<void> = Promise.resolve();
|
private manifestObservationTail: Promise<void> = Promise.resolve();
|
||||||
private recordedManifestSha256: string | null = null;
|
private recordedManifestSha256: string | null = null;
|
||||||
|
private lastAccepted: {readonly document: ModuleManifestDocument; readonly metadataVersion: number} | null = null;
|
||||||
|
|
||||||
public constructor(options: ModuleUpdaterOptions) {
|
public constructor(options: ModuleUpdaterOptions) {
|
||||||
this.store = options.store;
|
this.store = options.store;
|
||||||
@@ -448,7 +449,7 @@ export class ModuleUpdater {
|
|||||||
try {
|
try {
|
||||||
const document = await this.manifestRepository.fetchLatest();
|
const document = await this.manifestRepository.fetchLatest();
|
||||||
fetched = true;
|
fetched = true;
|
||||||
const manifest = await this.observeManifest(document);
|
const manifest = await this.acceptManifest(document);
|
||||||
const startupUpdateRequired = await this.startupUpdateRequired();
|
const startupUpdateRequired = await this.startupUpdateRequired();
|
||||||
if (!startupUpdateRequired) {
|
if (!startupUpdateRequired) {
|
||||||
return await this.launchIfAtFloor();
|
return await this.launchIfAtFloor();
|
||||||
@@ -502,7 +503,9 @@ export class ModuleUpdater {
|
|||||||
}
|
}
|
||||||
const delayMs = backoff.fail();
|
const delayMs = backoff.fail();
|
||||||
const exhausted =
|
const exhausted =
|
||||||
backoff.reachedCeiling || isRejectedManifestError(error) || (await this.onlyForcedByShellChange());
|
backoff.reachedCeiling ||
|
||||||
|
error instanceof ModuleManifestEquivocationError ||
|
||||||
|
(await this.onlyForcedByShellChange());
|
||||||
if (exhausted || !fetched) {
|
if (exhausted || !fetched) {
|
||||||
const decision = await this.planner.evaluateUnreachableLaunch(
|
const decision = await this.planner.evaluateUnreachableLaunch(
|
||||||
this.isStartupUpdateEnforced(),
|
this.isStartupUpdateEnforced(),
|
||||||
@@ -623,7 +626,7 @@ export class ModuleUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
|
private async resolveManifestForEnsure(): Promise<DesktopModuleUpdateManifest> {
|
||||||
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
|
return this.acceptManifest(await this.manifestRepository.resolveMemoizedDocument());
|
||||||
}
|
}
|
||||||
|
|
||||||
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
|
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
|
||||||
@@ -642,9 +645,19 @@ export class ModuleUpdater {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
|
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
|
||||||
const manifest = await this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY);
|
const [manifest, feedLatest] = await Promise.all([
|
||||||
const shellLatestVersion = manifest.shell.latestVersion.source;
|
this.fetchFreshManifest(ModuleManifestObservationMode.READ_ONLY),
|
||||||
const shellNewer = compareModuleVersions(manifest.shell.latestVersion, this.shellVersion) > 0;
|
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)) {
|
if (this.planner.shellUpdateRequired(manifest)) {
|
||||||
return {shellLatestVersion, shellNewer, modulesChanged: false};
|
return {shellLatestVersion, shellNewer, modulesChanged: false};
|
||||||
}
|
}
|
||||||
@@ -672,7 +685,25 @@ export class ModuleUpdater {
|
|||||||
|
|
||||||
private async fetchFreshManifest(mode: ModuleManifestObservationMode): Promise<DesktopModuleUpdateManifest> {
|
private async fetchFreshManifest(mode: ModuleManifestObservationMode): Promise<DesktopModuleUpdateManifest> {
|
||||||
this.manifestRepository.clearMemo();
|
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<DesktopModuleUpdateManifest> {
|
||||||
|
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(
|
private async collectPendingItems(
|
||||||
@@ -923,6 +954,10 @@ export class ModuleUpdater {
|
|||||||
): Promise<DesktopModuleUpdateManifest> {
|
): Promise<DesktopModuleUpdateManifest> {
|
||||||
const observe = async (): Promise<DesktopModuleUpdateManifest> => {
|
const observe = async (): Promise<DesktopModuleUpdateManifest> => {
|
||||||
const manifest = this.manifestRepository.parse(document);
|
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 = {
|
const observation: ModuleManifestFeedObservation = {
|
||||||
feed: this.manifestFeed,
|
feed: this.manifestFeed,
|
||||||
metadataVersion: manifest.metadataVersion,
|
metadataVersion: manifest.metadataVersion,
|
||||||
@@ -937,6 +972,7 @@ export class ModuleUpdater {
|
|||||||
await this.persistManifest(document, manifest, observation);
|
await this.persistManifest(document, manifest, observation);
|
||||||
this.recordedManifestSha256 = observation.manifestSha256;
|
this.recordedManifestSha256 = observation.manifestSha256;
|
||||||
}
|
}
|
||||||
|
this.lastAccepted = {document, metadataVersion: manifest.metadataVersion};
|
||||||
return manifest;
|
return manifest;
|
||||||
};
|
};
|
||||||
const observed = this.manifestObservationTail.then(observe, observe);
|
const observed = this.manifestObservationTail.then(observe, observe);
|
||||||
|
|||||||
@@ -148,7 +148,7 @@ function registerSelfUpdater(getMainWindow: () => BrowserWindow | null): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await checkDesktopUpdateNow();
|
await checkDesktopUpdateNow({manual: true});
|
||||||
send(getMainWindow(), {type: 'not-available', context});
|
send(getMainWindow(), {type: 'not-available', context});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Desktop update check failed', error);
|
log.warn('Desktop update check failed', error);
|
||||||
@@ -164,7 +164,7 @@ function registerManualUpdater(
|
|||||||
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
|
ipcMain.handle('updater-check', async (_e, context: UpdaterContext) => {
|
||||||
if (context === 'user') {
|
if (context === 'user') {
|
||||||
try {
|
try {
|
||||||
await checkDesktopUpdateNow();
|
await checkDesktopUpdateNow({manual: true});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.warn('Desktop module update check failed', error);
|
log.warn('Desktop module update check failed', error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {getDesktopDistributionPath} from '@electron/main/DesktopDistributionPath
|
|||||||
import {resolveDesktopLandingUrl} from '@electron/main/DesktopLastRoute';
|
import {resolveDesktopLandingUrl} from '@electron/main/DesktopLastRoute';
|
||||||
import {DesktopRuntimeSecurity} from '@electron/main/DesktopRuntimeSecurity';
|
import {DesktopRuntimeSecurity} from '@electron/main/DesktopRuntimeSecurity';
|
||||||
import {hasActiveDesktopTray, refreshDesktopTrayMenu} from '@electron/main/DesktopTray';
|
import {hasActiveDesktopTray, refreshDesktopTrayMenu} from '@electron/main/DesktopTray';
|
||||||
|
import {wakeDesktopUpdatePoll} from '@electron/main/DesktopUpdatePoll';
|
||||||
import {drainPendingDisplayMediaRequests, registerDisplayMediaRequestHandler} from '@electron/main/DisplayMedia';
|
import {drainPendingDisplayMediaRequests, registerDisplayMediaRequestHandler} from '@electron/main/DisplayMedia';
|
||||||
import {isNativeGatewayAvailable} from '@electron/main/GatewaySocketNativeBoundary';
|
import {isNativeGatewayAvailable} from '@electron/main/GatewaySocketNativeBoundary';
|
||||||
import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions';
|
import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions';
|
||||||
@@ -856,6 +857,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
|
|||||||
});
|
});
|
||||||
mainWindow.on('restore', refreshDesktopTrayMenu);
|
mainWindow.on('restore', refreshDesktopTrayMenu);
|
||||||
mainWindow.on('show', refreshDesktopTrayMenu);
|
mainWindow.on('show', refreshDesktopTrayMenu);
|
||||||
|
mainWindow.on('show', wakeDesktopUpdatePoll);
|
||||||
mainWindow.on('hide', refreshDesktopTrayMenu);
|
mainWindow.on('hide', refreshDesktopTrayMenu);
|
||||||
mainWindow.on('close', (event) => {
|
mainWindow.on('close', (event) => {
|
||||||
if (saveTimeout) clearTimeout(saveTimeout);
|
if (saveTimeout) clearTimeout(saveTimeout);
|
||||||
|
|||||||
Reference in new issue
Block a user