diff --git a/fluxer_desktop/src/main/DesktopHappyEyeballs.test.mjs b/fluxer_desktop/src/main/DesktopHappyEyeballs.test.mjs new file mode 100644 index 000000000..7ec4a16ec --- /dev/null +++ b/fluxer_desktop/src/main/DesktopHappyEyeballs.test.mjs @@ -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/); + }); +}); diff --git a/fluxer_desktop/src/main/DesktopHappyEyeballs.ts b/fluxer_desktop/src/main/DesktopHappyEyeballs.ts new file mode 100644 index 000000000..538cb896b --- /dev/null +++ b/fluxer_desktop/src/main/DesktopHappyEyeballs.ts @@ -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) { + 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 { + 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): ReadonlyArray { + const first = addresses[0]?.family; + const preferred = addresses.filter((address) => address.family === first); + const other = addresses.filter((address) => address.family !== first); + const ordered: Array = []; + 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, + 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(); + const errors: Array = []; + 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; + } +} diff --git a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts index 4df23f28e..85d5e8c62 100644 --- a/fluxer_desktop/src/main/DesktopOutboundHTTP.ts +++ b/fluxer_desktop/src/main/DesktopOutboundHTTP.ts @@ -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; diff --git a/fluxer_desktop/src/main/FileDownloads.test.mjs b/fluxer_desktop/src/main/FileDownloads.test.mjs index d6a26d1dc..1b6c5f5ca 100644 --- a/fluxer_desktop/src/main/FileDownloads.test.mjs +++ b/fluxer_desktop/src/main/FileDownloads.test.mjs @@ -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;