Drain the requests already on the wire before close() and unbind() tear down

This commit is contained in:
2026-08-30 20:08:36 +02:00
parent c06eda4582
commit bebd74b42b
13 changed files with 248 additions and 85 deletions
+5 -3
View File
@@ -27,6 +27,7 @@ export type ClientOptions = {
port?: number;
reconnect?: { maxDelay?: number; minDelay?: number };
responseTimeout?: number;
shutdownTimeout?: number;
signal?: AbortSignal;
systemType?: string;
tls?: ConnectionOptions | boolean;
@@ -145,6 +146,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
log,
maxOutstanding: options.maxOutstanding,
responseTimeout: options.responseTimeout,
shutdownTimeout: options.shutdownTimeout,
sock,
...(options.reconnect
? {
@@ -194,18 +196,18 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
const signal = options.signal;
if (signal?.aborted === true) {
session.close();
void session.close();
return { err: new Error('Aborted before binding') };
}
// Registered before the bind: an abort landing while it is in flight has to close the session.
signal?.addEventListener('abort', () => { session.close(); }, { once: true });
signal?.addEventListener('abort', () => { void session.close(); }, { once: true });
const bound = await bind(session, options);
if (bound.err) {
session.close();
void session.close();
return { err: bound.err };
}
+1 -1
View File
@@ -70,7 +70,7 @@ export class IncomingRequests {
break;
case 'unbind':
await this.session.sendReturn(pduObj);
this.session.close();
await this.session.close();
break;
default:
await this.unhandled(pduObj);
+31
View File
@@ -2,6 +2,7 @@
export class SendWindow {
private readonly limit: number;
private readonly waiting: (() => void)[] = [];
private readonly waitingForIdle: (() => void)[] = [];
private inFlight = 0;
constructor(limit: number) {
@@ -28,5 +29,35 @@ export class SendWindow {
}
this.inFlight--;
if (this.inFlight > 0) return;
for (const resolve of this.waitingForIdle.splice(0)) {
resolve();
}
}
/** Resolves once nothing is in flight, or on the timeout with how many still are. 0 never times out. */
idle(timeout: number): Promise<number> {
if (this.inFlight === 0) return Promise.resolve(0);
return new Promise<number>(resolve => {
let timer: NodeJS.Timeout | undefined = undefined;
const done = (): void => {
const index = this.waitingForIdle.indexOf(done);
if (timer) clearTimeout(timer);
if (index !== -1) this.waitingForIdle.splice(index, 1);
resolve(this.inFlight);
};
if (timeout > 0) {
timer = setTimeout(done, timeout);
timer.unref();
}
this.waitingForIdle.push(done);
});
}
}
+14 -13
View File
@@ -34,6 +34,7 @@ export type ServerOptions = {
port?: number;
reassemblyTimeout?: number;
responseTimeout?: number;
shutdownTimeout?: number;
signal?: AbortSignal;
systemId?: string;
tls?: TlsOptions | boolean;
@@ -114,20 +115,19 @@ export class SmppServer extends EventEmitter<ServerEvents> {
if (event !== 'serverError') this.emit('serverError', error);
}
/** Stops listening and closes every live session. */
close(): Promise<void> {
return new Promise(resolve => {
for (const session of this.sessions) {
try {
session.close();
} catch (thrown: unknown) {
this.emit('serverError', errorFrom(thrown));
}
}
/** Stops listening and closes every live session, draining each one first. */
async close(): Promise<void> {
const live = [...this.sessions];
this.sessions.clear();
this.server.close(() => { resolve(); });
});
this.sessions.clear();
await Promise.all(live.map(async session => {
const closed = await session.close().catch((thrown: unknown) => ({ err: errorFrom(thrown) }));
if (closed.err) this.emit('serverError', closed.err);
}));
return new Promise(resolve => { this.server.close(() => { resolve(); }); });
}
}
@@ -233,6 +233,7 @@ function onConnection(sock: Socket, options: ServerOptions, server: SmppServer):
onRequest: (bound, pduObj) => onRequest(bound, pduObj, options),
reassemblyTimeout: options.reassemblyTimeout,
responseTimeout: options.responseTimeout,
shutdownTimeout: options.shutdownTimeout,
sock,
systemId: options.systemId ?? defaults.systemId,
});
+5
View File
@@ -78,6 +78,8 @@ export type SessionOptions = {
reassemblyTimeout?: number | undefined;
reconnect?: ReconnectOptions | undefined;
responseTimeout?: number | undefined;
/** How long a drain waits for the requests already on the wire. 0 waits forever. */
shutdownTimeout?: number | undefined;
sock: Socket;
/** This end's own identity, answered to the peer in place of the one it sent. */
systemId?: string | undefined;
@@ -98,6 +100,7 @@ export const defaults = {
minDelay: 1000,
reassemblyTimeout: 300_000,
responseTimeout: 30_000,
shutdownTimeout: 5000,
systemId: defaultSystemId,
};
@@ -112,6 +115,7 @@ export function checkSessionOptions(options: SessionCounts): VoidResult {
['maxReassembly', options.maxReassembly ?? defaults.maxReassembly, 1],
['reassemblyTimeout', options.reassemblyTimeout ?? defaults.reassemblyTimeout, 0],
['responseTimeout', options.responseTimeout ?? defaults.responseTimeout, 0],
['shutdownTimeout', options.shutdownTimeout ?? defaults.shutdownTimeout, 0],
];
for (const [name, value, min] of limits) {
@@ -129,4 +133,5 @@ export type SessionCounts = {
maxReassembly?: number | undefined;
reassemblyTimeout?: number | undefined;
responseTimeout?: number | undefined;
shutdownTimeout?: number | undefined;
};
+57 -10
View File
@@ -67,6 +67,7 @@ export class Session extends EventEmitter<SessionEvents> {
private closed = false;
private concatReference = 0;
private draining = false;
private framer = new PduFramer();
/** A listener that throws is the application's bug; it must not become ours. Hard rule 1. */
@@ -160,6 +161,8 @@ export class Session extends EventEmitter<SessionEvents> {
if (this.closed) return { err: new Error('Session is closed') };
if (this.draining) return { err: new Error('Session is shutting down') };
// Before the window, or a full window makes an aborted call wait for a slot it will not use.
if (options.signal?.aborted === true) {
return { err: new Error('Aborted before the request was sent') };
@@ -213,22 +216,38 @@ export class Session extends EventEmitter<SessionEvents> {
return sent;
}
/** Unbinds politely, then closes. Many SMSCs drop the link instead of answering, which is fine. */
/**
* Drains, unbinds politely, then closes. Many SMSCs drop the link instead of answering the
* unbind, which is fine. Reports the unbind's own failure ahead of an unfinished drain.
*/
async unbind(): Promise<VoidResult> {
this.reconnectLoop?.stop();
const drained = await this.drain();
const wasOpen = !this.closed;
const sent = await this.send({ cmdName: 'unbind' });
// request(), not send(): the drain gate would refuse it, and the window is empty by now.
const sent = wasOpen
? await this.request({ cmdName: 'unbind' }, {})
: { err: new Error('Session is closed') };
const closedOnUnbind = wasOpen && this.closed;
this.close();
this.shutdown();
return sent.err && !closedOnUnbind ? { err: sent.err } : {};
return sent.err && !closedOnUnbind ? { err: sent.err } : drained;
}
/** Closes for good. A session closed this way never reconnects. */
close(): void {
/**
* Closes for good: refuses new sends, waits out the requests already on the wire up to
* `shutdownTimeout`, then tears down whatever is left. A session closed this way never reconnects.
*/
async close(): Promise<VoidResult> {
this.reconnectLoop?.stop();
this.teardown();
this.dlrMerger.clear();
const drained = await this.drain();
this.shutdown();
return drained;
}
private loopFor(reconnect: ReconnectOptions | undefined): ReconnectLoop | undefined {
@@ -311,6 +330,34 @@ export class Session extends EventEmitter<SessionEvents> {
return response;
}
/** Stops new sends and waits out the ones already issued. A dead link has nothing to wait for. */
private async drain(): Promise<VoidResult> {
this.draining = true;
this.timers.clear();
if (this.closed || this.sock.destroyed) return {};
const timeout = this.options.shutdownTimeout ?? defaults.shutdownTimeout;
const inFlight = await this.window.idle(timeout);
if (inFlight === 0) return {};
this.log.warn('session - shutting down with requests still in flight', { inFlight, timeout });
return { err: new Error(`Shut down with ${String(inFlight)} request(s) still in flight`) };
}
private shutdown(): void {
this.teardown();
this.dlrMerger.clear();
}
/** The link is unusable, so nothing can answer and there is nothing to drain. */
private abort(): void {
this.reconnectLoop?.stop();
this.shutdown();
}
private teardown(): void {
if (this.closed) return;
@@ -348,7 +395,7 @@ export class Session extends EventEmitter<SessionEvents> {
if (framed.err) {
this.log.warn('session - unusable stream, closing', { message: framed.err.message });
this.emit('sessionError', framed.err);
this.close();
this.abort();
return;
}
@@ -369,7 +416,7 @@ export class Session extends EventEmitter<SessionEvents> {
message: parsed.err.message,
});
this.emit('sessionError', parsed.err);
this.close();
this.abort();
return false;
}