Skip to content

forward.ts: Socket leak when client.on('error') fires — missing client.destroy() #670

Description

@Jobs-Chen

Description
When forward() encounters an error on the upstream http.ClientRequest (e.g. ECONNRESET, ECONNREFUSED, socket hang-up), the client.on('error') handler calls resolve() but never calls client.destroy(). This leaves the underlying TCP socket open even after the Agent has removed it from its socket pool.
Root cause
In src/forward.ts (line ~91):
client.on('error', (error: NodeJS.ErrnoException) => {
if (response.headersSent) {
resolve();
return;
}

const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;

response.statusCode = !proxy && error.code === 'ENOTFOUND' ? 404 : statusCode;
response.setHeader('content-type', 'text/plain; charset=utf-8');
response.end(http.STATUS_CODES[response.statusCode]);

resolve();

});
When http.ClientRequest emits 'error', Node.js internally emits 'agentRemove' on the socket, which removes it from the Agent's sockets map. However, the socket itself is not destroyed. Since agent.destroy() only destroys sockets still tracked by the Agent, these "orphaned" sockets are unreachable and remain open indefinitely.
This is asymmetric with chain.ts, which correctly does:
sourceSocket.on('close', () => client.destroy());
Impact

  • After server.close(true) + agent.destroy(), orphaned sockets keep the Node.js process alive, causing a delayed exit (observed ~3s in production with 40 concurrent browser instances).
  • The issue is intermittent — it only manifests when the error occurs during an active forward() request (i.e., the createSocket async window overlaps with server.close()).
    Reproduction
  1. Start a ProxyServer with a custom httpAgent (keepAlive: true) via prepareRequestFunction.

  2. Have a client (e.g. Puppeteer with --proxy-server) make an HTTP (non-CONNECT) request through the proxy.

  3. While the request is in flight, call server.close(true) and agent.destroy().

  4. Observe that process._getActiveHandles() still contains Socket objects whose connect stack traces back to forward.ts.
    Suggested fix
    Add client.destroy() in the error handler:
    client.on('error', (error: NodeJS.ErrnoException) => {
    client.destroy();

    if (response.headersSent) {
    resolve();
    return;
    }

    const statusCode = errorCodeToStatusCode[error.code!] ?? badGatewayStatusCodes.GENERIC_ERROR;

    response.statusCode = !proxy && error.code === 'ENOTFOUND' ? 404 : statusCode;
    response.setHeader('content-type', 'text/plain; charset=utf-8');
    response.end(http.STATUS_CODES[response.statusCode]);

    resolve();
    });
    Environment

  • proxy-chain: 3.0.0
  • Node.js: v22.18.0
  • OS: Ubuntu 22.04 (Docker)

Metadata

Metadata

Assignees

Labels

t-unblockingIssues with this label are in the ownership of the unblocking team.

Type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions