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
-
Start a ProxyServer with a custom httpAgent (keepAlive: true) via prepareRequestFunction.
-
Have a client (e.g. Puppeteer with --proxy-server) make an HTTP (non-CONNECT) request through the proxy.
-
While the request is in flight, call server.close(true) and agent.destroy().
-
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)
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;
}
});
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
Reproduction
Start a ProxyServer with a custom httpAgent (keepAlive: true) via prepareRequestFunction.
Have a client (e.g. Puppeteer with --proxy-server) make an HTTP (non-CONNECT) request through the proxy.
While the request is in flight, call server.close(true) and agent.destroy().
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