Compare commits

...
18 changed files with 667 additions and 105 deletions

No files matched your search

Generated
+3 -3
View File
@@ -49,9 +49,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.2.0"
version = "4.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f7ed3ac4252be4d3e36fb589ed5659356842587b21a31596a9dc89d3b2f5e2a"
checksum = "061e83b03c2681c18a6787d956e355c74e0b98ba7ba3d69b0822ade1e6f1d716"
dependencies = [
"cssparser 0.38.0",
"html5ever 0.40.1",
@@ -1266,7 +1266,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d045de693cb712d0b22c6a64be5b953f67b3ce00ab5ad3dd5d8b441886ab8e1a"
dependencies = [
"quote",
"syn 3.0.6",
"syn 2.0.119",
]
[[package]]
@@ -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);
});
+2 -2
View File
@@ -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;
+13 -49
View File
@@ -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<never> {
const {app} = await import('electron');
const {createChildLogger} = await import('@electron/common/Logger');
@@ -811,8 +767,8 @@ async function runModuleBootstrap(): Promise<void> {
);
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<void> {
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();
@@ -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', () => {
@@ -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}),
+30 -5
View File
@@ -11,7 +11,7 @@ export interface DesktopUpdateCheck {
}
interface DesktopUpdateController {
readonly check: () => Promise<DesktopUpdateCheck>;
readonly check: (manual: boolean) => Promise<DesktopUpdateCheck>;
readonly start: () => Promise<void>;
}
@@ -21,6 +21,7 @@ interface DesktopUpdateGateState {
controller: DesktopUpdateController | null;
lastCheck: DesktopUpdateCheck | null;
running: Promise<void> | null;
checking: {readonly manual: boolean; readonly settled: Promise<void>} | null;
notifiedAvailable: boolean;
notifiedUpdating: boolean;
readonly listeners: Set<DesktopUpdateStateListener>;
@@ -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<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;
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();
}
@@ -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 (;;) {
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<void> {
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;
@@ -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({
+14 -4
View File
@@ -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<DesktopUpdateCheck> {
public async check({manual = false}: {readonly manual?: boolean} = {}): Promise<DesktopUpdateCheck> {
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);
@@ -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<ModuleManifestDocument> | 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<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> {
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<ModuleManifestDocument | null> {
const url = this.manifestUrl();
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');
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> {
+123 -9
View File
@@ -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(
+43 -7
View File
@@ -324,6 +324,7 @@ export class ModuleUpdater {
private startupUpdatePolicy: ModuleStartupUpdatePolicy;
private manifestObservationTail: Promise<void> = 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<DesktopModuleUpdateManifest> {
return this.observeManifest(await this.manifestRepository.resolveMemoizedDocument());
return this.acceptManifest(await this.manifestRepository.resolveMemoizedDocument());
}
private async updateShellFirst(manifest: DesktopModuleUpdateManifest): Promise<boolean> {
@@ -642,9 +645,19 @@ export class ModuleUpdater {
}
public async checkForUpdate(): Promise<DesktopUpdateProbe> {
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<DesktopModuleUpdateManifest> {
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(
@@ -923,6 +954,10 @@ export class ModuleUpdater {
): Promise<DesktopModuleUpdateManifest> {
const observe = async (): Promise<DesktopModuleUpdateManifest> => {
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);
+2 -2
View File
@@ -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);
}
+2
View File
@@ -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);
+1 -1
View File
@@ -7,7 +7,7 @@ publish = false
[dependencies]
fluxer_common = { path = "../fluxer_common" }
ammonia = "4.2.0"
ammonia = "4.2.3"
anyhow = "1.0.104"
chrono = { version = "0.4.45", default-features = false, features = ["std"] }
encoding_rs = "0.8.41"