Retry the first connect and bind on reconnect fromStart (#80)
* Regression tests for a client that retries its first connect and bind
* Retry the first connect and bind on reconnect: { fromStart: true }
* Pin that the signal bounding the first bind also closes the session
* Name a failed connect once, default the backoff where it lives, and fix the deadline advice
* Carry the failure that started the retries on the abort that ends them
* Pin one retry announcement per attempt when a link drops mid-bind
* Announce a retry when its wait is over, not when it is scheduled
* Say what the process-hold assertion expects, not what a failure would mean
This commit is contained in:
+134
-34
@@ -6,6 +6,7 @@ import type { SmsIdFormat } from './sms-id.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
export type { BindType };
|
||||
|
||||
import { ReconnectLoop } from './reconnect-loop.ts';
|
||||
import { Session } from './session.ts';
|
||||
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
|
||||
import { connect as netConnect } from 'node:net';
|
||||
@@ -13,6 +14,9 @@ import { connect as tlsConnect } from 'node:tls';
|
||||
import { defaultInterfaceVersion } from './defs/constants.ts';
|
||||
import { guardedLog } from './log.ts';
|
||||
|
||||
/** `fromStart` puts the very first connect and bind through the same backoff loop as a drop. */
|
||||
type ReconnectTuning = { fromStart?: boolean; maxDelay?: number; minDelay?: number };
|
||||
|
||||
export type ClientOptions = {
|
||||
addressRange?: string;
|
||||
addrNpi?: number;
|
||||
@@ -26,7 +30,7 @@ export type ClientOptions = {
|
||||
maxOutstanding?: number;
|
||||
password?: string;
|
||||
port?: number;
|
||||
reconnect?: { maxDelay?: number; minDelay?: number } | false;
|
||||
reconnect?: ReconnectTuning | false;
|
||||
responseTimeout?: number;
|
||||
shutdownTimeout?: number;
|
||||
signal?: AbortSignal;
|
||||
@@ -96,6 +100,21 @@ function openSocket(options: ClientOptions): Promise<Result<{ sock: Socket }>> {
|
||||
});
|
||||
}
|
||||
|
||||
/** Every connect this client makes goes through here, so a failed one is named the same way once. */
|
||||
async function connectSocket(options: ClientOptions, log: SmppLog): Promise<Result<{ sock: Socket }>> {
|
||||
const opened = await openSocket(options);
|
||||
|
||||
if (opened.err) {
|
||||
log.warn('client - could not connect', {
|
||||
host: options.host ?? defaults.host,
|
||||
message: opened.err.message,
|
||||
port: options.port ?? defaults.port,
|
||||
});
|
||||
}
|
||||
|
||||
return opened;
|
||||
}
|
||||
|
||||
function bindParams(options: ClientOptions, systemId: string) {
|
||||
return {
|
||||
address_range: options.addressRange ?? '',
|
||||
@@ -139,13 +158,13 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
|
||||
return {};
|
||||
}
|
||||
|
||||
function reconnectFor(options: ClientOptions): ReconnectOptions | undefined {
|
||||
function reconnectFor(options: ClientOptions, log: SmppLog): ReconnectOptions | undefined {
|
||||
if (options.reconnect === false) return undefined;
|
||||
|
||||
const tuning = options.reconnect ?? {};
|
||||
|
||||
return {
|
||||
connect: () => openSocket(options),
|
||||
connect: () => connectSocket(options, log),
|
||||
maxDelay: tuning.maxDelay,
|
||||
minDelay: tuning.minDelay,
|
||||
onConnected: reconnected => bind(reconnected, options),
|
||||
@@ -160,7 +179,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
|
||||
idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor,
|
||||
log,
|
||||
maxOutstanding: options.maxOutstanding,
|
||||
reconnect: reconnectFor(options),
|
||||
reconnect: reconnectFor(options, log),
|
||||
responseTimeout: options.responseTimeout,
|
||||
shutdownTimeout: options.shutdownTimeout,
|
||||
smsIdFormat: options.smsIdFormat,
|
||||
@@ -168,38 +187,22 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
|
||||
});
|
||||
}
|
||||
|
||||
async function connect(options: ClientOptions, log: SmppLog): Promise<Result<{ sock: Socket }>> {
|
||||
const checked = checkSessionOptions(options);
|
||||
|
||||
if (checked.err) {
|
||||
log.warn('client - unusable option', { message: checked.err.message });
|
||||
|
||||
return { err: checked.err };
|
||||
}
|
||||
|
||||
const opened = await openSocket(options);
|
||||
|
||||
if (opened.err) {
|
||||
log.warn('client - could not connect', {
|
||||
host: options.host ?? defaults.host,
|
||||
message: opened.err.message,
|
||||
port: options.port ?? defaults.port,
|
||||
});
|
||||
|
||||
return { err: opened.err };
|
||||
}
|
||||
|
||||
return { sock: opened.sock };
|
||||
}
|
||||
|
||||
/** Connects to an SMSC and binds. */
|
||||
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
|
||||
const log = guardedLog(options.log);
|
||||
const opened = await connect(options, log);
|
||||
async function connectAndBind(
|
||||
options: ClientOptions,
|
||||
log: SmppLog,
|
||||
): Promise<Result<{ session: Session }>> {
|
||||
const opened = await connectSocket(options, log);
|
||||
|
||||
if (opened.err) return { err: opened.err };
|
||||
|
||||
const session = createSession(options, log, opened.sock);
|
||||
return bindOn(createSession(options, log, opened.sock), options);
|
||||
}
|
||||
|
||||
/** Binds a session the caller has not seen yet, so a failure takes it down instead of surfacing. */
|
||||
async function bindOn(
|
||||
session: Session,
|
||||
options: ClientOptions,
|
||||
): Promise<Result<{ session: Session }>> {
|
||||
const signal = options.signal;
|
||||
|
||||
if (signal?.aborted === true) {
|
||||
@@ -208,12 +211,16 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
|
||||
return { err: new Error('Aborted before binding') };
|
||||
}
|
||||
|
||||
const onAbort = (): void => { void session.close({ signal }); };
|
||||
|
||||
// Registered before the bind: an abort landing while it is in flight has to close the session.
|
||||
signal?.addEventListener('abort', () => { void session.close({ signal }); }, { once: true });
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
|
||||
const bound = await bind(session, options);
|
||||
|
||||
if (bound.err) {
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
// close() must reach the loop's stop() before its first await, or this session retries too.
|
||||
void session.close({ signal });
|
||||
|
||||
return { err: bound.err };
|
||||
@@ -221,3 +228,96 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
|
||||
|
||||
return { session };
|
||||
}
|
||||
|
||||
function retriesFromStart(reconnect: ClientOptions['reconnect']): reconnect is ReconnectTuning {
|
||||
return reconnect !== undefined && reconnect !== false && reconnect.fromStart === true;
|
||||
}
|
||||
|
||||
/** A fresh session per attempt, and the failure to answer an abort with when none of them binds. */
|
||||
function initialAttempts(options: ClientOptions, log: SmppLog, failed: Error) {
|
||||
let lastErr = failed;
|
||||
|
||||
return {
|
||||
bind: async (sock: Socket): Promise<Result<{ session: Session }>> => {
|
||||
const bound = await bindOn(createSession(options, log, sock), options);
|
||||
|
||||
if (bound.err) lastErr = bound.err;
|
||||
|
||||
return bound;
|
||||
},
|
||||
connect: async (): Promise<Result<{ sock: Socket }>> => {
|
||||
const opened = await connectSocket(options, log);
|
||||
|
||||
if (opened.err) lastErr = opened.err;
|
||||
|
||||
return opened;
|
||||
},
|
||||
lastErr: (): Error => lastErr,
|
||||
};
|
||||
}
|
||||
|
||||
/** Retries the first connect and bind, on the backoff a drop takes, until one of them binds. */
|
||||
function keepTrying(
|
||||
options: ClientOptions,
|
||||
log: SmppLog,
|
||||
tuning: ReconnectTuning,
|
||||
failed: Error,
|
||||
): Promise<Result<{ session: Session }>> {
|
||||
return new Promise(resolve => {
|
||||
const attempts = initialAttempts(options, log, failed);
|
||||
const signal = options.signal;
|
||||
let settled = false;
|
||||
const loop = new ReconnectLoop({
|
||||
connect: attempts.connect,
|
||||
log,
|
||||
maxDelay: tuning.maxDelay,
|
||||
minDelay: tuning.minDelay,
|
||||
onConnected: async sock => {
|
||||
const bound = await attempts.bind(sock);
|
||||
|
||||
if (bound.err) return { err: bound.err };
|
||||
|
||||
settle({ session: bound.session });
|
||||
|
||||
return {};
|
||||
},
|
||||
// Awaited with no other handle, so an unref()'d wait would exit the process unbound.
|
||||
unref: false,
|
||||
});
|
||||
|
||||
function settle(result: Result<{ session: Session }>): void {
|
||||
if (settled) return;
|
||||
|
||||
settled = true;
|
||||
loop.stop();
|
||||
signal?.removeEventListener('abort', onAbort);
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onAbort(): void {
|
||||
settle({ err: new Error('Aborted while connecting', { cause: attempts.lastErr() }) });
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true });
|
||||
loop.schedule();
|
||||
});
|
||||
}
|
||||
|
||||
/** Connects to an SMSC and binds. */
|
||||
export async function client(options: ClientOptions = {}): Promise<Result<{ session: Session }>> {
|
||||
const log = guardedLog(options.log);
|
||||
const checked = checkSessionOptions(options);
|
||||
|
||||
if (checked.err) {
|
||||
log.warn('client - unusable option', { message: checked.err.message });
|
||||
|
||||
return { err: checked.err };
|
||||
}
|
||||
|
||||
const first = await connectAndBind(options, log);
|
||||
const reconnect = options.reconnect;
|
||||
|
||||
if (!first.err || options.signal?.aborted === true || !retriesFromStart(reconnect)) return first;
|
||||
|
||||
return keepTrying(options, log, reconnect, first.err);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user