mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15: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 {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;
|
||||||
|
|||||||
Reference in new issue
Block a user