Last active
March 19, 2020 23:51
-
-
Save Yogu/8fd03f442c7bfc1bbedb1a359fd2c760 to your computer and use it in GitHub Desktop.
max-age for keep-alive in node
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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