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:
@@ -382,6 +382,23 @@ Grouped by what each one constrains.
|
||||
on arrival a fresh `minDelay` every cycle — one TCP connect and bind per second, forever. A drop
|
||||
after a healthy link still retries at `minDelay`.
|
||||
|
||||
- **`reconnect: { fromStart: true }` puts the first connect and bind through that same loop, and
|
||||
`client()` then resolves only once it is bound.** Maintainer's call, 2026-09-05: an application
|
||||
started before its SMSC is up otherwise writes that retry itself, around the one this library
|
||||
already owns. A field on `reconnect` rather than an option of its own, so the combination that
|
||||
would contradict `false` cannot be written at all — `false` carries no fields — and a top-level
|
||||
`fromStart` is refused by name rather than ignored. Nothing but the caller's `signal` ends the
|
||||
wait: a bound of its own would be a second spelling of a deadline the caller already writes with
|
||||
that signal, and giving up after one is what the default does. A bind the SMSC refuses is
|
||||
retried like any other failure — rejected: giving up on `ESME_RINVPASWD` and `ESME_RBINDFAIL`,
|
||||
which would have the initial attempts and a rebind disagree about what a refused bind means, and
|
||||
gives up on the operator whose provisioning lands a minute later; the backoff is what bounds the
|
||||
rate goal 4 cares about. The attempts before the first link report nothing, because the session
|
||||
running one has not reached the application: `disconnected` would have no listener and `close`
|
||||
would be a lie. Its wait is the one retry timer that is not `unref()`'d, for the reason
|
||||
`LinkGate`'s hold is not — it is awaited with no other handle, so a process whose only work is
|
||||
`client()` would exit unbound.
|
||||
|
||||
- **A stream this library cannot frame is a dead link; one PDU it cannot parse is not.**
|
||||
Maintainer's call, 2026-08-31, narrowed 2026-09-05 via the interop plan: a `command_length` below
|
||||
16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
|
||||
|
||||
@@ -78,10 +78,17 @@ Every one is optional.
|
||||
| `shutdownTimeout` | `5000` | How long `close()` and `unbind()` wait for the requests this end already sent and the messages the application has not answered. `0` waits forever for the requests, which end when the peer answers or `responseTimeout` expires — so setting both to `0` never ends. The messages fall back to `responseTimeout`, or to its default where that is `0` too, since nothing but the application ends that wait. |
|
||||
| `maxOutstanding` | `10` | Requests allowed on the wire at once; further sends queue. |
|
||||
| `smsIdFormat` | — | The notation the SMSC writes message ids in, per place it writes them: `{ receipt: 'decimal', submitResp: 'hex' }`. Only needed where the two disagree. |
|
||||
| `reconnect` | on | Re-binds after a drop, an idle timeout, or a stream the library cannot frame, backing off from `minDelay` 1 s to `maxDelay` 30 s and starting over at `minDelay` once a link has lasted `maxDelay`. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session. |
|
||||
| `reconnect` | on | Re-binds after a drop, an idle timeout, or a stream the library cannot frame, backing off from `minDelay` 1 s to `maxDelay` 30 s and starting over at `minDelay` once a link has lasted `maxDelay`. `{ minDelay, maxDelay }` retunes it; `false` turns it off, so a drop ends the session; `{ fromStart: true }` retries the first connect and bind too — see below. |
|
||||
| `log` | silent | Any object with `debug`, `error`, `info`, `verbose` and `warn` methods — see [Logging](#logging). |
|
||||
| `signal` | — | An `AbortSignal` that cancels connecting and tears the session down. |
|
||||
|
||||
`reconnect: { fromStart: true }` puts the very first connect and bind through that same loop, so a
|
||||
client started while its SMSC is down keeps retrying — a bind the SMSC refuses included — instead of
|
||||
failing on the first attempt. `client()` then resolves once it is bound, and nothing but an aborted
|
||||
`signal` ends the wait, however long the SMSC stays down. That signal also closes the session once it
|
||||
is bound, so write a deadline for the wait as an `AbortController` you stop arming when `client()`
|
||||
returns, rather than as `AbortSignal.timeout(ms)`, which would close the session it just bound.
|
||||
|
||||
### Sending
|
||||
|
||||
```javascript
|
||||
|
||||
+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);
|
||||
}
|
||||
|
||||
+21
-9
@@ -2,18 +2,27 @@ import type { Result, VoidResult } from './result.ts';
|
||||
import type { SmppLog } from './log.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
|
||||
export const backoffDefaults = {
|
||||
maxDelay: 30_000,
|
||||
minDelay: 1000,
|
||||
};
|
||||
|
||||
export type ReconnectLoopOptions = {
|
||||
connect: () => Promise<Result<{ sock: Socket }>>;
|
||||
log: SmppLog;
|
||||
maxDelay: number;
|
||||
minDelay: number;
|
||||
maxDelay?: number | undefined;
|
||||
minDelay?: number | undefined;
|
||||
now?: (() => number) | undefined;
|
||||
/** Brings the owner back up on a freshly opened socket. An err means try again. */
|
||||
onConnected: (sock: Socket) => Promise<VoidResult>;
|
||||
/** Whether the wait between attempts lets the process exit. Default true. */
|
||||
unref?: boolean | undefined;
|
||||
};
|
||||
|
||||
/** Reopens a dropped connection, backing off between attempts until it is told to stop. */
|
||||
export class ReconnectLoop {
|
||||
private readonly maxDelay: number;
|
||||
private readonly minDelay: number;
|
||||
private readonly now: () => number;
|
||||
private readonly options: ReconnectLoopOptions;
|
||||
private attempting = false;
|
||||
@@ -23,9 +32,11 @@ export class ReconnectLoop {
|
||||
private upAt: number | undefined;
|
||||
|
||||
constructor(options: ReconnectLoopOptions) {
|
||||
this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay;
|
||||
this.minDelay = options.minDelay ?? backoffDefaults.minDelay;
|
||||
this.now = options.now ?? Date.now;
|
||||
this.options = options;
|
||||
this.delay = options.minDelay;
|
||||
this.delay = this.minDelay;
|
||||
}
|
||||
|
||||
/** Read through a method: stop() can land while an attempt is awaiting. */
|
||||
@@ -37,23 +48,24 @@ export class ReconnectLoop {
|
||||
if (this.timer || this.attempting || this.isStopped()) return;
|
||||
|
||||
// Coming up is not proof: a stream we cannot read is only found once the link is bound.
|
||||
if (this.upAt !== undefined && this.now() - this.upAt >= this.options.maxDelay) {
|
||||
this.delay = this.options.minDelay;
|
||||
if (this.upAt !== undefined && this.now() - this.upAt >= this.maxDelay) {
|
||||
this.delay = this.minDelay;
|
||||
}
|
||||
|
||||
this.upAt = undefined;
|
||||
|
||||
const delay = this.delay;
|
||||
|
||||
this.options.log.info('reconnect - retrying after a drop', { delay });
|
||||
|
||||
// Announced when the wait is over rather than when it starts: a cancelled one never happened.
|
||||
this.timer = setTimeout(() => {
|
||||
this.timer = undefined;
|
||||
this.options.log.info('reconnect - retrying', { delay });
|
||||
void this.run();
|
||||
}, delay);
|
||||
this.timer.unref();
|
||||
|
||||
this.delay = Math.min(delay * 2, this.options.maxDelay);
|
||||
if (this.options.unref ?? true) this.timer.unref();
|
||||
|
||||
this.delay = Math.min(delay * 2, this.maxDelay);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
|
||||
+17
-8
@@ -7,6 +7,7 @@ import type { SmppLog } from './log.ts';
|
||||
import type { SmsIdFormat } from './sms-id.ts';
|
||||
import type { Sms } from './sms.ts';
|
||||
import type { Socket } from 'node:net';
|
||||
import { backoffDefaults } from './reconnect-loop.ts';
|
||||
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
|
||||
|
||||
export type SessionEvents = {
|
||||
@@ -103,12 +104,10 @@ export const defaults = {
|
||||
dlrMergeTimeout: 86_400_000,
|
||||
/** The peer gave up on an unanswered message long before this; the bound is against growth. */
|
||||
heldMessageTimeout: 300_000,
|
||||
maxDelay: 30_000,
|
||||
maxDlrMerges: 1000,
|
||||
maxHeldMessages: 1000,
|
||||
maxOutstanding: 10,
|
||||
maxReassembly: 1000,
|
||||
minDelay: 1000,
|
||||
reassemblyTimeout: 300_000,
|
||||
responseTimeout: 30_000,
|
||||
shutdownTimeout: 5000,
|
||||
@@ -120,6 +119,10 @@ export const defaults = {
|
||||
* queued behind a slot that is never freed, so the call never settles at all.
|
||||
*/
|
||||
export function checkSessionOptions(options: CheckableOptions): VoidResult {
|
||||
if (options.fromStart !== undefined) {
|
||||
return { err: new Error('fromStart is part of the reconnect policy, spell it reconnect: { fromStart: true }') };
|
||||
}
|
||||
|
||||
const checked = checkLimits([
|
||||
['idleTimeout', options.idleTimeout ?? 0, 0],
|
||||
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
|
||||
@@ -146,23 +149,27 @@ function checkLimits(limits: [string, number, number][]): VoidResult {
|
||||
return {};
|
||||
}
|
||||
|
||||
const backoffDelays: readonly string[] = ['maxDelay', 'minDelay'];
|
||||
const reconnectKeys: readonly string[] = ['fromStart', 'maxDelay', 'minDelay'];
|
||||
|
||||
function checkReconnect(reconnect: unknown): VoidResult {
|
||||
if (reconnect === undefined || reconnect === false) return {};
|
||||
|
||||
if (!isRecord(reconnect)) {
|
||||
return { err: new Error('reconnect takes { maxDelay, minDelay }, or false to turn it off') };
|
||||
return { err: new Error('reconnect takes { fromStart, maxDelay, minDelay }, or false to turn it off') };
|
||||
}
|
||||
|
||||
for (const key of Object.keys(reconnect)) {
|
||||
if (!backoffDelays.includes(key)) {
|
||||
return { err: new Error(`reconnect has no ${key}, name ${backoffDelays.join(' or ')}`) };
|
||||
if (!reconnectKeys.includes(key)) {
|
||||
return { err: new Error(`reconnect has no ${key}, name ${reconnectKeys.join(', ')}`) };
|
||||
}
|
||||
}
|
||||
|
||||
const maxDelay = delayOr(reconnect.maxDelay, defaults.maxDelay);
|
||||
const minDelay = delayOr(reconnect.minDelay, defaults.minDelay);
|
||||
if (reconnect.fromStart !== undefined && typeof reconnect.fromStart !== 'boolean') {
|
||||
return { err: new Error(`reconnect.fromStart must be true or false, got ${typeof reconnect.fromStart}`) };
|
||||
}
|
||||
|
||||
const maxDelay = delayOr(reconnect.maxDelay, backoffDefaults.maxDelay);
|
||||
const minDelay = delayOr(reconnect.minDelay, backoffDefaults.minDelay);
|
||||
// A delay of 0 never doubles, so the backoff never starts and every retry lands at once.
|
||||
const checked = checkLimits([['maxDelay', maxDelay, 1], ['minDelay', minDelay, 1]]);
|
||||
|
||||
@@ -211,6 +218,8 @@ function checkSmsIdFormat(smsIdFormat: unknown): VoidResult {
|
||||
|
||||
/** What the checker reads, as it arrives: a caller without types can put anything in it. */
|
||||
export type CheckableOptions = {
|
||||
/** Not an option: the one spelling is inside reconnect, and this is where the other is refused. */
|
||||
fromStart?: unknown;
|
||||
idleTimeout?: number | undefined;
|
||||
maxOutstanding?: number | undefined;
|
||||
maxReassembly?: number | undefined;
|
||||
|
||||
+2
-2
@@ -265,8 +265,8 @@ export class Session extends EventEmitter<SessionEvents> {
|
||||
return new ReconnectLoop({
|
||||
connect: reconnect.connect,
|
||||
log: this.log,
|
||||
maxDelay: reconnect.maxDelay ?? defaults.maxDelay,
|
||||
minDelay: reconnect.minDelay ?? defaults.minDelay,
|
||||
maxDelay: reconnect.maxDelay,
|
||||
minDelay: reconnect.minDelay,
|
||||
onConnected: sock => this.comeBackUp(sock, reconnect.onConnected),
|
||||
});
|
||||
}
|
||||
|
||||
+267
-1
@@ -74,6 +74,11 @@ function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => { setTimeout(resolve, ms); });
|
||||
}
|
||||
|
||||
/** Undefined where the promise never settled, which is an assertion rather than a hung run. */
|
||||
function within<T>(ms: number, promise: Promise<T>): Promise<T | undefined> {
|
||||
return Promise.race([promise, delay(ms).then((): undefined => undefined)]);
|
||||
}
|
||||
|
||||
function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
|
||||
return {
|
||||
cmdId: 0x00000004,
|
||||
@@ -547,7 +552,7 @@ describe('reconnect', () => {
|
||||
await closed;
|
||||
|
||||
assert.equal(disconnects, 0);
|
||||
assert.ok(!infos.includes('reconnect - retrying after a drop'));
|
||||
assert.ok(!infos.includes('reconnect - retrying'));
|
||||
});
|
||||
|
||||
test('re-binds after the connection drops, keeping the same session object', async t => {
|
||||
@@ -732,6 +737,267 @@ describe('reconnect', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('reconnect from the first bind', () => {
|
||||
type LogSpy = { delays: number[]; log: SmppLog; messages: string[] };
|
||||
|
||||
/** The backoff is only visible in the log, which is where the loop announces each wait. */
|
||||
function logSpy(): LogSpy {
|
||||
const delays: number[] = [];
|
||||
const messages: string[] = [];
|
||||
const noop = (): void => undefined;
|
||||
const record = (msg: string): void => { messages.push(msg); };
|
||||
|
||||
return {
|
||||
delays,
|
||||
log: {
|
||||
debug: noop,
|
||||
error: record,
|
||||
info: (msg, metadata) => {
|
||||
messages.push(msg);
|
||||
|
||||
if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay));
|
||||
},
|
||||
verbose: noop,
|
||||
warn: record,
|
||||
},
|
||||
messages,
|
||||
};
|
||||
}
|
||||
|
||||
/** A port nothing answers on: taken and given straight back. */
|
||||
async function closedPort(): Promise<number> {
|
||||
const listener = net.createServer();
|
||||
|
||||
await new Promise<void>(resolve => { listener.listen(0, resolve); });
|
||||
|
||||
const address = listener.address();
|
||||
const port = typeof address === 'object' && address !== null ? address.port : 0;
|
||||
|
||||
await new Promise<void>(resolve => { listener.close(() => { resolve(); }); });
|
||||
|
||||
return port;
|
||||
}
|
||||
|
||||
/** A client still retrying holds a socket and a timer nothing else releases. */
|
||||
function abortAfter(
|
||||
t: TestContext,
|
||||
controller: AbortController,
|
||||
connecting: ReturnType<typeof client>,
|
||||
): void {
|
||||
t.after(async () => {
|
||||
controller.abort();
|
||||
|
||||
const { session } = await connecting;
|
||||
|
||||
await session?.close({ signal: AbortSignal.abort() });
|
||||
});
|
||||
}
|
||||
|
||||
test('gives up on the first attempt where reconnect alone is asked for', async () => {
|
||||
const port = await closedPort();
|
||||
const spy = logSpy();
|
||||
const started = Date.now();
|
||||
const { err, session } = await client({ log: spy.log, port, reconnect: { maxDelay: 40, minDelay: 10 } });
|
||||
|
||||
assert.ok(err instanceof Error);
|
||||
assert.equal(session, undefined);
|
||||
assert.ok(Date.now() - started < 1000, 'the default answers the caller rather than retrying');
|
||||
assert.ok(!spy.messages.includes('reconnect - retrying'), 'nothing may retry what the default gave up on');
|
||||
});
|
||||
|
||||
test('retries the first connect with backoff and binds once the SMSC listens', async t => {
|
||||
const port = await closedPort();
|
||||
const spy = logSpy();
|
||||
const controller = new AbortController();
|
||||
const connecting = client({
|
||||
log: spy.log,
|
||||
port,
|
||||
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
abortAfter(t, controller, connecting);
|
||||
await delay(200);
|
||||
|
||||
const listening = await server({ port });
|
||||
|
||||
assert.equal(listening.err, undefined);
|
||||
assert.ok(listening.server);
|
||||
closeAfter(t, listening.server);
|
||||
|
||||
const { err, session } = await connecting;
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
assert.equal(session.loggedIn, true);
|
||||
assert.ok(spy.delays.length >= 3, 'the SMSC was down for several attempts');
|
||||
assert.deepEqual(spy.delays.slice(0, 3), [10, 20, 40], 'each wait doubles, up to maxDelay');
|
||||
assert.ok(
|
||||
!spy.messages.includes('session - reconnected'),
|
||||
'a first link is not a link coming back',
|
||||
);
|
||||
});
|
||||
|
||||
// Bind flooding is what the backoff bounds; credentials an SMSC refuses now it may take later.
|
||||
test('retries a bind the SMSC refuses rather than giving up on it', async t => {
|
||||
let binds = 0;
|
||||
const spy = logSpy();
|
||||
const smpp = await startServer(t, { authenticate: () => ++binds > 2 });
|
||||
const controller = new AbortController();
|
||||
const connecting = client({
|
||||
log: spy.log,
|
||||
port: smpp.port,
|
||||
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
abortAfter(t, controller, connecting);
|
||||
|
||||
const { err, session } = await connecting;
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
assert.equal(binds, 3, 'the two refusals were retried, not reported');
|
||||
assert.deepEqual(spy.delays, [10, 20], 'a link the SMSC refused a bind on does not reset the backoff');
|
||||
});
|
||||
|
||||
// A drop while the bind is in flight is where the attempt's own session could retry as well.
|
||||
test('retries once per attempt when the peer drops the link mid-bind', async t => {
|
||||
let binds = 0;
|
||||
const spy = logSpy();
|
||||
const smpp = await startServer(t, {
|
||||
authenticate: input => {
|
||||
binds++;
|
||||
|
||||
if (binds > 2) return true;
|
||||
|
||||
input.session.sock.destroy();
|
||||
|
||||
return false;
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const connecting = client({
|
||||
log: spy.log,
|
||||
port: smpp.port,
|
||||
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
abortAfter(t, controller, connecting);
|
||||
|
||||
const { err, session } = await connecting;
|
||||
|
||||
assert.equal(err, undefined);
|
||||
assert.ok(session);
|
||||
assert.deepEqual(spy.delays, [10, 20], 'only the loop that owns the retry announces one');
|
||||
});
|
||||
|
||||
test('hands back a session that reported nothing and reconnects like any other', async t => {
|
||||
const port = await closedPort();
|
||||
const controller = new AbortController();
|
||||
const connecting = client({
|
||||
port,
|
||||
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
abortAfter(t, controller, connecting);
|
||||
await delay(100);
|
||||
|
||||
const listening = await server({ port });
|
||||
|
||||
assert.equal(listening.err, undefined);
|
||||
assert.ok(listening.server);
|
||||
closeAfter(t, listening.server);
|
||||
|
||||
const { session } = await connecting;
|
||||
|
||||
assert.ok(session);
|
||||
|
||||
const events: string[] = [];
|
||||
|
||||
session.on('close', () => { events.push('close'); });
|
||||
session.on('disconnected', () => { events.push('disconnected'); });
|
||||
|
||||
const reconnected = once<true>(resolve => { session.on('reconnected', () => { resolve(true); }); });
|
||||
|
||||
await peerOf(listening.server).close();
|
||||
await reconnected;
|
||||
|
||||
assert.deepEqual(events, ['disconnected'], 'the attempts before the first link reported nothing');
|
||||
|
||||
const closed = once<true>(resolve => { session.on('close', () => { resolve(true); }); });
|
||||
|
||||
controller.abort();
|
||||
await closed;
|
||||
|
||||
// Which is why a deadline for the wait may not be a signal that goes on arming afterwards.
|
||||
assert.deepEqual(events, ['disconnected', 'close'], 'the signal that bounded the wait ends the session');
|
||||
});
|
||||
|
||||
test('lets an abort out of the initial retries', async () => {
|
||||
const port = await closedPort();
|
||||
const controller = new AbortController();
|
||||
const connecting = client({
|
||||
port,
|
||||
reconnect: { fromStart: true, maxDelay: 40, minDelay: 10 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
await delay(60);
|
||||
controller.abort();
|
||||
|
||||
const settled = await within(2000, connecting);
|
||||
|
||||
assert.ok(settled, 'an aborted signal is the way out of a wait nothing else ends');
|
||||
assert.ok(settled.err instanceof Error);
|
||||
assert.ok(settled.err.cause instanceof Error, 'an abort carries what the attempts kept failing with');
|
||||
assert.equal(settled.session, undefined);
|
||||
});
|
||||
|
||||
test('holds the process open between the initial attempts', async t => {
|
||||
const port = await closedPort();
|
||||
const controller = new AbortController();
|
||||
const timeouts = (): number => process.getActiveResourcesInfo().filter(name => name === 'Timeout').length;
|
||||
const before = timeouts();
|
||||
const connecting = client({
|
||||
port,
|
||||
reconnect: { fromStart: true, maxDelay: 5000, minDelay: 5000 },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
abortAfter(t, controller, connecting);
|
||||
await delay(200);
|
||||
|
||||
assert.ok(timeouts() > before, 'the wait should hold a process whose only work is this client');
|
||||
|
||||
controller.abort();
|
||||
|
||||
const settled = await within(2000, connecting);
|
||||
|
||||
assert.ok(settled);
|
||||
assert.ok(settled.err instanceof Error);
|
||||
});
|
||||
|
||||
test('refuses fromStart spelled anywhere but inside reconnect', async () => {
|
||||
const misspelled = { fromStart: true, port: 1, reconnect: false } as const;
|
||||
|
||||
assert.match(
|
||||
checkSessionOptions(misspelled).err?.message ?? '',
|
||||
/reconnect: \{ fromStart: true \}/,
|
||||
'off and from-start contradict each other, so the one spelling has to be named',
|
||||
);
|
||||
|
||||
const refused = await client(misspelled);
|
||||
|
||||
assert.ok(refused.err instanceof Error);
|
||||
assert.equal(refused.session, undefined);
|
||||
assert.match(checkSessionOptions({ reconnect: { fromStart: 'yes' } }).err?.message ?? '', /fromStart/);
|
||||
assert.equal(checkSessionOptions({ reconnect: { fromStart: true, minDelay: 10 } }).err, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sends across a reconnect', () => {
|
||||
/** Answers every message after the first, which is left to hold the send window open. */
|
||||
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
|
||||
|
||||
@@ -1638,7 +1638,7 @@ describe('application hooks that throw or reject', () => {
|
||||
debug: noop,
|
||||
error: noop,
|
||||
info: (msg, metadata) => {
|
||||
if (msg === 'reconnect - retrying after a drop') delays.push(Number(metadata?.delay));
|
||||
if (msg === 'reconnect - retrying') delays.push(Number(metadata?.delay));
|
||||
},
|
||||
verbose: noop,
|
||||
warn: noop,
|
||||
|
||||
Reference in New Issue
Block a user