fix(desktop): back off failed app loads and offer a retry (#2980)

This commit is contained in:
Hampus authored and GitHub committed 2026-09-27 16:18:01 +02:00
1 parent 6a6119ed1e
commit 677ef8491e
41 files changed
+3012 -2367

No files matched your search

@@ -0,0 +1,177 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {createRequire} from 'node:module';
import {describe, test} from 'node:test';
import {fileURLToPath} from 'node:url';
import vm from 'node:vm';
const require = createRequire(import.meta.url);
const esbuild = require('esbuild');
const sourcePath = fileURLToPath(new URL('./AppLoadRetry.ts', import.meta.url));
const source = readFileSync(sourcePath, 'utf8');
const transformedSource = esbuild.transformSync(source, {
loader: 'ts',
format: 'cjs',
platform: 'node',
target: 'node20',
}).code;
function loadAppLoadRetry() {
const module = {exports: {}};
const context = vm.createContext({
module,
exports: module.exports,
});
vm.runInContext(transformedSource, context, {filename: sourcePath});
return module.exports;
}
const APP_URL = 'https://web.canary.fluxer.app/';
const MIGRATED_URL = 'https://canary.fluxer.com/';
const silentLogger = {info() {}, warn() {}, error() {}};
function createHarness({appUrl = APP_URL, fallbackUrl = null} = {}) {
const listeners = new Map();
const timers = [];
const loads = [];
const prompts = [];
let commits = 0;
let outcome = 'dns-failure';
const webContents = {
on(event, listener) {
listeners.set(event, [...(listeners.get(event) ?? []), listener]);
},
emit(event, ...args) {
for (const listener of listeners.get(event) ?? []) listener({}, ...args);
},
isDestroyed: () => false,
isLoadingMainFrame: () => false,
loadURL(url) {
loads.push(url);
if (outcome === 'ok') {
webContents.emit('did-navigate', url, 200, 'OK');
webContents.emit('did-finish-load');
return Promise.resolve();
}
if (outcome === 'aborted') {
return Promise.reject(new Error(`ERR_ABORTED (-3) loading '${url}'`));
}
webContents.emit('did-fail-load', -105, 'ERR_NAME_NOT_RESOLVED', url, true);
webContents.emit('did-finish-load');
return Promise.reject(new Error(`ERR_NAME_NOT_RESOLVED (-105) loading '${url}'`));
},
};
const {createAppLoadRetry} = loadAppLoadRetry();
const retry = createAppLoadRetry({
webContents,
appUrl,
logger: silentLogger,
isTrustedUrl: () => true,
getFallbackUrl: (url) => (url === MIGRATED_URL ? fallbackUrl : null),
onRepeatedFailure: (failure) => prompts.push(failure),
onCommitted: () => {
commits += 1;
},
setTimer: (callback, delay) => {
const timer = {callback, delay, cleared: false};
timers.push(timer);
return timer;
},
clearTimer: (timer) => {
timer.cleared = true;
},
});
const settle = () => new Promise((resolve) => setImmediate(resolve));
return {
retry,
loads,
prompts,
get commits() {
return commits;
},
setOutcome(next) {
outcome = next;
},
pendingDelays: () => timers.filter((timer) => !timer.cleared && !timer.fired).map((timer) => timer.delay),
async fireNextTimer() {
const timer = timers.find((candidate) => !candidate.cleared && !candidate.fired);
assert.ok(timer, 'expected a pending retry timer');
timer.fired = true;
timer.callback();
await settle();
return timer.delay;
},
settle,
};
}
describe('AppLoadRetry', () => {
test('backs off on a DNS failure even though the error page fires did-finish-load', async () => {
const harness = createHarness();
harness.retry.start();
await harness.settle();
const delays = [];
for (let i = 0; i < 8; i += 1) {
assert.equal(harness.pendingDelays().length, 1);
delays.push(await harness.fireNextTimer());
}
assert.deepEqual(delays, [1000, 2000, 4000, 8000, 16000, 30000, 30000, 30000]);
assert.equal(harness.loads.length, 9);
});
test('resets the backoff and dismisses the prompt once the app commits', async () => {
const harness = createHarness();
harness.retry.start();
await harness.settle();
await harness.fireNextTimer();
await harness.fireNextTimer();
assert.equal(harness.prompts.length, 1);
harness.setOutcome('ok');
await harness.fireNextTimer();
assert.equal(harness.commits, 1);
assert.deepEqual(harness.pendingDelays(), []);
harness.setOutcome('dns-failure');
harness.retry.retryNow();
await harness.settle();
assert.deepEqual(harness.pendingDelays(), [1000]);
});
test('asks the user only after three consecutive failures', async () => {
const harness = createHarness();
harness.retry.start();
await harness.settle();
await harness.fireNextTimer();
assert.equal(harness.prompts.length, 0);
await harness.fireNextTimer();
assert.equal(harness.prompts.length, 1);
assert.equal(harness.prompts[0].errorCode, -105);
assert.equal(harness.prompts[0].url, APP_URL);
});
test('does not schedule a retry for an aborted load', async () => {
const harness = createHarness();
harness.setOutcome('aborted');
harness.retry.start();
await harness.settle();
assert.deepEqual(harness.pendingDelays(), []);
});
test('falls back from the migrated origin without backing off', async () => {
const harness = createHarness({appUrl: MIGRATED_URL, fallbackUrl: APP_URL});
harness.retry.start();
await harness.settle();
assert.deepEqual(harness.loads.slice(0, 2), [MIGRATED_URL, APP_URL]);
assert.equal(harness.retry.getAppUrl(), APP_URL);
assert.deepEqual(harness.pendingDelays(), [1000]);
});
});
+137
View File
@@ -0,0 +1,137 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
const INITIAL_RETRY_DELAY_MS = 1000;
const MAX_RETRY_DELAY_MS = 30000;
const FAILURES_BEFORE_PROMPT = 3;
const ERR_ABORTED = -3;
export interface AppLoadFailure {
errorCode: number;
errorDescription: string;
url: string;
}
interface AppLoadRetryLogger {
info(message: string, detail?: Record<string, unknown>): void;
warn(message: string, detail?: Record<string, unknown>): void;
error(message: string, detail?: Record<string, unknown>): void;
}
interface AppLoadRetryOptions {
webContents: Pick<Electron.WebContents, 'on' | 'isDestroyed' | 'isLoadingMainFrame' | 'loadURL'>;
appUrl: string;
logger: AppLoadRetryLogger;
isTrustedUrl: (url: string) => boolean;
getFallbackUrl: (failedUrl: string) => string | null;
onRepeatedFailure: (failure: AppLoadFailure) => void;
onCommitted: () => void;
setTimer?: (callback: () => void, delayMs: number) => ReturnType<typeof setTimeout>;
clearTimer?: (timer: ReturnType<typeof setTimeout>) => void;
}
export interface AppLoadRetry {
start(): void;
retryNow(): void;
getAppUrl(): string;
}
function isRetryableLoadError(errorCode: number): boolean {
return errorCode < 0 && errorCode !== ERR_ABORTED;
}
function getLoadErrorCode(error: unknown): number | null {
const message = error instanceof Error ? error.message : String(error);
const match = /\(([-\d]+)\)/.exec(message);
if (!match) return null;
const value = Number.parseInt(match[1], 10);
return Number.isFinite(value) ? value : null;
}
export function createAppLoadRetry(options: AppLoadRetryOptions): AppLoadRetry {
const {webContents, logger} = options;
const setTimer = options.setTimer ?? setTimeout;
const clearTimer = options.clearTimer ?? clearTimeout;
let appUrl = options.appUrl;
let attempt = 0;
let timer: ReturnType<typeof setTimeout> | null = null;
const cancelTimer = () => {
if (timer) {
clearTimer(timer);
timer = null;
}
};
const load = (reason: string) => {
if (webContents.isDestroyed()) return;
webContents.loadURL(appUrl).catch((error: unknown) => {
const errorCode = getLoadErrorCode(error);
if (errorCode !== null && !isRetryableLoadError(errorCode)) {
logger.info('Ignoring non-retryable app load rejection', {reason, errorCode});
return;
}
schedule(`${reason}-rejected`, {error});
});
};
const schedule = (reason: string, detail?: Record<string, unknown>) => {
if (timer) return;
const delay = Math.min(INITIAL_RETRY_DELAY_MS * 2 ** attempt, MAX_RETRY_DELAY_MS);
attempt += 1;
logger.warn('Scheduling app load retry', {reason, delay, attempt, ...detail});
timer = setTimer(() => {
timer = null;
if (webContents.isDestroyed()) return;
if (webContents.isLoadingMainFrame()) {
schedule('main-frame-still-loading');
return;
}
load('retry');
}, delay);
};
const reset = () => {
cancelTimer();
attempt = 0;
};
const fallBackFromMigratedOrigin = (failedUrl: string, detail: Record<string, unknown>): boolean => {
const fallbackUrl = options.getFallbackUrl(failedUrl);
if (fallbackUrl === null || fallbackUrl === appUrl) return false;
logger.warn('Migrated app origin failed to load, falling back to the legacy app URL', {failedUrl, ...detail});
appUrl = fallbackUrl;
reset();
load('legacy-fallback');
return true;
};
return {
start() {
webContents.on('did-navigate', (_event, url, httpResponseCode) => {
reset();
options.onCommitted();
if (httpResponseCode >= 400) {
fallBackFromMigratedOrigin(url, {httpResponseCode});
}
});
webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame) {
logger.error('App main-frame load failed', {errorCode, errorDescription, validatedURL});
}
if (!isMainFrame || !options.isTrustedUrl(validatedURL) || !isRetryableLoadError(errorCode)) {
return;
}
if (fallBackFromMigratedOrigin(validatedURL, {errorCode, errorDescription})) {
return;
}
schedule('did-fail-load', {errorCode, errorDescription, validatedURL});
if (attempt >= FAILURES_BEFORE_PROMPT) {
options.onRepeatedFailure({errorCode, errorDescription, url: validatedURL});
}
});
logger.info('Loading app URL', {appUrl});
load('initial-load');
},
retryNow() {
reset();
load('manual-retry');
},
getAppUrl() {
return appUrl;
},
};
}
+3
View File
@@ -85,6 +85,9 @@ const FALLBACK_STRINGS: Readonly<Record<string, string>> = Object.freeze({
'desktop.badge.unreadMessages': 'Unread messages',
'desktop.badge.unreadMessagesCount': 'Unread messages: {count}',
'desktop.startup.failedTitle': '{appName} failed to start',
'desktop.appLoad.failedTitle': "Can't connect",
'desktop.appLoad.failedMessage': "{appName} can't reach its servers. It will keep trying in the background.",
'desktop.appLoad.retry': 'Try again',
'desktop.linuxEntry.genericName': 'Instant Messenger',
'desktop.linuxEntry.comment': 'Instant messaging and VoIP',
});
+54 -89
View File
@@ -21,6 +21,7 @@ import {
} from '@electron/common/DesktopConfig';
import {createChildLogger} from '@electron/common/Logger';
import type {DesktopWindowBehaviorSettings} from '@electron/common/Types';
import {createAppLoadRetry} from '@electron/main/AppLoadRetry';
import {
shouldForwardRendererConsoleToMainLog,
shouldIgnoreWindowStateForLaunch,
@@ -29,19 +30,18 @@ import {
import {hasActiveDesktopTray, refreshDesktopTrayMenu} from '@electron/main/DesktopTray';
import {drainPendingDisplayMediaRequests, registerDisplayMediaRequestHandler} from '@electron/main/DisplayMedia';
import {shouldDisableV8CodeCache} from '@electron/main/LaunchOptions';
import {t} from '@electron/main/MainI18n';
import {openExternalDeduped} from '@electron/main/OpenExternal';
import {registerSpellcheck} from '@electron/main/Spellcheck';
import {resetStreamingPriority} from '@electron/main/StreamingPriority';
import {getMainWindowRendererGoneAction} from '@electron/main/WindowRendererLifecycle';
import {refreshWindowsBadgeOverlay} from '@electron/main/WindowsBadge';
import {app, BrowserWindow, screen} from 'electron';
import {app, BrowserWindow, dialog, screen} from 'electron';
import log from 'electron-log';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const logger = createChildLogger('Window');
const VISIBILITY_MARGIN = 32;
const INITIAL_APP_LOAD_RETRY_DELAY_MS = 1000;
const MAX_APP_LOAD_RETRY_DELAY_MS = 30000;
const RENDERER_GONE_REPEAT_WINDOW_MS = 30000;
const OPAQUE_WINDOW_BACKGROUND_COLOR = '#1a1a1a';
const TRANSPARENT_WINDOW_BACKGROUND_COLOR = '#00000000';
@@ -91,18 +91,6 @@ const trustedRendererPermissionTypes = new Set([
]);
const POPOUT_NAMESPACE = 'fluxer_';
function shouldRetryAppLoadFailure(errorCode: number): boolean {
return errorCode < 0 && errorCode !== -3;
}
function getElectronLoadErrorCode(error: unknown): number | null {
const message = error instanceof Error ? error.message : String(error);
const match = /\(([-\d]+)\)/.exec(message);
if (!match) return null;
const value = Number.parseInt(match[1], 10);
return Number.isFinite(value) ? value : null;
}
function getOrigin(url?: string): string | null {
if (!url) return null;
try {
@@ -733,7 +721,6 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
const acceptFirstMouseOnFocus = isMac;
initialUseNativeTitleBar = useNativeTitleBar;
initialAllowTransparency = allowTransparency;
let appUrl = getAppUrl();
const windowOptions: Electron.BrowserWindowConstructorOptions = {
width: windowWidth,
height: windowHeight,
@@ -746,7 +733,7 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
...getTitleBarWindowOptions(useNativeTitleBar),
trafficLightPosition: isMac ? CUSTOM_TITLEBAR_TRAFFIC_LIGHT_POSITION : undefined,
acceptFirstMouse: acceptFirstMouseOnFocus,
webPreferences: getSharedWebPreferences(allowTransparency, useNativeTitleBar, appUrl),
webPreferences: getSharedWebPreferences(allowTransparency, useNativeTitleBar, getAppUrl()),
};
if (isLinux) {
const iconPath = getLinuxWindowIconPath();
@@ -976,81 +963,55 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
});
registerDisplayMediaRequestHandler(session, webContents);
logPhase('handlers');
let appLoadRetryAttempt = 0;
let appLoadRetryTimer: NodeJS.Timeout | null = null;
const clearAppLoadRetry = () => {
let appLoadFailurePrompt: AbortController | null = null;
const dismissAppLoadFailurePrompt = () => {
appLoadFailurePrompt?.abort();
appLoadFailurePrompt = null;
};
const appLoadRetry = createAppLoadRetry({
webContents,
appUrl: getAppUrl(),
logger,
isTrustedUrl: isTrustedOrigin,
getFallbackUrl: getAppUrlFallback,
onCommitted: dismissAppLoadFailurePrompt,
onRepeatedFailure: (failure) => {
const window = mainWindow;
if (appLoadFailurePrompt || !isAliveWindow(window) || !window.isVisible()) return;
const prompt = new AbortController();
appLoadFailurePrompt = prompt;
void dialog
.showMessageBox(window, {
type: 'warning',
buttons: [t('desktop.appLoad.retry'), t('desktop.tray.quit')],
defaultId: 0,
cancelId: 0,
title: t('desktop.appLoad.failedTitle'),
message: t('desktop.appLoad.failedMessage'),
detail: `${failure.errorDescription} (${failure.errorCode})\n${failure.url}`,
noLink: true,
signal: prompt.signal,
})
.then(({response}) => {
if (appLoadFailurePrompt === prompt) appLoadFailurePrompt = null;
if (prompt.signal.aborted || isQuitting) return;
if (response === 1) {
app.quit();
return;
}
appLoadRetry.retryNow();
});
},
});
webContents.on('did-finish-load', () => {
rendererGoneReloaded = false;
mainWindowRendererGone = false;
if (appLoadRetryTimer) {
clearTimeout(appLoadRetryTimer);
appLoadRetryTimer = null;
}
appLoadRetryAttempt = 0;
};
const scheduleAppLoadRetry = (reason: string, detail?: Record<string, unknown>) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
if (appLoadRetryTimer) return;
const delay = Math.min(INITIAL_APP_LOAD_RETRY_DELAY_MS * 2 ** appLoadRetryAttempt, MAX_APP_LOAD_RETRY_DELAY_MS);
appLoadRetryAttempt += 1;
logger.warn('Scheduling app load retry', {reason, delay, attempt: appLoadRetryAttempt, ...detail});
appLoadRetryTimer = setTimeout(() => {
appLoadRetryTimer = null;
if (!mainWindow || mainWindow.isDestroyed()) return;
if (mainWindow.webContents.isLoadingMainFrame()) {
scheduleAppLoadRetry('main-frame-still-loading');
return;
}
mainWindow.loadURL(appUrl).catch((error) => {
scheduleAppLoadRetry('load-url-rejected', {error});
});
}, delay);
};
const fallBackFromMigratedAppOrigin = (failedUrl: string, detail: Record<string, unknown>): boolean => {
const fallbackUrl = getAppUrlFallback(failedUrl);
if (!mainWindow || mainWindow.isDestroyed() || fallbackUrl === null || appUrl === fallbackUrl) {
return false;
}
logger.warn('Migrated app origin failed to load, falling back to the legacy app URL', {failedUrl, ...detail});
appUrl = fallbackUrl;
clearAppLoadRetry();
mainWindow.loadURL(appUrl).catch((error) => {
scheduleAppLoadRetry('legacy-fallback-load-url-rejected', {error});
});
return true;
};
webContents.on('did-finish-load', clearAppLoadRetry);
webContents.on('did-navigate', (_event, url, httpResponseCode) => {
if (httpResponseCode >= 400) {
fallBackFromMigratedAppOrigin(url, {httpResponseCode});
}
});
webContents.on('did-fail-load', (_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (isMainFrame) {
logger.error('App main-frame load failed', {errorCode, errorDescription, validatedURL});
}
if (!isMainFrame || !isTrustedOrigin(validatedURL) || !shouldRetryAppLoadFailure(errorCode)) {
return;
}
if (fallBackFromMigratedAppOrigin(validatedURL, {errorCode, errorDescription})) {
return;
}
scheduleAppLoadRetry('did-fail-load', {errorCode, errorDescription, validatedURL});
});
const loadAppUrl = (): void => {
if (!mainWindow || mainWindow.isDestroyed()) return;
logger.info('Loading app URL', {appUrl});
mainWindow.loadURL(appUrl).catch((error) => {
const errorCode = getElectronLoadErrorCode(error);
if (errorCode !== null && !shouldRetryAppLoadFailure(errorCode)) {
logger.info('Ignoring non-retryable initial app load rejection', {errorCode});
return;
}
logger.error('Failed to load app URL:', error);
scheduleAppLoadRetry('initial-load-url-rejected', {error});
});
void clearStartupRenderingCaches(session).then(() => {
if (!isAliveWindow(mainWindow)) return;
appLoadRetry.start();
logPhase('load-url-dispatched');
};
void clearStartupRenderingCaches(session).then(loadAppUrl);
});
webContents.on('will-navigate', (event, url) => {
if (!isTrustedOrigin(url)) {
event.preventDefault();
@@ -1095,7 +1056,11 @@ export function createWindow(options: CreateWindowOptions = {}): BrowserWindow {
transparent: allowPopoutTransparency,
hasShadow: getWindowHasShadow(allowPopoutTransparency),
show: true,
webPreferences: getSharedWebPreferences(allowPopoutTransparency, getActiveUseNativeTitleBar(), appUrl),
webPreferences: getSharedWebPreferences(
allowPopoutTransparency,
getActiveUseNativeTitleBar(),
appLoadRetry.getAppUrl(),
),
};
return {action: 'allow', overrideBrowserWindowOptions};
}
+5
View File
@@ -302,6 +302,11 @@ if (launchConfigurationError) {
.then(() => app.whenReady())
.then(async () => {
log.info('App ready, initializing...');
try {
runStartupPhase('host-resolver', () => app.configureHostResolver({enableAdditionalDnsQueryTypes: false}));
} catch (error) {
log.error('[Init] Failed to configure the host resolver:', error);
}
await runStartupPhaseAsync('launch-net-log', startLaunchNetLog);
try {
await runStartupPhaseAsync('desktop-debug-info', async () => {