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:
2026-09-05 22:47:06 +02:00
committed by GitHub
parent 45d2f5548e
commit afe188eecd
8 changed files with 467 additions and 56 deletions
+17
View File
@@ -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 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`. 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.** - **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 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 16 or above `maxPduLength` leaves nothing that can say where the next PDU starts, so it tears the
+8 -1
View File
@@ -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. | | `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. | | `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. | | `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). | | `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. | | `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 ### Sending
```javascript ```javascript
+134 -34
View File
@@ -6,6 +6,7 @@ import type { SmsIdFormat } from './sms-id.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
export type { BindType }; export type { BindType };
import { ReconnectLoop } from './reconnect-loop.ts';
import { Session } from './session.ts'; import { Session } from './session.ts';
import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts'; import { checkSessionOptions, undeclaredInterfaceVersion } from './session-options.ts';
import { connect as netConnect } from 'node:net'; 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 { defaultInterfaceVersion } from './defs/constants.ts';
import { guardedLog } from './log.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 = { export type ClientOptions = {
addressRange?: string; addressRange?: string;
addrNpi?: number; addrNpi?: number;
@@ -26,7 +30,7 @@ export type ClientOptions = {
maxOutstanding?: number; maxOutstanding?: number;
password?: string; password?: string;
port?: number; port?: number;
reconnect?: { maxDelay?: number; minDelay?: number } | false; reconnect?: ReconnectTuning | false;
responseTimeout?: number; responseTimeout?: number;
shutdownTimeout?: number; shutdownTimeout?: number;
signal?: AbortSignal; 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) { function bindParams(options: ClientOptions, systemId: string) {
return { return {
address_range: options.addressRange ?? '', address_range: options.addressRange ?? '',
@@ -139,13 +158,13 @@ async function bind(session: Session, options: ClientOptions): Promise<VoidResul
return {}; return {};
} }
function reconnectFor(options: ClientOptions): ReconnectOptions | undefined { function reconnectFor(options: ClientOptions, log: SmppLog): ReconnectOptions | undefined {
if (options.reconnect === false) return undefined; if (options.reconnect === false) return undefined;
const tuning = options.reconnect ?? {}; const tuning = options.reconnect ?? {};
return { return {
connect: () => openSocket(options), connect: () => connectSocket(options, log),
maxDelay: tuning.maxDelay, maxDelay: tuning.maxDelay,
minDelay: tuning.minDelay, minDelay: tuning.minDelay,
onConnected: reconnected => bind(reconnected, options), onConnected: reconnected => bind(reconnected, options),
@@ -160,7 +179,7 @@ function createSession(options: ClientOptions, log: SmppLog, sock: Socket): Sess
idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor, idleTimeout: options.idleTimeout ?? enquireLinkInterval * defaults.idleTimeoutFactor,
log, log,
maxOutstanding: options.maxOutstanding, maxOutstanding: options.maxOutstanding,
reconnect: reconnectFor(options), reconnect: reconnectFor(options, log),
responseTimeout: options.responseTimeout, responseTimeout: options.responseTimeout,
shutdownTimeout: options.shutdownTimeout, shutdownTimeout: options.shutdownTimeout,
smsIdFormat: options.smsIdFormat, 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 }>> { async function connectAndBind(
const checked = checkSessionOptions(options); options: ClientOptions,
log: SmppLog,
if (checked.err) { ): Promise<Result<{ session: Session }>> {
log.warn('client - unusable option', { message: checked.err.message }); const opened = await connectSocket(options, log);
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);
if (opened.err) return { err: opened.err }; 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; const signal = options.signal;
if (signal?.aborted === true) { if (signal?.aborted === true) {
@@ -208,12 +211,16 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
return { err: new Error('Aborted before binding') }; 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. // 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); const bound = await bind(session, options);
if (bound.err) { 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 }); void session.close({ signal });
return { err: bound.err }; return { err: bound.err };
@@ -221,3 +228,96 @@ export async function client(options: ClientOptions = {}): Promise<Result<{ sess
return { session }; 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
View File
@@ -2,18 +2,27 @@ import type { Result, VoidResult } from './result.ts';
import type { SmppLog } from './log.ts'; import type { SmppLog } from './log.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
export const backoffDefaults = {
maxDelay: 30_000,
minDelay: 1000,
};
export type ReconnectLoopOptions = { export type ReconnectLoopOptions = {
connect: () => Promise<Result<{ sock: Socket }>>; connect: () => Promise<Result<{ sock: Socket }>>;
log: SmppLog; log: SmppLog;
maxDelay: number; maxDelay?: number | undefined;
minDelay: number; minDelay?: number | undefined;
now?: (() => number) | undefined; now?: (() => number) | undefined;
/** Brings the owner back up on a freshly opened socket. An err means try again. */ /** Brings the owner back up on a freshly opened socket. An err means try again. */
onConnected: (sock: Socket) => Promise<VoidResult>; 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. */ /** Reopens a dropped connection, backing off between attempts until it is told to stop. */
export class ReconnectLoop { export class ReconnectLoop {
private readonly maxDelay: number;
private readonly minDelay: number;
private readonly now: () => number; private readonly now: () => number;
private readonly options: ReconnectLoopOptions; private readonly options: ReconnectLoopOptions;
private attempting = false; private attempting = false;
@@ -23,9 +32,11 @@ export class ReconnectLoop {
private upAt: number | undefined; private upAt: number | undefined;
constructor(options: ReconnectLoopOptions) { constructor(options: ReconnectLoopOptions) {
this.maxDelay = options.maxDelay ?? backoffDefaults.maxDelay;
this.minDelay = options.minDelay ?? backoffDefaults.minDelay;
this.now = options.now ?? Date.now; this.now = options.now ?? Date.now;
this.options = options; this.options = options;
this.delay = options.minDelay; this.delay = this.minDelay;
} }
/** Read through a method: stop() can land while an attempt is awaiting. */ /** 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; 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. // 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) { if (this.upAt !== undefined && this.now() - this.upAt >= this.maxDelay) {
this.delay = this.options.minDelay; this.delay = this.minDelay;
} }
this.upAt = undefined; this.upAt = undefined;
const delay = this.delay; 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 = setTimeout(() => {
this.timer = undefined; this.timer = undefined;
this.options.log.info('reconnect - retrying', { delay });
void this.run(); void this.run();
}, delay); }, 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 { stop(): void {
+17 -8
View File
@@ -7,6 +7,7 @@ import type { SmppLog } from './log.ts';
import type { SmsIdFormat } from './sms-id.ts'; import type { SmsIdFormat } from './sms-id.ts';
import type { Sms } from './sms.ts'; import type { Sms } from './sms.ts';
import type { Socket } from 'node:net'; import type { Socket } from 'node:net';
import { backoffDefaults } from './reconnect-loop.ts';
import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts'; import { isSmsIdNotation, smsIdNotations, smsIdPlaces } from './sms-id.ts';
export type SessionEvents = { export type SessionEvents = {
@@ -103,12 +104,10 @@ export const defaults = {
dlrMergeTimeout: 86_400_000, dlrMergeTimeout: 86_400_000,
/** The peer gave up on an unanswered message long before this; the bound is against growth. */ /** The peer gave up on an unanswered message long before this; the bound is against growth. */
heldMessageTimeout: 300_000, heldMessageTimeout: 300_000,
maxDelay: 30_000,
maxDlrMerges: 1000, maxDlrMerges: 1000,
maxHeldMessages: 1000, maxHeldMessages: 1000,
maxOutstanding: 10, maxOutstanding: 10,
maxReassembly: 1000, maxReassembly: 1000,
minDelay: 1000,
reassemblyTimeout: 300_000, reassemblyTimeout: 300_000,
responseTimeout: 30_000, responseTimeout: 30_000,
shutdownTimeout: 5000, shutdownTimeout: 5000,
@@ -120,6 +119,10 @@ export const defaults = {
* queued behind a slot that is never freed, so the call never settles at all. * queued behind a slot that is never freed, so the call never settles at all.
*/ */
export function checkSessionOptions(options: CheckableOptions): VoidResult { 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([ const checked = checkLimits([
['idleTimeout', options.idleTimeout ?? 0, 0], ['idleTimeout', options.idleTimeout ?? 0, 0],
['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1], ['maxOutstanding', options.maxOutstanding ?? defaults.maxOutstanding, 1],
@@ -146,23 +149,27 @@ function checkLimits(limits: [string, number, number][]): VoidResult {
return {}; return {};
} }
const backoffDelays: readonly string[] = ['maxDelay', 'minDelay']; const reconnectKeys: readonly string[] = ['fromStart', 'maxDelay', 'minDelay'];
function checkReconnect(reconnect: unknown): VoidResult { function checkReconnect(reconnect: unknown): VoidResult {
if (reconnect === undefined || reconnect === false) return {}; if (reconnect === undefined || reconnect === false) return {};
if (!isRecord(reconnect)) { 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)) { for (const key of Object.keys(reconnect)) {
if (!backoffDelays.includes(key)) { if (!reconnectKeys.includes(key)) {
return { err: new Error(`reconnect has no ${key}, name ${backoffDelays.join(' or ')}`) }; return { err: new Error(`reconnect has no ${key}, name ${reconnectKeys.join(', ')}`) };
} }
} }
const maxDelay = delayOr(reconnect.maxDelay, defaults.maxDelay); if (reconnect.fromStart !== undefined && typeof reconnect.fromStart !== 'boolean') {
const minDelay = delayOr(reconnect.minDelay, defaults.minDelay); 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. // 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]]); 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. */ /** What the checker reads, as it arrives: a caller without types can put anything in it. */
export type CheckableOptions = { 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; idleTimeout?: number | undefined;
maxOutstanding?: number | undefined; maxOutstanding?: number | undefined;
maxReassembly?: number | undefined; maxReassembly?: number | undefined;
+2 -2
View File
@@ -265,8 +265,8 @@ export class Session extends EventEmitter<SessionEvents> {
return new ReconnectLoop({ return new ReconnectLoop({
connect: reconnect.connect, connect: reconnect.connect,
log: this.log, log: this.log,
maxDelay: reconnect.maxDelay ?? defaults.maxDelay, maxDelay: reconnect.maxDelay,
minDelay: reconnect.minDelay ?? defaults.minDelay, minDelay: reconnect.minDelay,
onConnected: sock => this.comeBackUp(sock, reconnect.onConnected), onConnected: sock => this.comeBackUp(sock, reconnect.onConnected),
}); });
} }
+267 -1
View File
@@ -74,6 +74,11 @@ function delay(ms: number): Promise<void> {
return new Promise(resolve => { setTimeout(resolve, ms); }); 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 { function submitPdu(seqNr: number, cmdStatus: ErrorName = 'ESME_ROK'): PduObject {
return { return {
cmdId: 0x00000004, cmdId: 0x00000004,
@@ -547,7 +552,7 @@ describe('reconnect', () => {
await closed; await closed;
assert.equal(disconnects, 0); 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 => { 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', () => { describe('sends across a reconnect', () => {
/** Answers every message after the first, which is left to hold the send window open. */ /** Answers every message after the first, which is left to hold the send window open. */
function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch { function answerAfterTheFirst(smpp: SmppServer, arrived: string[]): Latch {
+1 -1
View File
@@ -1638,7 +1638,7 @@ describe('application hooks that throw or reject', () => {
debug: noop, debug: noop,
error: noop, error: noop,
info: (msg, metadata) => { 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, verbose: noop,
warn: noop, warn: noop,