mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
fix(desktop): keep slow connects racing instead of aborting them (#3308)
This commit is contained in:
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;
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {isIPv4, isIPv6, type LookupFunction} from 'node:net';
|
||||
import {Readable, Transform} from 'node:stream';
|
||||
import type {ReadableStream as NodeReadableStream} from 'node:stream/web';
|
||||
import {createChildLogger} from '@electron/common/Logger';
|
||||
import {HappyEyeballsHttpAgent, HappyEyeballsHttpsAgent} from '@electron/main/DesktopHappyEyeballs';
|
||||
import {
|
||||
type DesktopProxyResolver,
|
||||
type DesktopSessionHTTPSender,
|
||||
@@ -88,11 +89,6 @@ export const DesktopOriginTrust = Object.freeze({
|
||||
|
||||
export type DesktopOriginTrust = (typeof DesktopOriginTrust)[keyof typeof DesktopOriginTrust];
|
||||
|
||||
const HAPPY_EYEBALLS_CONNECT_OPTIONS = Object.freeze({
|
||||
autoSelectFamily: true,
|
||||
autoSelectFamilyAttemptTimeout: 250,
|
||||
});
|
||||
|
||||
interface PinnedAddress {
|
||||
readonly address: string;
|
||||
readonly family: 4 | 6;
|
||||
@@ -710,9 +706,12 @@ interface DesktopProxiedSend {
|
||||
}
|
||||
|
||||
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 originRequestHttpAgent = new http.Agent({
|
||||
private readonly originRequestHttpAgent = new HappyEyeballsHttpAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
|
||||
});
|
||||
@@ -735,8 +734,12 @@ export class DesktopOutboundHTTP {
|
||||
this.sendThroughSession = options.sendThroughSession ?? sendThroughDesktopSession;
|
||||
const certificates = options.trustedCertificates ?? resolveDesktopTrustedCertificates();
|
||||
const trust = certificates.length > 0 ? {ca: [...certificates]} : {};
|
||||
this.httpsAgent = new https.Agent({keepAlive: true, maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS, ...trust});
|
||||
this.originRequestHttpsAgent = new https.Agent({
|
||||
this.httpsAgent = new HappyEyeballsHttpsAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_SOCKETS,
|
||||
...trust,
|
||||
});
|
||||
this.originRequestHttpsAgent = new HappyEyeballsHttpsAgent({
|
||||
keepAlive: true,
|
||||
maxSockets: DESKTOP_OUTBOUND_HTTP_MAX_IN_FLIGHT_PER_SERVICE,
|
||||
...trust,
|
||||
@@ -1074,7 +1077,6 @@ export class DesktopOutboundHTTP {
|
||||
const clientRequest = transport.request(request.url, {
|
||||
agent: secure ? this.httpsAgent : this.httpAgent,
|
||||
lookup: createPinnedHostLookup(request.url.hostname, pinned),
|
||||
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
|
||||
method: 'GET',
|
||||
});
|
||||
const deadline = setTimeout(() => {
|
||||
@@ -1291,7 +1293,6 @@ export class DesktopOutboundHTTP {
|
||||
agent: target.protocol === 'https:' ? this.originRequestHttpsAgent : this.originRequestHttpAgent,
|
||||
headers: requestHeaders(request.headers),
|
||||
lookup: createPinnedHostLookup(target.hostname, binding.addresses),
|
||||
...HAPPY_EYEBALLS_CONNECT_OPTIONS,
|
||||
method: request.method,
|
||||
});
|
||||
active = clientRequest;
|
||||
|
||||
@@ -29,6 +29,7 @@ function transform(name) {
|
||||
};
|
||||
}
|
||||
|
||||
const happyEyeballsSource = transform('DesktopHappyEyeballs.ts');
|
||||
const outboundSource = transform('DesktopOutboundHTTP.ts');
|
||||
const fileDownloadsSource = transform('FileDownloads.ts');
|
||||
|
||||
@@ -102,6 +103,15 @@ async function loadFileDownloads({registerOrigin = true} = {}) {
|
||||
};
|
||||
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: {}};
|
||||
sandbox.module = outboundModule;
|
||||
sandbox.exports = outboundModule.exports;
|
||||
|
||||
Reference in new issue
Block a user