fix(desktop): keep slow connects racing instead of aborting them (#3308)

This commit is contained in:
Hampus authored and GitHub committed 2026-10-08 20:04:39 +02:00
1 parent aa42bf13ce
commit 334fef52e1
4 files changed
+291 -11

No files matched your search

@@ -0,0 +1,55 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import assert from 'node:assert/strict';
import net from 'node:net';
import {describe, test} from 'node:test';
const {connectToFirstAnsweringAddress} = await import('./DesktopHappyEyeballs.ts');
function race(addresses, port, options) {
return new Promise((resolve) => {
connectToFirstAnsweringAddress(addresses, port, options, (error, socket) => resolve({error, socket}));
});
}
describe('DesktopHappyEyeballs', () => {
test('a slow first address keeps racing after the next one fails fast', async () => {
const server = net.createServer((socket) => socket.end());
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const {port} = server.address();
const dial = (options) => {
if (options.host !== '127.0.0.1') {
return net.connect(options);
}
const socket = new net.Socket();
setTimeout(() => socket.connect(options), 150);
return socket;
};
try {
const {error, socket} = await race(
[
{address: '127.0.0.1', family: 4},
{address: '::1', family: 6},
],
port,
{attemptDelayMs: 20, dial},
);
assert.equal(error, null, 'a connect slower than the attempt delay must not be abandoned');
assert.equal(socket.remoteAddress, '127.0.0.1');
socket.destroy();
} finally {
server.close();
}
});
test('when every address fails the error names each attempt', async () => {
const server = net.createServer();
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const {port} = server.address();
await new Promise((resolve) => server.close(resolve));
const {error, socket} = await race([{address: '127.0.0.1', family: 4}], port, {});
assert.equal(socket, null);
assert.equal(error.code, 'ECONNREFUSED');
assert.match(error.message, /ECONNREFUSED 127\.0\.0\.1/);
});
});
@@ -0,0 +1,214 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import http from 'node:http';
import https from 'node:https';
import net, {type LookupFunction} from 'node:net';
import type {Duplex} from 'node:stream';
const HAPPY_EYEBALLS_ATTEMPT_DELAY_MS = 250;
const HAPPY_EYEBALLS_CONNECT_TIMEOUT_MS = 30_000;
interface ConnectAddress {
readonly address: string;
readonly family: 4 | 6;
}
type ConnectCallback = (error: Error | null, socket: net.Socket | null) => void;
type Dial = (options: net.TcpNetConnectOpts) => net.Socket;
interface RaceOptions {
readonly attemptDelayMs?: number;
readonly dial?: Dial;
readonly keepAlive?: boolean;
readonly keepAliveInitialDelay?: number;
readonly noDelay?: boolean;
}
type CreatedConnectionCallback = (error: Error | null, stream: Duplex) => void;
interface HappyEyeballsConnectOptions {
readonly host?: string | null;
readonly hostname?: string | null;
readonly keepAlive?: boolean;
readonly keepAliveInitialDelay?: number;
readonly lookup?: LookupFunction;
readonly noDelay?: boolean;
readonly port?: number | string | null;
}
class HappyEyeballsConnectError extends AggregateError {
public readonly code: string | undefined;
public readonly syscall = 'connect';
public constructor(errors: ReadonlyArray<Error>) {
super(errors, `Could not connect to any resolved address (${errors.map((error) => error.message).join('; ')})`);
this.name = 'HappyEyeballsConnectError';
this.code = (errors[0] as NodeJS.ErrnoException | undefined)?.code;
}
}
function connectFailure(errors: ReadonlyArray<Error>): Error {
return errors.length === 1 ? errors[0] : new HappyEyeballsConnectError(errors);
}
function connectTimeoutError(): NodeJS.ErrnoException {
const error: NodeJS.ErrnoException = new Error('Connecting to the resolved addresses timed out');
error.code = 'ETIMEDOUT';
return error;
}
function interleaveFamilies(addresses: ReadonlyArray<ConnectAddress>): ReadonlyArray<ConnectAddress> {
const first = addresses[0]?.family;
const preferred = addresses.filter((address) => address.family === first);
const other = addresses.filter((address) => address.family !== first);
const ordered: Array<ConnectAddress> = [];
for (let index = 0; index < Math.max(preferred.length, other.length); index += 1) {
if (index < preferred.length) {
ordered.push(preferred[index]);
}
if (index < other.length) {
ordered.push(other[index]);
}
}
return ordered;
}
export function connectToFirstAnsweringAddress(
addresses: ReadonlyArray<ConnectAddress>,
port: number,
options: RaceOptions,
callback: ConnectCallback,
): void {
const attemptDelayMs = options.attemptDelayMs ?? HAPPY_EYEBALLS_ATTEMPT_DELAY_MS;
const dial = options.dial ?? net.connect;
const ordered = interleaveFamilies(addresses);
const pending = new Set<net.Socket>();
const errors: Array<Error> = [];
let next = 0;
let settled = false;
let attemptTimer: NodeJS.Timeout | undefined;
const finish = (error: Error | null, winner: net.Socket | null): void => {
if (settled) {
return;
}
settled = true;
clearTimeout(attemptTimer);
clearTimeout(deadline);
for (const socket of pending) {
if (socket !== winner) {
socket.destroy();
}
}
pending.clear();
callback(error, winner);
};
const startNext = (): void => {
clearTimeout(attemptTimer);
if (settled || next >= ordered.length) {
return;
}
const {address, family} = ordered[next];
next += 1;
const socket = dial({
family,
host: address,
keepAlive: options.keepAlive,
keepAliveInitialDelay: options.keepAliveInitialDelay,
noDelay: options.noDelay,
port,
});
pending.add(socket);
const onError = (error: Error): void => {
pending.delete(socket);
errors.push(error);
if (settled) {
return;
}
if (next < ordered.length) {
startNext();
} else if (pending.size === 0) {
finish(connectFailure(errors), null);
}
};
socket.once('error', onError);
socket.once('connect', () => {
socket.removeListener('error', onError);
finish(null, socket);
});
if (next < ordered.length) {
attemptTimer = setTimeout(startNext, attemptDelayMs);
}
};
const deadline = setTimeout(() => {
errors.push(connectTimeoutError());
finish(connectFailure(errors), null);
}, HAPPY_EYEBALLS_CONNECT_TIMEOUT_MS);
deadline.unref();
if (ordered.length === 0) {
finish(connectTimeoutError(), null);
return;
}
startNext();
}
function connectThroughLookup(options: HappyEyeballsConnectOptions, callback: ConnectCallback): void {
const lookup = options.lookup as LookupFunction;
const hostname = options.host ?? options.hostname ?? '';
lookup(hostname, {all: true}, (error, resolved) => {
if (error != null) {
callback(error, null);
return;
}
const addresses = (resolved as unknown as ReadonlyArray<{address: string; family: number}>).map(
(entry): ConnectAddress => ({address: entry.address, family: entry.family === 6 ? 6 : 4}),
);
connectToFirstAnsweringAddress(
addresses,
Number(options.port),
{
keepAlive: options.keepAlive,
keepAliveInitialDelay: options.keepAliveInitialDelay,
noDelay: options.noDelay,
},
callback,
);
});
}
export class HappyEyeballsHttpAgent extends http.Agent {
public override createConnection(
options: HappyEyeballsConnectOptions,
callback?: CreatedConnectionCallback,
): Duplex | null | undefined {
if (options.lookup == null || callback == null) {
return super.createConnection(options as http.ClientRequestArgs, callback);
}
connectThroughLookup(options, (error, socket) => {
if (error != null || socket == null) {
callback(error ?? connectTimeoutError(), undefined as unknown as Duplex);
return;
}
callback(null, socket);
});
return undefined;
}
}
export class HappyEyeballsHttpsAgent extends https.Agent {
public override createConnection(
options: HappyEyeballsConnectOptions,
callback?: CreatedConnectionCallback,
): Duplex | null | undefined {
if (options.lookup == null || callback == null) {
return super.createConnection(options as http.ClientRequestArgs, callback);
}
connectThroughLookup(options, (error, socket) => {
if (error != null || socket == null) {
callback(error ?? connectTimeoutError(), undefined as unknown as Duplex);
return;
}
callback(null, super.createConnection({...options, socket} as http.ClientRequestArgs) as Duplex);
});
return undefined;
}
}
+12 -11
View File
@@ -8,6 +8,7 @@ import {isIPv4, isIPv6, type LookupFunction} from 'node:net';
import {Readable, Transform} from 'node:stream'; import {Readable, Transform} from 'node:stream';
import type {ReadableStream as NodeReadableStream} from 'node:stream/web'; import type {ReadableStream as NodeReadableStream} from 'node:stream/web';
import {createChildLogger} from '@electron/common/Logger'; import {createChildLogger} from '@electron/common/Logger';
import {HappyEyeballsHttpAgent, HappyEyeballsHttpsAgent} from '@electron/main/DesktopHappyEyeballs';
import { import {
type DesktopProxyResolver, type DesktopProxyResolver,
type DesktopSessionHTTPSender, type DesktopSessionHTTPSender,
@@ -88,11 +89,6 @@ export const DesktopOriginTrust = Object.freeze({
export type DesktopOriginTrust = (typeof DesktopOriginTrust)[keyof typeof DesktopOriginTrust]; export type DesktopOriginTrust = (typeof DesktopOriginTrust)[keyof typeof DesktopOriginTrust];
const HAPPY_EYEBALLS_CONNECT_OPTIONS = Object.freeze({
autoSelectFamily: true,
autoSelectFamilyAttemptTimeout: 250,
});
interface PinnedAddress { interface PinnedAddress {
readonly address: string; readonly address: string;
readonly family: 4 | 6; readonly family: 4 | 6;
@@ -710,9 +706,12 @@ interface DesktopProxiedSend {
} }
export class DesktopOutboundHTTP { export class DesktopOutboundHTTP {
private readonly httpAgent = new http.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS}); private readonly httpAgent = new HappyEyeballsHttpAgent({
keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS,
});
private readonly httpsAgent: https.Agent; private readonly httpsAgent: https.Agent;
private readonly originRequestHttpAgent = new http.Agent({ private readonly originRequestHttpAgent = new HappyEyeballsHttpAgent({
keepAlive: true, keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
}); });
@@ -735,8 +734,12 @@ export class DesktopOutboundHTTP {
this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession; this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession;
const certificates = options.trustedCertificates ?? resolveDesktopTrustedCertificates(); const certificates = options.trustedCertificates ?? resolveDesktopTrustedCertificates();
const trust = certificates.length > 0 ? {ca: [...certificates]} : {}; const trust = certificates.length > 0 ? {ca: [...certificates]} : {};
this.httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS, ...trust}); this.httpsAgent = new HappyEyeballsHttpsAgent({
this.originRequestHttpsAgent = new https.Agent({ keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS,
...trust,
});
this.originRequestHttpsAgent = new HappyEyeballsHttpsAgent({
keepAlive: true, keepAlive: true,
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
...trust, ...trust,
@@ -1074,7 +1077,6 @@ export class DesktopOutboundHTTP {
const clientRequest = transport.request(request.url, { const clientRequest = transport.request(request.url, {
agent: secure ? this.httpsAgent : this.httpAgent, agent: secure ? this.httpsAgent : this.httpAgent,
lookup: createPinnedHostLookup(request.url.hostname, pinned), lookup: createPinnedHostLookup(request.url.hostname, pinned),
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
method: 'GET', method: 'GET',
}); });
const deadline = setTimeout(() => { const deadline = setTimeout(() => {
@@ -1291,7 +1293,6 @@ export class DesktopOutboundHTTP {
agent: target.protocol === 'https:' ? this.originRequestHttpsAgent : this.originRequestHttpAgent, agent: target.protocol === 'https:' ? this.originRequestHttpsAgent : this.originRequestHttpAgent,
headers: requestHeaders(request.headers), headers: requestHeaders(request.headers),
lookup: createPinnedHostLookup(target.hostname, binding.addresses), lookup: createPinnedHostLookup(target.hostname, binding.addresses),
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
method: request.method, method: request.method,
}); });
active = clientRequest; active = clientRequest;
@@ -29,6 +29,7 @@ function transform(name) {
}; };
} }
const happyEyeballsSource = transform('DesktopHappyEyeballs.ts');
const outboundSource = transform('DesktopOutboundHTTP.ts'); const outboundSource = transform('DesktopOutboundHTTP.ts');
const fileDownloadsSource = transform('FileDownloads.ts'); const fileDownloadsSource = transform('FileDownloads.ts');
@@ -102,6 +103,15 @@ async function loadFileDownloads({registerOrigin = true} = {}) {
}; };
const context = vm.createContext(sandbox); const context = vm.createContext(sandbox);
const happyEyeballsModule = {exports: {}};
const happyEyeballsContext = vm.createContext({
...sandbox,
exports: happyEyeballsModule.exports,
module: happyEyeballsModule,
});
vm.runInContext(happyEyeballsSource.code, happyEyeballsContext, {filename: happyEyeballsSource.path});
stubs['@electron/main/DesktopHappyEyeballs'] = happyEyeballsModule.exports;
const outboundModule = {exports: {}}; const outboundModule = {exports: {}};
sandbox.module = outboundModule; sandbox.module = outboundModule;
sandbox.exports = outboundModule.exports; sandbox.exports = outboundModule.exports;