From afe188eecdf07ac5c20a920ae42086683608d43e Mon Sep 17 00:00:00 2001 From: Lillem4n Date: Sat, 5 Sep 2026 22:47:06 +0200 Subject: [PATCH] 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 --- AGENTS.md | 17 +++ README.md | 9 +- src/client.ts | 168 +++++++++++++++++----- src/reconnect-loop.ts | 30 ++-- src/session-options.ts | 25 ++-- src/session.ts | 4 +- test/session-extras.test.ts | 268 +++++++++++++++++++++++++++++++++++- test/session.test.ts | 2 +- 8 files changed, 467 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 1f8db30..2536e28 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 77a9733..6af455e 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/src/client.ts b/src/client.ts index 23aa374..b935c2f 100644 --- a/src/client.ts +++ b/src/client.ts @@ -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> { }); } +/** 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> { + 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 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> { - 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> { - const log = guardedLog(options.log); - const opened = await connect(options, log); +async function connectAndBind( + options: ClientOptions, + log: SmppLog, +): Promise> { + 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> { const signal = options.signal; if (signal?.aborted === true) { @@ -208,12 +211,16 @@ export async function client(options: ClientOptions = {}): Promise { 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> => { + const bound = await bindOn(createSession(options, log, sock), options); + + if (bound.err) lastErr = bound.err; + + return bound; + }, + connect: async (): Promise> => { + 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> { + 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> { + 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); +} diff --git a/src/reconnect-loop.ts b/src/reconnect-loop.ts index efa5306..e59fb4b 100644 --- a/src/reconnect-loop.ts +++ b/src/reconnect-loop.ts @@ -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>; 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; + /** 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 { diff --git a/src/session-options.ts b/src/session-options.ts index 514541b..e8f613a 100644 --- a/src/session-options.ts +++ b/src/session-options.ts @@ -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; diff --git a/src/session.ts b/src/session.ts index fed5090..4d6a5b4 100644 --- a/src/session.ts +++ b/src/session.ts @@ -265,8 +265,8 @@ export class Session extends EventEmitter { 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), }); } diff --git a/test/session-extras.test.ts b/test/session-extras.test.ts index 0dbeff2..dedbff0 100644 --- a/test/session-extras.test.ts +++ b/test/session-extras.test.ts @@ -74,6 +74,11 @@ function delay(ms: number): Promise { return new Promise(resolve => { setTimeout(resolve, ms); }); } +/** Undefined where the promise never settled, which is an assertion rather than a hung run. */ +function within(ms: number, promise: Promise): Promise { + 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 { + const listener = net.createServer(); + + await new Promise(resolve => { listener.listen(0, resolve); }); + + const address = listener.address(); + const port = typeof address === 'object' && address !== null ? address.port : 0; + + await new Promise(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, + ): 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(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(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 { diff --git a/test/session.test.ts b/test/session.test.ts index 3ed39c6..04f3061 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -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,