diff --git a/src/interceptors/ClientRequest/MockHttpSocket.ts b/src/interceptors/ClientRequest/MockHttpSocket.ts index 2f4c28f03..085a866ac 100644 --- a/src/interceptors/ClientRequest/MockHttpSocket.ts +++ b/src/interceptors/ClientRequest/MockHttpSocket.ts @@ -263,36 +263,51 @@ export class MockHttpSocket extends MockSocket { } } - // Forward TLS Socket properties onto this Socket instance - // in the case of a TLS/SSL connection. - if (Reflect.get(socket, 'encrypted')) { - const tlsProperties = [ - 'encrypted', - 'authorized', - 'getProtocol', - 'getSession', - 'isSessionReused', - 'getCipher', - ] - - tlsProperties.forEach((propertyName) => { - Object.defineProperty(this, propertyName, { - enumerable: true, - get: () => { - const value = Reflect.get(socket, propertyName) - return typeof value === 'function' ? value.bind(socket) : value - }, - }) - }) - } - socket .on('lookup', (...args) => this.emit('lookup', ...args)) .on('connect', () => { this.connecting = socket.connecting this.emit('connect') }) - .on('secureConnect', () => this.emit('secureConnect')) + .on('secureConnect', () => { + /** + * Forward TLS Socket properties onto this Socket instance + * after the TLS handshake completes. This ensures the real socket + * has valid TLS information before we start forwarding it. + * + * We do this on 'secureConnect' rather than immediately in passthrough() + * because TLSSocket.encrypted is true even before the socket connects, + * but getCipher(), getProtocol() etc. return undefined until the + * TLS handshake completes. By waiting until secureConnect, we allow + * the mock TLS properties (set in constructor) to remain accessible + * until real values are available. + */ + invariant( + Reflect.get(socket, 'encrypted'), + 'Expected socket to have property `encrypted`' + ) + + const tlsProperties = [ + 'encrypted', + 'authorized', + 'getProtocol', + 'getSession', + 'isSessionReused', + 'getCipher', + ] + + tlsProperties.forEach((propertyName) => { + Object.defineProperty(this, propertyName, { + enumerable: true, + get: () => { + const value = Reflect.get(socket, propertyName) + return typeof value === 'function' ? value.bind(socket) : value + }, + }) + }) + + this.emit('secureConnect') + }) .on('secure', () => this.emit('secure')) .on('session', (session) => this.emit('session', session)) .on('ready', () => this.emit('ready')) diff --git a/src/utils/handleRequest.ts b/src/utils/handleRequest.ts index 4ef6fcd5a..01d5ebc0c 100644 --- a/src/utils/handleRequest.ts +++ b/src/utils/handleRequest.ts @@ -23,6 +23,16 @@ interface HandleRequestOptions { export async function handleRequest( options: HandleRequestOptions ): Promise { + /** + * @note If there are no "request" event listeners, passthrough immediately + * without going through the full async machinery. This reduces the number + * of microtask checkpoints, which helps avoid timing issues with + * high-concurrency requests (e.g., EPIPE errors with Unix sockets). + */ + if (options.emitter.listenerCount('request') === 0) { + return options.controller.passthrough() + } + const handleResponse = async ( response: Response | Error | Record ) => { diff --git a/test/modules/http/compliance/http-passthrough-early-return.test.ts b/test/modules/http/compliance/http-passthrough-early-return.test.ts new file mode 100644 index 000000000..5b17316fb --- /dev/null +++ b/test/modules/http/compliance/http-passthrough-early-return.test.ts @@ -0,0 +1,64 @@ +// @vitest-environment node +import { it, expect, beforeAll, afterAll } from 'vitest' +import http from 'node:http' +import path from 'node:path' +import { promisify } from 'node:util' +import { ClientRequestInterceptor } from '../../../../src/interceptors/ClientRequest' +import { waitForClientRequest } from '../../../helpers' + +const HTTP_SOCKET_PATH = path.join(__dirname, './test-early-return.sock') + +const httpServer = http.createServer((req, res) => { + res.writeHead(200) + res.end('ok') +}) + +const interceptor = new ClientRequestInterceptor() + +beforeAll(async () => { + await new Promise((resolve) => { + httpServer.listen(HTTP_SOCKET_PATH, resolve) + }) + interceptor.apply() +}) + +afterAll(async () => { + interceptor.dispose() + await promisify(httpServer.close.bind(httpServer))() +}) + +/** + * When no request listeners are registered, the interceptor should call + * passthrough() immediately without going through the full async machinery. + * This prevents timing issues with high-concurrency Unix socket requests. + * @see https://github.com/mswjs/interceptors/issues/760 + */ +it('performs passthrough over a Unix socket when no request listeners are attached', async () => { + const request = http.request({ + socketPath: HTTP_SOCKET_PATH, + path: '/resource', + }) + request.end() + const { res, text } = await waitForClientRequest(request) + + expect(res.statusCode).toBe(200) + await expect(text()).resolves.toBe('ok') +}) + +it('handles concurrent Unix socket requests when no listeners are attached', async () => { + const requests = Array.from({ length: 20 }, () => { + const req = http.request({ + socketPath: HTTP_SOCKET_PATH, + path: '/resource', + }) + req.end() + return waitForClientRequest(req) + }) + + const results = await Promise.all(requests) + + for (const { res, text } of results) { + expect(res.statusCode).toBe(200) + await expect(text()).resolves.toBe('ok') + } +})