Skip to content

Instantly share code, notes, and snippets.

@Yogu
Last active March 19, 2020 23:51
Show Gist options
  • Save Yogu/8fd03f442c7bfc1bbedb1a359fd2c760 to your computer and use it in GitHub Desktop.
Save Yogu/8fd03f442c7bfc1bbedb1a359fd2c760 to your computer and use it in GitHub Desktop.
max-age for keep-alive in node
import { injectable } from 'inversify';
import { Logger } from 'log4js';
import { Socket } from 'net';
import Timer = NodeJS.Timer;
export interface KeepaliveOptions {
/**
* The maximum time in milliseconds an unused socket will be kept alive before it will be closed
*
* This should be set to a smaller value than the server's keepalive timeout to avoid a race condition
* where the server closes the connection while the client sends another request
*/
maxSocketAgeMsecs?: number
/**
* When using HTTP KeepAlive, how often to send TCP KeepAlive packets over sockets being kept alive. Default = 1000.
* Only relevant if keepAlive is set to true.
*/
keepAliveMsecs?: number;
}
interface SocketInfo {
readonly socket: Socket;
isReused: boolean
isClosed: boolean
timeout?: Timer
closeEventHandler?: any
}
/**
* Manages agent sockets that are kept open due to Keep-Alive
*
* The default http/https agents of node do not allow to set a client-side timeout for the sockets.
* There is however a server-side timeout in most applications. We don't want the server to initiate
* the shutdown because this an inherent race-condition: if we send another request while the server
* has already sent the FIN segment, we will get a ECONNRESET which is difficult to recover from
* because we don't know if the request was received or not.
*
* To fix this, use this implementation and set maxSocketAgeMsecs to a value smaller than the server
* timeout.
*/
@injectable()
export class SocketKeeper {
private readonly socketInfos: Map<Socket, SocketInfo> = new Map();
private isShuttingDown = false;
constructor(
private readonly options: KeepaliveOptions,
private readonly logger: Logger
) {
}
/**
* Decides if a socket should be kept alive and performs the appropriate actions on it
*
* To be used as the method body of keepSocketAlive() in an http/https agent
*/
keepSocketAlive(socket: Socket): boolean {
// if a shutdown was initiated while this socket was still busy, it did not get closed in the shutdown() call
// to prevent it getting added to the list, let it close right away here
if (this.isShuttingDown) {
this.logger.trace(`Not keeping socket alive because shutting down`);
return false;
}
// regular keep-alive code of agents
socket.setKeepAlive(true, this.options.keepAliveMsecs);
socket.unref();
const info: SocketInfo = {
socket,
isClosed: false,
isReused: false
};
this.socketInfos.set(socket, info);
// if sockets should be closed after a specified time of inactivity, set a timeout
if (this.options.maxSocketAgeMsecs) {
this.logger.trace(`Keeping socket alive with timeout of ${this.options.maxSocketAgeMsecs} ms`);
info.timeout = setTimeout(() => {
this.logger.trace(`Closing socket because timeout of ${this.options.maxSocketAgeMsecs} ms elapsed`);
this.closeSocketSafely(info);
}, this.options.maxSocketAgeMsecs);
} else {
this.logger.trace(`Keeping socket alive without timeout`);
}
info.closeEventHandler = () => {
this.logger.trace(`Kept-alive socket was closed by server`);
info.isClosed = true;
if (info.timeout) {
clearTimeout(info.timeout);
info.timeout = undefined;
}
this.socketInfos.delete(socket);
};
socket.once('close', info.closeEventHandler);
return true;
}
/**
* To be called when a socket is about to be reused
*
* To be used as the method body of reuseSocket() in an http/https agent
*/
reuseSocket(socket: Socket): void {
// note that we can't reject sockets at this time, see comment in #closeSocketSafely()
const info: SocketInfo = this.socketInfos.get(socket);
if (!info) {
this.logger.warn(`Agent uses socket unknown that has not been kept alive`);
return;
}
// if `info` is no longer in the map, the socket has been closed
if (info.isClosed) {
this.logger.warn(`Agent reuses socket that has already been closed because of max-socket-age`);
}
if (info.isReused) {
this.logger.warn(`Agent reused socket twice without keeping it alive in between`);
}
if (info.timeout) {
this.logger.trace(`Reusing socket and clearing max-socket-age timeout`);
clearTimeout(info.timeout);
info.timeout = undefined;
} else {
this.logger.trace(`Reusing socket`);
}
this.socketInfos.delete(socket);
if (info.closeEventHandler) {
socket.removeListener('close', info.closeEventHandler);
info.closeEventHandler = undefined;
}
socket.ref();
}
/**
* Closes all kept sockets and makes sure no sockets will be kept in the future
*/
shutDown() {
this.logger.trace(`Shutting down socket keeper, closing ${this.socketInfos.size} sockets`);
this.isShuttingDown = true;
for (const info of this.socketInfos.values()) {
if (info.timeout) {
clearTimeout(info.timeout);
info.timeout = undefined;
}
// also removes it from the map
this.closeSocketSafely(info);
}
}
private closeSocketSafely(info: SocketInfo) {
// safeguard against the timeout firing after it was cleared
if (info.socket.destroyed || info.isReused || info.isClosed) {
this.logger.trace(`Not closing socket because it is no longer in the keeper pool`);
return;
}
if (info.closeEventHandler) {
info.socket.removeListener('close', info.closeEventHandler);
info.closeEventHandler = undefined;
}
info.isClosed = true;
info.socket.end();
// node makes it really really hard to properly implement this. The agents keep a list of free sockets
// to reuse on new requests. Sockets normally are only removed from this list when the event 'close'
// fires, which is when the connection is *completely* closed (that means for an active close, if the
// FIN ACK is received. However, the socket is unusable before because it will inevitably result in a
// ECONNRESET once we called end(). There is also no way to reject sockets in reuseSocket().
//
// The only way I see to remove sockets from the agent's free list in time is to emit the event
// "agentRemove" directly. It not explicitly documented in the API, but it is mentioned in the docs of
// http.Agent: https://nodejs.org/dist/latest-v8.x/docs/api/http.html#http_class_http_agent
info.socket.emit('agentRemove');
// remove it from the map to not leak memory / the socket
this.socketInfos.delete(info.socket);
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment